require()、import、import()加载模块详解(
静态编译:JavaScript 是先编译后执行的。可以理解成 JS 解释器先“翻译”你写的代码,在转译过程中如果没有发现错误,就按你写的意思去执行。
CommonJS 的 require()
在运行时按顺序加载模块,即以同步的方式检索模块的导出。
可以从 node_modules
引入库模块。 或者使用相对路径(例如 ./
、 ./foo
、 ./bar/baz
、 ../foo
)引入本地模块或 JSON 文件,路径会根据 __dirname
定义的目录名或当前工作目录进行处理。
CommonJS主要用于服务端,专为同步加载和服务器而设计,简称CJS。NodeJS采用CommonJS作为模块解决方案,是CJS的标准和最佳实现。如果用Node写过后端应用的话应该会很熟悉。CJS 不能在浏览器中工作,必须经过转换和打包。随着ES6 Module (简称ESM) 成为主流,Node也逐步添加了对 ECMAScript 模块 的支持。
语法:
导出:module.exports
和 exports
module
变量代表当前模块,module.exports 即模块对象下的 exports 属性,它的值是模块对外输出的接口 (默认值是一个空对象)。加载某个模块,就是加载该模块的 module.exports 属性。
// 添加/修改 module.exports 对象的属性
module.exports.name1 = any
// 也可以直接给 module.exports 赋值
module.exports = any
exports 是对 module.exports 对象的引用,为了便利提供的快捷方法。
使用方法是:exports.name1 = 'anyType'
,效果和module.exports.name1 = 'anyType'
是一样的。
可以通过修改/添加 exports 上面的属性将一些值 (简单类型、对象、函数、类都可) 挂到模块导出对象 (module.exports) 的根部。但不能直接给 exports 重新赋值,因为这样它就不再指向 module.exports 的引用地址了,失去和模块导出对象的关联了。
模拟下 require() 获取 module.exports 接口导出值的过程:
function require(/* ... */) {
// 初始化一个包含 exports 导出对象的 module 变量
const module = { exports: {} };
((module, exports) => {
// 此时传进来的 module 和 exports 都是复制了引用地址。
// 先执行模块代码。假设这个模块定义了一个函数。
function someFunc() {}
// 错误做法演示:
exports = someFunc; // 本来指向 module.exports 的引用地址,现在变成 someFunc 的地址了。
// 此时,exports 不再是一个 module.exports 的快捷方式,和导出对象失去关联。
// 因此模块没有受影响,导出值依然是一个空的默认对象。
module.exports = someFunc; // 直接让模块导出指向 someFunc 的地址
// 此时,该模块导出 someFunc,而不是默认对象。
})(module, module.exports); // 函数参数是值传递,简单类型复制值,引用类型传递地址
return module.exports; // 最后当前模块拿到了导出对象,someFunc 的引用地址
}
导入:require(模块的名称或路径)
指向当前模块的 module.require 命令,返回值是导入模块的浅拷贝副本。
常见于引入一些库或核心模块的方法,简单示例:
// ------ lib.js ------
const sqrt = Math.sqrt;
function square(x) {
return x * x;
}
function diag(x, y) {
return sqrt(square(x) + square(y));
}
module.exports = {
sqrt: sqrt,
square: square,
diag: diag,
};
// ------ main.js ------
const square = require('lib').square;
const diag = require('lib').diag;
console.log(square(11)); // 121
console.log(diag(4, 3)); // 5
require 的原理是先执行一遍模块的代码,拿到 module.exports 的值。如果 module.exports 是引用类型 (一般都是一个对象,因为导出一个简单数据没有什么意义),那么我们得到就是 module.exports 的引用地址。如果 module.exports 是个对象,那么对里面的属性同样是浅拷贝。
因此如果我们在当前模块修改了导入的某些属性,很可能会影响被导入模块的数据,具体根据属性的类型和修改的方式。所以一般不会试图修改引入模块的数据,模块化的目的本就是获取其他模块的方法和数据为主。
// ------ lib.js ------
let counter = 3
let obj = { count: 5 }
function incCounter() {
counter++
console.log('lib-fun', counter) // 4
}
console.log('lib1', counter, obj.count) // 3 5
setTimeout(function () {
console.log('lib2', counter, obj.count) // 4 10
})
module.exports = {
counter,
incCounter,
obj
}
// ------ main1.js ------
var counter = require('./lib').counter;
var incCounter = require('./lib').incCounter;
var obj = require('./b').obj;
// 执行这三句 require 的结果是:
var counter = 3
var incCounter = lib 的 incCounter 函数的引用地址
var obj = lib 的 obj 对象的引用地址
// 如果复制的属性是简单数据类型,现在只是和 lib 模块无关联的一个副本
console.log(counter); // 3
incCounter(); // 执行 lib 模块的 incCounter 方法,此时 lib 的 counter 变成4了
console.log(counter); // 3
// 导入的副本可以修改,简单数据类型是值拷贝,不会影响被导入模块的数据
// 而引用数据类型会影响被导入模块
counter = 8
obj.count = 10
console.log(`a.js-` + counter, obj.count) // 8 10
// ------ main2.js ------
var lib = require('./lib')
// 执行上面这句的结果是:
var lib = {
counter: 3, // 简单数据类型,复制值本身
incCounter, // 指向 lib 模块的 incCounter 方法的地址
obj // 复制了 lib 模块的 obj 的引用地址
}
// lib.counter 是简单数据类型,它的值是和 lib 模块无关联的一个副本
console.log(lib.counter) // 3
lib.incCounter() // 执行 lib 对象的 incCounter 方法, 此时 lib 模块的 counter 已经变成4
console.log(lib.counter) // 3
// 在当前模块直接修改简单数据类型的属性,不会影响导入模块
lib.counter = 8
// 修改复杂数据类型的属性的内容,会影响导入模块的数据
lib.obj.count = 10
console.log(`a.js-` + lib.counter, lib.obj.count) // 8 10
main1.js 和 main2.js 执行的效果完全是一样的。
循环依赖
模块在第一次加载后会运行一次,然后将运行结果缓存到内存里。 这意味多次调用require(‘foo’)
只会执行一次 foo 模块的代码,后面都是直接读取缓存的值。 这是一个重要的特性,借助它可以返回“部分完成”的对象,从而允许加载依赖的依赖。
module.require 的源码涉及的知识点较多,webpack 打包模块时对 require 的处理能简单地体现缓存原理,我们来参考一下:
// The require function
function __webpack_require__(moduleId) {
// 根据 moduleId 查找该模块是否存在于installedModules 中,
if(installedModules[moduleId]) {
// 如果存在直接读取模块的导出值,不会再初始化
return installedModules[moduleId].exports;
}
// 初始化一个 module 对象并放入 installedModules中,并进行缓存 (cache)
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
// 执行模块
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// 做个标记表示该 module 已经加载过了
module.l = true;
// 返回模块对象的 exports
return module.exports;
}
require 工作原理最核心的部分类似于这个webpack_require函数,执行 require() 的大致过程如下 (注意⚠️:补充结合了 module.require 的部分):
- 先检测传入的 moduleId 是否有效,必须是非空字符串。
- 如果无误,则调用主要负责加载新模块和管理模块缓存的 Module._load 方法,而 require 本身就是对该方法的一个封装。
- _load 方法内部,先调用 Module._resolveFilename 去获取文件地址。
- 判断是否存在该模块的缓存,如果有返回缓存模块的 exports 。
- 如果没有缓存,且不是核心模块,就创建一个新的 module/模块,并在 Module._cache 中缓存该模块对象。
- 尝试执行该模块的代码,如果报错,会清除该模块的缓存。
- 最后返回模块的输出对象 exports。
根据这个机制,我们看下面两个互相依赖的模块 a 和 b:
// 1. 加载 a 模块,会生成一个新 module 并放入缓存。
// a.js
let b = require('./b'); // 2. 运行 a.js:第一步加载 b 模块,新建一个 b module 放入缓存,并执行 b 的代码。
// 6. b 得到值 { b: 'bbb' }
module.exports = { // 8. 导出一个 module.exports 对象,指向全新的地址
a: 'aaa'
};
// 7. 把 a 模块定时器代码放到任务队列
setTimeout(() => {
console.log(`a.js-${b.b}`); // 10. 输出 a.js-bbb
});
// b.js
let a = require('./a'); // 3. 执行 b 马上要去加载 a,形成循环依赖。读取 a 模块缓存,返回值是初始空对象。
module.exports = { // 5. 导出一个 module.exports 对象,指向全新的地址
b: 'bbb'
}
setTimeout(() => { // 4. 把 b 模块定时器代码放到任务队列
console.log(`b.js-${a.a}`); // 9. a为空对象,a.a 输出 b.js-undefined
});
console 输出
- 执行
node a.js
,a 模块相当于第一次加载,会生成一个新 module 并放入缓存。 - 首先去加载模块 b,新建一个 b module 放入缓存,并执行 b 的代码。
- 在执行 b.js 第一行时发现又要去导入 a,这里形成了循环依赖。然而 a 模块已经存在一个缓存了,就直接去取缓存的 a 模块的 module.exports 值。但 a 模块的代码并没有执行完全,所以 module.exports 还是个空对象 (初始值)。a 变量得到的就是一个空对象地址。
- b 模块继续执行。把 setTimeout 里的代码放到任务队列,等所有同步代码执行完毕后再执行。然后正常导出一个
{ b: 'bbb' }
。 - 执行权回到 a,a 拿到了 b 导出的这个对象并赋值给 b 变量。跟着继续执行 a.js 的第二句,就是给 module.exports 赋值一个新对象
{ a: 'aaa' }
。 - 最后按顺序执行定时器任务队列里的两个 console。b 模块拿到的 a 变量是空对象,这一点后面不会改变,于是 a.a 就是 undefined。而 a = { b: 'bbb' } 就没什么疑问了。
但是这一输出的值没有达到我们的预期,因为用 module.exports 导出会让它指向一个全新的地址,也就是循环依赖拥有属性 a 的对象,跟 b.js 中拿到的对象并不是同一个。其实只要用 exports 挂载导出属性,而不是直接 module.exports = { ... }
,就能改变这一结果。接着看:
步骤就直接写在注释里了,和上面相同的会简略些或跳过。
// 1. 加载 a,缓存 a
// a.js
let b = require('./b') // 2. 加载 b,缓存 b
// 6. b 变量拿到 b 模块 exports 值 { b: 'bbb' },并且指向 b 模块 module.exports 同一引用地址
console.log(`a1.js-${b.b}`) // 7. 输出 a1.js-bbb
exports.a = 'aaa' // 8. 给 a 模块的导出对象添加一个 a 属性,值为 'aaa',此时 a 模块 module.exports 内容为 { a: 'aaa' }
setTimeout(() => {
console.log(`a2.js-${b.b}`) // 10. 读取 b 模块 module.exports 引用地址,输出 a2.js-bbb
})
// b.js
let a = require('./a') // 3. 读取缓存 a 的 exports,a = {},指向 a 模块 module.exports 同一引用地址
console.log(`b1.js-${a.a}`) // 4. 输出 b1.js-undefined
exports.b = 'bbb' // 5. 给 b 模块的导出对象添加一个 b 属性,值为 'bbb'
setTimeout(() => {
console.log(`b2.js-${a.a}`) // 9. 此时读取 a 模块 module.exports 引用地址,已经有了a属性,输出 b2.js-aaa
})
由此可见,要正确地处理模块循环依赖关系,保证模块导出对象地址始终如一,只能用 exports.name1 = anything
这种形式。
CJS如果在浏览器中运行,会有一个重大的局限:
const math = require('math');
math.add(2, 3);
因为 require 是同步加载,第二行代码必须等第一行运行完才能执行。而加载math模块又得先把它里面的内容执行一遍。这对服务端不是一个问题,因为所有的模块都存放在本地硬盘。但浏览器端加载模块要向服务器去请求,等待时长取决于网速的快慢,那么这会是个很大的问题。
下篇:require()、import、import()加载模块详解(二)
参考:CommonJS 模块