什么是打包器
一个完整的 JavaScript 项目(比如各种前端SPA)由各种各样的资源模块(module)组成,包括 JavaScript 代码,CSS 样式以及图片等各种文件。打包器(module bundler)可以分析入口文件(entry)引用了哪些模块,找到对应的文件,将其合并到一起。这样执行输出文件(output)的时候,一个完整的项目会呈现出来。
单页面应用包含大量 JavaScript 代码,为了合理地管理代码,开发时会将代码拆分到不同文件里面。在各个模块的代码编写完成之后,bundler 可以帮我们把各个分散的 JS 文件合并起来,输出一个完整的 JS 文件。
对于样式文件和图片等资源,我们也可以指定如何处理它们。一般的处理方式是直接插入到 HTML 或者 JS 文件中,或者通过指定文件网络地址(public path),在需要该文件的时候浏览器会通过网络请求获取到这些资源。
功能完备的打包器可以把各种资源模块聚集到一起,生成完整的 web app。但本文作为开篇只讨论如何实现一个 JavaSript bundler。
过程分析
一个最简单的 JS bundler 可以帮助我们:
- 找到 entry JavaScript 文件引用到的所有其他 JS 文件,并将其合并到目标 JS 文件(output)中。
- 保证各个模块的 JavaScript 代码都在自己的作用域中执行,避免命名冲突。
为了保证每个模块的 JS 代码都在自己的作用域中执行,可以参考 Node 执行 JS 代码的方式。可以概括为 5 步:
- resolve:通过 require 中的 string 定位到文件的真实地址。
- load:加载这个文件。
- wrap:将引入的代码包含在一个函数中,保证定义的变量只作用在本文件中。
- execute:执行代码。
- cache:缓存执行结果。
而打包的流程可以概括为:
- 找到起始文件的依赖文件,将其加载并描述为一个资源模块(asset),其包含的信息包括:
- id:唯一 id
- filename:绝对文件路径
- code:模块代码,将其包含在一个函数里面。并且需要把 ESM 的 import 和 export 改成 require 和 exports,这样可以执行函数参数里面的 require 和 exports,函数参数的 require 可以帮我们通过相对路径找到实际文件。
- dependencies:引入的模块。
- mapping:记录以来模块的相对路径和其模块 id 的对应关系。
- 当依赖的文件有其他依赖的时候,继续加载依赖文件。最终生成一个依赖图(dependency graph),包含所有模块之间的依赖关系。
- 拼凑一个完整 string,包含所有模块信息,并且执行起始文件。输出这个 string。
代码实现
转换 JS 文件为资源模块(asset):
const path = require('path');
const fs = require('fs');
const parser = require('babel-parser');
const { transformFromAst } = require('@babel/core');
const traverse = require('@babel/traverse').default;
let ID = 0;
function createAsset(filename) {
const content = fs.readFileSync(filename, 'utf-8');
const ast = parser.parse(content, {
sourceType: 'module',
});
const dependencies = [];
traverse(ast, {
importDelcaration: ({ node }) => {
dependencies.push(node.source.value);
}
});
const { code } = tranformFromAst(ast, null, {
presets: ['@babel/preset-env'],
});
return {
id: ID++,
filename,
dependencies,
code
};
}
生成依赖图:
function createGraph(entry) {
const mainAsset = createAsset(entry);
const queue = [mainAsset];
queue.forEach(asset => {
asset.mapping = {};
const dirname = path.dirname(asset.filename);
asset.dependencies.forEach((relativePath) => {
const filename = path.join(dirname, relativePath);
const child = createAsset(filename);
asset.mapping[relativePath] = child.id;
queue.push(child);
});
});
return queue;
}
合并 string:
function createBundle(entry) {
const graph = createGraph(entry);
let modules = '{';
graph.forEach((asset) => {
modules +=
`${asset.id}: [function (requre, module, exports) { ${asset.code} }, ${JSON.stringify(asset.mapping)}],`;
});
modules += '}';
const result = `function(modules) {
function require(id) {
const [fn, mapping] = modules[id];
function localRequire(relativePath) {
require(mapping[relativePath]));
}
const module = { exports: {} };
fn(localRequire, module, exports);
return moduele.exports;
}
require(0);
}(${modules)`;
}
备注项目依赖
"dependencies": {
"@babel/core": "7.9.6",
"@babel/parser": "7.9.6",
"@babel/preset-env": "7.9.6",
"@babel/traverse": "7.9.6"
}
附录
- 本文的知识来源和代码 minipack
- 一个正在施工中的 打包器