从 script 标签说到 webpack:javascript 模块化历史

1,449 阅读22分钟

前言

最近有个需求要用到 dynamic import ,发现 require 也可以实现同样的效果,但是 webpack 不推荐用多种 module method。后来就尝试去了解关于 static import / dynamic import / require 之间的区别。再后来就顺便复习了一下 requirejs 和 seajs,还有更早之前的通过闭包实现的模块化以及相应的设计模式。期间看了多篇模块化历史相关的文章,通过这些阅读把整个模块化的思路整理了一下,本文就尝试把整个 javascript 模块化的发展历史介绍一下。主要包含模块化历史,历史中各种解决方案的利弊,同时通过一些精简的例子来简单介绍一下这些方案的基本原理。

js模块化历史简述

 先简单说说 javascript 模块化发展的历史过程,了解一下各个解决方案在时间线上的前后关系。这有利于理解为什么会出现不同的解决方案,以及不同的解决方案在这个历史进程中尝试解决的是什么问题。

因为本人没有经历完整的 js 历史,所以有些关于历史的信息并不是非常准确,只是一个大概的说明,重点是了解模块化解决方案的时间线关系,明白各个方案之前的不同。如果有看到错误也可以在留言中回复,找到出处我会修改文章。

javascript 从最开始创建的时候就是为了给网页上面添加一些简单的表单校验和交互,所以最开始的 javascript 没有从语言级别的层面提供模块化的解决方案。mozilla 的成员都自嘲说:... back in 2007, the joke was that the length of a typical JavaScript program was one line,那个时候 js 已经诞生了 12 年了,可见那个时候的 js 也不需要考虑模块化的问题。再后来就有了 jquery,这个时候 js 的代码规模和项目复杂度已经开始增加了,在这个过程中已经开始需要考虑模块化的问题了,出现了很多模块化相关的设计模式,并且这些模块化的设计模式也能满足当时的需求。后来 nodejs 蓬勃发展,作为在服务端使用的 js,模块化是必要条件,所以 commonjs 应运而生(注意,有时候 commonjs 也被称为 cjs)。commonjs 是关于 js 的一系列规范,模块化相关的规范只是其中之一,但是这对于 js 模块化有重要的影响,npm 就是从 nodejs 发展而来,而现在 npm 上的包就是模块化发展的产物。但是 commonjs 只是服务端模块化的规范,客户端模块化的解决方案还一直没有。因此 commonjs 的一些成员从 commonjs 分化出来,自己开发出了一套客户端的模块化解决方案,也就是 requirejs,和 requirejs 相应的就是 amd 规范。同期,国内的玉伯大佬对 requirejs 的使用方面提出了一些自己的看法,但是未被采纳,然后自己开发了 seajs,与之相对应的就是 cmd 规范。但是截止到 es6 之前都没有语言级别的模块化解决方案。再后来就是 js 的重要里程碑:es6 的发布。es6 从语言级别对 js 做了很多变革和更新,模块化也是其中之一。虽然提出了语言层面的模块化解决规范,但是浏览器和 nodejs 在当时还都不支持这样的语法,所以其实在普及方面还需要有一个过程,或者说是需要一个 trick 的解决方案。react, angular 和 vue 三大框架盛行并且产生了与之相应的诸如 webpack 类的打包工具,webpack 从 v2 开始默认支持 es6 的模块化解决方案,其实 webpack 真正并没有支持语言级别的模块化规范,也是自己再包装了一层。我们可以在下文中逐一看到这些解决方案的真面目。

以上就是 js 模块化发展的大概历程。

本文会通过一些精简的例子说明以上提到的模块化方案:包括模块化相关的设计模式,requirejs 和 seajs,以及最后用一个简单的 minipack 来了解 webpack 真正实现的模块化方案,和 es6 模块化方案还是略有不同。

在详细了解各个解决方案真面目之前先略述一下模块化的优点。

模块化的优点

模块化算是工程化的一个方面,对于一个复杂项目而言,模块化有很多优势,实际项目中大家肯定也都深有体会。

