从零实现一个 JS 模块打包器

1,371 阅读5分钟

2019 年的前端技术栈,无论你是用 Vue 还是用 React ,应该都离不开一样工具 -- webpack。webpack 极大的简化了前端开发的构建过程,只需提供一个入口文件,webpack 就能自动帮我们分析出相关依赖,构建出 bundle 包。

webpack 很强大,但是大家知道 webpack 到底是怎么样打包的吗?本文将从一个很简单的例子,一步步带领大家探寻 webpack 打包的基本原理。

本文首发于 www.lvdawei.com/post/build-…

一、准备阶段

本文的代码详见webpack-mini step1。 在本文中,我们不考虑任何优化操作,尽可能的保持代码最简单。

假设我们的项目目录及文件列表如下,我们将使用命令 node webpack-mini/index.jssrc 下的代码打包成一个 bundle.js

├── dist             // 打包出来 bundle 文件所在目录
│   ├── bundle.js
├── src              // 业务代码目录
│   ├── bar.js
│   ├── foo.js
│   └── index.js     // 入口文件
└── webpack-mini     // mini 打包器代码目录
    └── index.js
// src/index.js
import foo from './foo.js';
import { say } from './bar.js';

foo();
say();
// src/foo.js
export default function() {
    console.log('foo: Hello I am foo!');
}
// src/bar.js
export const say = function() {
    console.log('bar.say: Hello I am bar!');
}

二、分析结构

先来看入口文件, src/index.js 通过 ES6 的 import 语法引入了 foo 的 default 方法和 bar 的 say 方法。

如何解析 index.js 、foo.js、bar.js 之间的依赖关系呢?字符串查找?不行,这样要考虑的边界情况太多。正则?也不行,同样很复杂。那么正统的处理方式是什么?语法解析。

在这个 DEMO 中,我们使用 babel 来解析 JS 文件,生成抽象语法树。先看下 src/index.js 中的 import foo from './foo'; 通过在线语法解析网站 axtexplorer.net 生成的 json 结构(省略了部分无关节点信息)。

{
  "type": "File",
  "start": 0,
  "end": 24,
  "loc": {},
  "program": {
    "type": "Program",
    "start": 0,
    "end": 24,
    "loc": {},
    "sourceType": "module",
    "body": [
      {
        "type": "ImportDeclaration",             // 划重点
        "start": 0,
        "end": 24,
        "loc": {},
        "specifiers": [
          {
            "type": "ImportDefaultSpecifier",    // 划重点
            "start": 7,
            "end": 10,
            "loc": {},
            "local": {
              "type": "Identifier",
              "start": 7,
              "end": 10,
              "loc": {},
              "name": "foo"
            }
          }
        ],
        "importKind": "value",
        "source": {
          "type": "Literal",
          "start": 16,
          "end": 23,
          "loc": {},
          "value": "./foo",                      // 划重点
          "rawValue": "./foo",
          "raw": "'./foo'"
        }
      }
    ]
  },
  "comments": [],
  "tokens": []
}

我们的解析工具识别出了这是一个 import 声明(ImportDeclaration),并且是一个默认的声明(ImportDefaultSpecifier),声明标识符(Identifier)为 foo,引用的资源路径为 ./foo。解析工具帮我们把字符串代码转换为了结构化的对象,有了结构化的对象,我们就能进行下一步了。

三、生成依赖图

依赖解析

想要打包 JS 模块,先要拿到 JS 的依赖,上图就是生成依赖图的流程图。下面我们看下具体代码实现:

const path = require('path');
const { transformFileSync } = require('@babel/core');

/**
 * 读取资源文件,修改 import 语句,ES6 import/export 语法转换成 require/exports 的形式,并生成依赖图
 * @param {string} filePath - 资源文件路径
 */
function createGraph(filePath) {
    if (!path.isAbsolute(filePath)) filePath = path.resolve(__dirname, filePath);
    const dirPath = path.dirname(filePath);

    const dependencies = [];

    const visitor = {
        // 我们要修改的节点是 import 声明节点。
        ImportDeclaration({ node }) {
            // 递归遍历 import 引用的资源文件,将相对路径转换为绝对路径,作为对应模块的 key
            node.source.value = path.resolve(dirPath, node.source.value);
            dependencies.push(createGraph(node.source.value))
        }
    };

    const { code } = transformFileSync(filePath, {
        presets: ['@babel/env'],
        plugins: [
            {
                // babel 提供的访问者模式,详细解释可参考下文
                // https://daweilv.com/2018/07/21/教练我想写一个-helloworld-Babel-插件/
                visitor
            }
        ]
    });

    return {
        filePath,
        code,
        dependencies
    }
}

