寻觅 webpack - 29 - 解构 webpack - 自己动手写 webpack

本系列已集结成书从 0 到 Webpack:学习 Modern Web 专案的建置方式,这是一本完整介绍 Webpack 的专书,如有学习 Webpack 相关的问题可以参考此书。

本文目标在於实作一个简易的打包工具。

本文的范例程序放在 peterhpchen/webpack-quest 中,每个程序码区块的第一行都会标注档案的位置,请搭配文章作参考。

webpack 是个拥有强大功能的工具,本文将尝试自己实作 webpack 的核心功能:打包,接着跟着我一起试试写个简易版的打包器吧。

本文 Ronen Amiel 的 Build Your Own Webpack 启发,因此实作方式会以 Ronen Amiel 的 minipack 方式做展示,与 webpack 的打包方式比起来, minipack 化繁为简,对於初学打包技巧的开发者会是比较好入门的方式。

打包的范例

我们使用下面这个例子做打包的示范:

// ./src/index.js
import message from "./message.js";

console.log(message);

// ./src/message.js
import { demoName } from "./demoName.js";

export default `hello ${demoName}`;

// ./src/demoName.js
export const demoName = "simple";

这个范例的模组相依图(Module Graph)如下图:

https://ithelp.ithome.com.tw/upload/images/20201014/20107789BTW9znmW5h.png

之後下面所有的展示都以上面的代码作为打包对象。

webpack 的 bundle

在自己动手前,先来观察 webpack 是如何打包的,现在执行建置後, webpack 会产出 ./dist/main.js ,内容如下:

// ./demos/simple/dist/main.js
(function (modules) {
  // webpackBootstrap
  // The require function
  function __webpack_require__(moduleId) {
    // Create a new module
    var module = {
      i: moduleId,
      exports: {},
    };

    // Execute the module function
    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      __webpack_require__
    );

    // Return the exports of the module
    return module.exports;
  }

  // define getter function for harmony exports
  __webpack_require__.d = function (exports, name, getter) {
    if (!__webpack_require__.o(exports, name)) {
      Object.defineProperty(exports, name, {
        enumerable: true,
        get: getter,
      });
    }
  };

  // define __esModule on exports
  __webpack_require__.r = function (exports) {
    Object.defineProperty(exports, "__esModule", {
      value: true,
    });
  };

  // Object.prototype.hasOwnProperty.call
  __webpack_require__.o = function (object, property) {
    return Object.prototype.hasOwnProperty.call(object, property);
  };

  // Load entry module and return exports
  return __webpack_require__(0);
})([
  /* 0 */
  function (module, __webpack_exports__, __webpack_require__) {
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    var _message_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);

    console.log(_message_js__WEBPACK_IMPORTED_MODULE_0__["default"]);
  },
  /* 1 */
  function (module, __webpack_exports__, __webpack_require__) {
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    var _demoName_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);

    __webpack_exports__[
      "default"
    ] = `hello ${_demoName_js__WEBPACK_IMPORTED_MODULE_0__["demoName"]}`;
  },
  /* 2 */
  function (module, __webpack_exports__, __webpack_require__) {
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    __webpack_require__.d(__webpack_exports__, "demoName", function () {
      return demoName;
    });
    const demoName = "simple";
  },
]);

上述的代码已经将不是非必要的代码删去,让我们集中精神在打包的部分,这段代码有下面几个看点:

产出了一个 IIFE

webpack 的 bundle 产生了一个 IIFE ,传入模组当作其参数。

(function(modules) {...})([...]);

模组内容被函式包覆

每个模组内容都被 function 包覆,这个函式传入了 module, __webpack_exports____webpack_require__

(function(modules) {...})
([
  /* 0 */
  (function(module, __webpack_exports__, __webpack_require__) {
      ...
  }),
  /* 1 */
  (function(module, __webpack_exports__, __webpack_require__) {
      ...
  }),
  /* 2 */
  (function(module, __webpack_exports__, __webpack_require__) {
      ...
  })
]);
  • module: 模组资讯
  • __webpack_exports__: 模组汇出的资源
  • __webpack_require__: 引入资源的方法

模组执行方式

webpack 在 IIFE 中直接引入入口资源,开始执行代码。

