AboutPosts

从 mini-pack 中理解 js 的打包原理

Zhanghao,8 min read

本文主要内容来自于 mini-pack。示例代码跟原项目的代码有部分不同。

开发 web app 时一般采用模块化的开发方式,即不同的业务需求、组件会编写到不同的文件中,最后再根据需要进行将不同的文件合并成一个或者多个文件,这样的过程就是打包。

image.png

上图展示了一个常见的文件结构,入口文件(entry.js)依赖 a.jsc.jsa.js 依赖 b.js,而 c.js 依赖 d.jse.js 。当然,实际的结构会比示例更复杂。如果说我们需要对这样的代码组织结构进行打包,那么所有的文件都应该打包的一个文件中。

首先,我们需要分析出每个文件的依赖关系:

在代码中我们可以大致这样表示:

{ "entry.js": { "id": 0, "code": "xxx", "deps": ["a.js", "c.js"] }, "a.js": { "id": 1, "code": "xxx", "deps": ["b.js"] }, "c.js": { "id": 2, "code": "xxx", "deps": ["d.js", "f.js"] }, "b.js": { "id": 3, "code": "xxx", "deps": [] }, "d.js": { "id": 4, "code": "xxx", "deps": [] }, "e.js": { "id": 0, "code": "xxx", "deps": [] } }

每个文件都有自身的 id,code 和 deps,在进行打包时我们就可以从入口文件出发对 code 进行转译(基于兼容性考虑),然后使用 id 和 deps 查找其他的文件进行相同的操作,最后将所有的文件以字符串的形式合并到一个文件中,这样就完成了打包。

接下来从一个实际的场景出发:

# 项目文件结构 - `mini-bundle` - `index.js` - `example/` - `entry.js` - `hello.js` - `name.js`

entry.js

import { sayHello } from "./hello.js"; console.log(sayHello());

hello.js

import { name } from "./name.js"; export function sayHello() { return `hello ${name}`; }

name.js

export const name = "mini-bundle";

首先,我们需要为每个 js 生成 asset,asset 可以理解为每个 js 文件对应的用于打包的数据结构

image%201.png

id 是文件的唯一标识符,用于查询编译后的模块;

filename 是文件的相对路径(也可以是绝对路径或者别名),用于找到读取并编译文件内容;

dependencies 是文件的依赖项;

code 是编译后的代码;

mapping 是 filename 跟 id 的映射,跟 dependencies 配合可以找到依赖项的 id;

所以,我们需要先实现一个 createAsset 函数,代码中 parse 和 traverse 是 babel 的函数,用于解析并生成 AST 和遍历查询依赖。

