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

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

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

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

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

  • entry.js 依赖 a.jsc.js
  • a.js 依赖 b.js
  • c.js 依赖 d.jse.js

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

json 复制代码
{
  "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 查找其他的文件进行相同的操作,最后将所有的文件以字符串的形式合并到一个文件中,这样就完成了打包。

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

markdown 复制代码
# 项目文件结构

- `mini-bundle`
  - `index.js`
    - `example/`
	    - `entry.js`
	    - `hello.js`
	    - `name.js`

entry.js

js 复制代码
import { sayHello } from "./hello.js";

console.log(sayHello());

hello.js

js 复制代码
import { name } from "./name.js";

export function sayHello() {
  return `hello ${name}`;
}

name.js

js 复制代码
export const name = "mini-bundle";

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

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

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

dependencies 是文件的依赖项;

code 是编译后的代码;

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

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

js 复制代码
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,
  };
}

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

js 复制代码
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

js 复制代码
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 的规范)中,这样就完成了脚本的打包。

以下是完整代码:

js 复制代码
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 打包后的代码如下:

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 的概念和如何查找依赖。其他诸如缓存、循环引用等问题没有进行涉及。

相关推荐
前端大卫1 小时前
Vue3 + Element-Plus 自定义虚拟表格滚动实现方案【附源码】
前端
却尘2 小时前
Next.js 请求最佳实践 - vercel 2026一月发布指南
前端·react.js·next.js
ccnocare2 小时前
浅浅看一下设计模式
前端
Lee川2 小时前
🎬 从标签到屏幕:揭秘现代网页构建与适配之道
前端·面试
Ticnix2 小时前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人2 小时前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl2 小时前
OpenClaw 深度技术解析
前端
崔庆才丨静觅2 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人2 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼2 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端