本文不打算详细说明,这里就引用一下tylermcginnis.com/javascript-… 提到的几个优势,具体可以参考该文章,还有 wikipedia 的 modular programming

Reusability

Composability

Leverage

Isolation

Organization

接下来进入核心内容。

js模块化的历史详述

script 标签

最开始 js 通过文件的方式来做模块划分,在 html 中引入多个 js 文件,每个 js 文件算是一个模块,显然在早期是满足日常开发需求的。但这只是一种物理隔离,并不能算是真正的模块化,因为每个 js 文件中的变量都会对全局变量造成污染。

IIFE

然后就是各种设计模式类的解决方案。js 是函数级作用域,因此开发者尝试函数来实现模块之间的隔离,避免污染全局,或者命名冲突。IIFE 是这些解决方案的共性。具体有哪些方法,可以参考下面两篇文章

addyosmani.com/resources/e…
medium.com/free-code-c…

这种方式在前端早期开发中非常盛行,尤其是 jquery 时代,大部分的所谓的库、插件或者模块都是通过这种方式来实现的。不论是哪种模式,都是通过 IIFE 的方式来实现私有变量,避免全局污染,通过传参的方式引用其他模块,而通过明确的返回内容暴露自己的公共变量和公共方法。现在的前端模块化已经有自己的语言规范了,但是这种通过 IIFE 的方式在实际项目中依然还是非常有用的。

nodejs 和 commonjs

nodejs 是运行在服务端的 javascript,没有 html 也无法通过 script 标签的形式来加载 js 文件。同时运行在服务端就对模块化的要求也很强烈,所以 nodejs 形成了自己的模块化解决方案:commonjs。

commonjs 是一系列规范,并不是专门针对模块化,模块化的方案只是规范的其中一部分内容。wiki 如是说:

... CommonJS, a group with a goal of building up the JavaScript ecosystem for web servers, desktop and command line apps and in the browser.

关于 commonjs 模块化的规范可以看 github 还有 官网

上面的描述中虽然提到了 browser,但是 commonjs 的模块化解决方案主要用在服务器端,是同步加载的,并没有实现异步加载,也就并不支持浏览器宿主的 js,也就是客户端 js。使用过 nodejs 的开发者应该也都很熟悉了这一套规范了,不了解的可以移步 nodejs 官网

在 nodejs 中有 module, exports, require 这些关键词,或者说全局变量。其中的 exports 和 module.exports 两种用法,和后面 es6 module 中的 export 关键词很容易搞混。现在回忆起来,突然明白了为什么当时我对于模块化引用中的 export 和 module.exports 搞混,以至于也不明白 import 和 require 之间的区别的原因,因为他们根本就不是同一个规范定义的。commonjs 和 es6 modules 之间的区别我会在后面有个部分详述,这里不深入说明。

requirejs 和 amd

服务端模块化的方案已经有了,但是这个方案并不适用于客户端,也就是浏览器环境,因为 commonjs 规范是同步的,并不是异步的。客户端模块化发展也有自己的诉求,这个时候 requirejs 出现了。从历史的角度来看,requirejs 是从 commonjs 分化而来的,因为 requirejs 的创始人原来就是 commonjs 规范的参与者。具体情况大家可以自行查看一些网络资源。

所以对于 commonjs,requirejs 的说法是:

CommonJS defines a module format. Unfortunately, it was defined without giving browsers equal footing to other JavaScript environments...

RequireJS tries to keep with the spirit of CommonJS, with using string names to refer to dependencies, and to avoid modules defining global objects, but still allow coding a module format that works well natively in the browser...

为什么用 requirejs,官网也有说明

...However, we need something that works well in the browser. The CommonJS require() is a synchronous call, it is expected to return the module immediately. This does not work well in the browser.

说来说去都是同步异步的问题,官网举了个例子:

var Employee = require("types/Employee");

function Manager () {
    this.reports = [];
}

//Error if require call is async
Manager.prototype = new Employee();
As the comment indicates above, if require() is async, this code will not work. However, loading scripts synchronously in the browser kills performance. So, what to do?