async function createAsset(filename) { // 读取文件内容 const content = await readFile(filename, "utf-8"); // 构建 ast const ast = parse(content, { sourceType: "module", }); // 收集依赖 const dependencies = []; traverse.default(ast, { ImportDeclaration: ({ node }) => { dependencies.push(node.source.value); }, }); const id = ID++; // 转译代码 const { code } = transformFromAst(ast, null, { presets: ["@babel/preset-env"], }); // 目前返回的对象中没有 mapping 属性 // 该属性会在构建依赖图时创建 return { id, filename, dependencies, code, }; }

接着我们需要从入口文件开始构建出整个依赖图(所有文件的依赖关系)

image%202.png

async function createGraph(entry) { const mainAsset = await createAsset(entry); // 记录所有的模块资产 const queue = [mainAsset]; // 广度优先遍历 for (const asset of queue) { // 记录模块的依赖关系 // 映射模块 id 和 模块路径 asset.mapping = {}; // 获取模块的目录 const dirname = path.dirname(asset.filename); for (const relativePath of asset.dependencies) { // 获取绝对路径 const absolutePath = path.join(dirname, relativePath); // 创建子模块 const child = await createAsset(absolutePath); // 记录模块的依赖关系 asset.mapping[relativePath] = child.id; // 将子模块添加到队列中 queue.push(child); } } return queue; } // const graph = await createGraph("./example/entry.js");

构建依赖图时会从入口文件(entry.js)开始生成 mainAsset,然后将 mainAsset 加入 queue,遍历 queue 找到 dependencies 所对应的文件,接着依次生成依赖项的 child asset 并更新 mapping,然后再将 child 加入 queue,直到所有的文件都生成完成。

最后通过 bundle 函数拼接所有的 asset

function bundle(graph) { let modules = ""; graph.forEach((mod) => { modules += `${mod.id}: [ function(require, module, exports) { ${mod.code} }, ${JSON.stringify(mod.mapping)} ], `; }); return ` (function(modules) { function require(id) { const [fn, mapping] = modules[id]; function localRequire(name) { return require(mapping[name]); } const module = { exports: {} }; fn(localRequire, module, module.exports); return module.exports; } require(0); })({${modules}}) `; }

在 bundle 函数中首先将 graph 转化为 { id: [fn, mapping] } 结构的 modules 对象,然后将其传入立即执行函数(按照 commonjs 的规范)中,这样就完成了脚本的打包。

以下是完整代码:

import { readFile } from "node:fs/promises"; import { parse } from "@babel/parser"; import traverse from "@babel/traverse"; import { transformFromAst } from "@babel/core"; import path from "node:path"; import { mkdirSync, writeFileSync } from "node:fs"; let ID = 0; async function createAsset(filename) { // 读取文件内容 const content = await readFile(filename, "utf-8"); // 构建 ast const ast = parse(content, { sourceType: "module", }); // 收集依赖 const dependencies = []; traverse.default(ast, { ImportDeclaration: ({ node }) => { dependencies.push(node.source.value); }, }); const id = ID++; // 转译代码 const { code } = transformFromAst(ast, null, { presets: ["@babel/preset-env"], }); return { id, filename, dependencies, code, }; } async function createGraph(entry) { const mainAsset = await createAsset(entry); // 记录所有的模块资产 const queue = [mainAsset]; // 广度优先遍历 for (const asset of queue) { // 记录模块的依赖关系 // 映射模块 id 和 模块路径 asset.mapping = {}; // 获取模块的目录 const dirname = path.dirname(asset.filename); for (const relativePath of asset.dependencies) { // 获取绝对路径 const absolutePath = path.join(dirname, relativePath); // 创建子模块 const child = await createAsset(absolutePath); // 记录模块的依赖关系 asset.mapping[relativePath] = child.id; // 将子模块添加到队列中 queue.push(child); } } return queue; } function bundle(graph) { let modules = ""; graph.forEach((mod) => { modules += `${mod.id}: [ function(require, module, exports) { ${mod.code} }, ${JSON.stringify(mod.mapping)} ], `; }); return ` (function(modules) { function require(id) { const [fn, mapping] = modules[id]; function localRequire(name) { return require(mapping[name]); } const module = { exports: {} }; fn(localRequire, module, module.exports); return module.exports; } require(0); })({${modules}}) `; } (async () => { const graph = await createGraph("./example/entry.js"); const bundleCode = bundle(graph); mkdirSync("./dist", { recursive: true }); writeFileSync("./dist/bundle.js", bundleCode); })();

将 entry.js 、hello.js 和 name.js 打包后的代码如下:

(function (modules) { function require(id) { const [fn, mapping] = modules[id]; function localRequire(name) { return require(mapping[name]); } const module = { exports: {} }; fn(localRequire, module, module.exports); return module.exports; } require(0); })({ 0: [ function (require, module, exports) { "use strict"; var _hello = require("./hello.js"); console.log((0, _hello.sayHello)()); }, { "./hello.js": 1 }, ], 1: [ function (require, module, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true, }); exports.sayHello = sayHello; var _name = require("./name.js"); function sayHello() { return "hello ".concat(_name.name); } }, { "./name.js": 2 }, ], 2: [ function (require, module, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true, }); exports.name = void 0; var name = (exports.name = "mini-bundle"); }, {}, ], });

这篇文章的内容是最基本的打包原理,主要是了解 asset、graph 的概念和如何查找依赖。其他诸如缓存、循环引用等问题没有进行涉及。

Twitter · GitHub · Email
© Zhang Hao.