Skip to content

Node.js 模块化

推荐阅读:ECMAScript 6 入门 - Module 的语法

第一章:概述

一、模块化介绍

Node 由模块(每一个 JS 即是一个模块)组成,采用 CommonJS 模块规范(提供了模块引入导出的规则)。每个文件就是一个模块,有自己的作用。在一个文件里面定义的变量、函数、类(class)都是私有的,对其他文件不可见(模块作用域)。在服务器端,模块的加载是运行时同步加载的。

运行时同步加载的?
主要说的是 CommonJS。
运行时:模块在代码执行过程中加载,不是预先编译时确定。
同步:require() 会阻塞代码执行,直到模块完全加载完成。

模块化是指解决一个复杂问题时,自顶向下逐层把系统划分成若干模块的过程,对于整个系统来说,模块是可组合,分解和更换的单元。

二、模块化的好处

  • 提高代码的复用性。
  • 提高代码的可维护性。
  • 可以实现按需加载。
  • 防止命名冲突。

三、模块化规范

① CommonJS 规范

CommonJS (CJS) 是一种模块化规范,最初提出来是在浏览器以外的地方使用,并且当时命名为 ServerJS,后来为了体现它的广泛性,更名为 CommonJS,也可以简称为 CJS。Node 是 CommonJS 在服务端一个具有代表性的实现,Browserify 是 CommonJS 在浏览器端的一种实现。webpack 具备对 CommonJS 的支持与转换。

② AMD 规范

AMD 主要是应用于浏览器端的一种模块化规范,AMD 是 Asynchronous Module Definition(异步模块定义)的缩写,它采用的是异步加载模块,事实上 AMD 的规范早于 CommonJS,但是现在 CommonJS 仍被使用,但 AMD 已经很少用了。 实现 AMD 规范的库主要是 require.js 和 curl.js。

③ CMD 规范

CMD 也是应用于浏览器端的一种模块化规范,CMD 是 Common Module Definition(通用模块定义)的缩写,它也是采用了异步加载模块,但是它将 CommonJS 的优点吸收了过来,这个目前也很少使用了。

实现 CMD 规范的库主要是 sea.js。

④ ES Module 规范

ES Module (ESM) 规范是 ES 提出的,是官方的模块化规范。

四、Node 中模块的分类

Node.js 中根据模块来源的不同,将模块分为了 3 大类,分别是:

  • 内置模块(由 Node.js 官方提供,例如:fs, path, http)。
  • 第三方模块:由第三方开发出来的模块,并非官方提供的内置模块,也不是用户创建的自定义模块,使用前需要先下载。
  • 自定义模块:用户创建的每个 JS 文件,都是自定义模块。

Node 支持 CommonJS 和 ES6 两种模块化规范。

第二章:CommonJS 模块规范

一、语法 / 使用

1. 在模块中暴露数据

模块内如果没有暴露数据,引入模块的时候会得到一个空对象。多次导入模块,实际只会执行一次代码,不会报错。

1)通过为 module.exports 赋值,实现暴露数据。module.exports 的值就是要暴露的数据。

javascript
module.exports = true;

module.exports = 5211314;

const data = [10,20,30,40,50,60];
module.exports = data;

module.exports = () => {
  console.log(123456);
}

// 开发常用
module.exports =  {
  school: "克莱登大学",
  name: "张三",
  age: 18
}

这样暴露数据,后面的会把前面的覆盖掉。不推荐这种写法。

2)通过为 module.exports 设置属性。module.exports 的默认值是个空对象,可以为空对象添加属性。

javascript
module.exports.isNB = true;
module.exports.msg = 'hahaha';
module.exports.say = ()=>{};

3)通过为 exports 设置属性,暴露数据。默认 exports 与 module.exports 指向同一个对象,为 exports 设置属性就是为 moudule.exports 设置属性;但不能给 exports 赋值,那样会改变其引用地址,exports 与 module.exports 就不再是一个对象了。