这篇文章中后面的内容分析了 requirejs 最终如何做出选择的。

Talk is cheap, show me the code,接下来我们就用一个简单的例子来看下 requirejs 大概是如何使用的,以及如何实现 amd 的。这个例子参考了es6系列之模块加载方案

项目目录:

requirejs
├── index.html
└── vender
    ├── add.js
    ├── main.js
    ├── multiply.js
    ├── require.js
    └── square.js

index.html 的内容如下:

<!DOCTYPE html>
<html>
    <head>
        <title>require.js</title>
    </head>
    <body>
        <h1>Content</h1>
        <script data-main="vender/main" src="vender/require.js"></script>
    </body>
</html>

main.js 的内容:

// main.js
require(['./add', './square'], function(addModule, squareModule) {
    console.log(addModule.add(1, 1))
    console.log(squareModule.square(3))
});

add.js 的内容:

// add.js
define(function() {
    console.log('加载了 add 模块');
    var add = function(x, y) {&emsp;
        return x + y;
    };

    return {&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;
        add: add
    };
});

square.js 的内容:

// square.js
define(['./multiply'], function(multiplyModule) {
    console.log('加载了 square 模块')
    return {&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;
        square: function(num) {
            return multiplyModule.multiply(num, num)
        }
    };
});

square.js 依赖了 multiply.js:

// multiply.js
define(function() {
    console.log('加载了 multiply 模块')
    var multiply = function(x, y) {&emsp;
        return x * y;
    };

    return {&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;
        multiply: multiply
    };
});

我们简单分析一下。index.html 中只有一个 script 标签,就是 requirejs,然后通过 data-main="vender/main" 来指定入口文件。双击 index.html 在浏览器中打开,查看 Elements 面板,会发现 <head> 标签中多了 4 个 <scripit> 标签。


也就是 requirejs 官网中所说的,通过添加 script 标签的方式实现模块加载,等所有依赖模块加载完成后再执行代码。

最终在 console 面板中看到如下内容:

加载了 add 模块
加载了 multiply 模块
加载了 square 模块
2
9

可见 requirejs 是通过 script 添加 script 标签来加载依赖 js 的,并且注意一点是所有的依赖都加载完毕之后才开始执行代码。

require 和 define 函数都是 require.js 定义的全局变量,直接双击打开 index.html 之后,在 console 面板输入 window.require / window.define 可以看到 require 和 define 都被打印在 console 面板中了,右键选择 show function defintion 就可以跳转到 requirejs 中这两个函数定义的地方。


感兴趣可以看下大概的实现过程。因为已经成为了历史,所以也不打算花太多的时间和篇幅去做深入的研究。网上也有很多实现 requirejs 的方案,我在网上随便找了一篇,好像还不错:http://codemacro.com/2017/02/05/mini-requirejs/

具体代码可以查看这个 github 仓库clone 下来之后,直接双击打开 index.html 就可以看到效果。

recap: requirejs 是从 commonjs 中分化出来的,原因就是为了解决客户端异步加载模块的问题,然后形成了 amd 规范(先有 requirejs 后有 amd)。

seajs 和 cmd

和 requirejs 相对同期的另一种客户端解决方案是 seajs,对应的规范是 cmd 规范。

requirejs 虽然很好的解决了客户端模块化的问题,但是也并不是完美的,js 社区中就有人对这个规范有异议。国内的玉伯大佬在使用的过程中给 requirejs 提过很多建议,但是没有被采纳,然后就自己写了 seajs。他自己在 seajs 的 issue 里写了如下内容:

再后来,在实际使用 RequireJS 的过程中,遇到了很多坑。那时 RequireJS 虽然很火,但真不够完善。...
我没 FlyScript 的作者那么伟大,在不断给 RequireJS 提建议,但不断不被采纳后,开始萌生了自己写一个 loader 的念头。
这就是 Sea.js。
Sea.js 借鉴了 RequireJS 的不少东西,比如将 FlyScript 中的 module.declare 改名为 define 等。Sea.js 更多地来自 Modules/2.0 的观点,但尽可能去掉了学院派的东西,加入了不少实战派的理念