(function (modules) {
  // webpackBootstrap
  // The require function
  function __webpack_require__(moduleId) {
    // Create a new module
    var module = {
      i: moduleId,
      exports: {},
    };

    // Execute the module function
    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      __webpack_require__
    );

    // Return the exports of the module
    return module.exports;
  }

  // Load entry module and return exports
  return __webpack_require__(0);
})([
  /* 0 */
  function (module, __webpack_exports__, __webpack_require__) {},
  /* 1 */
  function (module, __webpack_exports__, __webpack_require__) {},
  /* 2 */
  function (module, __webpack_exports__, __webpack_require__) {},
]);

执行 __webpack_require__(0) 时实际上利用 modules[moduleId].call(...)/* 0 */ 函式执行,这时开发者所撰写的内容才真正的执行起来。

引入其他模组

引入其他模组时,使用 __webpack_require__ 执行模组并将会出内容加至 __webpack_exports__

(function (modules) {
  // webpackBootstrap
  // The require function
  function __webpack_require__(moduleId) {
    // Create a new module
    var module = {
      i: moduleId,
      exports: {},
    };

    // Execute the module function
    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      __webpack_require__
    );

    // Return the exports of the module
    return module.exports;
  }
})([
  /* 0 */
  function (module, __webpack_exports__, __webpack_require__) {
    var _message_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);

    console.log(_message_js__WEBPACK_IMPORTED_MODULE_0__["default"]);
  },
  /* 1 */
  function (module, __webpack_exports__, __webpack_require__) {},
  /* 2 */
  function (module, __webpack_exports__, __webpack_require__) {},
]);

webpack 会将模组内的引入语法改为 __webpack_require__ ,这里我们可以发现 webpack 将外部的 __webpack_require__ 方法带入的原因是内部代码要做引用时也可以使用此方法处理。

/* 0 */ 这个入口模组中使用 __webpack_require__(1) 执行并引用 /* 1 */ 的资源。

汇出资源

在汇出资源的时候会将资源带进 __webpack_exports__ ,而 __webpack_require__ 会帮忙 return 出去。

(function (modules) {
  // webpackBootstrap
  // The require function
  function __webpack_require__(moduleId) {
    // Create a new module
    var module = {
      i: moduleId,
      exports: {},
    };

    // Execute the module function
    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      __webpack_require__
    );

    // Return the exports of the module
    return module.exports;
  }
})([
  /* 0 */
  function (module, __webpack_exports__, __webpack_require__) {},
  /* 1 */
  function (module, __webpack_exports__, __webpack_require__) {
    var _demoName_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);

    __webpack_exports__[
      "default"
    ] = `hello ${_demoName_js__WEBPACK_IMPORTED_MODULE_0__["demoName"]}`;
  },
  /* 2 */
  function (module, __webpack_exports__, __webpack_require__) {},
]);

可以看到 /* 1 */ 中汇出的资源被加入 __webpack_exports__["default"] ,如此一来 __webpack_require__ 在最後 return module.exports 的时候就可以使外部模组取得期望的资源。

标示为 ES Module

你会发现到三个模组的都有个 __webpack_require__.r(__webpack_exports__) ,这会将此模组标示为 ES Module ,告知此模组为 ES Module 。

(function (modules) {
  // define __esModule on exports
  __webpack_require__.r = function (exports) {
    Object.defineProperty(exports, "__esModule", {
      value: true,
    });
  };
})([
  /* 0 */
  function (module, __webpack_exports__, __webpack_require__) {
    __webpack_require__.r(__webpack_exports__);
  },
  /* 1 */
  function (module, __webpack_exports__, __webpack_require__) {
    __webpack_require__.r(__webpack_exports__);
  },
  /* 2 */
  function (module, __webpack_exports__, __webpack_require__) {
    __webpack_require__.r(__webpack_exports__);
  },
]);

__webpack_require__.r 会将 exports 加上 __esModule 的值,以表示此为 ES Module 。

汇出非预设资源

webpack 对於非 default 的汇出资源处理如下:

