JS 模块化 [ 7K字总结 ! ]

325 阅读21分钟

前言

本篇将从理解到实现总结 JS 模块化中的一些问题,它包括了如下几个部分:

  1. 对模块化的理解

    • 理解模块化对开发前端 JS 应用的价值
  2. 几大流行模块化规范的区别

    • 理解 CommonJS、AMD、ES Module 这些规范的区别
  3. 模块系统的实现

    • 理解 Node 中模块系统的实现
    • 理解 Webpack 中模块系统的实现(CommonJS,ES Module,动态加载)

对模块化的理解

这部分可以拆分为两个问题:

  1. 什么是模块化开发?模块化开发有什么好处?
  2. 模块化对开发前端 JS 应用的价值?

模块化开发其实特别普遍,它是开发大型应用的必要手段,C,Java,Python 等语言都对模块化进行了支持。模块化就是将一个大型应用拆分为独立的多个模块,各个模块间只能通过特定的接口进行交互。模块化的好处是可以实现关注点分离,一个模块负责一个单独的功能点,这样方便重用和维护。

对于 JavaScript 而言,之前一直没有一个统一的模块化标准,直到 ES 2015 提出 ES Module 模块化规范,该规范已被 Node.js 和一些主流浏览器支持(现在的 浏览器支持率 已达到 93.4 👏)。也许有人会疑问,既然浏览器上都不支持,那前端怎么做模块化开发呢?

确实,在浏览器还没有支持模块化的时候,我们就已经在用模块化语法了,importexport 这些都是我们每天在用的。只不过这些代码在放到浏览器上执行前,都需要经过 代码转换,最终它们会被转换为大多数浏览器都兼容的语法。(这些会在后面细说)

如果你感兴趣,你还可以了解在更早的时候,在没有这些打包工具前,那些前端工程师是怎么来编写 JS 应用的。推荐看看 前端模块化开发的价值,一个前端老兵的实际经历。最早的时候,js 代码都是被一个个 script 标签引入的,像下面这样:

<script src="./time.js"></script> 
<script src="./util.js"></script>
<script src="./main.js"></script>

你可以把一个script 当作一个“模块”,但是,它们是在同一个作用域下,而并不是隔离的😺,这一点就足以让你的开发体验大打折扣了。你不得不担心,你定义的变量会不会和其他文件冲突呢?即使可以用命名空间等方法来约束,可这仍然需要人为来定义和管理。这还只是其中一点,另外,script 的引入顺序也是需要人为维护的,如果依赖的文件没有引入或未在当前文件前加载,同样也会产生错误。

所以,用这种方式来编写大型 JS 应用,特别是在多人协作时,还是会产生很多问题的,这也就体现了 JS 模块化开发的价值。

于是,后面慢慢出现了很多 JS 模块化规范,如 CommonJS,AMD,CMD,ES6 等,相关的实现也相继出现,以使开发者可以用模块化的方式编写 JS 应用。

接下来将会对其中流行的一些规范进行介绍,我们不需要熟悉这些规范的具体细则,但知道它们的一些区别和设计初衷还是有益处的。

模块化规范之 CommonJS & AMD & ES6

首先我们要清楚 模块化规范模块化实现 的区别,规范是定义,比如规定 A 对象应该有 B 属性,实现则是参照这些规范去支持对应的模块化语法,它可以是平台或工具库。

一种规范可以有多个实现,CommonJS、AMD、ES6 这些都是模块化规范,而 NodeJS ModuleRequireJSWebpack Module 等是对这些模块化规范的实现,在这些实现的基础上,我们才能使用对应的模块化语法。

在开始之前,也建议读读 前端模块化开发那点历史 这篇文章,和前面 前端模块化开发的价值 是同一个作者,该作者编写了有名的 seajs 库,seajs 可支持 JS 模块化开发,遵循的是 CMD 规范。

(1)CommonJS