requirejs 具体有哪些设计不合理的地方或者说缺点,可以参考这篇文章

但是无论是 requirejs 还是 seajs,都是尝试在浏览器环境中解决异步加载模块的问题。虽然说有不同,但是都算是浏览器环境的模块化解决方案,是在这个前提条件下的不同。

下面我们也简单用一个例子展示一下 seajs 的使用方式和原理。

项目目录和 requirejs 相同:

requirejs
├── index.html
└── vender
    ├── add.js
    ├── main.js
    ├── multiply.js
    ├── require.js
    └── square.js

index.html 中的内容如下:

<!DOCTYPE html>
<html>
<head>
    <title>sea.js</title>
</head>
<body>
    <h1>Content</h1>
    <script src="vender/sea.js"></script>
    <script>
    // 在页面中加载主模块
    seajs.use("./vender/main");
    </script>
</body>

</html>

main.js 中的内容如下:

// main.js
define(function(require, exports, module) {
    var addModule = require('./add');
    console.log(addModule.add(1, 1))

    var squareModule = require('./square');
    console.log(squareModule.square(3))
});

add.js 中的内容如下:

// add.js
define(function(require, exports, module) {
    console.log('加载了 add 模块')
    var add = function(x, y) {&emsp;
        return x + y;
    };
    module.exports = {&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;
        add: add
    };
});

square.js 的内容如下:

// square.js
define(function(require, exports, module) {
    console.log('加载了 square 模块')
    var multiplyModule = require('./multiply');
    module.exports = {&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;
        square: function(num) {
            return multiplyModule.multiply(num, num)
        }
    };

});

multiply.js 的内容如下:

// multiply.js
define(function(require, exports, module) {
    console.log('加载了 multiply 模块')
    var multiply = function(x, y) {&emsp;
        return x * y;
    };
    module.exports = {&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;
        multiply: multiply
    };
});

双击打开 index.html 在控制台可以看到如下内容:

加载了 add 模块
2
加载了 square 模块
加载了 multiply 模块
9

从 seajs 的打印结果和 requirejs 的打印结果对比中可以看到,seajs 中的 js 加载一部分就执行了,而不是等全部加载。完整的代码可以克隆这个 github 仓库。双击打开 index.html 在浏览器中打开,查看元素,发现 head 标签中并没有增加 <script> 标签,看起来似乎通过其他方式获取的 js 文件,但是其实不是,在 src/util-request.js 中可以看到如下内容:

 function request(url, callback, charset, crossorigin) {
   var node = doc.createElement("script")
   ...
   baseElement ?
       head.insertBefore(node, baseElement) :
       head.appendChild(node)
   ...
 }

 function onload(error) {
   ...
   // Remove the script to reduce memory leak
   if (!data.debug) {
     head.removeChild(node)
   }
   ...
 }

实际上 seajs 也是通过添加 script 标签的方式来获取 js,只不过获取之后就把 script 标签删掉了。

关于 seajs 和 requirejs 的不同之处,可以查看这篇文章

es6 modules

服务端和客户端模块化方案分别出现之后,语言级别的模块化解决方案呼之欲出。

终于在 2015 年,ECMAScript2015(i.e. ES6) 出现了。里面提出语言级别的模块化解决方案,参见 mdnes6既然是语言级别的规范,就希望前后端的语法是统一的。但是问题就在于 es6 modules 是新的规范,那么在规范出现之前,不论是浏览器还是 nodejs 都不支持这种语法。下图是 es6 modules 在各个宿主环境下的支持情况。


可以看到 IE 现在都不支持,chrome 是在版本 61 之后完全支持的,时间大约是 2017 - 2018 年左右。而 nodejs 则是在13.2.0 开始默认支持,并且是有条件的。在 12.0.0 的时候还需要通过 --experimental-modules 这样的 runtime flag 来支持。这段历史可以参考这篇文章