(function (modules) {
  // define getter function for harmony exports
  __webpack_require__.d = function (exports, name, getter) {
    if (!__webpack_require__.o(exports, name)) {
      Object.defineProperty(exports, name, {
        enumerable: true,
        get: getter,
      });
    }
  };

  // Object.prototype.hasOwnProperty.call
  __webpack_require__.o = function (object, property) {
    return Object.prototype.hasOwnProperty.call(object, property);
  };
})([
  /* 0 */
  function (module, __webpack_exports__, __webpack_require__) {},
  /* 1 */
  function (module, __webpack_exports__, __webpack_require__) {},
  /* 2 */
  function (module, __webpack_exports__, __webpack_require__) {
    __webpack_require__.d(__webpack_exports__, "demoName", function () {
      return demoName;
    });
    const demoName = "simple";
  },
]);

这个例子中 demoName.js 传回的是 export const demoName = "simple" , webpack 利用 Object.defineProperty 的方式将 __webpack_exports__.demoNamegetter 设为 return demoName 如此一来其他模组便能取得此资源值。

webpack 的 bundle 一览总结

webpack 所产出的 bundle 乍看之下很复杂,其实也就只有 requireexport 两个重点,只要搞懂了这两个概念,看懂 bunlde 就再也不是问题了。

自己动手做打包器

终於要开始写打包器了,下面是整个打包所需的步骤:

https://ithelp.ithome.com.tw/upload/images/20201014/20107789mFRY9K61Me.png

简单顺过一遍流程後,细节会在之後讲解,现在我们先建立专案:

// ./demos/read-file/package.json
{
  "scripts": {
    "build": "node boundler.js"
  }
}

在指令中加上 node boundler.js ,我们将会把打包器内容写於 bundler.js ,并使用 node 执行。

接着我们就一步一步来建构我们的打包器吧。

读入入口模组

第一步,我们需要将入口模组 ./src/index.js 的内容读入:

// ./demos/read-file/boundler.js
const fs = require("fs");

const content = fs.readFileSync("./src/index.js", "utf-8");

console.log(content);

使用 node.jsfs 模组读入档案。

本文所有的范例都采用同步方式,让读者可以容易进入状况。

结果如下:

https://ithelp.ithome.com.tw/upload/images/20201014/20107789SALOKhAecL.png

可以看到 ./src/index.js 的内容顺利读入了。

将代码内容转为 AST

AST 抽象语法术,他可以以树状结构表示各个代码的内容,使用 AST 就可以去控制或查找目标资料,而不需要直接分析字串。

现在我们使用 @babel/parser 将代码内容转为 AST:

// ./demos/ast-parser/boundler.js
const fs = require("fs");
const { parse } = require("@babel/parser");

const content = fs.readFileSync("./src/index.js", "utf-8");
const ast = parse(content, {
  sourceType: "module",
});

console.log(ast);

需要跟 Parser 说明此代码内容为 ES Module ,因此 sourceType 要设为 module

输出结果如下:

https://ithelp.ithome.com.tw/upload/images/20201014/20107789km0LzRrptR.png

可以看到代码的内容被转为物件的形式,这样我们就可以用代码的方式去控制语法了。

为了更加方便的使用 AST ,我们可以使用 AST Explorer 来观察 AST 。

webpack 使用的 Parser 为 acorn@babel/parser 也是以此为基础做开发的。

找出引入的语法

有了 AST 的帮助,我们可以用 ImportDeclaration 搜索 AST 找出 import 的内容。

为了方便的搜索 AST ,可以使用 @babel/traverse

// ./demos/tree-walker/boundler.js
const fs = require("fs");
const { parse } = require("@babel/parser");
const traverse = require("@babel/traverse").default;

const content = fs.readFileSync("./src/index.js", "utf-8");
const ast = parse(content, {
  sourceType: "module",
});

traverse(ast, {
  ImportDeclaration: ({ node }) => {
    console.log(node);
  },
});

结果如下:

https://ithelp.ithome.com.tw/upload/images/20201014/201077893WpFSsG5wh.png

我们发现 node.source.value 中有引入的档案路径资讯。

建立资源资讯

利用上节找出的 import 资讯,我们可以将入口资源的资讯记起来:

// ./demos/create-asset/boundler.js
const fs = require("fs");
const { parse } = require("@babel/parser");
const traverse = require("@babel/traverse").default;

let ID = 0;