CommonJS 的官网上我们可以了解到这个项目的初衷:“官方的 JavaScript 规范定义了一些适用于浏览器端应用的 API,但是缺乏针对于更多场景(如服务器端应用、命令行工具、桌面应用等)的标准库声明。CommonJS API 将通过定义常见场景下的 API 来填补这个空缺,最终提供一个像 Python、Ruby、Java 那样丰富的标准库。”

The official JavaScript specification defines APIs for some objects that are useful for building browser-based applications. However, the spec does not define a standard library that is useful for building a broader range of applications.

The CommonJS API will fill that gap by defining APIs that handle many common application needs, ultimately providing a standard library as rich as those of Python, Ruby and Java

当时 Javascript 仅仅只运行在浏览器上,而 CommonJS 的愿景是让开发者使用标准的 CommonJS API,在未来通过兼容 CommonJS 的解释器或平台,使 Javascript 可以被用来编写任何应用

所以准确来说,CommonJS 项目不仅仅是一个模块化规范,它包含了许多场景下的 Javascript API 规范定义,比如 I/O,文件系统(Filesystem),包管理(Package)等等,模块化规范只是它的一个部分。

CommonJS 模块规范

CommonJS Module 1.0 规范中,定义了要支持模块化所需实现的最小特性。

  1. 在一个模块中,应该有一个可直接使用的函数“require”

    (1) “require”函数接收一个模块标识符参数

    (2) “require”返回外部模块导出的 API

    (3)如果存在循环依赖(dependency cycle),“require”返回的对象应至少包含外部模块在调用引入本模块的代码之前的导出结果

    (4)如果被引入的模块无法返回,“require”必须抛出一个错误。

  2. 在一个模块中,应该有一个可直接使用的对象“exports”,在模块执行时将导出的 API 添加到这个对象中。

  3. 模块必须使用“exports”对象作为导出 API 的唯一方式。

循环依赖 是什么意思呢?我们可以通过下面的例子来帮助理解。“a.js”(模块A) 和 “b.js”(模块B),A 引入 B ,B 也引入了 A,这样就陷入了死循环。

// a.js
const b = require('./b');
console.log('Print b in module a =>', b);
exports.name = 'a';

// b.js
const a = require('./a');
console.log('Print a in module b =>', a);
exports.name = 'b';

也可以类比下面一个函数循环调用的例子:

function funcA() {
  const b = funcB();
  return 'a' + b;
}

function funcB() {
  const a = funcA();
  return 'b' + a;
}

Node(v15.9.0)中运行上面的代码会报错,“RangeError: Maximum call stack size exceeded”,由于循环调用,最终栈溢出了。

所以,模块系统必须要解决循环依赖的问题。规范中没有说明解决循环依赖的标准方法,但是声明了出现循环依赖时应该返回怎样的结果。即下面的代码中,运行 a.js,最终 b.js 中可以正确获取到导致循环依赖的 require 语句之前的导出结果。

// a.js
exports.val = 100; //此处应该被正确导出,因为它在发生循环依赖的 require 语句之前
const b = require('./b'); //发生循环依赖
console.log('Print b in module a =>', b);
exports.name = 'a';

// b.js
const a = require('./a');
console.log('Print a in module b =>', a);
exports.name = 'b';

// 运行 a.js 后的打印结果
Print a in module b => { val: 100 }
Print b in module a => { name: 'b' }

一个完整的 CommonJS 模块示例 如下:

// math.js
exports.add = function() { //导出一个 add 方法
    var sum = 0, i = 0, args = arguments, l = args.length;
    while (i < l) {
        sum += args[i++];
    }
    return sum;
};

// increment.js
var add = require('math').add; // 引入 math 模块的 add 方法
exports.increment = function(val) {
    return add(val, 1);
};

// program.js
var inc = require('increment').increment; // 引入 increment 模块的 increment 方法
var a = 1;
inc(a); // 2

总结而言,CommonJS 定义了一套模块导出和引入机制,每个模块互不干扰,只能通过“require” 和 “exports”接口进行交互,这样即解决了模块化开发的问题。

