最近测试了几个 ES module 和 Commonjs 的例子,理解了之前不太理解的概念,记录一下。要是想多了解的可以去看看阮老师的 Module 那部分。会贴一小部分的代码,不会贴所有验证的代码。
本质上 Commonjs 一直是 node 在使用的规范,虽然其他平台也可以使用。
最重要的应该是解析依赖了,ES module 如果都是同步的,会很慢。都说 ES module 是异步的,在不同环境会有不同的结果。其实 ES module 的三个步骤是可以分开异步进行。在浏览器,会使用 HTML 的规范,最后的实例化是同步,在 node 环境,文件都是在本地,同步就显得很容易。
Commonjs 可以用 exports.xxx 导出,也可以用 module.exports = {}导出,因为整个文件读取之后会包裹到一个自执行函数,差不多是这样:
(function(exports, require, module, filename, dirname){
})(exports, require, module, filename, dirname)
如果直接 exports = {}那么导出是无效的。下面三个例子就可以很好的理解:
function fn(obj){
obj.num = 2;
};
let obj = {
num: 1;
};
fn(obj);
console.log(obj);
function fn(obj){
obj = {num: 2};
};
let obj = {
num: 1;
};
fn(obj);
console.log(obj);
function fn(obj){
obj = 2;
};
let obj = 1;
fn(obj);
console.log(obj);
对象是指针的引用,相当于 obj = xxxx,用 obj.xx 赋值其实就是给指针 xxxx 指向的对象赋值,如果 obj = {},相当于 obj 的指针改变了,相当于 obj = xx,所以 exports = {}是无效的。
这块其实挺好实验的,导出一个变量,调用函数改变这个变量再输出,可以得到 Commonjs 的值是不会因为执行了 add 就改变,ES module 就会:
let a = 10;
exports.a = a;
exports.add = () => {
a++;
};
let a = 10;
export const b = a;
exports.add = () => {
a++;
};
运行时加载也比较好实验(个人观点这样可以表示是运行时加载):
main.js
let a = require('./a.js');
let b = require('./b.js');
a.js
exports.a = 'a';
let b = require('./b.js');
exports.aa = 'aa';
b.js
let a = require('./a.js');
console.log(a,'in b.js');
这样去执行的时候,b.js 里面的 a 是{a: ‘a’},如果把 exports.aa = ‘aa’;放到 let b = require(‘./b.js’);之前,b.js 里面的 a 是{a: ‘a’, aa: ‘aa’}。
所以 Commonjs 是一边运行一边加载,当 a.js 执行到 let b = require(‘./b.js’);的时候,之前的代码是执行过了,并缓存起来,这时候就会去加载 b.js 并执行。
不太确定是否能这样理解:
index.js
import {c} from './c.js';
c.js
import {d} from './d.js';
export let c = 'c';
d.js
import {c} from './c.js';
console.log(c,'in d.js');
export const d = 'd';
得到的结果会报错:Cannot access ‘c’ before initialization,如果 let c 改成 var c,结果是 undefined in d.js。
ES module 会有一个跟 JavaScript 解析一样的过程,先是解析整个 js,做一些变量提升,然后再执行。就是说会先加载所有的文件,并且解析,不会执行,在所有依赖文件加载解析完成,再开始执行。所以我是这样去理解的 ES module 是编译时输出。
这点其实挺重要的,ES module 和 Commonjs 都是通过缓存来解决循环引用的问题,不会造成死循环。Commonjs 是运行时加载,在解析到 require 的时候,会先检查缓存,如果没有,会先进行缓存再继续往下执行:
main.js
require('./a.js');
a.js
let b = require('./b.js');
exports.a = 'a';
console.log('a.js', b);
b.js
let a = require('./a.js');
console.log('b.js', a);
exports.b = 'b';
result:
b.js {}
a.js { b: 'b' }
大概流程:
所以 Commonjs 多次引入和循环引入的解决方案,是先缓存,再根据执行的内容新增缓存的内容,而且只会执行一次。
ES module 解决多次引入和循环引入也是依赖缓存,但是缓存的机制不一样。ES module 是值的引用和编译时输出,ES module 导出的是内存地址的索引:
index.js
import {c} from './c.js';
c.js
import {d} from './d.js';
console.log(d,'in c.js');
export var c = 'c';
d.js
import {c} from './c.js';
console.log(c,'in d.js');
export const d = 'd';
result
undefined in d.js
d in c.js
当解析到 d.js 的 import {c} from ‘./c.js’;,会去 module map 检查是否有 c moduel record,有,建立模块指向。当依赖解析完成之后,代码也解析完成了,最后实例化运行代码,所以 d.js 执行的时候 c 是 undefined。
Commonjs 的 require 可以是动态的,也不一定要放在顶层,ES module 的 import 就必须放在最顶层。动态加载在实际应用场景是必须的,对于性能方面有非常大的提升。最典型的就是路由懒加载,如果不是有动态 import,打包出来的是一个文件,首次加载会非常慢。还有是一些条件语句决定是否加载某些文件,对性能也非常友好。
ES module 可以实现 tree shaking,核心就是 ES module 是编译时输出,新进行编译再执行,编译过程就能确定哪些内容是无用的,Commonjs 就无法实现,只有在执行过程中才知道哪些内容是无用的。
如果文件后缀是.mjs(node 执行的后缀是.cjs),那么 node 会根据 ES module 规范去执行,如果是 js,那么 package.json 里面要新增"type": “module”,否则会报错:
Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
SyntaxError: Cannot use import statement outside a module
也可以配置 exports 做兼容,exports 优先级高于 main:
"exports": {
"import": "./src/index.js",
"require": "./src/index.cjs"
}
先看是否是内置包,如果是直接返回;看是否是相对路径,是就处理成可识别绝对路径,如果找不到就报错;不是内置包没有相对路径,从当前目录开始寻找 node_modules,找不到依次往上的目录寻找 node_modules,直到根目录,还找不到就报错。会先以文件名找,再依次是.js、.json、.node。
欢迎关注订阅号 coding个人笔记