javascript
const userName = "张三";
const age = 18;

// 以下方式可以暴露数据
exports.userName = userName;    // 等价于 module.exports.userName = userName;
exports.age = age;              // 等价于 module.exports.userName = userName;

// 以下写法无法暴露数据,因为修改了 exports 的引用地址
exports = {username, age};

2. 导入模块

通过 require() 方法可以引入模块,该方法的返回值就是模块中暴露的数据。

javascript
const 变量名 = require('自定义模块地址');
const {变量1, 变量2} = require('自定义模块地址');  // 如果模块暴露的数据是对象,可以使用结构赋值获取其中的属性方法

// 例子
const mod = require('./mode');  // 等同于 require('./mod.js')

二、实现原理

模块导入的加载流程

require 导入自定义模块会按照以下流程加载:

① 将相对路径转为绝对路径,定位目标文件。

② 缓存检测。

③ 读取目标文件代码。

④ 包裹为一个函数并执行(自执行函数)。通过 arguments.callee.toString() 查看自执行函数。

⑤ 缓存模块的值。

⑥ 返回 module.exports 的值。

结论一:模块在被第一次引入时,模块中的 js 代码会被运行一次。

模块被多次引入时,会缓存,最终只加载(运行)一次。为什么只会加载运行一次呢?这是因为每个模块对象 module 都有一个属性:loaded。为 false 表示还没有加载,为 true 表示已经加载。

结论二:如果有循环引入,那么加载顺序是什么?图结构在遍历的过程中,有深度优先搜索(DFS, depth first search)和广度优先搜索(BFS, breadth first search)。Node 采用的是深度优先算法:main --> aaa --> ccc --> ddd --> eee --> bbb。

第三章:AMD & CMD

CommonJS 无法在浏览器端使用,只能在 Node.js 中,因为 CommonJS 是同步的。

要在浏览器中实现模块化,解决办法:

  1. 远程加载 JS 浪费了时间?做成异步即可,加载完成后调用一个回调就行了。
  2. 模块中的代码需要放置到函数中执行?编写模块时,直接放函数中就行了。

基于这种简单有效的思路,出现了 AMD (Asynchronous Module Definition) 和 CMD (Common Module Definition) 规范,有效的解决了浏览器模块化的问题。

一、AMD

require.js 实现了 AMD 规范。

在 AMD 中,导入和导出模块的代码,都必须放置在 define 函数中。

javascript
define([依赖的模块列表], function(模块名称列表){
  // 模块内部的代码
  return 导出的内容
})

例子

html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    <script data-main="./js/index.js" src="./js/require.js"></script>
</body>
</html>
javascript
// define(["b", "a"], function (b, a) {
//     // 等b.js、a.js加载完成后运行该函数
//     console.log(b, a)
// })

define((require, exports, module) => {
    var a = require("a"),
        b = require("b");
    console.log(b, a)
})
javascript
// define(["b"], function(b){
//     console.log("a模块的内部代码")
//     return "a模块的内容";
// })

define(function (require, exports, module) {
    var b = require("b")
    console.log("a模块的内部代码", b)
    module.exports = "a模块的内容"
})
javascript
// define({name:"alice", age:18})

// define(function () {
//     // 模块内部的代码
//     console.log("b模块的内部代码")
//     var a = 1;
//     var b = 234;
//     return {
//         name: "b模块",
//         data: "b模块的数据"
//     }
// })

define(function (require, exports, module) {
    // 模块内部的代码
    console.log("b模块的内部代码")
    module.exports = {
        name: "b模块",
        data: "b模块的数据"
    }
})

二、CMD

sea.js 实现了 CMD 规范。

在 CMD 中,导入和导出模块的代码,都必须放置在 define 函数中。

javascript
define(function(require, exports, module){
  // 模块内部的代码
})

例子

html
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>

<body>
    <script src="./js/sea.js"></script>
    <script>
        seajs.use("./js/index")
    </script>
