模块化
Nodejs
主流支持两套模块化规范,社区维护的CommonJS
以及官方标准的ES Module
。
目前主流的模块化规范:
CommonJS
ES Module
AMD
CMD
UMD
INFO
UMD
规范实际上是一种通用写法,可以适配现存主流的模块规范AMD + CMD + 浏览器全局变量
的结合。
模块就是一个存在使用导入或导出的JS
文件,它实现部分功能,对外隐藏内部实现,向外暴漏接口供其他模块适用。
核心要素有两点:隐藏
和暴漏
。使用模块的人只需要关心怎么使用,而不用关心内部实现。
CommonJS
CommonJS
规范是一种运行时同步加载模块的方式。通过require
引用加载模块和exports
对外输出接口。
module.exports
exports
是module.exports
的引用require
用于加载模块文件(加载模块文件执行模块内的JavaScript
代码并返回该模块的module.exports
对象)
CommonJS
细节
模块为同步加载,加载顺序是由书写代码的顺序决定的。
模块运行取决于该模块有没有被其他模块所引入。
exports
实际是module.exports
的引用,在默认情况下,模块内的module.exports
、exports
、this
这三者指向的是同一个引用,但如果给这三个任一个重新赋值,都会使它们的关系发生变化。module.exports
和exports
尽量避免修改引用。js// 默认情况下,this、module.exports、exports指向同一个对象(比如temp) this.a = 1 // temp = { a: 1 } exports.b = 2 // temp = { a: 1, b: 2 } // 引用关系发生改变,指向另外对象,如temp2 exports = { // temp2 = { c: 3 } c: 3 } // 引用关系发生改变,指向另外对象,如temp3 module.exports = {//temp3 = { d: 4 } d: 4 } exports.e = 5 // temp2 = { c: 3, e: 5 } this.f = 6 // temp = { a: 1, b: 2, f: 6 }
CommonJS
的代码运行在模块作用域内,不会对全局进行变量污染模块可以多次加载,但只会在第一次加载时运行,然后会被缓存起来,之后的加载都会执行缓存结果。只有清除缓存才能让模块重新执行(通过操作
require.cache
可达到清除缓存)。
后缀名
在使用require
进行导入时,如果省略了文件后缀名,Nodejs
会按顺序分别尝试补全文件名:
.js
.json
.node
如果在加载时,上述后缀名的文件都查找不到,会判断该路径是否为目录视为第三方模块,会去查找目录下的package.json
的main
字段,如果不存在,就会去加载目录下的index.[js|json|node]
TIP
举个🌰:require('./src/page')
./src/page => ./src/page.[js|json|node] => ./src/page/index.[js|json|node]
还有一种情况,在加载第三方模块时,该模块在其下的package.json
指定了main
字段的文件路径,在加载模块时,会优先去查找这个指定的文件路径,如果没有指定,则会去查找其下的index
文件。
模块查找
Nodejs
对于模块的查找,更多是npm
对于模块查找的解释。
实现解析
模块的导入,实际上是将开发人员编写的代码文件,用一个函数包裹,读入并执行这个函数,返回该模块的module.exports
对象。
// .js文件处理
const wrapper = ["(function (exports, require, module, __filename, __dirname) { ", "\n})"];
// 3.将开发人员编写的代码,包裹在一个函数中,得到以下结果
//(function (exports, require, module, __filename, __dirname) {
// const xm = 18 => 开发人员自己写的代码,通过wrap方法组装起来
//\n});
let wrap = function (script) {
return wrapper[0] + script + wrapper[1];
};
function wrapSafe(filename, content, cjsModuleInstance) {
const wrapper = Module.wrap(content);
// 将wrapper后的方法字符串,通过vm虚拟机的Script类,实例化为一个script执行结果,可以视为 window.eval()
const script = new Script(wrapper, {
filename,
});
//返回一个可执行的全局上下文函数
return script.runInThisContext({
displayErrors: true,
});
}
Module.prototype._compile = function (content, filename) {
let redirects;
// 4.创建module对象
const compiledWrapper = wrapSafe(filename, content, this);
const dirname = path.dirname(filename);
// 5.创建module对象
const require = makeRequireFunction(this, redirects);
const exports = this.exports;
const thisValue = exports;
// 这就是为什么在模块中用this操作属性会影响到module对象
const module = this;
// 5.将exports,require,module,filename,dirname映射参数到compiledWrapper方法中,实际上也就是wrapper[0]中的那几个参数:(exports, require, module, __filename, __dirname)
let result = ReflectApply(compiledWrapper, thisValue, [exports, require, module, filename, dirname]);
// 6.返回执行结果
return result;
};
// ↑
Module._extensions[".js"] = function (module, filename) {
const cached = cjsParseCache.get(module);
let content;
if (cached?.source) {
// 1.如果存在缓存,直接返回缓存结果
content = cached.source;
cached.source = undefined;
} else {
// 2.否则从文件系统读取源代码
content = fs.readFileSync(filename, "utf8");
}
//读取package.json文件
const pkg = readPackageScope(filename);
//如果package.json文件中有type字段,并且type字段的值为module,并且你使用了require
//则抛出一个错误,提示不能在ES模块中使用require函数
if (pkg?.data?.type === "module") {
const parent = moduleParentCache.get(module);
const parentPath = parent?.filename;
const packageJsonPath = path.resolve(pkg.path, "package.json");
const usesEsm = hasEsmSyntax(content);
const err = new ERR_REQUIRE_ESM(filename, usesEsm, parentPath, packageJsonPath);
throw err;
}
// 3.调用_compile方法,将js源码编译一个模块函数包裹的代码
module._compile(content, filename);
};
加载过程
- 模块在首次被引入时,会执行一次,运行结果会被缓存起来,等下次再被引入,会使用缓存结果
- 代码由上自下同步加载
- 模块的加载过程实际上就是一个图形的数据结构遍历过程,会进行一个深度优先遍历加载每个模块。所以存在循环依赖问题,会在一次次的深度优先遍历标记已经访问中解决。
图数据结构深度优先遍历基本步骤:
- 从图中的某个顶点开始,标记该顶点为已访问。
- 选择一个与当前顶点相邻且未被访问的顶点,移动到该顶点,并标记为已访问——
module.loaded
。 - 重复步骤2,直到当前顶点没有未被访问的相邻顶点。
- 回溯到上一个顶点,检查是否有未被访问的相邻顶点。
- 重复步骤2-4,直到所有顶点都被访问过。
ES Module
ESM
是官方发布的标准模块化方案,是一种静态结构的模块化方案,在编译时就能确定模块的依赖关系。能更方便执行一些优化操作,比如:按需加载,方便Tree shaking
。
ESM
是通过import
引用加载模块和export
对外输出接口。
- 导入语句必须出现在模块顶层,导入时文件路径必须是确定的,不能是动态的
ESM
导入的变量是引用传递- 每个
ESM
都有自己的作用域,作用域内部的变量不会污染全局,且顶层this
是undefined
- 支持动态导入,允许按需加载模块,返回一个
Promise
,包含了模块的默认导出和具名导出。
具名导出
- 声明并导出/导入
// a.js
export const person = {
name: '张三',
age: 18
}
// index.js
// 在没有脚手架帮助的情况下,需要添加上后缀名
import { person } from './a.js'
- 别名导出/导入
// a.js
const hp = 100
const attack = 50
// 用as可以对导出的内容别名
export {
hp as heroHp,
attack as heroAttack
}
// index.js
// 同样导入也可以进行别名
import { heroHp as heroHp2, heroAttack as heroAttack2 } from './a.js'
// 或者命名空间别名的导入
import * as paperInfo from './a.js'
const { heroHp, heroAttack } = paperInfo
- 集中导出/导入
// a.js
const foo = 'foo'
const fn = function(){}
// 用as可以对导出的内容别名
export {
foo,
fn
}
// index.js
// 命名空间别名的导入
import * as aModule from './a.js'
const { foo, fn } = aModule
export
和import
结合适用
// module/a.js
const foo = 'foo'
const fn = function(){}
export {
foo,
fn
}
// module/index.js
/**
* 实际上是导入导出的结合体,分为两步走
* import xxx from 'path'
* export [default] xxx
* 但是在中转层只能允许存在一个default默认导出
*/
export * from './a.js'
export { foo as default } from './a.js'
// main.js
import {} from './module'
默认导出
一个模块用default
关键字表示默认导出,有且只能存在一个默认导出。
// a.js
// 默认导出可以具名也能匿名
// export default age = 18
// export default 18;
// export default {
// age: 18,
// };
// export default function () {}
export default () => {};
// index.js
import aModule from './a.js'
导入并导出
export
和import
可以结合一起使用,实际上是将两个步骤——导入并导出,合成了一个步骤的语法糖。
- 导入:
import xxx from 'path'
- 导出:
export [default] xxx
- 合成:
export * from 'path'
或export { xxx } from 'path'
// module/a.js
const foo = 'foo'
const fn = function(){}
export {
foo,
fn
}
// module/index.js
// 导入并导出
export * from './a.js'
export { foo as default } from './a.js'
// 或者这种写法
// export * as default from './a.js'
// main.js
import defaultExport,{ fn } from './module'
这种写法存在多个默认导出时,必须要在中转层模块中声明指定的默认导出。
这种方式是为了方便统一暴漏所有接口,所以在模块之上加多了一层中转层模块统一导出。
动态导入
ESM
支持用import
动态导入模块,此时的import
作为一个函数,返回一个Promise
。
用这种方式导入的模块是懒加载(也叫按需加载)。
(async function (){
const rst = await import('./a.js')
})()
区别
CommonJS
是模块属性值的浅拷贝(在webpack
编译CommonJS
的代码中有体现)js// a.js exports.num = 1 // index.js const { num } = require("./a"); console.log(num); // 打包后的代码 (() => { var __webpack_modules__ = { 967: (module) => { let num = 1; module.exports = { num, }; }, }; var __webpack_module_cache__ = {}; function __webpack_require__(moduleId) { var cachedModule = __webpack_module_cache__[moduleId]; if (cachedModule !== undefined) { return cachedModule.exports; } var module = (__webpack_module_cache__[moduleId] = { exports: {}, }); __webpack_modules__[moduleId](module, module.exports, __webpack_require__); return module.exports; } // 浅拷贝的解构复制属性值 const { num } = __webpack_require__(967); console.log(num); })();
而
ESM
是引用传递,所以尽量保持导出的内容是《纯》的,没有副作用的。js// a.js export let num = 1 setTimeout(()=>{ num += 1 },1000) // index.js import { num } from './a.js' console.log(num) // 1 setTimeout(()=>{ console.log('index---',num) // 2 },2000)
ESM
和CommonJS
想要混合使用的前提是,需要打包工具的支持,不然只能两者选其一。ESM
用export、import
导入导出,CommonJS
用module.exports、require
导入导出。ESM
解析时处理模块依赖关系,CommonJS
运行时导入加载依赖。
集成环境
Nodejs
默认使用CommonJS
规范,如果集成了TypeScript
的话,TS
是默认开启了ESM
,所以会造成导入的报错,有三种常见的方式不需要编译也能在Nodejs
中执行TS
。
ts-node
使用nodemon
工具持续监听并更新代码,ts-node
执行TS
文件,TS
提供类型帮助。
安装依赖
shellpnpm add ts-node nodemon typescript -D
初始化
tsconfig.json
shellnpx tsc init
并添加以下配置
json{ "compilerOptions": { "module": "CommonJS", "types": ["node"], }, "ts-node": { "esm": true }, }
在
package.json
添加启动脚本命令json{ "engines": { "node": "<=18.18.0" }, "script":{ "start": "nodemon --watch src --ext ts,js --exec ts-node --esm ./src/index.ts" } }
TIP
使用ts-node
的方式要注意Node
的版本^13-18.18.0
tsx
使用tsx
相对ts-node
就简单很多,不需要额外的配置
安装依赖
shellpnpm add tsx -D
添加脚本命令
json{ "script":{ "start": "tsx watch ./src/index.ts" } }