function createAsset(fileName) {
  const content = fs.readFileSync(fileName, "utf-8");
  const ast = parse(content, {
    sourceType: "module",
  });

  const dependencies = [];

  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      dependencies.push(node.source.value);
    },
  });

  const id = ID++;

  return {
    id,
    fileName,
    dependencies,
  };
}

console.log(createAsset("./src/index.js"));

我们用 createAsset 包住之前的程序,为之後要建立相依图做准备,并将相依的路径加入 dependencies 中。

结果如下:

https://ithelp.ithome.com.tw/upload/images/20201014/20107789nj8XEHeopn.png

建立相依图

有了相依资讯,我们可以来建立相依图了。

// ./demos/create-graph/boundler.js
const fs = require("fs");
const path = require("path");
const { parse } = require("@babel/parser");
const traverse = require('@babel/traverse').default

let ID = 0

function createAsset(fileName) {
  ...
}

function createGraph(entry) {
  const graph = [createAsset(entry)];

  for (const asset of graph) {
    const dirname = path.dirname(asset.fileName);

    asset.dependencies.forEach((dependencyRelativePath) => {
      const dependencyAbsolutePath = path.join(dirname, dependencyRelativePath);
      const dependencyAsset = createAsset(dependencyAbsolutePath);
      graph.push(dependencyAsset);
    });
  }

  return graph;
}

console.log(createGraph("./src/index.js"));

巡览每个模组的每个相依,将各个模组输出来 Asset 资讯,结果如下:

https://ithelp.ithome.com.tw/upload/images/20201014/20107789zPwytL79IW.png

利用相依图,我们可以清楚地了解各个模组的相依情形,藉此来建立 bundle 。

建立模组对应表

有了相依的资讯後,接着需要将资讯对应至各个模组的 ID 上,因此要在巡览完当前模组的全部相依後,建立对应表。

// ./demos/mapping/boundler.js
const fs = require("fs");
const path = require("path");
const { parse } = require("@babel/parser");
const traverse = require("@babel/traverse").default;

function createAsset(filename, id) {
  ...
}

function createGraph(entry) {
  let id = 0;
  const graph = [createAsset(entry, id++)];

  for (const asset of graph) {
    asset.mapping = {}; // 初始对应物件

    const dirname = path.dirname(asset.filename);

    asset.dependencies.forEach((dependencyRelativePath) => {
      const dependencyAbsolutePath = path.join(dirname, dependencyRelativePath);

      const dependencyAsset = createAsset(dependencyAbsolutePath, id++);

      asset.mapping[dependencyRelativePath] = dependencyAsset.id; // 将相依模组与模组的 ID 做对应

      graph.push(dependencyAsset);
    });
  }

  return graph;
}

console.log(createGraph("./src/index.js"));

建置结果如下:

https://ithelp.ithome.com.tw/upload/images/20201014/201077899k6JKiB10C.png

现在我们拥有了所有的资讯了,接着就来建立 bundle 的执行代码吧。

建立 bundle 执行代码

执行代码的建立有下面几个要点:

  • 模组代码的转换
  • 利用 IIFE 执行代码
  • 模组包覆函式
  • require 方法的实作

模组代码的转换

范例中的代码为 ES Module 的类型,使用 import 的方式引入模组,在建立 bundle 时需要修改引入的方式让其符合 bundle 内的执行使用。

这里我们使用 babel 将代码做转换,以 ./src/index.js 为例:

import message from "./message.js";

console.log(message);

会被转为:

"use strict";

var _message = _interopRequireDefault(require("./message.js"));

function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}

console.log(_message.default);

babel 会改为使用 require 引入模组,先记住这点,这让我们有机会客制属於我们自己打包器的引入方式。

利用 IIFE 执行代码

我们使用与 webpack 相仿的方式,藉由 IIFE 引入模组,并在执行代码中实作 require ,最後执行入口模组。

(function(modules) {
  function require(id) {
    ...
  }
  require(0);
})({...})

模组包覆函式

模组作为参数时包裹为一个函式,此函式传入 module, exports, require 三个与 webpack 相仿的物件、方法。

(function(modules) {
  function require(id) {
    ...
  }
  require(0);
})({
  0: [
    function (module, exports, require) {},
    { "./message.js": 1 },
  ],
  1: [
    function (module, exports, require) {},
    { "./demoName.js": 2 },
  ],
  2: [
    function (module, exports, require) {},
    {},
  ],})