如何使用呢?其实和我们现在的使用方式还是有些不同的,比如 script 标签要写成 <script type="module"> 的形式,js 文件要改成 .mjs 的后缀,所以在实际中,真正应用 es6 modules 的项目很少。javascript-info 中说:

In real-life, browser modules are rarely used in their “raw” form. Usually, we bundle them together with a special tool such as Webpack and deploy to the production server.

下面我们也用一个 mdn 中的例子来简单了解下 es6 modules 的使用。

项目结构:

index.html
main.js
modules/
    canvas.js
    square.js

index.html 内容如下:

 <!DOCTYPE html>
 <html lang="en-US">
   <head>
     <meta charset="utf-8">
     <title>Basic JavaScript module example</title>
     <style>
       canvas {
         border: 1px solid black;
       }
     </style>
     <script type="module" src="main.js"></script>
   </head>
   <body>

   </body>
 </html>

可以看到 script 只有一个入口文件的 script 标签,并且和平时我们的 script 标签不同之处在于使用 type='module' 这样的属性。

You can only use import and export statements inside modules; not regular scripts.

其他还有不同之处,都可以查看上面的 mdn 内容。

main.js 中的内容如下:

 import { create, createReportList } from './modules/canvas.js';
 import { name, draw, reportArea, reportPerimeter } from './modules/square.js';
 import randomSquare from './modules/square.js';

 let myCanvas = create('myCanvas', document.body, 480, 320);
 let reportList = createReportList(myCanvas.id);

 let square1 = draw(myCanvas.ctx, 50, 50, 100, 'blue');
 reportArea(square1.length, reportList);
 reportPerimeter(square1.length, reportList);

 // Use the default
 let square2 = randomSquare(myCanvas.ctx);

可以看到 main.js 中直接通过 import 的方式引入了其他模块,在项目根目录下,通过 `python -m SimpleHTTPServer 8000` 命令启动一个服务,然后打开 localhost:8000 就能看到效果。


截图中删掉了一些没有意义的内容,可以看到 html 中并没有添加 script 标签,但是却实现了载入 cavas.js 和 square.js 两个模块的效果。这一点在 network 面板可以看到。


可以看到 canvas.js 和 sqare.js 的 type 都是 script,而 initiator 都是 main.js。

关于详细和具体的使用可以参考 developer.mozilla.org/en-US/docs/… 和 javascript.info/modules-int…

上面说了,es6 modules 是新规范,很多宿主都不支持,那么既然不支持,就需要想办法,而此时 babel 和 webpack 出现了。

我们先说说 babel。

babel

虽然提出了语言级别的解决方案,但是浏览器和nodejs 都不支持。babeljs  号称:Use next generation JavaScript, today。那我们看看 babel 如何来编译 import 和 export 的。

打开 babeljs.io/repl,输入一行简单的代码:

import { name } from 'name.js'

可以看到 babel 将代码编译为

"use strict";
var _name = require("name.js");

如果输入:

var firstName = 'Michael';
var lastName = 'Jackson';
var year = 1958;
export {firstName, lastName, year};

则获得:

"use strict";
Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.year = exports.lastName = exports.firstName = void 0;
var firstName = 'Michael';
exports.firstName = firstName;
var lastName = 'Jackson';
exports.lastName = lastName;
var year = 1958;
exports.year = year;

看上去似乎只是转换成了 commonjs 的写法,如果我没有理解错的话,就是说 babel 在模块化方面只是转化成了 commonjs 的规范,而浏览器是不支持这种规范的。那浏览器怎么支持 es6 规范呢?此时,一只 webpack 路过。

当然这里需要澄清两点:

1. 除了 webpack 还有其他打包工具,webpack 并不是唯一解决方案。我们之所以用 webpack 举例,是因为 webpack 是目前使用最广泛和功能最强大的打包工具。

2. webpack 不光支持客户端,也支持服务端。

其中第 2 点在 webpack 的文档中也有明确说明:

Because JavaScript can be written for both server and browser, webpack offers multiple deployment targets that you can set in your webpack configuration.


