Yukiniro

Yukiniro

Change the world.

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

本文主要內容來自於 mini-pack。示例代碼跟原項目的代碼有部分不同。

開發 web app 時一般採用模組化的開發方式,即不同的業務需求、元件會編寫到不同的檔案中,最後再根據需要將不同的檔案合併成一個或者多個檔案,這樣的過程就是打包。

image

上圖展示了一個常見的檔案結構,入口檔案(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

在代碼中我們可以大致這樣表示:

{
  "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 1

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 2

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 的概念和如何查找依賴。其他諸如快取、循環引用等問題沒有進行涉及。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。