</body>

</html>
javascript
define((require, exports, module) => {
    require.async("a", function(a){
        console.log(a)
    })
    require.async("b", function(b){
        console.log(b)
    })
})
javascript
define(function (require, exports, module) {
    var b = require("b")
    console.log("a模块的内部代码", b)
    module.exports = "a模块的内容"
})
javascript
define(function (require, exports, module) {
    // 模块内部的代码
    console.log("b模块的内部代码")
    module.exports = {
        name: "b模块",
        data: "b模块的数据"
    }
})

第四章:ES6 模块规范

ECMA 组织参考了众多社区模块化标准,终于在 2015 年,随着 ES6 的发布,推出了官方的模块化标准,后成为 ES6 模块化 (ESM) 。

ES6 模块化具有以下的特点:

  • 使用依赖预声明的方式导入模块

    • 依赖延迟声明

      优点:某些时候可以提高效率

      缺点:无法在一开始确定模块依赖关系(比较模糊)

    • 依赖预声明

      优点:在一开始可以确定模块依赖关系

      缺点:某些时候效率较低

  • 灵活的多种导入导出方式

  • 规范的路径表示法:所有路径必须以 ./../ 开头

    最新:如果不带有路径,只是一个模块名,那么必须有配置文件,告诉 JavaScript 引擎该模块的位置。

一、语法

1. 在模块中暴露数据

备注:ESM 是静态依赖(编译时加载),而 CMJ 是动态依赖(运行时加载)。

1)默认导出(Default Exports)

使用 export default 可以在模块中暴露单个数据。语法:

javascript
export default 默认导出的数据

export {默认导出的数据 as default}

注意:每个脚本文件中 export default 语句只能出现一次,出现多个 export default 语句会报错!且 export default 后不能是声明语句。

javascript
export default 100;

const data = [10,20,30,40,50];
export default data;

function say() {}
function eat() {}
export default {
  say,
  eat
}

本质上,export default 就是输出一个叫做 default 的变量,然后系统允许你为它取任意名字。所以,下面的写法是有效的。

javascript
// modules.js
function add(x, y) {
  return x * y;
}
export {add as default};
// 等同于
// export default add;

// app.js
import { default as foo } from 'modules';
// 等同于
// import foo from 'modules';
2)命名 / 基本导出(Named Exports)

使用 export 可以暴露多个数据,有两种写法。

javascript
// 第一种写法
export 声明表达式

// 第二种写法
声明表达式
export {具名符号}
// or
export {具名符号 as 导出的名字}

举个例子:

javascript
// 第一种写法: 在声明变量的同时暴露
export const name = 'Simei';
export var year = 1918;
export function fn() {};
export const obj = {name:'张三',age:18}
export class Person {}

// ------------------------------------------------------

// 第二种写法: 在文件底部统一暴露 (推荐)
const name = 'Simei';
var year = 1918;
function fn() {};
const obj = {name:'张三',age:100}
export class Person {}

// 注意: export 右边的是一种语法结构, 并不是对象字面量语法
export {name,
  year as Year, // 重命名为 Year
  fn,
  obj,
  Person
}

注意:export 后面要么是各种声明语句,要么是 {},其他都会报错。

2. 引入模块

1)导入默认导出

模块使用 export default 暴露单个数据。

javascript
import 变量名 from '模块地址';
2)导入命名 / 基本导出

由于使用的是依赖预加载,因此,导入任何其他模块,导入代码必须放置到所有代码之前。

对于基本导出,如果要进行导入,使用下面的代码。

javascript
import { 导入的符号列表 } from "模块路径" 
// or
import { 导入的符号列表 as 重命名导出的名字 } from "模块路径"

举个例子:

javascript
// 获取的变量名必须与模块暴露的变量名一致, 可以多次分别获取, 可以取别名
import {name, year as y} from '模块地址';
import {fn} from '模块地址';

