手把手教你撸一个简易的|手把手教你撸一个简易的 webpack

背景 随着前端复杂度的不断提升,诞生出很多打包工具,比如最先的gruntgulp。到后来的webpackParcel。但是目前很多脚手架工具,比如vue-cli已经帮我们集成了一些构建工具的使用。有的时候我们可能并不知道其内部的实现原理。其实了解这些工具的工作方式可以帮助我们更好理解和使用这些工具,也方便我们在项目开发中应用。
一些知识点 在我们开始造轮子前,我们需要对一些知识点做一些储备工作。
模块化知识 首先是模块的相关知识,主要的是 es6 modulescommonJS模块化的规范。更详细的介绍可以参考这里 CommonJS、AMD/CMD、ES6 Modules 以及 webpack 原理浅析。现在我们只需要了解:

  1. es6 modules 是一个编译时就会确定模块依赖关系的方式。
  2. CommonJS的模块规范中,Node 在对 JS 文件进行编译的过程中,会对文件中的内容进行头尾包装 ,在头部添加(function (export, require, modules, __filename, __dirname){\n 在尾部添加了\n}; 。这样我们在单个JS文件内部可以使用这些参数。
AST 基础知识 什么是抽象语法树?
在计算机科学中,抽象语法树(abstract syntax tree 或者缩写为 AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。树上的每个节点都表示源代码中的一种结构。之所以说语法是「抽象」的,是因为这里的语法并不会表示出真实语法中出现的每个细节。


大家可以通过Esprima 这个网站来将代码转化成 ast。首先一段代码转化成的抽象语法树是一个对象,该对象会有一个顶级的type属性Program,第二个属性是body是一个数组。body数组中存放的每一项都是一个对象,里面包含了所有的对于该语句的描述信息:
type:描述该语句的类型 --变量声明语句 kind:变量声明的关键字 -- var declaration: 声明的内容数组,里面的每一项也是一个对象 type: 描述该语句的类型 id: 描述变量名称的对象 type:定义 name: 是变量的名字 init: 初始化变量值得对象 type: 类型 value: 值 "is tree" 不带引号 row: "\"is tree"\" 带引号 复制代码

进入正题 webpack 简易打包 有了上面这些基础的知识,我们先来看一下一个简单的webpack打包的过程,首先我们定义3个文件:
// index.js import a from './test'console.log(a)// test.js import b from './message'const a = 'hello' + bexport default a// message.js const b = 'world'export default b 复制代码

方式很简单,定义了一个index.js引用test.jstest.js内部引用message.js。看一下打包后的代码:
(function (modules) { var installedModules = {}; function __webpack_require__(moduleId) { if (installedModules[moduleId]) { return installedModules[moduleId].exports; }var module = installedModules[moduleId] = { i: moduleId, l: false, exports: {} }; modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); // Flag the module as loaded module.l = true; // Return the exports of the module return module.exports; }// expose the modules object (__webpack_modules__) __webpack_require__.m = modules; // expose the module cache __webpack_require__.c = installedModules; // define getter function for harmony exports __webpack_require__.d = function (exports, name, getter) { if (!__webpack_require__.o(exports, name)) { Object.defineProperty(exports, name, {enumerable: true, get: getter}); } }; // define __esModule on exports __webpack_require__.r = function (exports) { if (typeof Symbol !== 'undefined' && Symbol.toStringTag) { Object.defineProperty(exports, Symbol.toStringTag, {value: 'Module'}); } Object.defineProperty(exports, '__esModule', {value: true}); }; // create a fake namespace object // mode & 1: value is a module id, require it // mode & 2: merge all properties of value into the ns // mode & 4: return value when already ns object // mode & 8|1: behave like require __webpack_require__.t = function (value, mode) { /******/ if (mode & 1) value = https://www.it610.com/article/__webpack_require__(value); if (mode & 8) return value; if ((mode & 4) && typeof value ==='object' && value && value.__esModule) return value; var ns = Object.create(null); __webpack_require__.r(ns); Object.defineProperty(ns, 'default', {enumerable: true, value: value}); if (mode & 2 && typeof value != 'string') for (var key in value) __webpack_require__.d(ns, key, function (key) { return value[key]; }.bind(null, key)); return ns; }; // getDefaultExport function for compatibility with non-harmony modules __webpack_require__.n = function (module) { var getter = module && module.__esModule ? function getDefault() { return module['default']; } : function getModuleExports() { return module; }; __webpack_require__.d(getter, 'a', getter); return getter; }; // Object.prototype.hasOwnProperty.call __webpack_require__.o = function (object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; // __webpack_public_path__ __webpack_require__.p = ""; // Load entry module and return exports return __webpack_require__(__webpack_require__.s = "./src/index.js"); })({ "./src/index.js": (function (module, __webpack_exports__, __webpack_require__) {"use strict"; eval("__webpack_require__.r(__webpack_exports__); \n/* harmony import */ var _test__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./test */ \"./src/test.js\"); \n\n\nconsole.log(_test__WEBPACK_IMPORTED_MODULE_0__[\"default\"])\n\n\n//# sourceURL=webpack:///./src/index.js?"); }), "./src/message.js": (function (module, __webpack_exports__, __webpack_require__) { // ... }), "./src/test.js": (function (module, __webpack_exports__, __webpack_require__) { // ... }) }); 复制代码

看起来很乱?没关系,我们来屡一下。一眼看过去我们看到的是这样的形式:
(function(modules) { // ... })({ // ... }) 复制代码

这样好理解了吧,就是一个自执行函数,传入了一个modules对象,modules 对象是什么样的格式呢?上面的代码已经给了我们答案:
{ "./src/index.js": (function (module, __webpack_exports__, __webpack_require__) { // ... }), "./src/message.js": (function (module, __webpack_exports__, __webpack_require__) { // ... }), "./src/test.js": (function (module, __webpack_exports__, __webpack_require__) { // ... }) } 复制代码

【手把手教你撸一个简易的|手把手教你撸一个简易的 webpack】是这样的一个 路径 --> 函数 这样的 key,value 键值对。而函数内部是我们定义的文件转移成 ES5 之后的代码:
"use strict"; eval("__webpack_require__.r(__webpack_exports__); \n/* harmony import */ var _test__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./test */ \"./src/test.js\"); \n\n\nconsole.log(_test__WEBPACK_IMPORTED_MODULE_0__[\"default\"])\n\n\n//# sourceURL=webpack:///./src/index.js?"); 复制代码

到这里基本上结构是分析完了,接着我们看看他的执行,自执行函数一开始执行的代码是:
__webpack_require__(__webpack_require__.s = "./src/index.js"); 复制代码

调用了__webpack_require_函数,并传入了一个moduleId参数是"./src/index.js"。再看看函数内部的主要实现:
// 定义 module 格式 var module = installedModules[moduleId] = { i: moduleId, // moduleId l: false, // 是否已经缓存 exports: {} // 导出对象,提供挂载}; modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 复制代码

这里调用了我们modules中的函数,并传入了__webpack_require__函数作为函数内部的调用。module.exports参数作为函数内部的导出。因为index.js里面引用了test.js,所以又会通过__webpack_require__来执行对test.js的加载:
var _test__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/test.js"); 复制代码

test.js内又使用了message.js所以,test.js内部又会执行对message.js的加载。message.js执行完成之后,因为没有依赖项,所以直接返回了结果:
var b = 'world' __webpack_exports__["default"] = (b) 复制代码

执行完成之后,再一级一级返回到根文件index.js。最终完成整个文件依赖的处理。 整个过程中,我们像是通过一个依赖关系树的形式,不断地向数的内部进入,等返回结果,又开始回溯到根。
开发一个简单的 tinypack 通过上面的这些调研,我们先考虑一下一个基础的打包编译工具可以做什么?
  1. 转换ES6语法成ES5
  2. 处理模块加载依赖
  3. 生成一个可以在浏览器加载执行的 js 文件
第一个问题,转换语法,其实我们可以通过babel来做。核心步骤也就是:
  • 通过babylon生成AST
  • 通过babel-core将AST重新生成源码
/** * 获取文件,解析成ast语法 * @param filename // 入口文件 * @returns {*} */ function getAst (filename) { const content = fs.readFileSync(filename, 'utf-8')return babylon.parse(content, { sourceType: 'module', }); }/** * 编译 * @param ast * @returns {*} */ function getTranslateCode(ast) { const {code} = transformFromAst(ast, null, { presets: ['env'] }); return code } 复制代码

接着我们需要处理模块依赖的关系,那就需要得到一个依赖关系视图。好在babel-traverse提供了一个可以遍历AST视图并做处理的功能,通过 ImportDeclaration 可以得到依赖属性:
function getDependence (ast) { let dependencies = [] traverse(ast, { ImportDeclaration: ({node}) => { dependencies.push(node.source.value); }, }) return dependencies }/** * 生成完整的文件依赖关系映射 * @param fileName * @param entry * @returns {{fileName: *, dependence, code: *}} */ function parse(fileName, entry) { let filePath = fileName.indexOf('.js') === -1 ? fileName + '.js' : fileName let dirName = entry ? '' : path.dirname(config.entry) let absolutePath = path.join(dirName, filePath) const ast = getAst(absolutePath) return { fileName, dependence: getDependence(ast), code: getTranslateCode(ast), }; } 复制代码

到目前为止,我们也只是得到根文件的依赖关系和编译后的代码,比如我们的index.js依赖了test.js但是我们并不知道test.js还需要依赖message.js,他们的源码也是没有编译过。所以此时我们还需要做深度遍历,得到完成的深度依赖关系:
/** * 获取深度队列依赖关系 * @param main * @returns {*[]} */ function getQueue(main) { let queue = [main] for (let asset of queue) { asset.dependence.forEach(function (dep) { let child = parse(dep) queue.push(child) }) } return queue } 复制代码

那么进行到这一步我们已经完成了所有文件的编译解析。最后一步,就是需要我们按照webpack的思想对源码进行一些包装。第一步,先是要生成一个modules对象:
function bundle(queue) { let modules = '' queue.forEach(function (mod) { modules += `'${mod.fileName}': function (require, module, exports) { ${mod.code} },` }) // ... } 复制代码

得到 modules 对象后,接下来便是对整体文件的外部包装,注册requiremodule.exports
(function(modules) { function require(fileName) { // ... } require('${config.entry}'); })({${modules}}) 复制代码

而函数内部,也只是循环执行每个依赖文件的 JS 代码而已,完成代码:
function bundle(queue) { let modules = '' queue.forEach(function (mod) { modules += `'${mod.fileName}': function (require, module, exports) { ${mod.code} },` })const result = ` (function(modules) { function require(fileName) { const fn = modules[fileName]; const module = { exports : {} }; fn(require, module, module.exports); return module.exports; }require('${config.entry}'); })({${modules}}) `; return result; } 复制代码

到这里基本上也就介绍完了,我们来打包试一下:
(function (modules) { function require(fileName) { const fn = modules[fileName]; const module = {exports: {}}; fn(require, module, module.exports); return module.exports; }require('./src/index.js'); })({ './src/index.js': function (require, module, exports) { "use strict"; var _test = require("./test"); var _test2 = _interopRequireDefault(_test); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : {default: obj}; }console.log(_test2.default); }, './test': function (require, module, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var _message = require("./message"); var _message2 = _interopRequireDefault(_message); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : {default: obj}; }var a = 'hello' + _message2.default; exports.default = a; }, './message': function (require, module, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var b = 'world'; exports.default = b; }, }) 复制代码

再测试一下:

恩,基本上已经完成一个简易的 tinypack
参考文章 抽象语法树 Abstract syntax tree
一看就懂的JS抽象语法树
源码 tinypack 所有的源码已经上传 github

    推荐阅读