关于 babel 这里多说一点:关于 __esModule 这个属性。你如果在 https://babeljs.io/repl 中输入:

import React from 'react'

会得到:

"use strict";

var _react = _interopRequireDefault(require("react"));

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

可以看到除了转成 require 这种 commonjs 的形式之外,还额外通过  _interopRequireDefault 这个函数,包装成了一个 {default: obj} 的形式。但是如果 obj 本身就含有 __esModule 属性的话,就直接返回 obj 。可见 __esModule 这个属性是为了兼容。看看这个 SO 的问答,可以很好的理解为什么要这么处理。而在 google 中搜出来制定的文章是 Making transpiled ES modules more spec-compliant,从标题就可以看出来是为了符合规范而产生的。文章的第一段说的就是:

A proposed “spec mode” for Babel makes transpiled ES modules more spec-compliant. That’s a crucial step in preparing for native ES modules.

为了更符合规范,而且是为原生 es modules 做准备的关键步骤。

webpack

webpack modules 中,有如下内容:

The import and export statements have been standardized in ES2015. They are supported in most of the browsers at this moment, however there are some browsers that don't recognize the new syntax. But don't worry, webpack does support them out of the box.

可见 webpack 解决了 es6 modules 在很多环境中不兼容的问题,因为毕竟 es6 modules 方案出来之前就已经有很多浏览器了。

why webpack 中也说:

The good news for web projects is that modules are becoming an official feature in the ECMAScript standard. However, browser support is incomplete and bundling is still faster and currently recommended over these early module implementations.

所以说 webpack 并不是真正的从语言级别去支持模块化的,它也是为了支持那些不支持 es6 modules 的浏览器使用了一种自己的方案。除了 es6 模块化方案之外,webpack 还支持很多其他的模块化方案,比如 commonjs,amd 等。在这里你可以看到 webpack 支持的模块化方案,以及它和其他打包工具在模块化方案支持方面的对比。

那 webpack 到底是如何实现打包的呢?打包之后的代码又是什么样子呢?我们接着往下看。

webpack modules 中说:

Behind the scenes, webpack actually "transpiles" the code so that older browsers can also run it. If you inspect dist/main.js, you might be able to see how webpack does this, it's quite ingenious! Besides import and export, webpack supports various other module syntaxes as well, see Module API for more information.
Note that webpack will not alter any code other than import and export statements. If you are using other ES2015 features, make sure to use a transpiler such as Babel or Bublé via webpack's loader system.

上面这段话的意思是说 webpack 只对 import 和 export 做了修改(因为 webpack 只是一个打包工具,所以改造的只是 import 和 export),并且它提示你用它的例子打包之后去看 dist/main.js 的内容,然后你会看到 webpack 是如何做的。如果只是分析打包后的代码,其实还不足以了解打包工具的原理。minipack 作为一个简版的 webpack,很好的解决了我们的疑惑。

minipack

minipack 是一个简化版的 webpack,我们可以通过分析 minipack 的源码和打包后的代码了解 webpack 的打包过程和最终的打包结果。从 github 上 clone minipack 的源码,直接打开看,包含注释的代码量总共就 259 行,非常精炼。我们从上往下看。


bundle 的原理就是从入口文件开始分析依赖,然后再分析依赖的依赖,最终形成一个依赖关系图(dependency graph)。

creatAsset 函数:

 function createAsset(filename) {
   // 获取文件内容
   const content = fs.readFileSync(filename, 'utf-8');

   // 用 js parser: babylon 生成 AST
   const ast = babylon.parse(content, {
     sourceType: 'module',
   });

   const dependencies = [];

   // traverse 是 babel 的一个函数,用于遍历 AST
   // 遍历 AST,将所有 import 的依赖都放入 dependencies 中
   traverse(ast, {
     ImportDeclaration: ({node}) => {
       dependencies.push(node.source.value);
     },
   });

   // 给当前模块一个 id 值
   const id = ID++;

   // transformFromAst 是 babel 的一个函数,作用是用 babel 将代码编译为浏览器支持的格式
   const {code} = transformFromAst(ast, null, {
     presets: ['env'],
   });

   // 返回当前模块的所有信息
   return {
     id,
     filename,
     dependencies,
     code,
   };
 }