注意以下细节:

  • 导入时,可以通过关键字 as 对导入的符号进行重命名。
  • 导入时使用的符号是常量,不可修改。
  • 可以只执行模块中的代码而不导入任何东西。

    javascript
    import "模块地址"
3)模块的整体加载

可以使用 * 号导入所有的命名与默认导出,映射为一个对象。

javascript
// 可以将模块中的数据整体加载
import * as 别名 from '模块地址';

注:如果使用 * 号,会将所有基本导出和默认导出聚合到一个对象中,默认导出会作为 default 属性存在。

3. export 与 import 的复合写法

如果在一个模块之中,先输入后输出同一个模块,import 语句可以与 export 语句写在一起。

javascript
import { foo, bar } from 'my_module';
export { foo, bar };

// 简写
export { foo, bar } from 'my_module';

上面代码中,export 和 import 语句可以结合在一起,写成一行。但需要注意的是,写成一行以后,foo 和 bar 实际上并没有被导入当前模块,只是相当于对外转发了这两个接口,导致当前模块不能直接使用 foo 和 bar。

模块的接口改名和整体输出,也可以采用这种写法。

javascript
// 接口改名
export { foo as myFoo } from 'my_module';

// 整体输出
export * from 'my_module'; // export * 命令会忽略 my_module 模块的 default

默认接口的写法如下。

javascript
export { default } from 'foo';

具名接口改为默认接口的写法如下。

javascript
export { es6 as default } from './someModule';

// 等同于
import { es6 } from './someModule';
export default es6;

同样地,默认接口也可以改名为具名接口。

javascript
export { default as es6 } from './someModule';

ES2020 之前,有一种 import 语句,没有对应的复合写法。

javascript
import * as someIdentifier from "someModule";

ES2020 补上了这个写法。

javascript
export * as ns from "mod"; // export * 命令会忽略 mod 模块的 default。如果需要使用 default, 则需要 import exp from 'mod';

// 等同于
import * as ns from "mod";
export {ns};

二、import 扩展

1. import 属性

ES2025 引入的特性。

javascript
// 静态导入
import configData from './config-data.json' with { type: 'json' };

// 动态导入
const configData = await import(
  './config-data.json', { with: { type: 'json' } }
);

export 与 import 复合使用时,也可以使用。

javascript
export { default as config } from './config-data.json' with { type: 'json' };

2. import.meta

ES 2020 为 import 命令添加了一个元属性 import.meta,返回当前模块的元信息。

这个属性返回一个对象,该对象的各种属性就是当前运行的脚本的元信息。具体包含哪些属性,标准没有规定,由各个运行环境自行决定。一般来说,import.meta 至少会有下面两个属性。

1)import.meta.url

在浏览器中是 URL 地址。在 Node.js 环境中,import.meta.url 返回的总是本地路径,即 file:URL 协议的字符串,比如 file:///home/user/foo.js

javascript
new URL('data.txt', import.meta.url)
2)import.meta.scriptElement

import.meta.scriptElement 是浏览器特有的元属性,返回加载模块的那个 <script> 元素,相当于 document.currentScript 属性。

javascript
// HTML 代码为
// <script type="module" src="my-module.js" data-foo="abc"></script>

// my-module.js 内部执行下面的代码
import.meta.scriptElement.dataset.foo
// "abc"
3)其他

Deno 现在还支持 import.meta.filenameimport.meta.dirname 属性,对应 CommonJS 模块系统的 __filename__dirname 属性。

  • import.meta.filename:当前模块文件的绝对路径。
  • import.meta.dirname:当前模块文件的目录的绝对路径。

这两个属性都提供当前平台的正确的路径分隔符,比如 Linux 系统返回 /dev/my_module.ts,Windows 系统返回 C:\dev\my_module.ts

本地或远程模块都可以使用这两个属性。

3. import 函数

ES 2020 引入 import() 函数,支持动态加载模块。