这里与 webpack 不同的地方在於我们不只有引入模组,还有引入模组的相依模组对应表,这是因为我们再转至代码时使用的是 babel 这样常规的转译器,它并不能向 webpack 自制的转译器那般可以直接将引入的路径替换成 bundle 时的模组 index,因此我们在这样要多带个对应的资讯,以利後续引入关联模组。

require 方法的实作

(function(modules) {
  function require(id) {
    const [fn, mapping] = modules[id];
    function localRequire(name) {
      return require(mapping[name]);
    }
    const module = { exports : {} };
    fn(module, module.exports, localRequire);
    return module.exports;
  }
  require(0);
})({...})
  1. require 是带入 id 资讯
  2. localRequire 用来在模组中引入相依模组,须将相依模组的路径转为 bundle 中的 index
  3. 接着执行模组,带入 module, exports, require
  4. 传回 module.exports,使外部模组使用汇出资源

详细的 bundle 产生方式比较繁杂,想要了解的可以参考 ./demos/bundle 范例。

写入档案

最後需要将完成的 bundle 写入输出档案中。

// ./demos/write-file/boundler.js
function output(code, outputPath) {
  const dirname = path.dirname(outputPath);
  fs.mkdirSync(dirname, { recursive: true });
  const prettierCode = prettier.format(code, { parser: "babel" });
  fs.writeFileSync(outputPath, prettierCode);
}
  • 使用 fs.mkdirSync 建立资料夹
  • 使用 prettier 排版代码
  • 使用 fs.writeFileSync 写入档案

产生的代码如下所示:

// ./demos/write-file/dist/main.js
(function (modules) {
  function require(id) {
    const [fn, mapping] = modules[id];
    function localRequire(name) {
      return require(mapping[name]);
    }
    const module = { exports: {} };
    fn(module, module.exports, localRequire);
    return module.exports;
  }
  require(0);
})({
  0: [
    function (module, exports, require) {
      "use strict";

      var _message = _interopRequireDefault(require("./message.js"));

      function _interopRequireDefault(obj) {
        return obj && obj.__esModule ? obj : { default: obj };
      }

      console.log(_message["default"]);
    },
    { "./message.js": 1 },
  ],
  1: [
    function (module, exports, require) {
      "use strict";

      Object.defineProperty(exports, "__esModule", {
        value: true,
      });
      exports["default"] = void 0;

      var _demoName = require("./demoName.js");

      var _default = "hello ".concat(_demoName.demoName);

      exports["default"] = _default;
    },
    { "./demoName.js": 2 },
  ],
  2: [
    function (module, exports, require) {
      "use strict";

      Object.defineProperty(exports, "__esModule", {
        value: true,
      });
      exports.demoName = void 0;
      var demoName = "simple";
      exports.demoName = demoName;
    },
    {},
  ],
});

可以直接将其贴在 dev tool 中的 console 就可以看到结果了。

总结

本文分为两段,第一段讲解如何理解 webpack 所产生的 bundle ,第二阶段实际带大家一步一步写出简易的打包器。

理解了整个打包过程,之後对於 webpack 的掌握上会更加了得心应手。

参考资料


<<:  拯救资工系学生的基本素养—怎样写图形介面应用程序

>>:  javasScript 进阶笔记二 (object.prototype.call)

输家的特质

股市只有两种人:输家跟赢家。 其中长期赢家只占极少部分,若我们不想成为绝大部分的输家,以下几种行为请...

【Day 09】Text Message 应用

前一天我们讲到综合版可以接收所有种类的讯息。 @csrf_exempt def callback(r...

musl libc 简介与其 porting(五) Knocking on Heaven's Door

C runtime/lib 通常都会有一个测试自己实做正确性的testsuite,像在glibc内部...

Day 16 中场休息,来做点酷东西

好的,Max 的课程进行到这边,是时候来休息一下,整合前面所学的东西了。要来做的是一个可以让使用者新...

有关多台网页伺器, 但对外IP才几个

各位大大 因企业运维来了一位大哥建议公司把各种网站服务放在VM上,VM可以切很多台服务器出来,这样子...