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

相关推荐
C_V_Better5 分钟前
浏览器缓存机制:JavaScript 文件缓存导致 404 错误的解决方案
开发语言·前端·javascript·缓存
小救星小杜、8 分钟前
a = b &&c 的含义
开发语言·前端·javascript
uhakadotcom10 分钟前
Babylon.js:轻松打造Web 3D体验
前端·javascript·面试
parade岁月11 分钟前
告别代码质量隐患:Husky 生态工具链在前端工程化中的实战应用
前端·javascript
小成C11 分钟前
为什么会演化出RSC,SSR和RSC关系大解密
前端·react.js
过期的H2O213 分钟前
【H2O2 | 软件开发】Axios发送Http请求
前端·http·axios·交互
bug总结19 分钟前
vue3 public下引入图片路径打包后线上不显示问题解决
前端·javascript·vue.js
悠然青年帅21 分钟前
基于Vue+Canvas实现的画板绘画以及保存功能
前端
screct_demo25 分钟前
详细讲一下 Webpack 主要生命周期钩子流程(重难点)
前端·webpack·node.js
小妖66625 分钟前
vue2的webpack(vue.config.js) 怎么使用请求转发 devServer.proxy
javascript·vue.js·webpack