学习呦
14 10月 2025 12 min read

模块

Written by 米豆

模块

node常见的模块

  • ECMAScript Modules(ESM)
  • CommonJS Modules (CJS)

浏览器和Node都支持ESM,Node从v12版本开始正式支持ESM,但需要package.json配置type或要添加命令行参数--input-type
CJS为Node从始至终都支持的模块格式,写到.cjs(自动当CJS模块处理)或.js结尾的文件中

浏览器

ESM

各个node环境下的模块加载

package.json

exports 和 imports 配置

这里提到的配置为node环境独有,vite、webpack不能识别这个配置

{
"name": "my-package",
"exports": {
// 下面的.和相对路径的./不同,不会影响模块相对路径解析
".": "./lib/index.js",
"./lib": "./lib/index.js",
"./lib/index": "./lib/index.js",
// 下面可以让一个模块同时支持esm导入和cjs导入
"import": "./index-module.js",
"require": "./index-require.cjs"
},
// 这里配置的是导入的私有模块
// 这个只能使用esm模块导入、cjs不支持
"imports": {
"#internal/*.js": "./src/internal/*.js"
}
}

exports
子路径导出,可以配置当前模块的多个入口,也可以重写import和require导入方法

imports
子路径导入,可以配置私有包等

通用配置

下面提到的通用配置就可以在所有环境使用

workspaces

{
"name": "my-workspaces-powered-project",
"workspaces": ["packages/a"]
}

当执行npm i时,会在项目的node_modules文件夹下创建一个符合链接,链接到这个a包,当然a包中得有package.json
还是挺巧妙的,node_modules文件夹不论那种找包策略都会去找,这样就不存在兼容性文件

CJS

使用require()方法来加载模块,且加载的模块必须是同步的

function require(/* ... */) {
const module = { exports: {} };
// 自执行函数,拿到导入模块的导出
((module, exports) => {
function someFunc() {}
exports = someFunc;
module.exports = someFunc;
})(module, module.exports);
// 自执行函数外的module
return module.exports;
}

复制了node官方文档关于require方法的代码,基本就是这个导入方法的原理

题外话,require()要支持ESM模块加载了 (目前还没正式上线2025.8.18)

alt text

缓存

require加载模块会node会创建加载这个模块的缓存,是根据模块的名字来控制缓存的,并且大小写敏感。但是,当一个文件和另一个名字同名,内容不同,导入可能会出问题,不会建立新文件的缓存

循环导入

a.js导入b.js,b.js在导入a.js,之后在main.js中导入他们俩

比如main.js中,导入a语句在最顶部

  1. 就先进入a.js执行,解析到导入b.js就会又去执行b.js
  2. 之后解析b.js又配到导入a了,就先创建一个未完成的exports对象,是a.js的exports的拷贝对象,返回给b,让b先正常解析
  3. 等a解析完了,再回过头来替换b中的a导入

找包策略

如果导入的包路径不已相对路径开头'/', '../', or './'
那么就会按下面方式找

  • 找当前路径下的node_modules
  • 找上一级目录的node_modules
  • 再找上一级的node_modules,直到系统根目录(比如mac上的/目录)

CJS支持文件夹查找,文件夹根目录得包含package.json,node会把这个文件夹当成一个模块

PS. 注意
CJS不支持node的私有包导入

require.resolve()
// 下面是上面方法的签名
interface RequireResolve {
(request: string, options?: RequireResolveOptions): string;
paths(request: string): string[] | null;
}
```
这个方法就是cjs解析一个模块的方法,这个`RequireResolve`接口内部有一个"调用签名"(ts中定义调用签名的接口可以直接把这个类型给一个变量,这个变量就可以像函数调用了)
以及一个`path`方法
具体的解析过程已经在上面了

#### CJS模块Node处理

node会包装每一个cjs,如下

```js
(function(exports, require, module, __filename, __dirname) {
// 这里就是我们写的代码
});

可以看到常用的变量,exports、__dirname等,这也就是可以直接在cjs模块中访问的原因
但同时,也要看作用域,被限制到了一个函数内,cjs全局的一些this指向就要注意了,并不是globalThis

require函数

模块使用 require引入,伪代码如下

// require函数的伪代码
function require(path){
if(该模块有缓存吗){
return 缓存结果;
}
function _run(exports, require, module, __filename, __dirname){
// 模块代码会放到这里
}

var module = {
exports: {}
}

_run.call(
module.exports,
module.exports,
require,
module,
模块路径,
模块所在目录
);

把 module.exports 加入到缓存;
return module.exports;
}

ESM

Node ESM环境

支持

  • 协议 file:url,data:url,node:url
  • 裸模块(Bare specifier package)node_modules里的
  • 私有模块

找包策略

找包策略大体和cjs相同,不过需要注意要考虑上面支持的这些url
简单来说会调用ESM_RESOLVE方法,对有拓展名的导入url还会调用ESM_FILE_FORMAT方法格式化文件(仅支持json,js,cjs,mjs),如果不是这些拓展名就会报错了

循环导入

foo.js导入bar.js,bar.js在导入foo.js,之后在main.js中导入他们俩

// foo.js
import {bar} from './bar.js';

console.log('foo导入了bar', bar);
const foo = 'fooModule'

export { foo }
// bar.js
import {foo} from './foo.js';

console.log("bar导入了foo", foo);
const bar = 'barModule'
export { bar };
// main.js
import {foo} from './foo.js';

console.log('main模块导入了', foo);

执行结果

ReferenceError: Cannot access 'foo' before initialization

比如main.js中,导入a语句在最顶部

  1. 就先进入foo.js执行,解析到导入bar.js就会又去执行bar.js
  2. bar.js中要使用foo.js(console输出的那句话)但是foo.js还没执行完毕,没有导出,所以直接报ReferenceError

前端浏览器

浏览器环境需要<script type="module" src="path_to_js"></script>,src中引入的文件格式可以是.js或者是.mjs(准确说,设置了MIME类型text/javascript的文件)

加载

浏览器模块加载是异步的,并且每一个模块对应一个模块请求,如果遇到循环导入,和上文ESM处理方式一致

浏览器找文件策略

只能导入.js或.mjs文件,或者Content-Type设置text/javascript的文件

ESM的符号绑定

在ESM中的导出,导出的符号 export const foo = 'bar',在 import中导入这个符号,import {foo} from 'foo.js',导入的是这个符号的引用,意味着导入的 foo会随着导出模块中的 foo值的变化而变化

这是很奇怪的现象,按理说,模块的出现就是为了隔离变量,但是现在这种情况看似是打破了规则

同时还有两点要遵守:

  • 导入绑定是只读的
  • 实时绑定(Live Binding)
    实时绑定:就是上面的描述,修改了导出模块中导出符号的值,所有导入模块的符号对应的值都会修改

export导出

Export和Exposts

在模块中的顶层变量会处在每一个模块的词法作用域里,不会污染到全局的环境

↑