请说说ES6 module和CommonJS的区别
面试常问系列。
”
老规矩先看目录:
CommonJS:
1.module代表当前模块:
在CommonJS
中,一个文件就是一个模块,模块中的变量,函数,类都是私有的外部不可以访问,并规定module
代表当前模块,exports
是对外的接口。CommonJS
主要依赖于module
这个类,我们可以看一下module
上面的相关属性:
Module {
id: '.', // 如果是 mainModule id 固定为 '.',如果不是则为模块绝对路径
exports: {}, // 模块最终 exports
filename: '/absolute/path/to/entry.js', // 当前模块的绝对路径
loaded: false, // 模块是否已加载完毕
children: [], // 被该模块引用的模块
parent: '', // 第一个引用该模块的模块
paths: [ // 模块的搜索路径
'/absolute/path/to/node_modules',
'/absolute/path/node_modules',
'/absolute/node_modules',
'/node_modules'
]
}
2.为什么可以直接使用exports,module,__dirname这些方法属性?
要回答这个问题我们要从CommonJS内部执行代码的原理说起。
在CommonJS
规范中代码在运行时会被包裹在一个立即执行函数中,之后我们会改变这个立即执行函数中内部this
的指向,指向的便是module.exports
这个空对象。这便可以很好的解释我们node.js中内部this指向的是一个空对象的问题。
逻辑代码:
(function (exports, require, module, __filename, __dirname) {
let name = "lm";
exports.name = name;
});
jsScript.call(module.exports, args);
之后我们会给其传递exports, require, module,,__filename
等参数,所以我们可以在直接在编写node.js代码中使用这些变量。
3.exports与module.exports有什么区别?
在node.js中我们导出一个变量,函数,或者类一般有两种导出方法:
function A() {
console.log('过年好!');
}
// 法一:module.exports.A = A;
// 法二:exports.A = A;
这两种方法有什么区别吗?其实exports只是module.exports的引用罢了,所以实际上这两种方法在使用上的效果是一样的。
const module = {
'exports': {
}
}
const exports = module.exports;
exports.name = 'Andy'; //完全等价于 module.exports.name = 'Andy';
所以当我们使用exports
或者module.exports
导出模块时,其实也就是给module.exports
这个对象添加属性,之后我们使用require
引入模块时得到的便是module.exports
这个对象。
注意:既然是对象属性的引用,所以当我们使用一个模块中的方法修改该模块中的变量,之后导出的变量的结果是不变的,也就是说只要一个变量已经被导出了之后在模块内部对变量的修改都将无意义,这个情况要格外注意。(这点与ES6 module
有很大的不同)
a.js:
let count = 1;
function add() {
count += 1;
}
exports.count = count;
exports.add = add;
b.js:
let Module = require('./a');
console.log(Module.count); // 1
Module.add();
console.log(Module.count); // 1
4.模块引入后自动缓存
我们在使用require时可能是这样的:
let Module = require('./a');
如果是系统模块,或者第三方模块我们可以直接写模块名:
let fs = require('fs');
但实际上在require模块时我们都是要根据计算机中的绝对地址来引入,这个根据相对地址或者包名来查找文件的过程是比较消耗时间的,我们可以通过module.paths
来打印一下查找的过程:
[
'c:\\Users\\dell\\Desktop\\web-design\\前端相关题目\\字节跳动\\node_modules',
'c:\\Users\\dell\\Desktop\\web-design\\前端相关题目\\node_modules',
'c:\\Users\\dell\\Desktop\\web-design\\node_modules',
'c:\\Users\\dell\\Desktop\\node_modules',
'c:\\Users\\dell\\node_modules',
'c:\\Users\\node_modules',
'c:\\node_modules'
]
所以为了提高性能,我们每次在文件中引入一个模块时,我们都会将引入的这个模块与其相应的绝对地址进行缓存,如果在一个文件中多次引入相同的模块这个模块只会被加载一次。
我们可以使用require.cache
打印出当前模块的依赖模块看看,我们可以发现其是以绝对地址为key
,模块为value
的对象:
[Object: null prototype] {
'c:\\Users\\dell\\Desktop\\web-design\\前端相关题目\\字节跳动\\b.js': Module {
id: '.',
path: 'c:\\Users\\dell\\Desktop\\web-design\\前端相关题目\\字节跳动',
exports: {},
parent: null,
filename: 'c:\\Users\\dell\\Desktop\\web-design\\前端相关题目\\字节跳动\\b.js',
loaded: false,
children: [ [Module] ],
paths: [
'c:\\Users\\dell\\Desktop\\web-design\\前端相关题目\\字节跳动\\node_modules',
'c:\\Users\\dell\\Desktop\\web-design\\前端相关题目\\node_modules',
'c:\\Users\\dell\\Desktop\\web-design\\node_modules',
'c:\\Users\\dell\\Desktop\\node_modules',
'c:\\Users\\dell\\node_modules',
'c:\\Users\\node_modules',
'c:\\node_modules'
]
},
'c:\\Users\\dell\\Desktop\\web-design\\前端相关题目\\字节跳动\\a.js': Module {
id: 'c:\\Users\\dell\\Desktop\\web-design\\前端相关题目\\字节跳动\\a.js',
path: 'c:\\Users\\dell\\Desktop\\web-design\\前端相关题目\\字节跳动',
exports: { count: 1, add: [Function: add] },
parent: Module {
id: '.',
path: 'c:\\Users\\dell\\Desktop\\web-design\\前端相关题目\\字节跳动',
exports: {},
parent: null,
filename: 'c:\\Users\\dell\\Desktop\\web-design\\前端相关题目\\字节跳动\\b.js',
loaded: false,
children: [Array],
paths: [Array]
},
filename: 'c:\\Users\\dell\\Desktop\\web-design\\前端相关题目\\字节跳动\\a.js',
loaded: true,
children: [],
paths: [
'c:\\Users\\dell\\Desktop\\web-design\\前端相关题目\\字节跳动\\node_modules',
'c:\\Users\\dell\\Desktop\\web-design\\前端相关题目\\node_modules',
'c:\\Users\\dell\\Desktop\\web-design\\node_modules',
'c:\\Users\\dell\\Desktop\\node_modules',
'c:\\Users\\dell\\node_modules',
'c:\\Users\\node_modules',
'c:\\node_modules'
]
}
}
从而可以很好的解释这个例子:
// a.js
module.exports = {
foo: 1,
};
// main.js
const a1 = require('./a.js');
a1.foo = 2;
const a2 = require('./a.js');
console.log(a2.foo); // 2
console.log(a1 === a2); // true
我们可以理解为只要模块一引入加载完,即使再次引入也还是之前的模块。
同时缓存还很好的解决了循环引用的问题:举个例子,现在有模块 a require 模块 b;而模块 b 又 require 了模块 a。
// main.js
const a = require('./a');
console.log('in main, a.a1 = %j, a.a2 = %j', a.a1, a.a2);
// a.js
exports.a1 = true;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.a2 = true;
// b.js
const a = require('./a.js');
console.log('in b, a.a1 = %j, a.a2 = %j', a.a1, a.a2);
复制代码
程序执行结果如下:
in b, a.a1 = true, a.a2 = undefined
in main, a.a1 = true, a.a2 = true
复制代码
实际上在模块 a 代码执行之前就已经创建了 Module 实例写入了缓存,此时代码还没执行,exports 是个空对象。
'/Users/evan/Desktop/module/a.js':
Module {
exports: {},
//...
}
}
代码 exports.a1 = true;
修改了 module.exports
上的 a1
为 true
, 这时候 a2
代码还没执行。
'/Users/evan/Desktop/module/a.js':
Module {
exports: {
a1: true
}
//...
}
}
进入 b
模块,require a.js
时发现缓存上已经存在了,获取 a
模块上的 exports
。打印 a1, a2
分别是 true
,和 undefined
。
运行完 b
模块,继续执行 a
模块剩余的代码,exports.a2 = true;
又往 exports
对象上增加了 a2
属性,此时 module a
的 export
对象 a1, a2
均为 true
。
exports: {
a1: true,
a2: true
}
再回到 main
模块,由于 require('./a.js')
得到的是 module a
export
对象的引用,这时候打印 a1, a2
就都为 true
。
这里还有一个需要注意的点就是,模块在加载时是同步阻塞的,只有引入的模块加载完才会执行后面的语句,大家记住就好。
5.总结:
说了这么多我们主要的目的还是为了面试,所以这里小小的总结一下:
在CommonJS中一个文件就是一个模块,模块中的变量,方法,类都是私有的 module代表当前模块, module.exports
代表模块对外的接口模块在加载时所有内容会被放在一个立即执行函数中,函数的this指向 module.exports
这个空对象,而exports
只是module.exports
的引用而已加载模块是同步阻塞的,加载后会进行缓存,多次引入只会加载一次 require得到的模块中变量,方法,类的拷贝,并不是直接的引用
ES6 module:
这个是我们最常用的,我们通常会在Vue或者Webpack中来使用,其并不像是CommonJS
那样将代码放在一个立即执行函数中(依靠闭包)从而完成模块化,而是从语法层面完成的模块化。一般情况下我们写的ES6 module
语法会还是会通过bable
或者Webpack
等工具转化为CommonJS语法的。
对于ES6 module
就不详细介绍其实现原理了,主要想说一下其特点并且和CommonJS相比有区别来方便大家记忆。
1.在执行模块前会先加载所有的依赖模块
这点也是最重要的一点,通过上面我们知道CommonJS是在执行到需要加载依赖模块时,会(同步阻塞)停下当前任务去加载相应的依赖模块,而对于ES module来说无论你在哪一行引用依赖模块,其都会在一开始就进行加载相应的依赖模块。
// a.mjs
export const a1 = true;
import * as b from './b.mjs';
export const a2 = true;
// b.mjs
import { a1, a2 } from './a.mjs'
console.log(a1, a2);
在这种情况下,如果是之前的CommonJS会输出true与undefined,而现在会直接报错:ReferenceError: Cannot access 'a1' before initialization。
同样的原因我们在CommonJS中可以这样写,而在ES module中会报错:
require(path.join('xxxx', 'xxx.js'))
同样如果我们在CommonJS中引入一个没有exports的变量那么在代码执行时才会报错,而ES module在刚开始就会报错。
2.import的是变量的引用
在CommonJS的情况下:
// counter.js
let count = 1;
function increment () {
count++;
}
module.exports = {
count,
increment
}
// main.js
const counter = require('counter.cjs');
counter.increment();
console.log(counter.count); // 1
在ES module情况下:
// counter.mjs
export let count = 1;
export function increment () {
count++;
}
// main.mjs
import { increment, count } from './counter.mjs'
increment();
console.log(count); // 2
这一次我们导入是变量的引用了,这样可以避免之前CommonJS在实际开发中的很多问题,实际类似于这样。
exports.counter = 1;
exports.increment = function () {
exports.counter++;
}
3.ES module是部分导入
这个很好理解,在CommonJS中我们加载一个模块需要将该模块的所有接口导入进来,而ES6 module里我们可以按需只导入我们想要的接口。
最后顺便再提一点:出于兼容性考虑对于像Webpack我们在使用的ES module
时最终还是会转换为CommonJS
规范,所以有些时候我们使用require时导入的并不是目标值,我们往往需要加一个.defult
才行,这就是因为ES module
的exports defult
语法所造成的。
4.总结:
其实ES6 module相对于CommonJS最大的区别就是两点:
在执行模块前首先需要加载所有的依赖模块,如果加载有问题直接报错 ES6 module的模块引入的都是变量,函数,类的引用这是很有先进性的
还有值得一提的就是ES6 module可以按需引入自己需要的接口,两者也是具有相同点的就是都会对已经引入的模块进行缓存,如果多次引入只会执行一次。
参考:
FESKY:CommonJS 和 ES6 Module 究竟有什么区别?[1]
雨中前行:再次梳理AMD、CMD、CommonJS、ES6 Module的区别[2]
参考
CommonJS 和 ES6 Module 究竟有什么区别?: https://juejin.cn/post/6844904080955932680#heading-7
[2]再次梳理AMD、CMD、CommonJS、ES6 Module的区别: https://juejin.cn/post/6844903983987834888#heading-10