作为前端开发者,你是否也曾有过疑惑,为什么可以代码中可以直接使用 require
方法加载模块,为什么加载第三方包的时候 Node 会知道选择哪个文件作为入口,以及常被问到的,为什么 ES6 Module export 基础数据类型的时候会有【引用类型】的效果?
带着这些疑问和好奇,希望阅读这篇文章能解答你的疑惑。
在 ES6 之前,ECMAScript 并没有提供代码组织的方式,那时候通常是基于 IIFE 来实现“模块化”,随着 JavaScript 在前端大规模的应用,以及服务端 Javascript 的推动,原先浏览器端的模块规范不利于大规模应用。于是早期便有了 CommonJS 规范,其目标是为了定义模块,提供通用的模块组织方式。
在 Commonjs 中,一个文件就是一个模块。定义一个模块导出通过 exports
或者 module.exports
挂载即可。
exports.count = 1;
导入一个模块也很简单,通过 require
对应模块拿到 exports
对象。
const counter = require('./counter');
console.log(counter.count);
CommonJS 的模块主要由原生模块 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'
]
}
在编写 CommonJS 模块的时候,我们会使用 require
来加载模块,使用 exports
来做模块输出,还有 module
,__filename
, __dirname
这些变量,为什么它们不需要引入就能使用?
原因是 Node 在解析 JS 模块时,会先按文本读取内容,然后将模块内容进行包裹,在外层裹了一个 function,传入变量。再通过 vm.runInThisContext
将字符串转成 Function
形成作用域,避免全局污染。
let wrap = function(script) {
return Module.wrapper[0] + script + Module.wrapper[1];
};
const wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
于是在 CommmonJS 的模块中可以不需要 require,直接访问到这些方法,变量。
参数中的 module
是当前模块的的 module
实例(尽管这个时候模块代码还没编译执行),exports
是 module.exports
的别名,最终被 require
的时候是输出 module.exports
的值。require
最终调用的也是 Module._load 方法。__filename,__dirname
则分别是当前模块在系统中的绝对路径和当前文件夹路径。
开发者在使用 require 时非常简单,但实际上为了兼顾各种写法,不同类型的模块,node_module
s packages 等模块的查找过程稍微有点麻烦。
首先,在创建模块对象时,会有 paths 属性,其值是由当前文件路径计算得到的,从当前目录一直到系统根目录的 node_modules
。可以在模块中打印 module.paths
看看。
[
'/Users/evan/Desktop/demo/node_modules',
'/Users/evan/Desktop/node_modules',
'/Users/evan/node_modules',
'/Users/node_modules',
'/node_modules'
]
除此之外,还会查找全局路径(如果存在的话)
[
execPath/../../lib/node_modules, // 当前 node 执行文件相对路径下的 lib/node_modules
NODE_PATH, // 全局变量 NODE_PATH
HOME/.node_modules, // HOME 目录下的 .node_module
HOME/.node_libraries' // HOME 目录下的 .node-libraries
]
按照官方文档给出的查找过程已经足够详细,这里只给出大概流程。
从 Y 路径运行 require(X)
1. 如果 X 是内置模块(比如 require('http'))
a. 返回该模块。
b. 不再继续执行。
2. 如果 X 是以 '/' 开头、
a. 设置 Y 为 '/'
3. 如果 X 是以 './' 或 '/' 或 '../' 开头
a. 依次尝试加载文件,如果找到则不再执行
- (Y + X)
- (Y + X).js
- (Y + X).json
- (Y + X).node
b. 依次尝试加载目录,如果找到则不再执行
- (Y + X + package.json 中的 main 字段).js
- (Y + X + package.json 中的 main 字段).json
- (Y + X + package.json 中的 main 字段).node
c. 抛出 "not found"
4. 遍历 module paths 查找,如果找到则不再执行
5. 抛出 "not found"
模块查找过程会将软链替换为系统中的真实路径,例如 lib/foo/node_moduels/bar
软链到 lib/bar,bar
包中又 require('quux'),最终运行 foo module 时,require('quux') 的查找路径是 lib/bar/node_moduels/quux
而不是 lib/foo/node_moduels/quux
。
当运行 node index.js
时,Node 调用 Module 类上的静态方法 _load(process.argv[1])
加载这个模块,并标记为主模块,赋值给 process.mainModule
和 require.main
,可以通过这两个字段判断当前模块是主模块还是被 require
进来的。
CommonJS
规范是在代码运行时同步阻塞性地加载模块,在执行代码过程中遇到 require(X)
时会停下来等待,直到新的模块加载完成之后再继续执行接下去的代码。
虽说是同步阻塞性,但这一步实际上非常快,和浏览器上阻塞性下载、解析、执行 js
文件不是一个级别,硬盘上读文件比网络请求快得多。
文件模块查找挺耗时的,如果每次 require 都需要重新遍历文件夹查找,性能会比较差;还有在实际开发中,模块可能包含副作用代码,例如在模块顶层执行 addEventListener
,如果 require 过程中被重复执行多次可能会出现问题。
CommonJS
中的缓存可以解决重复查找和重复执行的问题。模块加载过程中会以模块绝对路径为 key
, module
对象为 value
写入 cache
。在读取模块的时候会优先判断是否已在缓存中,如果在,直接返回 module.exports
;如果不在,则会进入模块查找的流程,找到模块之后再写入 cache
。
// 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
以上例子中,require a.js
并修改其中的 foo
属性,接着再次 require a.js
可以看到两次 require
结果是一样的。
模块缓存可以打印 require.cache
进行查看。
{
'/Users/evan/Desktop/demo/main.js':
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/evan/Desktop/demo/main.js',
loaded: false,
children: [ [Object] ],
paths:
[ '/Users/evan/Desktop/demo/node_modules',
'/Users/evan/Desktop/node_modules',
'/Users/evan/node_modules',
'/Users/node_modules',
'/node_modules'
]
},
'/Users/evan/Desktop/demo/a.js':
Module {
id: '/Users/evan/Desktop/demo/a.js',
exports: { foo: 1 },
parent:
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/evan/Desktop/demo/main.js',
loaded: false,
children: [Array],
paths: [Array] },
filename: '/Users/evan/Desktop/demo/a.js',
loaded: true,
children: [],
paths:
[ '/Users/evan/Desktop/demo/node_modules',
'/Users/evan/Desktop/node_modules',
'/Users/evan/node_modules',
'/Users/node_modules',
'/node_modules' ] } }
缓存还解决了循环引用的问题。举个例子,现在有模块 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
。
小结:
CommonJS
模块加载过程是同步阻塞性地加载,在模块代码被运行前就已经写入了 cache
,同一个模块被多次 require
时只会执行一次,重复的 require
得到的是相同的 exports
引用。
值得留意:cache key
使用的是模块在系统中的绝对位置,由于模块调用位置的不同,相同的 require('foo')
代码并不能保证返回的是统一个对象引用。我之前恰巧就遇到过,两次 require('egg-core')
但是他们并不相等。
ES6
模块是前端开发同学更为熟悉的方式,使用 import
, export
关键字来进行模块输入输出。ES6
不再是使用闭包和函数封装的方式进行模块化,而是从语法层面提供了模块化的功能。
ES6
模块中不存在 require
, module.exports
, __filename
等变量,CommonJS
中也不能使用 import
。两种规范是不兼容的,一般来说平日里写的 ES6
模块代码最终都会经由 Babel
, Typescript
等工具处理成 CommonJS 代码。
使用 Node
原生 ES6
模块需要将 js
文件后缀改成 mjs
,或者 package.json "type"`` 字段改为 "module",通过这种形式告知
Node使用
ES Module` 的形式加载模块。
ES6 模块的加载过程分为三步:
ES6 模块会在程序开始前先根据模块关系查找到所有模块,生成一个无环关系图,并将所有模块实例都创建好,这种方式天然地避免了循环引用的问题,当然也有模块加载缓存,重复 import 同一个模块,只会执行一次代码。
这一步完成的工作是 living binding import export
,借助下面的例子来帮助理解。
// 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
上面 CommonJS
的例子执行结果很好理解,修改 count++`` 修改的是模块内的基础数据类型变量,不会改变
exports.count`,所以打印结果认为 1。
// counter.mjs
export let count = 1;
export function increment () {
count++;
}
// main.mjs
import { increment, count } from './counter.mjs'
increment();
console.log(count); // 2
从结果上看使用 ES6
模块的写法,当 export
的变量被修改时,会影响 import
的结果。这个功能的实现就是 living binding
,具体规范底层如何实现可以暂时不管,但是知道 living binding
比网上文章描述为 "ES6 模块输出的是值的引用" 更好理解。
更接近 ES6 模块的 CommonJS 代码可以是下面这样:
exports.counter = 1;
exports.increment = function () {
exports.counter++;
}
到第三步,会基于第一步生成的无环图进行深度优先后遍历填值,如果这个过程中访问了尚未初始化完成的空间,会抛出异常。
// 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);
上面的例子会在运行时抛出异常 ReferenceError: Cannot access 'a1' before initialization
。如果改成 import * as a from 'a.mjs'
可以看到 a 模块中 export
的对象已经占好坑了。
// b.mjs
import * as a from './a.mjs'
console.log(a);
将输出 { a1:, a2:} 可以看出,ES6 模块为 export 的变量预留了空间,不过尚未赋值。这里和 CommonJS 不一样,CommonJS 到这里是知道 a1 为 true, a2 为 undefined
除此之外,我们还能推导出一些 ES6 模块和 CommonJS 的差异点:
CommonJS
可以在运行时使用变量进行 require
, 例如 require(path.join('xxxx', 'xxx.js'))
,而静态 import
语法(还有动态 import,返回 Promise)不行,因为 ES6
模块会先解析所有模块再执行代码。
require
会将完整的 exports
对象引入,import
可以只 import
部分必要的内容,这也是为什么使用 Tree Shaking
时必须使用 ES6 模块 的写法。import 另一个模块没有 export
的变量,在代码执行前就会报错,而 CommonJS 是在模块运行时才报错。
前面提到 ES6
模块和 CommonJS
模块有很大差异,不能直接混着写。这和开发中表现是不一样的,原因是开发中写的 ES6
模块最终都会被打包工具处理成 CommonJS
模块,以便兼容更多环境,同时也能和当前社区普通的 CommonJS
模块融合。
在转换的过程中会产生一些困惑,比如说:
使用转换工具处理 ES6
模块的时候,常看到打包之后出现 __esModule
属性,字面意思就是将其标记为 ES6 Module
。这个变量存在的作用是为了方便在引用模块的时候加以处理。
例如 ES6
模块中的 export default
在转化成 CommonJS 时会被挂载到 exports['default']
上,当运行 require('./a.js')
时 是不能直接读取到 default 上的值的,为了和 ES6 中 import a from './a.js'
的行为一致,会基于 __esModule
判断处理。
// a.js
export default 1;
// main.js
import a from './a';
console.log(a);
转化后
// a.js
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = 1;
// main.js
'use strict';
var _a = require('./a');
var _a2 = _interopRequireDefault(_a);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
console.log(_a2.default);
a 模块 export defualt
会被转换成 exports.default = 1
;,这也是平时前端项目开发中使用 require
为什么还常常需要 .default
才能取到目标值的原因。
接着当运行 import a from './a.js'
时,es module
预期的是返回 export 的内容。工具会将代码转换为 _interopRequireDefault
包裹,在里面判断是否为 esModule,是的话直接返回,如果是 commonjs
模块的话则包裹一层 {default: obj}
,最后获取 a
的值时,也会被装换成 _a1.default
。
https://nodejs.org/api/modules.html
https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/
https://segmentfault.com/a/1190000004940294
https://www.infoq.cn/article/nodejs-module-mechanism
❤️爱心三连击1.看到这里了就点个在看支持下吧,你的「在看」是我创作的动力。
2.关注公众号程序员成长指北,「带你一起学Node」!
3.特殊阶段,带好口罩,做好个人防护。
4.添加微信【ikoala520】,拉你进技术交流群一起学习。
“在看转发”是最大的支持