通过 import 加载一个模块,是不可以在其放到逻辑代码中的。但是某些情况下,确确实实希望动态的来加载某一个模块。这个时候需要 使用 import() 函数来动态加载。import 函数返回一个 Promise,可以通过 then 获取结果。

javascript
let flag = true;
if(flag) {
  import('./modules/aaa.js').then(aaa => {
    aaa.aaa();
  })
} else {
  import('./modules/bbb.js').then(bbb => {
    bbb.bbb();
  })
}

三、实战

导出

javascript
export const a = 1; // 具名,常用
export function b() {} // 具名,常用
export const c = () => {}  // 具名,常用
const d = 2;
export { d } // 具名
const k = 10
export { k as temp } // 具名

export default 3 // 默认,常用
export default function() {} // 默认,常用
const e = 4;
export { e as default } // 默认

const f = 4, g = 5, h = 6
export { f, g, h as default} // 基本 + 默认

// 以上代码将导出下面的对象
/*
{
	a: 1,
	b: fn,
	c: fn,
	d: 2,
	temp: 10,
	f: 4,
	g: 5,
	default: 6
}
*/

导入

javascript
// 仅运行一次该模块,不导入任何内容
import "模块路径"
// 常用,导入属性 a、b,放到变量a、b中。a->a, b->b
import { a, b } from "模块路径"   
// 常用,导入属性 default,放入变量c中。default->c
import c from "模块路径"  
// 常用,default->c,a->a, b->b
import c, { a, b } from "模块路径" 
// 常用,将模块对象放入到变量obj中
import * as obj from "模块路径" 


// 导入属性a、b,放到变量temp1、temp2 中
import {a as temp1, b as temp2} from "模块路径" 
// 导入属性default,放入变量a中,default是关键字,不能作为变量名,必须定义别名
import {default as a} from "模块路径" 
//导入属性default、b,分别放入变量a、b中
import {default as a, b} from "模块路径" 
// 以上均为静态导入

import("模块路径") // 动态导入,返回一个Promise,完成时的数据为模块对象

四、其他

1. ES Module 的解析流程

1)理论

模块加载流程:先下载模块文件,然后解析模块依赖,递归下载解析模块依赖。当所有依赖下载解析完成且文档解析完成后,递归执行依赖模块,最后执行当前模块。

ES Module 的解析过程可以划分为三个阶段:

阶段一:构建(Construction )。根据地址查找 JS 文件,并且下载,将其解析成模块记录(Module Record)。

阶段二:实例化(Instantiation)。对模块记录进行实例化,并且分配内存空间,解析模块的导入和导出语句,把模块指向对应的内存地址。

注意:任何 export 的函数声明都在这个阶段初始化。

在这一步的最后,我们使得所有的模块实例导出/导入的变量的内存地址链接起来了。

阶段三:运行(Evaluation)。运行代码,计算值,并且将值填充到内存地址中。

参考地址:ES modules: A cartoon deep-dive

2)实战
javascript
import foo from "./foo.js";
import bar from "./bar.js";

import("./dynamic.js").then((m) => {
  console.log("main", m.default);
});

console.log("main", foo, bar);
javascript
import bar from "./bar.js";

console.log("foo", bar);

export default "foo";
javascript
console.log("bar");

export default "bar";
javascript
export default "dynamic";
输出结果
    bar
    foo bar
    main foo bar
    dynamic bar
    main dynamic
 
[1] 模块解析

即, 把相关的 js 文件全部下载下来

  1. 找到入口文件 ***/main.js,(如果是相对路径,则会转为绝对路径),然后开始下载
  2. 下载完成后,会进入到该 js 文件中去看顶层的静态导入语句,并把它们提到整个代码的最前边
  3. 递归的下载并且解析对应的 js。下载完成后,再进到它们的文件中去看静态导入语句,如果没有下载,则继续下载(下载过的就跳过,不再下载了)
  4. 都下载完成后,开始下一步 ———— 模块的执行