这里需要说明的一点是,由于 ES6 import 的语法支持程度还很低,并且需要特殊的加载方式( <script type="module"> ),另外,我们后面还要兼容 commonjs 的 module.exports/exports 语法,所以我们需要用 @babel/env 转换一下代码。分别看下 src/index.jssrc/foo.js 转换后的样子,下一步我们将使用转换后的代码,在 exports 上做文章。

// src/index.js
"use strict";

var _foo = _interopRequireDefault(require("/Users/david/project/webpack-mini/src/foo.js"));

var _bar = require("/Users/david/project/webpack-mini/src/bar.js");

// 这个方法是为了统一 commonjs 和 es module 的 default 语法
// Modules in Common JS :
// module.exports = function () {};
//
// Modules in ES6 :
// export default function () {}
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }

(0, _foo["default"])();

// 这种语法与 Object(_bar.say)() 实现效果相同,使得 _bar.say 作为函数调用
// 而不是作为 _bar 的 say 方法,这与 es6 module 的 export 行为一致
(0, _bar.say)();
// src/foo.js
"use strict";

Object.defineProperty(exports, "__esModule", {
    value: true
});

// export default function () {}
// default function 被挂载到了 exports 上
exports["default"] = _default;

function _default() {
    console.log('foo: Hello I am foo!');
}

四、生成 bundle 文件

拿到依赖图后,我们就要开始组装 bundle 包了。

先遍历一遍依赖图,将依赖图转为数组,方便下一步生成入口文件的时候使用。

/**
 * 递归遍历,将依赖图展开成平级的数组
 * @param {object} graph - 依赖图
 * @param {array} modules - 展开后的数组
 */
function flattenGraph(graph, modules) {
    if (!modules) modules = [];
    // 这里将文件的绝对路径作为 module 的id
    modules.push({ id: graph.filePath, code: graph.code })
    if (graph.dependencies.length) {
        graph.dependencies.forEach(o => {
            flattenGraph(o, modules)
        })
    }
    return modules
}

拿到模块数组后,开始拼接代码块。

/**
 * 生成入口文件,拼接模块数组
 * @param {array} modules 模块数组
 */
function createBundle(modules) {
    return `(function (modules) {
    function require(moduleId) {
        let exports = {};
        modules[moduleId](exports, require);
        return exports;
    }
    require("${modules[0].id}")
})({${modules.map(module =>
        (`"${module.id}":${generateModuleTemplate(module.code)}`)
    )}})`
}

上述代码我们可以分两块看下,最外层实际上就是一个立即执行函数表达式,将 modulesTemplateObject 对象传给了 modules。

((function (modules) {
    // 这里的 modules 就是最下面传过来的 kv 对象
    // ...
})(modulesTemplateObject)}

再看下 generateModuleTemplate 做了什么,

function generateModuleTemplate(code) {
    // 把每个 JS 文件中的代码块包在一个 function 里
    // 将外部的 exports 和 require 传入 function 内
    return `function (exports, require) {
    ${code}
}`
}

接着重头戏来了,我们本篇文章的核心代码,

function require(moduleId) {
    let exports = {};
    // 这里用 Object 包裹了 module,使得 module 作为值调用
    modules[moduleId](exports, require);
    return exports;
}
// 执行第一个 module,也就是 src/index.js
require("${modules[0].id}")

接着我们将整个过程串起来,

function webpackMini (fileEntry) {
  const graph = createGraph(fileEntry)
  // console.log(graph);
  const modules = flattenGraph(graph)
  // console.log(modules);
  const bundle = createBundle(modules)
  // console.log(bundle);
  // 生成文件方法就不在此赘述了
  generateFile(bundle)
}

webpackMini('../src/index.js')

执行程序,看看在 dist 目录下是不是得到了 bundle.js。运行 bundle.js ,得到输出:

foo: Hello I am foo!
bar.say: Hello I am bar!

至此,一个最简单的 JS 模块打包器就完成了!

总结

回顾一下,我们先是通过 babel 将 JS 字符串转换成了可供分析的语法结构树,然后遍历得到模块间的依赖关系,最后将依赖关系通过我们自己实现的 require 方法加载进来,这样就实现了一个最简单的 JS 模块打包器。

bundle

后续我们将继续完善代码,诸如打包 commonjs 代码、组件加载缓存、处理组件循环调用等功能。本文的代码保存在 webpack-mini,欢迎 star 关注。