CommonJS和ES6 Module 模块规范原理浅析

4,211 阅读10分钟

这个两个规范曾经困扰过我,因为他们的关键词都很像:import/export/export default/require/module.exports... 总是傻傻分不清楚。

关于他们之间的区别只是听说一个动态加载、一个静态加载;一个导出副本,一个导出引用。这又是什么意思,为什么?

为了解惑,我使用了webpack+babel将这两种规范的模块代码打包成ES5,看看他们到底都是怎么做的。本文先解释他们各自的概念、表现,再来分析代码、剖析原理。(使用babel主要是为了把ES6 Module转成ES5看它内部是怎么工作的(更新:webpack原生支持import/export,无需使用babel转译))

为什么要模块化

如果不采用模块化,从body底部引入js文件时必须要确保引用顺序正确,否则将无法运行。而当js文件数量太大的时候,文件之间的依赖关系存在不确定性,无法保证顺序正确,因此出现了模块化。

打包代码的webpack配置

最小化的配置如下,主要是要配置source-map,和开发模式,这样打包出来的代码就是普通的代码,没有压缩、没有用eval包含,比较易读。然后就可以创建两个js文件各种尝试了。(本次使用的 webpack 版本为:4.43.0)

// webpack.config.js
{
// ...
  mode: 'development',
  devtool: 'source-map',
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /(node_modules)/,
        loader: 'babel-loader',
      }
    ]
  }
// ...
}

// .babelrc
{
  "presets": [
    "@babel/preset-env"
  ]
}

CommonJS规范

CommonJS规范的实现非常简单,放在前面来说。

概念

CommonJS规范是Node.js处理模块的标准。npm生态就是建立在CommonJS标准之上的。

可以导出的有:变量、function、对象等。 现在使用频率最高。它使用同步加载的方式将模块一次性加载出来。

使用示例:

/* 导出 */
// 直接挂到exports上
exports.uppercase = str => str.toUpperCase();
// 挂到module.exports上
module.exports.a = 1;
// 重写module.exports
module.exports = { xxx: xxx };

/* 导入 */
 // 可以访问package.a / package.b...
const package = require('module-name');
// 结构赋值
const { a, b, c } = require('./uppercase.js');
原理分析

下面是两个具有引用关系的模块的打包文件,删去各种花里胡哨的注释后,露出了非常简单的真面目。

源码:

// index.js
const m = require('./module1.js');
console.log(m)

// module1.js
const m = 1;
module.exports = m;

打包后的文件如下:

可以很清楚地看到,webpack把整个打包后的代码处理成了一个立即执行的函数,参数是一个包含所有模块的对象,每个文件表现为一个键值对,用文件路径字符串作为属性名,将文件内的代码,也就是整个模块的内容全都包装进了一个函数里作为属性的值。在模块内部使用的require也用__webpack_require__方法来替换了。

// 1. 是一个立即执行的函数
(function (modules) {
  var installedModules = {};
  
  // 4. 执行函数
  function __webpack_require__(moduleId) {
    // 5. 检查如果有缓存直接返回
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // 6. 创建一个模块并存入缓存
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };
    // 7. 根据模块id从模块对象里取出并执行
    // this绑定到module.exports 并注入module, module.exports
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // 8. 标记这个模块为已加载
    module.l = true;
    // 9. 返回module.exports
    return module.exports;
  }
  // 3. 传入入口文件名
  return __webpack_require__(__webpack_require__.s = "./index.js");
})({ // 2. 将模块对象作为参数传入
  "./index.js":
    (function (module, exports, __webpack_require__) {
      var m = __webpack_require__("./module1.js")
      console.log(m)
    }),

  "./module1.js":
    (function (module, exports) {
      var m = 1;
      module.exports = m;
    })
});

顺着执行过程可以发现CommonJS模块处理的流程:

  1. 调用__webpack_require__,给要执行的模块(文件)创建一个module。
var module = {
  i: moduleId,
  exports: {}
}
  1. 通过call方法调用这个模块被包装成的函数,将this绑定为module.exports,传入module和module.exports以及__webpack_require__方法。
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__)
  1. 如果这个模块内部还有require别的模块,会继续调用__webpack_require__,递归重复这个过程。
  2. 一个模块执行结束后返回module.exports。

所以我们知道了,上面的使用示例中使用exports.xxx = xxxmodule.exports.xxx = xxx,其实本质上是一样的,都是将变量挂到module.exports对象中,甚至还可以写成this.exports.xxx = xxx也有同样的效果;别的模块导入时,得到的也是module.exports对象。

因此,如果直接使用module.exports = { xxx: xxx }的方式,就相当于重写了导出的模块,故而对 exportsthis.exports 的任何操作也就没什么意义了。