一点历史:CommonJS 最开始叫 ServerJS,这点可以在官网上查到,可见它原本目的是想推广 JS 到服务端的,不过后来这个范围更广了,它也改名叫 CommonJS 了。Node.js 当时就是借鉴 CommonJS 规范来实现自身的模块系统的,并且取得了不错的效果。

(2)AMD

AMD 全称是 Asynchronous Module Definition(异步模块定义),它制定了一种能使模块以及模块的依赖可以被异步加载的机制。

在有了 CommonJS 的情况下为何还需要 AMD 呢?RequireJS(AMD的一种实现)的这两篇文章 WHY WEB MODULES?WHY AMD 说出了他们的考虑。一言以蔽之:CommonJS 并不适合浏览器环境

不同于 Node.js,浏览器本身并不支持 CommonJS 语法,如果我们想要使用它,就不得不借助一些额外的“辅助措施”。

// CommonJS require
var add = require('math').add;

办法有两种:

  1. 部署前对代码进行转换

    将开发代码部署到浏览器前,对代码字符串进行转换。如使用函数包装模块代码(这也就是后来 BrowserifyWebpack 的做法)

    function(module, exports, require){
     // Module code goes here
    }
    
  2. 或使用 XMLHttpRequest (XHR) 加载模块文本后在浏览器中进行转换解析

RequireJS 的作者认为,这两种方式都会给开发者带来多余的负担。另外一点,CommonJS 中规定,一个 JS 文件只能有一个模块,这对于浏览器而言也是一个限制。如果模块很多,那代表着要加载很多个 JS文件,但浏览器并行网络请求的个数是有限的,所以这样做并不是高效的办法。

综合以上这些问题,AMD 提出了新的模块规范,以适用于浏览器环境。

AMD 模块规范

AMD API 定义了一个全局函数 define,它接收三个参数:

 define(id?, dependencies?, factory);

其中,id 是模块标识(可省略),dependencies 是需要引入的模块 ID,factory 可以是函数或对象,如果是函数,则其返回值将被当作该模块的导出,如果是对象,则该对象就是模块的导出。

简单使用示例如下:

define(['alpha'], function (alpha) {
  return {
    verb: function () {
      return alpha.verb() + 2;
    }
  };
});

alpha 即引入的模块依赖,只有当所有依赖加载完成,后面的回调函数才会执行。所有的模块代码都被封装在一个工厂函数中,它的依赖通过参数传入,内部的作用域是隔离的,最后通过函数返回导出模块内容。

AMD 还提供了一种可以在一个文件中声明多个模块的方法,如下:

// a.js
define('a1', ['my/cart', 'my/inventory'], function (cart, inventory) {
  // module code...
});

define('a2', ['my/cart', 'my/inventory'], function (cart, inventory) {
  // module code...
});

其中,a1a2 是模块标识,这样就可以区分开在一个文件中定义的多个模块。

AMD 的一种流行的实现就是 RequireJS ,它也曾有过一段风光的鼎盛时期。RequireJS 的使用非常简单,我们只需引入 requirejs 库就能在浏览器中使用 AMD 模块语法。引入方法如下:

 <script data-main="scripts/main" src="scripts/require.js"></script>

只需指定模块的入口 data-main,接下来其他的所有事情都只用交给 RequireJS 处理就行了。

AMD VS. CommonJS

CommonJS 的模块规范针对于 Javascript 语言,但却并未限制在浏览器平台,想要将其应用于浏览器环境,就需要一些额外的“辅助措施”。而 AMD 发现了这里的不足,提出了一种异步模块加载规范,这个规范更容易在浏览器上实现。

虽说 AMD 规范后来被推广开来,但是其中的一些机制其实是不被 CommonJS 社区以及很多开发者所接受的,比如模块的执行时机。

在 CommonJS 中,第一次 require 才会加载和执行对应的模块。

// b.js
var a = require("./a") // require 这里才执行 a.js

而在 AMD 中,则是在 define 引入依赖时,依赖模块就已经加载和执行了。