静态导入语句只能写到顶层,不能写到循环和条件语句中

[2] 模块执行

① 从入口文件开始执行,先进入到 main.js

main.js 的第一行是 import foo from './foo.js,则进入 foo.jsfoo.js 的第一行是 import bar from './bar.js,则又会进入 bar.jsbar.js 的第一行是 console.log("bar");,在控制台打印了一个 bar。接着,默认导出一个字符串 bar,生成一个表格,然后 bar.js 执行完成了。

bar.js

default"bar"

② 然后回到 foo.jsfoo.js 是通过 import bar from './bar.js 导入的 bar.js,这里的变量 barbar.js 的默认导出是符号绑定(用的同一块内存空间),打印出一个 foo bar。接着,foo.js 中也有一个默认导出,也会生成一个表格,然后 foo.js 执行完成了。

foo.js

default"foo"

③ 然后回到 main.js 中的下一行 import bar from "./bar.js";,因为 bar.js 已经运行过了,所以直接把 bar.js 表格中的内容直接取出来了。接着是一个动态导入,之前没下载过,所以先去下载解析。这里是异步的,所以主线程继续往后运行到 console.log("main", foo, bar); 打印对应的结果 main foo bar,然后 main.js 运行结束。

④ 等到动态的 js 下载完成后,继续进行模块的解析和执行,和刚刚的过程是一样的,然后导出的结果也会生成一个表格。然后执行 import("./dynamic.js").then((m)) 回调,这里的 m 就是 dynaic.js 模块生成的表格。

dynamic.js

default"dynamic"

模块的导出会给模块生成一个表格,每个模块最终都会变成一个表格,表格中会记录模块导出的结果。记录是为了方便缓存,以后再次导入该模块时,就不会从头到尾运行了,而是从表格中取相应的结果进行返回。

2. ES6 模块与 CommonJS 模块的差异

  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用,且是只读。
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
  • CommonJS 模块的 require() 是同步加载模块,ES6 模块的 import 命令是异步加载,有一个独立的模块依赖的解析阶段。

第四章:Node.js 中的规则

一、Node 中模块查找规则

模块以类似 ./../ 开始的,会走文件和文件夹查找规则。

例子: require(./x)

X 是以 ./ 或 ../ 或 / (根目录) 开头的。这是模块文件的相对路径, 相对于当前的执行的 JS 脚本的位置, 并非命令行打开的目录。

第一步 文件模块: 将 X 当做一个文件在对应的目录下查找;
    1. 如果有后缀名, 按照后缀名的格式查找对应的文件。
    2. 如果没有后缀名, 会按照如下顺序:
        1> 直接查找文件 X
        2> 查找 X.js 文件。读取文件内容并编译执行并获取模块中暴露的数据。
        3> 查找 X.json 文件。读取文件,用 JSON.parse() 解析返回结果作为获取的数据。
        4> 查找 X.node 文件。c/c++ 编写的扩展文件, 通过 dlopen() 方法编译。
        5> 其他扩展名, 文件内容会被当做 JavaScript 代码去解析。
第二步 目录作为模块: 没有找到对应的文件, 将 X 作为一个目录。
        1> 如果使用了目录作为模块名,并且目录中包含一个package.json文件,则Node.js会查找该文件中指定的main入口文件。
        2> 查找 X / index.js 文件。
        3> 查找 X / index.json 文件。
        4> 查找 X / index.node 文件。
总结: 自定义模块的地址可以省略扩展名, 如果模块路径没有扩展名, 会依次查找 .js 文件、.json 文件、目录。

模块没有以类似 ./../ 开始的,会走内置模块和自定义模块的查找规则。

第一步 核心模块: X 是一个 Node 核心模块, 比如 path、http, 直接返回核心模块, 并且停止查找。