注:上面提到了 AST,关于 AST 的内容不打算深入,这个主题可以单独写一篇文章。

createGraph 方法:

 function createGraph(entry) {
   // 从入口文件开始
   const mainAsset = createAsset(entry);

   // 创建分析队列
   const queue = [mainAsset];

   // 遍历队列
   for (const asset of queue) {
     asset.mapping = {};
     const dirname = path.dirname(asset.filename);

     // 遍历依赖放入 queue 中,queue 新增内容,新增后的内容被继续遍历再新增内容
     asset.dependencies.forEach(relativePath => {
       const absolutePath = path.join(dirname, relativePath);
       const child = createAsset(absolutePath);
       asset.mapping[relativePath] = child.id;
       queue.push(child);
     });
   }

   // At this point the queue is just an array with every module in the target
   // application: This is how we represent our graph.
   return queue;
 }

生成的 graph :

 [
   { id: 0,
     filename: './example/entry.js',
     dependencies: [ './message.js' ],
     code:
      '"use strict";\n\nvar _message = require("./message.js");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);',
     mapping: { './message.js': 1 }
   },
   { id: 1,
     filename: 'example/message.js',
     dependencies: [ './name.js' ],
     code:
      '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\n\nvar _name = require("./name.js");\n\nexports.default = "hello " + _name.name + "!";',
     mapping: { './name.js': 2 }
   },
   { id: 2,
     filename: 'example/name.js',
     dependencies: [],
     code:
      '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\nvar name = exports.name = \'world\';',
     mapping: {}
   }
 ]

bundle 方法:

 function bundle(graph) {
   let modules = '';

   graph.forEach(mod => {
     // Our modules, after we transpiled them, use the CommonJS module system:
     // They expect a `require`, a `module` and an `exports` objects to be
     // available. Those are not normally available in the browser so we'll
     // implement them and inject them into our function wrappers.
     modules += `${mod.id}: [
       function (require, module, exports) {
         ${mod.code}
       },
       ${JSON.stringify(mod.mapping)},
     ],`;
   });

   const result = `
     (function(modules) {
       function require(id) {
         const [fn, mapping] = modules[id];

         // 自定义的 require 方法
         function localRequire(name) {
           return require(mapping[name]);
         }

         const module = { exports : {} };

         fn(localRequire, module, module.exports);

         return module.exports;
       }

       require(0);
     })({${modules}})
   `;

   return result;
 }

最终生成的内容如下:

 (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 _message = require("./message.js");
       var _message2 = _interopRequireDefault(_message);
       function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
       console.log(_message2.default);
     },
     {"./message.js":1},
   ],
   1: [
     function (require, module, exports) {
       "use strict";
       Object.defineProperty(exports, "__esModule", {
         value: true
       });
       var _name = require("./name.js");
       exports.default = "hello " + _name.name + "!";
     },
     {"./name.js":2},
   ],
   2: [
     function (require, module, exports) {
       "use strict";
       Object.defineProperty(exports, "__esModule", {
         value: true
       });
       var name = exports.name = 'world';
     },
     {},
   ]
 });

具体代码直接参考 minipack 仓库,看完会发现豁然开朗。

关于 webpack 对其他模块化方案的支持,可以看下面几篇有赞前端团队的文章和掘金之前的一篇很早的文章:

webpack模块化原理-commonjs

webpack模块化原理-ES module

webpack模块化原理-Code Splitting

简单易懂的 webpack 打包后 JS 的运行过程

后记

写完发现真长啊!写到后面感觉自己已经口干舌燥,有种想吐的感觉了。

虽然说写完了,但是其实还有很多内容可以继续深入挖掘,也有很多内容还没有完善。留着以后完善吧,可以写的有: es6 modules 和 commonjs 之间的不同之处,还有 dynamic import 和 statis import,等等。

写文章不容易,希望各位大佬看完觉得有价值给个赞。