define(['alpha'], function (alpha) { 
  // 这里 alpha 模块已被加载和执行
  return {
    verb: function () {
      return alpha.verb() + 2;
    }
  };
});

// 另一个例子
define(["a", "b"], function(a, b) {
   if (false) {
       // 即便压根儿没用到模块 b,但 b 还是提前执行了
       b.foo()
   }

})

所以,AMD算是 提前执行,所有的模块都是在最前面声明依赖时执行,而不是在代码中用到的地方执行。

看了下面一个 CMDCMD 规范综合了 CommonJS 和 AMD 的一些特征,本文没有单独介绍)模块的例子应该就更容易理解了。

define(function(require, exports) {
  var util = require('./util.js'); //在这里加载模块

  exports.init = function() {
      //...
  };
});

社区中也有一些流行的 CMD 的实现,比如 sea.js

但总的来说,CommonJS、AMD、CMD 这些都是能算是社区规范,浏览器没有对它们进行原生支持,直到 官方的 ES Module 规范出现,才真正有了一统之势。

(3)ES Module

ECMAScript 是 JavaScript 的语言标准,目的是在各大浏览器中建立统一的规范。ES 2015 中提出了模块化的定义,如今各主流浏览器都对它进行了原生支持,也就代表着我们的模块语法可以直接在浏览器上跑啦👏。

ES Module 的语法相信前端小伙伴们都很熟悉,如下是基本的几种导入导出语法。

// 导入语法
import { create, createReportList } from './modules/canvas.js';
import randomSquare from './modules/square.js';
​
// 导出语法
export { name, draw, reportArea, reportPerimeter };
export default randomSquare;

要在浏览器上运行,我们只用添加 type=module 用来标识这是一个模块就行了。

<script type="module" src="./main.js"></script>

ES Module VS. CommonJS

接下来,说说 ES Module 与 CommonJS 在导入导出机制上的一些不同之处。网上的很多文章,包括阮一峰写的 ES6 Module 中都有提到:CommonJS 模块是运行时加载,而ES6 模块是编译时输出接口。

怎么理解呢?在 es-modules-a-cartoon-deep-dive 这篇文章中作者对 ES Module 在浏览器中的实现原理进行了解析,总结而言,就是在模块代码执行前,会有一个编译阶段,将所有的 import 和 export 指向对应变量的存储空间

这也就是为什么在引入模块时无法使用变量的原因,因为编译阶段是做静态解析,代码不会运行,所以是无法获取到变量的值的。

import mod from `${path}/foo.js` // error

但有时候我们确实需要根据不同情况加载不同的模块代码,ES Module 提供了另一种方法, dynamic import(动态加载)来解决这个问题,示例如下:

import('/modules/myModulejs')
  .then((module) => {
    // Do something with the module.
  });

拷贝还是引用?

“运行时”与“编译时”的区别就是,一个返回的是值的拷贝,而另一个返回的是值的引用。

我们可以用一段代码来看下 CommonJS 与 ESModule 的差别。下面是一个 CommonJS 的模块例子,运行环境是 Node.js V15.9.0。

// lib.js
let counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter,
  incCounter,
};
​
// main.js
let mod = require('./lib');
​
console.log(mod.counter);  // 3
mod.incCounter();
console.log(mod.counter); // 3

main.js 中调用 mod.incCounter() 方法会改变 lib.js 中定义的 counter 值,可这对 module.exports 毫无影响,最终两次打印的结果都是 3。

相同的例子我们试试 ES Module,并放到支持 ES Module 的浏览器中执行。

// lib.js
export let counter = 3;
export function incCounter() {
  counter++;
}
​
// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4

打印的结果是 3,4,这说明 import 进来的 counter 就是 lib.js 中定义的 counter

总结

本篇对 CommonJS、AMD、ES Module 这三种规范进行了介绍和分析,当然 JS 的模块化规范远不止这些。目前看来,ES Module 有望一统服务端和浏览器端,成为通用的模块规范。