第二步 非原生模块: 直接是一个 X (没有路径) , 并且 X 不是一个核心模块。
    1. Node.js 将在当前文件所在目录下的 node_modules 目录中查找名为 X 的模块。
        1> 查找逻辑跟文件和文件夹查找规则一样, 只不过会跑到 node_modules 目录下查找。
        2> 如果没有找到, 它会移动到上级目录的 node_modules 目录中继续查找,依此类推,直到到达文件系统的根目录。
           查找的具体顺序如下:
               ./node_modules/X
               ../node_modules/X
               ../../node_modules/X
               ../../../node_modules/X
               以此类推,直到根目录的 /node_modules/X
    2. 全局 node_modules 目录
        如果在上述所有目录中都没有找到模块 X, Node.js 会尝试在全局的 node_modules 目录中查找, 这个目录的位置依赖于 Node.js 的安装路径和配置。

如果上面的路径中都没有找到, 那么报错: not found

二、Node 的模块加载方法

1. 使用不同模块规范

从 Node.js v13.2 版本开始,Node.js 已经默认打开了 ES6 模块支持。

Node.js 要求 ES6 模块采用 .mjs 后缀文件名。

如果不希望将后缀名改成 .mjs,可以在项目的 package.json 文件中,指定 type 字段为 module。

如果这时还要使用 CommonJS 模块,那么需要将 CommonJS 脚本的后缀名都改成 .cjs。如果没有 type 字段,或者 type 字段为 commonjs,则 .js 脚本会被解释成 CommonJS 模块。

结论:.mjs 文件总是以 ES6 模块加载,.cjs 文件总是以 CommonJS 模块加载,.js 文件的加载取决于 package.json 里面 type 字段的设置。

2. 模块入口文件

package.json 文件有两个字段可以指定模块的入口文件:main 和 exports。

1)package.json 的 main 字段

比较简单的模块,可以只使用 main 字段,指定模块加载的入口文件。

javascript
// ./node_modules/es-module-package/package.json
{
  "type": "module",
  "main": "./src/index.js"
}

上面代码指定项目的入口脚本为 ./src/index.js,它的格式为 ES6 模块。

然后,import 命令就可以加载这个模块。

javascript
// ./my-app.mjs

import { something } from 'es-module-package';
// 实际加载的是 ./node_modules/es-module-package/src/index.js
2)package.json 的 exports 字段

exports 字段的优先级高于 main 字段。

(1) 子目录别名

package.json 文件的 exports 字段可以指定脚本或子目录的别名。

javascript
// ./node_modules/es-module-package/package.json
{
  "exports": {
    "./submodule": "./src/submodule.js"
  }
}

上面的代码指定 src/submodule.js 别名为 submodule,然后就可以从别名加载这个文件。

javascript
import submodule from 'es-module-package/submodule';
// 加载 ./node_modules/es-module-package/src/submodule.js

下面是子目录别名的例子。

javascript
// ./node_modules/es-module-package/package.json
{
  "exports": {
    "./features/": "./src/features/"
  }
}

import feature from 'es-module-package/features/x.js';
// 加载 ./node_modules/es-module-package/src/features/x.js

如果没有指定别名,就不能用“模块+脚本名”这种形式加载脚本。

javascript
// 报错
import submodule from 'es-module-package/private-module.js';

// 不报错
import submodule from './node_modules/es-module-package/private-module.js';

(2) main 的别名

exports 字段的别名如果是 .,就代表模块的主入口,优先级高于 main 字段,并且可以直接简写成 exports 字段的值。

javascript
{
  "exports": {
    ".": "./main.js"
  }
}

// 等同于
{
  "exports": "./main.js"
}

由于 exports 字段只有支持 ES6 的 Node.js 才认识,所以可以搭配 main字段,来兼容旧版本的 Node.js。

javascript
{
  "main": "./main-legacy.cjs",
  "exports": {
    ".": "./main-modern.cjs"
  }
}

上面代码中,老版本的 Node.js (不支持 ES6 模块)的入口文件是 main-legacy.cjs,新版本的 Node.js 的入口文件是 main-modern.cjs

