这个两个规范曾经困扰过我,因为他们的关键词都很像: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模块处理的流程:
- 调用
__webpack_require__
,给要执行的模块(文件)创建一个module。
var module = {
i: moduleId,
exports: {}
}
- 通过call方法调用这个模块被包装成的函数,将this绑定为module.exports,传入module和module.exports以及
__webpack_require__
方法。
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__)
- 如果这个模块内部还有require别的模块,会继续调用__webpack_require__,递归重复这个过程。
- 一个模块执行结束后返回module.exports。
所以我们知道了,上面的使用示例中使用exports.xxx = xxx
和module.exports.xxx = xxx
,其实本质上是一样的,都是将变量挂到module.exports
对象中,甚至还可以写成this.exports.xxx = xxx
也有同样的效果;别的模块导入时,得到的也是module.exports
对象。
因此,如果直接使用module.exports = { xxx: xxx }
的方式,就相当于重写了导出的模块,故而对 exports
和 this.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对象。
在模块内部,导出模块的流程是:
- 先给
__webpack_exports__
也就是module.exports对象添加一个Symbol.toStringTag属性值为{value: 'Module'}
,这么做的作用就是使得module.exports调用toString方法可以返回[Object Module]
来表明这是一个模块。 - 将要导出的变量添加到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"])
不同的导入和导出方式也会有不同的表现:
- 不同方式导出的表现:
- 方式一:直接导出,只能是这三种形式,这三种是等价的。注意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);
- 不同方式导入的表现:
- 方式一:整体导入:直接获取整个模块的值
__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。