这一部分介绍了模块化规范,下面让我们一起来看看一个模块系统如何实现。

JS 模块系统的实现

要实现一个模块系统需要做哪些工作?首先我们可以看看 Node.js 中的实现,它是最早支持 JS 模块化开发的,虽然与浏览器环境有差异,但我们也能从中取经学习。

Node.js

《深入浅出 Node.js》“Node的模块实现”一章中将 Node 引入模块分为了三个步骤:

  1. 路径分析
  2. 文件定位
  3. 编译执行 前面两步“路径分析”和“文件定位”,即通过 require 传入的模块标识定位到正确的文件位置。
const utils = require('./utils') // './utils'即模块标识

这里面包含了很多小细节,比如标识符可能是:

  • Node内部提供的模块,如 http,fs
  • 相对路径或绝对路径
  • 引入的 NPM 包,如 express,axios

另外,代码中的模块标识符通常是简化的,比如 ./utils,由于它没有带扩展名,所以可能是.js.json ,甚至是 .utils/index.js

这些细节我们开发者不用关心,但是对于模块系统而言却一定要分析清楚。

第二部分“编译执行”。在文件定位后,Node 首先会对文件进行编译。Node 中支持 .js,.node,.json 文件,它们的载入方法也有所不同。每一个编译成功的模块都会以文件路径作为索引保存在缓存中,以提高二次引入的性能,这也就是我们常说的模块缓存

这里主要讲讲 JS 文件的编译。在JS模块执行前,Node 会对获取的 JS 文件进行一次封装。比如,这样一个模块文件:

 const math = require('math'); 
 exports.area = function (radius) { 
    return Math.PI * radius * radius; 
 };

在封装后则变成了:

(function (exports, require, module, __filename, __dirname) { 
   const math = require('math'); 
      exports.area = function (radius) { 
   return Math.PI * radius * radius; 
   }; 
});

通过函数包装,每个模块文件之间都进行了作用域隔离,同时每个模块内部都能直接使用requireexports进行导入导出,并统一由模块系统管理,这样 Node.js 即支持了模块化。

Webpack

接下来看看前端领域中的一些模块化实现,你会发现他们与 Node.js 的实现存在许多相似之处。

首先是 Webpack,我们可以通过在 webpack.config.js 中配置 mode=developmentdevtool=false 来方便阅读打包后的代码。

// webpack.config.js
const path = require('path');
module.exports = {
  entry: path.join(__dirname, 'main.js'),
  output: {
    path: path.join(__dirname, 'output'),
    filename: 'index.js',
  },
  mode: 'development',
  devtool: false,
};

CommonJS Module

首先我们配置一个入口文件 main.js 和一个模块依赖 bar.js,然后运行 webpack (webpack 5.51.1,webpack-cli 4.8.0)打包。

// main.js
let bar = require('./bar');
function foo() {
  return bar.bar();
}
​
//bar.js
exports.bar = function () {
  return 1;
};

打包结果的所有内容如下(省略了不必要的注释):