ES6 Module

概念

在ES6以前,JS没有模块体系。只有社区指定的一些模块加载方案,如用于服务器端的CommonJS和用于浏览器端的AMD。

ES6导出的不是对象,无法引用模块本身,模块的方法单独加载。因此可以在编译时加载(也即静态加载),因而可以进行静态分析,在编译时就可以确定模块的依赖关系和输入输出的变量,提升了效率。

而CommonJS和AMD输出的是对象,引入时需要查找对象属性,因此只能在运行时确定模块的依赖关系及输入输出变量(即运行时加载),因此无法在编译时做“静态优化”。

使用示例如下:

/* 导出 */
// 导出一个变量
export let firstName = 'Michael';
// 导出多个变量
let firstName = 'Michael';
let lastName = 'Jackson';
export { firstName, lastName };
// 导出一个函数
export function multiply(x, y) { 
  return x * y;
}
// 给导出的变量重命名
export {
  v1 as streamV1,
  v2 as streamV2
};
// 默认输出(本质上将输出变量赋值给default),import时可以随便命名且无需加大括号{}:
export default function crc32() {}

/* 引用 */
// import只能放在文件顶层
import { stat, exists, readFild } from 'fs';
import { lastName as surname } from './profile.js'
// 引入整个模块,然后用circle.xxx获取内部变量或方法
import * as circle from './circle';
import crc from 'crc32';
原理分析

源码:

// index.js
import { m } from './module1';
console.log(m);

// module1.js
const m = 1;
const n = 2;
export { m, n };

打包后:

// 1. 是一个立即执行函数
(function (modules) {
  var installedModules = {};
  // 4. 处理入口文件模块
  function __webpack_require__(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // 5. 创建一个模块
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };
    // 6. 执行入口文件模块函数
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    module.l = true;
    // 7. 返回
    return module.exports;
  }
  __webpack_require__.d = function (exports, name, getter) {
    if (!__webpack_require__.o(exports, name)) { // 判断name是不是exports自己的属性
      Object.defineProperty(exports, name, {enumerable: true, get: getter});
    }
  };
  __webpack_require__.r = function (exports) {
    if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
      // Symbol.toStringTag作为对象的属性,值表示这个对象的自定义类型 [Object Module]
      // 通常只作为Object.prototype.toString()的返回值
      Object.defineProperty(exports, Symbol.toStringTag, {value: 'Module'});
    }
    Object.defineProperty(exports, '__esModule', {value: true});
  };
  __webpack_require__.o = function (object, property) {
    return Object.prototype.hasOwnProperty.call(object, property);
  };
  // 3. 传入入口文件id
  return __webpack_require__(__webpack_require__.s = "./index.js");
})({ // 2. 模块对象作为参数传入
  "./index.js":
    (function (module, __webpack_exports__, __webpack_require__) {
      // __webpack_exports__就是module.exports
      "use strict";
      // 添加了__esModule和Symbol.toStringTag属性
      __webpack_require__.r(__webpack_exports__);
      var _module1__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./module1.js");
      console.log(_module1__WEBPACK_IMPORTED_MODULE_0__["m"])
    }),

  "./module1.js":
    (function (module, __webpack_exports__, __webpack_require__) {
      "use strict";
      __webpack_require__.r(__webpack_exports__);
      // 把m/n这些变量添加到module.exports中,并设置getter为直接返回值
      __webpack_require__.d(__webpack_exports__, "m", function () {return m;});
      __webpack_require__.d(__webpack_exports__, "n", function () {return n;});
      var m = 1;
      var n = 2;
    })
});

可以看到,跟CommonJS差不多,同样也是将模块对象传入一个立即执行的函数。只是模块函数内部稍稍复杂了一些。执行一个模块的流程前两步和CommonJS一样,也是先创建了一个module,然后再绑定this到module,传入module和module.exports对象。

在模块内部,导出模块的流程是:

  1. 先给__webpack_exports__也就是module.exports对象添加一个Symbol.toStringTag属性值为{value: 'Module'},这么做的作用就是使得module.exports调用toString方法可以返回[Object Module]来表明这是一个模块。
  2. 将要导出的变量添加到module.exports中,然后设置变量的getter,getter里只是简单地返回了同名变量的值。

导入的表现也很不一样,不是单单导入module.exports这个对象,而是做了一些额外的工作。在这个例子中,index.js文件引入了m,然后打印了m。但是打包结果却是导入的m和访问的m并不相同,访问m的时候其实访问的是m['m'],也就是说webpack在访问的时候自动帮我们访问内部的同名属性。

import { m } from './module1';
console.log(m);

// webpack
var _module1__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./module1.js");
console.log(_module1__WEBPACK_IMPORTED_MODULE_0__["m"])

