前端知库

怎样手写一个简易打包器?依赖图和 __webpack_require__ 式运行时是怎样生成的?

从入口文件开始,找出每个模块 require 了哪些文件,递归收集成依赖图;再把每个模块的代码包进一个函数,放进一个以模块 ID 为键的对象,最后拼上一个自己实现的 require 函数,带缓存,从入口开始执行。webpack 打包后的 __webpack_require__ 也是按模块 ID 找模块、执行并缓存导出。

参考答案

简答

比如 index.js 引用了 math.js 和 greet.js,greet.js 又引用了 math.js。打包器要做的就是从 index.js 出发,把这些文件找全,合成一个浏览器能直接运行的文件。

第一步是建依赖图。读入口文件的源码,找出里面 require 了哪些路径,再对每个依赖做同样的事,直到没有新文件。webpack 文档把这个过程描述为:一个文件依赖另一个文件,就是一个依赖,从入口开始递归构建出依赖图。

第二步是生成运行时。每个模块的代码包进一个函数,参数是 module、exports 和 require,所有模块放进一个以模块 ID 为键的对象。再写一个 __webpack_require__:按 ID 找到函数、执行、缓存并返回 module.exports。webpack 文档也说,打包后源码里的 import 和 require 都变成了指向模块标识的 __webpack_require__ 调用。

手写版只是演示原理。真实打包器用 AST 而不是正则找依赖,还要做路径解析、loader 转换、ES 模块、代码分割这些事。

先看一个能跑的打包器

示例不读磁盘,用一个对象模拟三个文件的源码,依赖用正则从 require('...') 里提取。要看的是三件事:依赖图长什么样、生成的代码长什么样、执行结果对不对。

JavaScript
const files = {
  './index.js': "const { add } = require('./math.js');\nconst { greet } = require('./greet.js');\nmodule.exports = greet('打包器') + ' ' + add(1, 2);",
  './math.js': 'exports.add = (a, b) => a + b;',
  './greet.js': "const { add } = require('./math.js');\nexports.greet = (name) => name + add(0, 1);",
};

function parseDeps(code) {
  const deps = [];
  const pattern = /require\(\s*['"]([^'"]+)['"]\s*\)/g;
  let match;
  while ((match = pattern.exec(code)) !== null) deps.push(match[1]);
  return deps;
}

function buildGraph(entry) {
  const graph = {};
  const queue = [entry];
  while (queue.length > 0) {
    const id = queue.shift();
    if (graph[id]) continue;
    const code = files[id];
    if (code === undefined) throw new Error('找不到模块 ' + id);
    const deps = parseDeps(code);
    graph[id] = { code, deps };
    queue.push(...deps);
  }
  return graph;
}

function bundle(entry) {
  const graph = buildGraph(entry);
  const modules = Object.keys(graph)
    .map((id) => JSON.stringify(id) + ': function (module, exports, require) {\n' + graph[id].code + '\n}')
    .join(',\n');
  return `(function (modules) {
  const cache = {};
  function __webpack_require__(id) {
    if (cache[id]) return cache[id].exports;
    const module = (cache[id] = { exports: {} });
    modules[id](module, module.exports, __webpack_require__);
    return module.exports;
  }
  return __webpack_require__(${JSON.stringify(entry)});
})({
${modules}
})`;
}

const graph = buildGraph('./index.js');
console.log(Object.keys(graph).join(' ')); // ./index.js ./math.js ./greet.js
console.log(graph['./greet.js'].deps.join(' ')); // ./math.js
const output = bundle('./index.js');
console.log(new Function('return ' + output)()); // 打包器1 3

依赖图里有三个模块,顺序是 ./index.js ./math.js ./greet.js。./math.js 被引用了两次,可图里只出现一次,因为 buildGraph 遇到已经收集过的模块就跳过。这一步也防止了循环引用让收集过程死循环。

最后一行把生成的字符串当代码执行,得到 打包器1 3。greet('打包器') 返回 打包器1,add(1, 2) 返回 3,说明三个模块都被正确连起来了。

生成的代码为什么要把模块包进函数

如果把三个文件的代码直接拼在一起,index.js 和 greet.js 都声明了 const { add },会报重复声明。包进函数以后,每个模块有自己的作用域,变量互不干扰,这和 CommonJS 在 Node.js 里给每个文件包一层函数是同一个思路。模块化的演变见《前端模块化经历了哪些阶段?IIFE、CommonJS、AMD、UMD 和 ES Modules 有什么区别?》。

函数的三个参数 module、exports、require 由运行时传进去,所以模块里写的 require('./math.js') 实际调用的是 __webpack_require__,exports.add = ... 写进的是运行时准备好的对象。

webpack_require 做了什么

它按模块 ID 去 modules 对象里找函数,先查缓存,有就直接返回 exports;没有就新建一个 { exports: {} } 放进缓存,再执行模块函数,最后返回 module.exports。

缓存有两个作用。第一,math.js 被两个模块引用,只执行一次,两边拿到的是同一个导出对象。第二,先放缓存再执行,遇到循环引用时,后面的模块能拿到前面模块“执行到一半”的 exports,而不是无限递归。

webpack 的 Manifest 文档说,编译器会记录所有模块的信息,运行时靠这些数据按模块标识找到并加载模块;不管源码用的是哪种模块语法,import 和 require 都会变成指向模块标识的 __webpack_require__ 调用。示例里以路径为键的 modules 对象,就相当于一个最简单的 manifest。

真实打包器比这多做了什么

正则提取依赖只适合演示。注释里、字符串里的 require(...) 也会被匹配,动态拼接的路径又匹配不到。真实打包器先把源码解析成 AST,再从语法树里找导入语句,做法见《AST 在前端工程里有什么用?Babel、ESLint 和代码转换是怎样用它的?》。

示例里的模块 ID 就是写死的路径,真实场景要把 ./math 这样的相对路径按当前文件位置、扩展名、node_modules 规则解析成绝对路径,再作为 ID。非 JS 文件要先经过 loader 转成 JS,webpack 文档提到图片、字体这类资源也能作为依赖进入依赖图。整个流程的插件钩子见《webpack 的构建流程是怎样的?Tapable 钩子有哪些类型?》。

常见追问

依赖图和拓扑排序有什么关系?

依赖图是有向图,边从模块指向它依赖的模块。示例的运行时是在执行时按需递归调用,不需要事先排序;如果要提前算出“依赖先于使用者”的执行顺序,或者检测循环依赖,就要做拓扑排序,见《如何用拓扑排序解析前端任务和模块依赖?》。

为什么示例用广度优先收集,而不是递归?

两种都可以,只要记录已访问的模块,避免重复和循环。用队列写不会因为依赖链很长而递归过深,收集出的顺序也直观,先是入口,再是入口的直接依赖。

ES 模块的 import 和 export 怎样处理?

一种做法是先用 Babel 把 ES 模块转成 CommonJS,再套用示例的运行时。webpack 在 Manifest 文档里说明,不管源码用哪种模块语法,最后都会变成 __webpack_require__ 调用。