(() => {
  // webpackBootstrap
  var __webpack_modules__ = {
    './bar.js': (__unused_webpack_module, exports) => {
      exports.bar = function () {
        return 1;
      };
    },
  };
​
  // The module cache
  var __webpack_module_cache__ = {};
​
  // The require function
  function __webpack_require__(moduleId) {
    // Check if module is in cache
    var cachedModule = __webpack_module_cache__[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    // Create a new module (and put it into the cache)
    var module = (__webpack_module_cache__[moduleId] = {
      exports: {},
    });
​
    // Execute the module function
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
​
    // Return the exports of the module
    return module.exports;
  }
​
  var __webpack_exports__ = {};
  (() => {
    // ./main.js
    let bar = __webpack_require__('./bar.js');
    function foo() {
      return bar.bar();
    }
  })();
})();

我们看懂这两个部分就能完全理解了。

  1. __webpack_modules__
var __webpack_modules__ = {
  './bar.js': (__unused_webpack_module, exports) => {
    exports.bar = function () {
      return 1;
    };
  },
};

__webpack_modules__ 是一个对象,其中以模块路径为 key 值存储模块文件,而我们本来写的模块代码在这里也被进行了一层封装,它被一个函数包裹起来,其中传入了 __unused_webpack_moduleexports

补充:这里和 Node.js 的做法非常相似。第一个参数其实是 module,只不过在该模块中没有用到,所以被标记为 __unused_webpack_module;另外第三个参数应该是 require,这里也没有用到,但是可以在下面的 main.js 中看到,即 __webpack_require__

  1. __webpack_require__
var __webpack_module_cache__ = {};
​
function __webpack_require__(moduleId) {
  // Check if module is in cache
  var cachedModule = __webpack_module_cache__[moduleId];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
  // Create a new module (and put it into the cache)
  var module = (__webpack_module_cache__[moduleId] = {
    exports: {},
  });
​
  // Execute the module function
  __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
​
  // Return the exports of the module
  return module.exports;
}

它其实就是对应着我们用的 require 函数,只是这里改了个名字。可以看到里面用到了模块缓存,在引入模块时,首先会从 __webpack_module_cache__ 中获取,如果存在则直接返回,不存在则新建一个 module 对象,将它放入缓存中,同时执行对应的模块,将这个 module 对象以及 module.exports 传入模块函数中。

到这里,你应该能理解 Webpack 是如何让浏览器能够 “运行 CommonJS 代码” 的了,而它的做法真的跟 Node.js 非常相似😺。

循环依赖

到这里,我们可以再回顾一下“循环依赖”的问题,其实使用模块缓存就可以解决了。同样我们准备两个文件 a.jsb.js

// a.js
const b = require('./b.js');
console.log(b.val);
exports.val = 'aaa';

//b.js 
const a = require('./a.js');
console.log(a.val);
exports.val = 'bbb';

a.js 为入口文件,打包后的结果为:

(() => {
  // webpackBootstrap
  var __webpack_modules__ = {
    './a.js': (__unused_webpack_module, exports, __webpack_require__) => {
      const b = __webpack_require__('./b.js');
      console.log(b.val);
      exports.val = 'aaa';
    },

    './b.js': (__unused_webpack_module, exports, __webpack_require__) => {
      const a = __webpack_require__('./a.js');
      console.log(a.val);
      exports.val = 'bbb';
    },
  };
  // The module cache
  var __webpack_module_cache__ = {};

  // The require function
  function __webpack_require__(moduleId) {
    // Check if module is in cache
    var cachedModule = __webpack_module_cache__[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    // Create a new module (and put it into the cache)
    var module = (__webpack_module_cache__[moduleId] = {
      exports: {},
    });

    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);

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

  // startup
  // Load entry module and return exports
  // This entry module is referenced by other modules so it can't be inlined
  var __webpack_exports__ = __webpack_require__('./a.js');
})();

我们再回顾下 __webpack_require__ 这个加载模块的函数。在引入一个模块时,首先会检查它是不是已经在缓存中,如果存在则直接返回缓存模块的 exports,不存在才会进行后面的创建模块缓存并执行模块函数。

顺着这个思路,我们执行 a.js,首先会创建一个 __webpack_module_cache__['./a.js'] 的缓存对象,并把该对象的 exports 属性传入 a.js 模块执行。接着,在 a.js 中引入 b.js 模块:

const b = require('./b.js');

由于不存在 b.js 的缓存,首先会像前面一样创建 b.js 的缓存并执行它,这时在 b.js 中引入 a.js (发生循环引用):

const a = require('./a.js');

但由于'./a.js'的模块缓存已经存在,所以不会再次执行 a.js,而是直接返回缓存中的内容,即 __webpack_module_cache__['./a.js'].exports ,当然它现在还是个空对象 {},但是循环引用的问题就解决了。

ES Module

前面有提到 ES Module 与 CommonJS 的区别,这里我们也来看看 Webpack 打包 ES Module 后的代码,我们可以定下此行的目标:Webpack 是怎么实现 ES Module 的导出是引用而不是简单的拷贝的?

同样,准备两个文件:

// bar.js
export let counter = 1;
​
​
// main.js
import { counter } from './bar';
console.log(counter);

打包的结果如下:

(() => {
  // webpackBootstrap
  'use strict';
  var __webpack_modules__ = {
    './bar.js': (
      __unused_webpack_module,
      __webpack_exports__,
      __webpack_require__,
    ) => {
      __webpack_require__.r(__webpack_exports__);
      __webpack_require__.d(__webpack_exports__, {
        counter: () => counter,
      });
      let counter = 1;
    },
  };
​
  // The module cache
  var __webpack_module_cache__ = {};
​
  // The require function
  function __webpack_require__(moduleId) {
    // Check if module is in cache
    var cachedModule = __webpack_module_cache__[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    // Create a new module (and put it into the cache)
    var module = (__webpack_module_cache__[moduleId] = {
      // no module.id needed
      // no module.loaded needed
      exports: {},
    });
​
    // Execute the module function
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
​
    // Return the exports of the module
    return module.exports;
  }
  /* webpack/runtime/define property getters */
  (() => {
    // define getter functions for harmony exports
    __webpack_require__.d = (exports, definition) => {
      for (var key in definition) {
        if (
          __webpack_require__.o(definition, key) &&
          !__webpack_require__.o(exports, key)
        ) {
          Object.defineProperty(exports, key, {
            enumerable: true,
            get: definition[key],
          });
        }
      }
    };
  })();
​
  /* webpack/runtime/hasOwnProperty shorthand */
  (() => {
    __webpack_require__.o = (obj, prop) =>
      Object.prototype.hasOwnProperty.call(obj, prop);
  })();
​
  /* webpack/runtime/make namespace object */
  (() => {
    // define __esModule on exports
    __webpack_require__.r = (exports) => {
      if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
        Object.defineProperty(exports, Symbol.toStringTag, {
          value: 'Module',
        });
      }
      Object.defineProperty(exports, '__esModule', { value: true });
    };
  })();
​
  var __webpack_exports__ = {};
  // This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk.
  (() => {
    // ./main.js
​
    __webpack_require__.r(__webpack_exports__);
    /* harmony import */
    var _bar__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__('./bar.js');
​
    console.log(_bar__WEBPACK_IMPORTED_MODULE_0__.counter);
  })();
})();

我们将其中关键的部分提取出来:

var __webpack_modules__ = {
  './bar.js': (
    __unused_webpack_module,
    __webpack_exports__,
    __webpack_require__,
  ) => {
    __webpack_require__.r(__webpack_exports__);
    __webpack_require__.d(__webpack_exports__, { // 1. 注意这里🌟
      counter: () => counter,
    });
    let counter = 1;
  },
};
​
/* webpack/runtime/define property getters */
(() => {
  // define getter functions for harmony exports
  __webpack_require__.d = (exports, definition) => {
    for (var key in definition) {
      if (
        __webpack_require__.o(definition, key) &&
        !__webpack_require__.o(exports, key)
      ) {
        Object.defineProperty(exports, key, { // 2. 还有这里🌟
          enumerable: true,
          get: definition[key],
        });
      }
    }
  };
})();

原来,这里做了一层代理😺。我们将代码稍作简化以方便理解,打包前后的代码分别为:

// 打包前
export let counter = 1;
​
//打包后
let counter = 1;
Object.defineProperty(exports, 'counter', {
   enumerable: true,
   get: () => counter
})

这样,我们在另一个模块中访问 exports.counter,实际上将会直接代理到这个模块中定义的 counter 变量。

到这里,你应该能理解在 Webpack 中对 CommonJS Module 与 ES Module 处理的区别了。

动态加载

我们常常使用 Webpack 的动态加载来异步导入模块,从而减少部分文件的体积。这里将使用 import() 示例来演示动态加载。

同样,准备两个文件 main.jsfoo.js,如下:

// main.js
import('./foo.js').then((module) => {
  console.log(module.foo());
});
​
// foo.js
export function foo() {
  return 100;
}

使用 webpack 打包后会导出两个文件 index.js(入口文件) 和 foo_js.index.js ,内容如下:

// foo.js.index.js
(self['webpackChunk'] = self['webpackChunk'] || []).push([
  ['foo_js'],
  {
    './foo.js': (__unused_webpack_module, exports) => {
      exports.foo = function () {
        return 100;
      };
    },
  },
]);
// index.js
// 全部代码很长,这里只截取了其中的模块加载函数
__webpack_require__.l = (url, done, key, chunkId) => {
  if (inProgress[url]) {
    inProgress[url].push(done);
    return;
  }
  var script, needAttach;
  if (key !== undefined) {
    var scripts = document.getElementsByTagName('script');
    for (var i = 0; i < scripts.length; i++) {
      var s = scripts[i];
      if (s.getAttribute('src') == url) {
        script = s;
        break;
      }
    }
  }
  if (!script) {
    needAttach = true;
    script = document.createElement('script');
​
    script.charset = 'utf-8';
    script.timeout = 120;
    if (__webpack_require__.nc) {
      script.setAttribute('nonce', __webpack_require__.nc);
    }
​
    script.src = url;
  }
  inProgress[url] = [done];
  var onScriptComplete = (prev, event) => {
    // avoid mem leaks in IE.
    script.onerror = script.onload = null;
    clearTimeout(timeout);
    var doneFns = inProgress[url];
    delete inProgress[url];
    script.parentNode && script.parentNode.removeChild(script);
    doneFns && doneFns.forEach((fn) => fn(event));
    if (prev) return prev(event);
  };
  var timeout = setTimeout(
    onScriptComplete.bind(null, undefined, {
      type: 'timeout',
      target: script,
    }),
    120000,
  );
  script.onerror = onScriptComplete.bind(null, script.onerror);
  script.onload = onScriptComplete.bind(null, script.onload);
  needAttach && document.head.appendChild(script);
};

可见,Webpack 使用添加 script 标签的方式来加载 JS 模块。全部的代码很多,这里仅使用很简单的一个示例来帮助理解,可以使用一个 index.html 引入 main.js 在浏览器中运行。

// main.js
const modules = (self.modules = {}); // 存储所有模块
const dynamicImport = (url) => {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = url;
    document.head.appendChild(script); // 加载 JS 模块

    const onScriptComplete = (err) => { // 加载完成的回调函数
      if (err) {
        reject(err);
      } else {
        const fn = modules[url];
        fn && resolve(fn);
      }
    };

    script.onload = onScriptComplete.bind(null, null);
    script.onerror = onScriptComplete.bind(null, 'load error');
  });
};

dynamicImport('./foo.js')
  .then((module) => console.log(module()))
  .catch((err) => {
    console.error(err);
  });
​
// foo.js
Object.assign((self.modules = self.modules || {}), { // 在 modules 中添加模块
  './foo.js': () => {
    return 100;
  },
});

我们使用 script 标签加载 JS 模块,并在模块 foo.js 中将本模块注册到 modules 中,在模块加载完成后触发回调函数,这样就可以实现动态加载了。

参考

最后附上所有的参考文档&文章

  1. AMD
  2. CommonJS
  3. Relation-between-commonjs-amd-and-requirejs
  4. Browserify
  5. Node.js Module
  6. RequireJS
  7. WHY WEB MODULES?
  8. WHY AMD
  9. 前端模块化开发的价值 (推荐阅读🌟)
  10. 前端模块化开发那点历史 (推荐阅读🌟)
  11. 《深入浅出 Node.js》
  12. 浏览器加载 CommonJS 模块的原理与实现
  13. ES6 Module 阮一峰
  14. ESModule 深入解析
  15. webpack模块化原理-commonjs
  16. webpack模块化原理-ES module
  17. webpack是如何实现动态导入的
  18. 前端构建这十年