不同的导入和导出方式也会有不同的表现:

  1. 不同方式导出的表现:
  • 方式一:直接导出,只能是这三种形式,这三种是等价的。注意export后面不能接一个常量,因为export要输出的是接口,要和变量一一对应,例如:var a = 1; export a;相当于 export 1 没意义,会报错。
export { bar, foo }
export var bar = xxx
export function foo = xxx
const obj = { a: 1 };
export { obj };

// webpack 
// __webpack_exports__就是导出的结果
__webpack_require__.d(__webpack_exports__, "obj", function() { return obj; });
var obj = { a: 1 };

// __webpack_require__.d这个函数首先判断要导出的变量是不是__webpack_exports__上的属性
// 如果不是就把这个变量挂在__webpack_exports__上,并设置getter
__webpack_require__.d = function(exports, name, getter) {
  if(!__webpack_require__.o(exports, name)) {
    Object.defineProperty(exports, name, { enumerable: true, get: getter });
  }
};
  • 方式二:export default的导出方式,只会简单地把要导出的变量放在对象里,然后挂到__webpack_exports__.default
let obj = { a: 1 }
export default { obj }

// webpack
var obj = { a: 1 };
__webpack_exports__["default"] = ({ obj: obj });

// export default后面跟什么值就没有限制了
export default obj
// webpack
__webpack_exports__["default"] = (obj);

export default obj.a
// webpack
__webpack_exports__["default"] = (obj.a);

export default 1
// webpack
__webpack_exports__["default"] = (1);
  1. 不同方式导入的表现:
  • 方式一:整体导入:直接获取整个模块的值__webpack_exports__,访问的时候会自动查找default属性的值,如果导出没有使用export default,会得到一个undefined。
import obj from './module1'
console.log(obj) // 没有default会得到undefined
obj.c = 2; // 没有default会报错

// webpack
var _module1__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./module1.js");
console.log(_module1__WEBPACK_IMPORTED_MODULE_0__["default"]);
_module1__WEBPACK_IMPORTED_MODULE_0__["default"].c = 2;
  • 方式二:以 * 整体导入,不会自动查找内层属性,直接访问__webpack_exports__
import * as obj from './module1'
console.log(obj)
obj.c = 2;

// webpack
var _module1__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./module1.js");
console.log(_module1__WEBPACK_IMPORTED_MODULE_0__);
_module1__WEBPACK_IMPORTED_MODULE_0__["c"] = 2;
  • 方式三:解构赋值的方式导入具体模块,访问的时候会自动查找花括号里同名的属性值。
import { obj } from './module1'
console.log(obj)
obj.c = 2;

// webpack
var _module1__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./module1.js");
console.log(_module1__WEBPACK_IMPORTED_MODULE_0__["obj"]);
_module1__WEBPACK_IMPORTED_MODULE_0__["obj"].c = 2;

CommonJS和ES6 Module的对比

现在就能明白文章开头所说的动态加载、静态加载、复制、引用都是什么意思了。

CommonJS导出的是对象,内部要导出的变量在导出的那一刻就已经赋值给对象的属性了,也就有了“CommonJS输出的是值的拷贝”这种说法,后面再在模块里修改变量,其他模块是感觉不到的,因为已经没有关系了。但是对象还是会影响,因为对象拷贝的只是对象的引用。

也是因为CommonJS导出的是对象,在编译阶段不会读取对象的内容,并不清楚对象内部都导出了哪些变量、这些变量是不是从别的文件导入进来的。只有等到代码运行时才能访问对象的属性,确定依赖关系。因此才说CommonJS的模块是动态加载的。

而对ES6 Module来说,由于内部对每个变量都定义了getter,因此其他模块导入后访问变量时触发getter,返回模块里的同名变量,如果变量值发生变化,则外边的引用也会变化。

但是export default没有走getter的形式,也是直接赋值,所以输出的也是一份拷贝。例如下列代码,可以看到只是简单地将变量 m 的值拷贝了一份挂到 default 属性上。(经评论区提醒,webpack5 更正了这一行为,default 也走 getter 了。)

const m = 1;
export default m;

// webpack
(function (module, __webpack_exports__, __webpack_require__) {
  "use strict";
  __webpack_require__.r(__webpack_exports__);
  var m = 1;
  __webpack_exports__["default"] = (m);
})

ES6 Module导出的不是一个对象,导出的是一个个接口,因此在编译时就能确定模块之间的依赖关系,所以才说ES6 Module是静态加载的。Tree Shaking就是根据这个特性在编译阶段摇掉无用模块的。

ES6 Module还提供了一个import()方法动态加载模块,返回一个Promise。

References

  1. es6.ruanyifeng.com/#docs/modul…