提问
CommonJS 中的 require/exports 和 ES6 中的 import/export 区别?
回答
- CommonJS 模块是运行时加载,ES6 Modules 是编译时加载并输出接口。
- CommonJS 输出是值的拷贝;ES6 Modules输出的是值的引用,被输出模块的内部的改变会影响引用的改变。
- CommonJs 导入的模块路径可以是一个表达式,因为它使用的是 require() 方法,甚至这个表达式计算出来的内容是错误的路径,也可以通过编译到执行阶段再出错;而ES6 Modules 只能是字符串,并且路径不正确,编译阶段就会抛错。
- CommonJS this 指向当前模块,ES6 Modules this 指向 undefined
- ES6 Modules 中没有这些顶层变量:arguments、require、module、exports、__filename、__dirname
此总结出自 如何回答好这个高频面试题:CommonJS和ES6模块的区别?,笔者在这里做一些其他的分析
关于第一个差异运行时加载和编译时加载
这是最大的一个差别。commonjs 模块在引入时就已经运行了,它是“运行时”加载的;但 es6 模块在引入时并不会立即执行,内核只是对其进行了引用,只有在真正用到时才会被执行,这就是“编译时”加载(引擎在编译代码时建立引用)。很多人的误区就是 JS 为解释型语言,没有编译阶段,其实并非如此。举例来说 Chrome 的 v8 引擎就会先将 JS 编译成中间码,然后再虚拟机上运行。
CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
由此引发一些区别,如 require 理论上可以运用在代码的任何地方,可以在引入的路径里加表达式,甚至可以在条件判断语句里处理是否引入的逻辑。因为它是运行时的,在脚本执行时才能得知路径与引入要求,故而甚至时路径填写了一个压根不存在的地址,它也不会有编译问题,而在执行时才抛出错误。
// ...a lot code
if (true) {
require(process.cwd() + '/a');
}
但是 import 则不同,它是编译时的,在编译时就已经确定好了彼此输出的接口,可以做一些优化,而 require 不行。所以它必须放在文件开头,而且使用格式也是确定的,路径里不许有表达式,路径必须真实能找到对应文件,否则编译阶段就会抛出错误。
import a from './a'
// ...a lot code
关于第一个差异,是因为CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
关于第二点 CommonJS 输出的是值的拷贝
的补充
// a.js
var name = '张三';
var sex = 'male';
var tag = ['good look']
setTimeout(function () {
console.log('in a.js after 500ms change ', name)
sex = 'female';
tag.push('young');
}, 500)
// exports.name = name;
// exports.sex = sex;
// exports.tag = tag;
module.exports = {
name,
sex,
tag
}
// b.js
var a = require('./a');
setTimeout(function () {
console.log(`after 1000ms in commonjs ${a.name}`, a.sex)
console.log(`after 1000ms in commonjs ${a.name}`, a.tag)
}, 1000)
console.log('in b.js');
若运行 b.js,得到下面的输出
$ node b.js
in b.js
in a.js after 500ms change 张三
after 1000ms in commonjs 张三 male
after 1000ms in commonjs 张三 [ 'good look', 'young' ]
把 a 和 b 看成两个不相干的函数,a 之中的 sex 是基础属性当然影响不到 b,而 a 和 b 的 tag 是引用类型,并且是共用一份地址的,自然 push 能影响。
补充说明 require 原理
require 是怎么做的?先根据 require('x') 找到对应文件,在 readFileSync 读取, 随后注入exports、require、module三个全局变量再执行源码,最终将模块的 exports 变量值输出
Module._extensions['.js'] = function(module, filename) {
var content = fs.readFileSync(filename, 'utf8');
module._compile(stripBOM(content), filename);
};
读取完毕后编译
Module.prototype._compile = function(content, filename) {
var self = this;
var args = [self.exports, require, self, filename, dirname];
return compiledWrapper.apply(self.exports, args);
};
上面代码等同于
(function (exports, require, module, __filename, __dirname) {
// 模块源码
});
模块的加载实质上就是,注入exports、require、module三个全局变量,然后执行模块的源码,然后将模块的 exports 变量的值输出。
补充说明 Babel 下的 ES6 模块转化
Babel 也会将 export/import的时候,Babel也会把它转换为exports/require的形式。
// m1.js
export const count = 0;
// index.js
import {count} from './m1.js'
console.log(count)
Babel 编译后就应该是
// m1.js
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.count = void 0;
const count = 0;
// index.js
"use strict";
var _m = require("./m1.js");
console.log(_m.count);
exports.count = count;
正因为有 Babel 做了转化,所以 require 和 import 才能被混用在一个项目里,但是你应该知道这是两个不同的模块系统。
题外话
留个思考题给大家,这两种模块系统对于循环引用的区别?有关于循环引用是啥,参见我这篇Node 模块循环引用问题