(3) 条件加载

利用 . 这个别名,可以为 ES6 模块和 CommonJS 指定不同的入口。

javascript
{
  "type": "module",
  "exports": {
    ".": {
      "require": "./main.cjs",
      "default": "./main.js"
    }
  }
}

上面代码中,别名 . 的 require 条件指定 require() 命令的入口文件(即 CommonJS 的入口),default 条件指定其他情况的入口(即 ES6 的入口)。

上面的写法可以简写如下。

javascript
{
  "exports": {
    "require": "./main.cjs",
    "default": "./main.js"
  }
}

注意,如果同时还有其他别名,就不能采用简写,否则会报错。

javascript
{
  // 报错
  "exports": {
    "./feature": "./lib/feature.js",
    "require": "./main.cjs",
    "default": "./main.js"
  }
}

3. 互相加载

1. CommonJS 模块加载 ES6 模块

CommonJS 的 require() 命令不能加载 ES6 模块,会报错,只能使用 import() 这个方法加载。

javascript
// 在 cjs 模块中只能使用 import() 函数加载 ESM, require() 函数不支持, 因为它是同步加载的
(async () => {
  await import('./my-app.mjs');
})();

require() 不支持 ES6 模块的一个原因是,它是同步加载,ES6 模块是异步。

2. ES6 模块加载 CommonJS 模块

ES6 模块的 import 命令可以加载 CommonJS 模块,但是只能整体加载,不能只加载单一的输出项。

javascript
// 正确
import packageMain from 'commonjs-package';

// 报错
import { method } from 'commonjs-package';
[3] 同时支持两种格式的模块
javascript
import cjsModule from '../index.js'; // 此模块是 cjs
export const foo = cjsModule.foo; // 把上面模块导出的内容转为 ESM

可以把文件的后缀名改为 .mjs,或者将它放在一个子目录,再在这个子目录里面放一个单独的 package.json 文件,指明 { type: "module" }

另一种做法是在 package.json 文件的 exports 字段,指明两种格式模块各自的加载入口。

javascript
"exports":{
  "require": "./index.js"
  "import": "./esm/wrapper.js"
}

上面代码指定 require()import,加载该模块会自动切换到不一样的入口文件。

4. 循环加载

1)CommonJS 模块的循环加载

CommonJS 的一个模块,就是一个脚本文件。require 命令第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象。

javascript
{
  id: '...',        // 模块名
  exports: { ... }, // 模块输出的各个接口
  loaded: true,     // 该模块的脚本是否执行完毕
  ...
}

以后需要用到这个模块的时候,就会到 exports 属性上面取值。即使再次执行 require 命令,也不会再次执行该模块,而是到缓存之中取值。

2)ESM 模块的循环加载
[1] 例子一

index.html

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ESM 模块的循环加载</title>
  <script type="module" src="./a.js"></script> // [!code focus]
</head>
<body>
  
</body>
</html>

a.js 内容:

javascript
import {bar} from './b.js';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';

b.js 内容:

javascript
import {foo} from './a.js';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';

访问 index.html 后,控制台报错。

首先,执行a.mjs以后,引擎发现它加载了 b.js,因此会优先执行 b.js,然后再执行 a.js。接着,执行 b.js 的时候,已知它从 a.js 输入了 foo 接口,这时不会去执行 a.js,而是认为这个接口已经存在了,继续往下执行。执行到第三行 console.log(foo) 的时候,才发现这个接口根本没定义,因此报错。

[2] 例子二
javascript
// even.js
import { odd } from './odd'
export var counter = 0;
export function even(n) {
  counter++;
  return n === 0 || odd(n - 1);
}

// odd.js
import { even } from './even';
export function odd(n) {
  return n !== 0 && even(n - 1);
}

ESM 可以执行上面代码,但 cjs 不行,报错。

preview
图片加载中
预览

Released under the MIT License.