ESModule和CommonJS的比较以及注意事项
在 ES6 中,我们知道 import、export 取代了 require、module.exports 用来引入和导出模块,但是如果不了解 ES6 模块特性的话,代码可能就会运行出一些匪夷所思的结果,下面我将通过这篇文章为你揭开 ES6 模块机制特点。
关于二者的使用方式我就不具体介绍了,有兴趣的可以看一下我以前的博客:JavaScript Module使用语法
本文主要针对以下几个问题:
- 这二者输出的是拷贝还是引用,拷贝的话是深拷贝还是浅拷贝?
- 二者的加载运行时机有什么不同?
- 它们是如何解决循环依赖以及重复加载问题的?
- 二者的运行环境有什么不同,它们什么情况下可以混合使用,为什么可以?
拷贝 or 引用
CommonJS
我们看一下下面这段代码及其运行结果:
1 | // b.js |
这段代码在a引入b之后,在b的内部修改了foo和bar,然后再次打印了foo和bar,可以看到,foo的值没有改变,而bar改变了。
这说明了什么?module.exports.foo已经与内部的foo不是一个变量了,而module.exports.bar与内部的bar还是一个变量。
但是这能说明CommonJS是拷贝还是引用吗?
这里做一个简单说明,module.exports在初始时就是exports,就是说,有一个exports变量,然后我们把module.exports赋值为这个exports,所以exports.a 与 module.exports.a赋值是完全相同的效果。
区别在于我们require的是module.exports,而不是exports,如果我们直接修改了module.exports的引用,那我们在exports上挂载的属性是完全无用的。
举个例子:
exports.a = 1;
exports.b = 2;
module.exports = { c: 3 };
如果只有前两行,我们require的时候会得到 { a: 1, b: 2 },但是第三行直接覆写了module.exports,这个时候我们require的话,得到的就是{ c: 3 }
我再举一个例子,大家思考一下它的结果:
1 | // b.js |
从上面这个例子我们可以看出,require的结果其实就是module.exports的引用。
- 为什么第一个例子中foo没有改而第二个改变了,因为在生成module.exports的时候,foo是基本类型,被深复制给了module.exports.foo,我们require进来的是module.exports.foo而不是内部的b内部的foo。
- 为什么bar一直都是改了,因为在生成module.exports的时候,bar是个对象,module.exports.bar中保存的是bar的地址,而不是深复制,所以我们require进来的module.exports.bar就是内部的bar,它们两个指向同一个内存地址
你也可以这么理解:
我们在构造module.exports的时候其实是一层浅拷贝,把值浅拷贝给了module.exports中的属性。
但是我们require的时候引入的是module.exports的引用。
ESModule
ESModule是ES6引入的,所以没有babel,node是没法识别的,我们就用浏览器来执行。
但如果你直接用html引入js的方式,有两点需要注意:
- script的type需要是module
- 你需要其一个服务器,可以用anywhere这个npm package,因为浏览器对于file协议认为是跨域的
老规矩,看代码
1 |
|
1 | // b.js |
从这个结果来看,ES6 模块是动态关联模块中的值,也就是说看起来是引用
之所以说是看起来,是因为我还没有去看过具体实现,只是从表象上来看,是引用
二者的加载运行时机有什么不同?
ES6 模块编译时执行,而 CommonJS 模块总是在运行时加载,也就是说
对于CommonJS只有在require的时候,才会去引入。
ES6 模块编译时执行会导致有以下两个特点:
- import 命令会被 JavaScript 引擎静态分析,优先于模块内的其他内容执行。
- export 命令会有变量声明提前的效果。
1 | // a.js |
重复加载问题
CommonJS
1 | // b.js |
这个例子说明,每个module只会加载一次,每次require返回的都是同一个引用。
ESModule
这个很好理解,无论是 ES6 模块,当你重复引入某个相同的模块时,模块只会执行一次。
1 | // a.js |
循环依赖问题
解决了重复引用到问题,那么循环依赖的问题就可以解释了
CommonJS
1 | // a.js |
结合之前讲的特性很好理解,当你从 b 中想引入 a 模块的时候,因为 node 之前已经加载过 a 模块了,所以它不会再去重复执行 a 模块,而是直接去生成当前 a 模块吐出的 module.exports 对象,因为 a 模块引入 b 模块先于给 done 重新赋值,所以当前 a 模块中输出的 module.exports 中 done 的值仍为 false。而当 a 模块中输出 b 模块的 done 值的时候 b 模块已经执行完毕,所以 b 模块中的 done 值为 true。
从上面的执行过程中,我们可以看到,在 CommonJS 规范中,当遇到 require() 语句时,会执行 require 模块中的代码,并缓存执行的结果,当下次再次加载时不会重复执行,而是直接取缓存的结果。正因为此,出现循环依赖时才不会出现无限循环调用的情况。虽然这种模块加载机制可以避免出现循环依赖时报错的情况,但稍不注意就很可能使得代码并不是像我们想象的那样去执行。因此在写代码时还是需要仔细的规划,以保证循环模块的依赖能正确工作。
ESModule
跟 CommonJS 模块一样,ES6 不会再去执行重复加载的模块,又由于 ES6 动态输出绑定的特性,能保证 ES6 在任何时候都能获取其它模块当前的最新值。
1 | // a.js |
动态 import()
ES6 模块在编译时就会静态分析,优先于模块内的其他内容执行,所以导致了我们无法写出像下面这样的代码:
1 | if(some condition) { |
因为编译时静态分析,导致了我们无法在条件语句或者拼接字符串模块,因为这些都是需要在运行时才能确定的结果在 ES6 模块是不被允许的,所以 动态引入 import() 应运而生。
import() 允许你在运行时动态地引入 ES6 模块,想到这,你可能也想起了 require.ensure 这个语法,但是它们的用途却截然不同的。
- require.ensure 的出现是 webpack 的产物,它是因为浏览器需要一种异步的机制可以用来异步加载模块,从而减少初始的加载文件的体积,所以如果在服务端的话 require.ensure 就无用武之地了,因为服务端不存在异步加载模块的情况,模块同步进行加载就可以满足使用场景了。 CommonJS 模块可以在运行时确认模块加载。
- 而 import() 则不同,它主要是为了解决 ES6 模块无法在运行时确定模块的引用关系,所以需要引入 import()
我们先来看下它的用法:
- 动态的 import() 提供一个基于 Promise 的 API
- 动态的import() 可以在脚本的任何地方使用
- import() 接受字符串文字,你可以根据你的需要构造说明符
举个简单的使用例子:
1 | // a.js |
当然,如果在浏览器端的 import() 的用途就会变得更广泛,比如 按需异步加载模块,那么就和 require.ensure 功能类似了。
二者什么情况下可以混用
Node.js 的区分
Node.js 要求 ES6 模块采用.mjs
后缀文件名。也就是说,只要脚本文件里面使用import
或者export
命令,那么就必须采用.mjs
后缀名。Node.js 遇到.mjs
文件,就认为它是 ES6 模块,默认启用严格模式,不必在每个模块文件顶部指定"use strict"
。
如果不希望将后缀名改成.mjs
,可以在项目的package.json
文件中,指定type
字段为module
。
1
2
3 {
"type": "module"
}
一旦设置了以后,该目录里面的 JS 脚本,就被解释用 ES6 模块。
1
2 # 解释成 ES6 模块
$ node my-app.js
如果这时还要使用 CommonJS 模块,那么需要将 CommonJS 脚本的后缀名都改成.cjs
。如果没有type
字段,或者type
字段为commonjs
,则.js
脚本会被解释成 CommonJS 模块。
总结为一句话:.mjs
文件总是以 ES6 模块加载,.cjs
文件总是以 CommonJS 模块加载,.js
文件的加载取决于package.json
里面type
字段的设置。
注意,ES6 模块与 CommonJS 模块尽量不要混用。require
命令不能加载.mjs
文件,会报错,只有import
命令才可以加载.mjs
文件。反过来,.mjs
文件里面也不能使用require
命令,必须使用import
。
CommonJS 模块加载 ES6 模块
CommonJS 的require()
命令不能加载 ES6 模块,会报错,只能使用import()
这个方法加载。
1
2
3 (async () => {
await import('./my-app.mjs');
})();
上面代码可以在 CommonJS 模块中运行。
require()
不支持 ES6 模块的一个原因是,它是同步加载,而 ES6 模块内部可以使用顶层await
命令,导致无法被同步加载。
ES6 模块加载 CommonJS 模块
ES6 模块的import
命令可以加载 CommonJS 模块,但是只能整体加载,不能只加载单一的输出项。
1
2
3
4
5 // 正确
import packageMain from 'commonjs-package';
// 报错
import { method } from 'commonjs-package';
这是因为 ES6 模块需要支持静态代码分析,而 CommonJS 模块的输出接口是module.exports
,是一个对象,无法被静态分析,所以只能整体加载。
加载单一的输出项,可以写成下面这样。
1
2 import packageMain from 'commonjs-package';
const { method } = packageMain;
同时支持两种格式的模块
一个模块同时要支持 CommonJS 和 ES6 两种格式,也很容易。
如果原始模块是 ES6 格式,那么需要给出一个整体输出接口,比如export default obj
,使得 CommonJS 可以用import()
进行加载。
如果原始模块是 CommonJS 格式,那么可以加一个包装层。
1
2 import cjsModule from '../index.js';
export const foo = cjsModule.foo;
上面代码先整体输入 CommonJS 模块,然后再根据需要输出具名接口。
你可以把这个文件的后缀名改为.mjs
,或者将它放在一个子目录,再在这个子目录里面放一个单独的package.json
文件,指明{ type: "module" }
。
另一种做法是在package.json
文件的exports
字段,指明两种格式模块各自的加载入口。
1
2
3
4 "exports":{
"require": "./index.js",
"import": "./esm/wrapper.js"
}
上面代码指定require()
和import
,加载该模块会自动切换到不一样的入口文件。
webpack + babel编译
webpack本身就可以支持用自身的方式去__webpack_exports__
,__webpack_require__
去替代CommonJS,ESModule等。
而Babel则是将ES6的import,export转化为CommonJS,也就是说转化后的代码是没有import和export这种写法的。
具体的源码我们另开一篇博客分析,目前的内容已经很多了。