目录
JavaScript 9. 模块系统与包管理
随着程序规模增大,把所有代码放在一个文件或全局作用域中会产生明显问题:标识符容易冲突,文件之间的依赖关系不清晰,初始化顺序难以控制,公共接口与内部实现无法区分,测试、构建和代码复用也会变得困难。
模块系统负责把程序拆分为具有明确输入和输出的代码单元;包管理系统负责描述、获取、安装、解析和更新可复用的软件包及其依赖。
二者相关,但不是同一概念:
- 模块通常对应一个源文件或一个可独立加载的代码单元;
- 包通常是由
package.json描述的目录或发布归档,可以包含多个模块、资源文件和命令行程序; - 包管理器负责安装包,模块加载器负责在运行时解析
import、require()等模块请求; - 构建工具可以分析和转换模块,也可以把多个模块打包为少量部署文件。
现代 JavaScript 主要涉及两种模块系统:
- ECMAScript Modules(ESM):由 ECMAScript 标准定义,浏览器、Node.js 和其他现代运行时共同支持;
- CommonJS(CJS):Node.js 早期采用的模块系统,以
require()和module.exports为核心。
本章首先介绍 ESM 的语言语义,再讨论浏览器和 Node.js 的宿主解析规则,随后介绍 CommonJS、两种模块系统的互操作以及包管理机制。
9.1 模块的基本目标
一个良好的模块通常应满足以下要求:
- 只公开调用者真正需要的接口;
- 隐藏内部实现细节;
- 明确声明外部依赖;
- 避免通过全局变量隐式通信;
- 可以被独立测试;
- 初始化副作用尽量少;
- 不依赖偶然的文件加载顺序;
- 保持循环依赖可控。
例如,可以把计算逻辑放在独立模块中:
// math.js
export function add(first, second) {
return first + second;
}
export function multiply(first, second) {
return first * second;
}
其他模块显式导入:
// app.js
import {
add,
multiply,
} from "./math.js";
console.log(add(1, 2));
console.log(multiply(3, 4));
app.js 的依赖可以直接从源码中看出,而不需要依赖某个全局 math 对象。
9.2 ECMAScript Modules 的基本语义
ESM 使用 export 声明模块对外公开的绑定,使用 import 引用其他模块的导出。
9.2.1 模块作用域
每个模块拥有独立的顶层作用域:
// first.js
const value = 10;
// second.js
const value = 20;
两个顶层 value 不会发生冲突,也不会自动成为 globalThis 的属性。
// module.js
const localValue = 10;
console.log(globalThis.localValue); // undefined
如果确实需要共享值,应通过导出和导入建立明确关系。
9.2.2 模块自动使用严格模式
模块代码始终按照严格模式语义执行,不需要写:
"use strict";
例如,独立调用普通函数时:
// module.js
function inspectThis() {
return this;
}
console.log(inspectThis()); // undefined
给未声明标识符赋值也会抛出 ReferenceError。
9.2.3 模块顶层 this
ESM 顶层的 this 为 undefined:
// module.js
console.log(this); // undefined
这与浏览器传统脚本顶层的 this 通常指向 window 不同。跨环境访问全局对象时,应使用 globalThis。
9.2.4 模块只求值一次
在同一个模块加载环境中,同一个已解析模块通常只会被实例化和求值一次:
// state.js
console.log("state module evaluated");
export const state = {
count: 0,
};
// first.js
import { state } from "./state.js";
state.count += 1;
// second.js
import { state } from "./state.js";
console.log(state.count);
如果 first.js 和 second.js 位于同一模块图中,并且两个说明符解析到同一模块记录,它们共享同一导出对象。
需要注意,“同一个文件”不总是等同于“同一个模块身份”。浏览器和 Node.js 经常以解析后的 URL 识别模块。查询参数、片段、大小写、符号链接和不同解析路径可能形成不同身份,具体规则取决于宿主环境。
import "./state.js?version=1";
import "./state.js?version=2";
这两个 URL 可能被视为不同模块并分别求值。不要通过人为改变查询参数来复制有状态模块,除非应用明确依赖这种 URL 级语义。
9.3 命名导出
9.3.1 声明时导出
export const version = "1.0.0";
export function createUser(name) {
return { name };
}
export class UserService {
// ...
}
一个模块可以具有多个命名导出。
9.3.2 统一导出列表
const version = "1.0.0";
function createUser(name) {
return { name };
}
class UserService {
// ...
}
export {
version,
createUser,
UserService,
};
导出列表不会创建副本,而是把现有本地绑定加入模块导出表。
9.3.3 导出与导入别名
function internalCreateUser(name) {
return { name };
}
export {
internalCreateUser as createUser,
};
导入侧也可以重命名:
import {
createUser as buildUser,
} from "./user.js";
9.3.4 导入绑定不可重新赋值
import { version } from "./version.js";
// TypeError
// version = "2.0.0";
导入名称是只读绑定,导入模块不能重新赋值。
如果导入值是对象,对象内部仍然可以被修改,除非对象自身受到冻结或接口限制:
// state.js
export const state = {
count: 0,
};
// app.js
import { state } from "./state.js";
state.count += 1;
这里没有重新赋值 state 绑定,而是修改它所引用对象的属性。共享可变对象会使模块之间产生隐式耦合,更清晰的设计通常是通过函数封装修改。
9.4 实时绑定
ESM 的导入和导出采用实时绑定(Live Bindings)。
// counter.js
export let count = 0;
export function increment() {
count += 1;
}
// app.js
import {
count,
increment,
} from "./counter.js";
console.log(count); // 0
increment();
console.log(count); // 1
导入的 count 会反映导出模块中绑定的最新值。它不是在导入时复制的数值快照。
实时绑定由模块环境记录维护,不是普通对象 getter,也不是 Proxy。导入模块仍然不能执行:
// TypeError
// count += 1;
只有定义该绑定的模块可以赋值。
9.4.1 模块命名空间对象
import * as counter from "./counter.js";
console.log(counter.count);
counter.increment();
console.log(counter.count);
counter 是模块命名空间对象,其属性反映模块导出的实时值。
模块命名空间对象具有特殊语义:
- 属性名称来自模块导出;
- 对象不可扩展;
- 导出属性不能通过普通赋值修改;
- 原型为
null; - 属性表现与普通可写对象不同。
console.log(Object.getPrototypeOf(counter)); // null
console.log(Object.isExtensible(counter)); // false
不要把命名空间对象当作普通配置对象修改。
9.5 默认导出
一个模块最多只能有一个默认导出。
9.5.1 默认导出声明
export default function createUser(name) {
return { name };
}
导入时不使用花括号:
import createUser from "./create-user.js";
导入方可以选择不同的本地名称:
import buildUser from "./create-user.js";
9.5.2 导出表达式
export default {
theme: "dark",
};
export default class {
// ...
}
匿名默认导出在调试和重构中可能不如具名声明清晰。公共库通常更适合导出具名类或函数。
9.5.3 默认导出也是名为 default 的导出
import * as moduleNamespace from "./service.js";
console.log(moduleNamespace.default);
转发默认导出:
export {
default as UserService,
} from "./user-service.js";
9.5.4 默认导出与实时绑定的细节
下面的默认导出会导出表达式求值结果:
let value = 1;
export default value;
value = 2;
导入方通常看到初始导出的 1。
如果希望默认导出继续跟随本地绑定,可以使用导出列表:
let value = 1;
export {
value as default,
};
value = 2;
此时默认导出与 value 保持实时绑定。
公共 API 通常不应让默认导出表示频繁变化的状态。命名导出更容易表达可变绑定。
9.6 默认导出还是命名导出
命名导出的优点:
- 导入名称与导出名称对齐;
- 一个模块可以公开多个接口;
- 编辑器和重构工具更容易追踪;
- 统一命名有利于代码搜索;
- 拼写错误通常在模块链接阶段暴露;
- 聚合导出更加直观。
默认导出的优点:
- 模块只有一个核心概念时语法简洁;
- 导入方可以选择符合本地语境的名称;
- 适合页面组件、单一工厂或单一配置等明确入口。
默认导出并非错误,命名导出也不必用于所有文件。关键是保持项目约定一致,并避免一个模块同时混合大量无关导出。
9.7 重新导出
9.7.1 转发指定导出
export {
createUser,
UserService,
} from "./user.js";
当前模块不会创建同名本地绑定,只负责转发。
9.7.2 转发并重命名
export {
createUser as buildUser,
} from "./user.js";
9.7.3 星号转发
export * from "./math.js";
export * 会转发命名导出,但不会自动转发默认导出。
如果多个星号来源导出同名标识符,而当前模块没有显式解决冲突,该名称可能不会形成可导入的明确导出。
export * from "./first.js";
export * from "./second.js";
如果两个模块都导出 value,应显式选择:
export {
value as firstValue,
} from "./first.js";
export {
value as secondValue,
} from "./second.js";
9.7.4 命名空间转发
export * as math from "./math.js";
9.7.5 Barrel 模块
聚合多个子模块的入口文件常称为 Barrel:
// index.js
export { Button } from "./button.js";
export { Dialog } from "./dialog.js";
export { formatDate } from "./date.js";
优点:
- 对外入口统一;
- 内部目录可以调整;
- 包的公共 API 更明确。
风险:
- 容易形成大型循环依赖;
- 不必要的聚合可能扩大构建分析范围;
- 模块初始化副作用更难追踪;
- 同名导出容易冲突。
Barrel 应围绕稳定公共边界设计,而不是为每个目录机械创建。
9.8 静态 import 的语法限制
静态 import 声明只能位于模块顶层:
import { createUser } from "./user.js";
不能放入普通条件或循环:
// SyntaxError
// if (condition) {
// import { createUser } from "./user.js";
// }
模块说明符必须是字符串字面量:
// SyntaxError
// import { createUser } from modulePath;
这种静态结构使宿主和工具可以在模块求值前分析依赖图。
静态导入会在模块图链接阶段建立依赖关系,但模块具体下载、实例化和求值顺序由模块加载算法决定。不能简单把 ESM 理解为“从上到下执行 import 语句”。
9.9 模块图的加载阶段
从概念上,可以把 ESM 加载分为三个主要阶段:
- 解析与获取:根据模块说明符定位并获取依赖;
- 实例化与链接:创建模块环境、解析导入和导出绑定;
- 求值:执行模块顶层代码。
这个模型有助于理解:
- 为什么导入绑定在模块代码求值前已经建立;
- 为什么循环依赖可以建立双向绑定;
- 为什么某些循环依赖会在初始化前访问时报错;
- 为什么顶级
await会影响依赖模块的求值。
9.9.1 依赖先求值
// dependency.js
console.log("dependency");
export const value = 10;
// app.js
import { value } from "./dependency.js";
console.log("app");
通常先输出:
dependency
app
9.9.2 相同依赖不会重复求值
// shared.js
console.log("shared");
// first.js
import "./shared.js";
// second.js
import "./shared.js";
在同一模块图中,shared.js 通常只求值一次。
9.10 循环依赖
模块 A 导入模块 B,同时模块 B 又导入模块 A,就形成循环依赖。
ESM 能够建立循环模块图,因为导入的是绑定,而不是必须先获得完整导出对象的同步复制。但如果模块在绑定初始化之前读取它,仍会产生暂时性死区错误。
更安全的循环结构可以通过函数延迟读取:
// a.js
import { getValueB } from "./b.js";
export const valueA = "A";
export function getCombinedA() {
return valueA + getValueB();
}
// b.js
import { valueA } from "./a.js";
const valueB = "B";
export function getValueB() {
return valueB;
}
export function getCombinedB() {
return valueB + valueA;
}
只有在两个模块都完成初始化后调用函数,读取才安全。
少量循环不一定错误,但经常表明模块边界需要调整。常见解决方法:
- 提取共同依赖到第三个模块;
- 把数据类型和执行逻辑分开;
- 使用依赖注入;
- 延迟调用而不是顶层读取;
- 减少模块顶层副作用;
- 重新定义上下层依赖方向;
- 把双向通信改为事件或回调接口。
不要依赖偶然的求值顺序修复循环问题。
9.11 浏览器中的模块脚本
9.11.1 type=“module”
<script
type="module"
src="./app.js"
></script>
内联模块:
<script type="module">
import {
startApplication,
} from "./app.js";
startApplication();
</script>
9.11.2 模块脚本默认延迟执行
外部模块脚本默认具有类似 defer 的行为:
- HTML 解析可以继续;
- 模块及依赖并行获取;
- 文档解析完成后按模块求值规则执行。
通常不需要额外写 defer。
9.11.3 async 模块脚本
<script
type="module"
async
src="./analytics.js"
></script>
async 模块脚本在模块图准备完成后尽快执行,不保证与其他脚本保持文档顺序。适合彼此独立、不依赖页面主初始化顺序的模块。
9.11.4 CORS 与 MIME 类型
跨源模块脚本使用 CORS 获取规则。服务器需要返回适当的跨源响应头。
浏览器还会严格检查模块的 JavaScript MIME 类型。如果服务器把 .js 返回为 text/html,例如错误回退页面,模块加载会失败。
开发环境中不应直接通过 file:// 双击 HTML 文件测试复杂模块图。应使用本地 HTTP 服务器,以获得正确的 URL、CORS 和 MIME 行为。
9.11.5 文件扩展名和 URL 解析
浏览器中的相对模块说明符通常需要写完整路径和扩展名:
import { createUser } from "./user.js";
不能依赖 Node.js 或构建工具的自动扩展名解析。
浏览器相对模块说明符相对于当前模块 URL 解析,而不是相对于 HTML 文件或当前工作目录。
9.11.6 裸说明符
import React from "react";
"react" 是裸说明符,不以 /、./ 或 ../ 开头。
浏览器本身不能像 Node.js 一样自动搜索 node_modules。裸说明符需要:
- Import Map;
- 构建工具重写;
- 运行时加载器;
- 显式使用可解析 URL。
9.12 Import Maps
Import Map 允许浏览器把裸说明符映射到 URL:
<script type="importmap">
{
"imports": {
"math": "/modules/math.js",
"lib/": "/vendor/lib/"
}
}
</script>
随后:
import { add } from "math";
import { helper } from "lib/helper.js";
Import Map 可以定义精确映射、前缀映射和 scopes。它只影响模块说明符解析,不负责从 npm Registry 安装包,不自动转换 CommonJS,也不执行 Tree-shaking。
对于小型浏览器项目,它可以减少打包需求;大型应用仍可能需要构建、代码分割、资源优化和兼容处理。
9.13 动态 import()
动态导入使用:
const moduleNamespace = await import(
"./feature.js",
);
import() 是表达式,返回 Promise。Promise 兑现值为模块命名空间对象。
9.13.1 条件加载
async function loadEditor(enabled) {
if (!enabled) {
return null;
}
const { Editor } = await import(
"./editor.js",
);
return new Editor();
}
9.13.2 计算说明符
const modulePath = `./locales/${locale}.js`;
const localeModule = await import(modulePath);
构建工具可能无法静态枚举任意运行时路径。通常应限制可能值:
const localeLoaders = {
en: () => import("./locales/en.js"),
"zh-CN": () => import("./locales/zh-CN.js"),
};
const loader = localeLoaders[locale];
if (loader === undefined) {
throw new RangeError(
`不支持语言:${locale}`,
);
}
const localeModule = await loader();
9.13.3 错误处理
动态导入可能因为网络、CORS、MIME、语法、依赖加载、顶层求值或包导出路径等原因失败,应通过 try...catch 或 Promise 拒绝处理。
如果说明符解析到已加载的同一模块,后续 import() 通常不会重新执行模块顶层代码。不要用重复 import() 作为重新初始化模块的机制。
9.14 import.meta
import.meta 是模块专用的元属性,具体属性由宿主环境提供。
9.14.1 import.meta.url
console.log(import.meta.url);
在浏览器中通常是当前模块的绝对 URL;在 Node.js 中通常是 file: URL。
相对资源可以基于模块 URL 解析:
const dataUrl = new URL(
"./data.json",
import.meta.url,
);
这种方式比依赖进程当前工作目录更加稳定。
9.14.2 import.meta.resolve
支持的环境可以使用:
const resolved = import.meta.resolve(
"./feature.js",
);
它返回按当前模块解析规则得到的 URL 字符串。不同宿主和版本的支持情况可能不同。
9.14.3 Node.js 扩展属性
较新的 Node.js 版本还提供:
import.meta.dirname
import.meta.filename
这些是 Node.js 宿主扩展,不属于浏览器通用 ECMAScript 语义。跨环境库应优先使用 import.meta.url 和 URL API。
9.15 导入属性与 JSON 模块
现代模块语法使用 Import Attributes 为导入提供额外信息:
import configuration
from "./config.json"
with {
type: "json",
};
动态导入:
const {
default: configuration,
} = await import(
"./config.json",
{
with: {
type: "json",
},
},
);
较早实现曾使用 assert 语法。现代标准采用 with,新代码不应继续使用旧 Import Assertions 语法。
JSON 模块通常只提供默认导出,不能假定顶层字段自动成为命名导出。
Import Attributes 是 ECMAScript 语法,但具体模块类型由宿主支持。JSON、CSS、文本等模块类型的可用性和语义可能因浏览器、Node.js 和构建工具而不同。
9.16 顶级 await
模块顶层可以直接使用 await:
const response = await fetch(
"/config.json",
);
if (!response.ok) {
throw new Error(
`HTTP ${response.status}`,
);
}
export const configuration = await response.json();
导入该模块的模块需要等待它完成异步求值。
顶级 await 会挂起相关模块图分支的求值,不是同步阻塞整个事件循环。其他不依赖该模块的任务和模块分支仍可能推进。
适合:
- 模块级配置加载;
- 必需资源初始化;
- WebAssembly 模块准备;
- 数据库或运行时环境连接;
- 只有完成后模块导出才有意义的初始化。
风险:
- 延迟所有依赖模块的可用时间;
- 隐藏启动期间的网络等待;
- 循环依赖更加复杂;
- 错误会导致整个依赖模块图求值失败;
- 库使用顶级
await会把异步初始化成本传给所有消费者。
许多场景更适合显式异步工厂,让调用者决定何时等待、如何重试以及如何处理失败。
9.17 Tree-shaking 与代码分割
Tree-shaking 是构建工具根据模块导出、导入和副作用信息,尝试删除未使用代码的优化过程。ESM 的静态结构为这类分析提供基础,但不能保证未使用代码一定被删除。
效果还取决于:
- 构建工具;
- 输出目标;
- 模块是否包含副作用;
- CommonJS 互操作;
- 动态属性访问;
- 重导出结构;
- 包的
sideEffects配置; - 压缩器;
- 模块是否在运行时动态选择。
库模块应尽量减少顶层副作用,避免导入即修改全局、注册插件或发起请求。
部分构建工具读取 package.json 中的生态字段:
{
"sideEffects": false
}
如果部分文件有副作用,应明确列出:
{
"sideEffects": [
"./src/polyfill.js",
"./src/styles.css"
]
}
错误声明 sideEffects: false 可能使构建工具删除必要初始化代码。该字段是构建工具约定,不是 npm 安装或 Node.js 模块加载的通用标准字段。
动态 import() 可以成为构建工具的代码分割边界,但具体 chunk 数量、名称、预加载策略和缓存行为由构建配置决定。
9.18 CommonJS 基础
CommonJS 是 Node.js 传统模块系统。
9.18.1 导出属性
// math.cjs
exports.add = function (first, second) {
return first + second;
};
exports.multiply = function (first, second) {
return first * second;
};
导入:
// app.cjs
const math = require("./math.cjs");
console.log(math.add(1, 2));
9.18.2 module.exports
// user-service.cjs
class UserService {
// ...
}
module.exports = UserService;
const UserService = require(
"./user-service.cjs",
);
require() 返回当前模块的 module.exports 值。
9.18.3 exports 是初始别名
Node.js 初始化 CommonJS 模块时,exports 最初引用 module.exports:
console.log(
exports === module.exports,
); // true
给 exports 添加属性,会修改同一个对象:
exports.add = add;
但重新赋值 exports 只改变局部变量,不会改变真正导出:
// 错误
exports = {
add,
};
应写:
module.exports = {
add,
};
9.18.4 CommonJS 模块包装
Node.js 会把 CommonJS 文件放入类似函数包装的环境中,使其获得:
exports;require;module;__filename;__dirname。
模块顶层变量因此不会自动成为全局变量。这属于 Node.js CommonJS 宿主机制,不是浏览器 JavaScript 的通用语义。
9.18.5 同步加载
传统 require() 是同步接口:
const configuration = require(
"./config.cjs",
);
模块首次加载、解析和求值会在当前调用中完成。读取本地缓存后的调用可以较快,但仍保持同步接口。
9.19 CommonJS 缓存
CommonJS 模块通常按照解析后的文件名缓存:
const first = require(
"./state.cjs",
);
const second = require(
"./state.cjs",
);
console.log(first === second); // 通常为 true
模块顶层代码首次加载时执行,后续 require() 返回缓存的 module.exports。
9.19.1 CommonJS 不只是值快照
假设模块导出对象:
// state.cjs
module.exports = {
count: 0,
};
消费者:
const state = require(
"./state.cjs",
);
state.count += 1;
其他获得同一导出对象引用的消费者可以看到变化。
因此,把 CommonJS 描述为“永远导出值的静态快照”是不准确的。require() 返回 module.exports 当前值:
- 如果它是对象,消费者可以共享对象引用;
- 如果消费者执行解构,局部变量可能成为当时属性值的普通复制;
- 如果模块在加载完成后把
module.exports重新指向新对象,已经持有旧对象的消费者不会自动切换; - CommonJS 没有 ESM 那种语言级导入绑定更新机制。
9.19.2 删除缓存
Node.js 可以通过:
delete require.cache[
require.resolve(
"./state.cjs",
)
];
删除缓存条目。
这种方式不等于完整卸载模块:
- 其他代码可能仍持有旧导出引用;
- 模块创建的定时器、监听器和全局状态仍然存在;
- 依赖模块缓存不会自动全部清理;
- 原生扩展和复杂模块可能无法安全重载。
测试和热更新工具需要更加完整的隔离策略。
9.20 CommonJS 循环依赖
CommonJS 在模块完成求值前,可以向依赖方暴露尚未完成的 module.exports 对象。
循环中可能读取到部分初始化的导出对象。这与 ESM 的实时绑定和暂时性死区机制不同,但两种系统都不意味着循环依赖自动安全。
解决原则仍然是:
- 降低顶层副作用;
- 避免加载阶段立即读取对方完整状态;
- 抽取公共依赖;
- 使用函数延迟访问;
- 重新设计依赖方向。
9.21 Node.js 如何确定模块类型
Node.js 同时支持 ESM 和 CommonJS。
9.21.1 扩展名
.mjs:始终作为 ESM;.cjs:始终作为 CommonJS;.js:通常由最近的父级package.json中的"type"决定。
9.21.2 type 字段
{
"type": "module"
}
该包范围内的 .js 文件按 ESM 解释。
{
"type": "commonjs"
}
该包范围内的 .js 文件按 CommonJS 解释。
如果没有 "type",Node.js 通常把 .js 视为 CommonJS,但现代 Node.js 还可能根据语法执行额外检测。为了避免工具和运行版本产生歧义,包应显式声明 "type"。
9.21.3 混合模块
在 "type": "module" 包中,可以使用 .cjs 保存 CommonJS 文件;在 "type": "commonjs" 包中,可以使用 .mjs 保存 ESM 文件。
9.21.4 明确扩展名
Node.js ESM 的相对和绝对导入通常要求完整文件扩展名:
import {
helper,
} from "./helper.js";
目录索引也不会按 CommonJS 的旧习惯自动补全。这使 Node.js ESM 更接近浏览器 URL 语义。
9.22 Node.js 模块说明符
Node.js ESM 常见说明符类型包括:
9.22.1 相对说明符
import { helper } from "./helper.js";
9.22.2 绝对 file URL
import moduleValue
from "file:///path/to/module.js";
9.22.3 包说明符
import packageValue
from "package-name";
Node.js 会根据包解析算法、node_modules 和 package.json 字段解析。
9.22.4 包子路径
import feature
from "package-name/feature";
如果包定义了 "exports",只有公开的子路径可以访问。
9.22.5 node: 说明符
Node.js 内置模块推荐使用:
import {
readFile,
} from "node:fs/promises";
node: 前缀明确表示 Node.js 内置模块,避免与同名第三方包混淆。
9.23 Node.js 中的 ESM 与 CommonJS 互操作
9.23.1 ESM 导入 CommonJS
import commonjsValue
from "./legacy.cjs";
默认导入通常对应 CommonJS 的 module.exports。
Node.js 可能通过静态分析为部分 CommonJS 属性提供命名导出:
import {
namedValue,
} from "./legacy.cjs";
这种命名检测是宿主互操作机制,不是 CommonJS 的语言级实时绑定。复杂赋值、动态属性或后续变更可能无法可靠映射。
跨格式导入时,更稳妥的方式通常是默认导入或命名空间导入后显式读取:
import legacy
from "./legacy.cjs";
const {
namedValue,
} = legacy;
9.23.2 CommonJS 加载 ESM
CommonJS 可以使用动态导入:
async function loadModernModule() {
return import("./modern.mjs");
}
较新的 Node.js 版本还支持在满足条件时通过 require() 同步加载部分 ESM,例如模块图不能包含顶级 await。这一能力在不同 Node.js 版本中的限制和返回形式可能不同。
为了获得更稳定的跨版本行为,CommonJS 加载 ESM 时优先使用动态 import(),除非项目明确规定最低 Node.js 版本并测试了同步加载路径。
9.23.3 ESM 中创建 require
ESM 没有普通 CommonJS 全局 require。Node.js 专用代码可以使用:
import {
createRequire,
} from "node:module";
const require = createRequire(
import.meta.url,
);
const legacy = require(
"./legacy.cjs",
);
这属于 Node.js API,不适用于浏览器。
9.23.4 双包风险
一个包如果同时提供 ESM 和 CommonJS 入口,可能出现:
- 同一包被加载两次;
- ESM 和 CommonJS 获得不同单例状态;
instanceof失败;- 注册表重复;
- 静态字段和缓存不共享;
- 默认导出与命名导出行为不一致。
包作者应尽量让两个入口共享同一核心实现,明确条件导出,并分别测试两种加载方式。新项目如果不需要兼容 CommonJS,可以只发布 ESM,以减少复杂性。
9.24 ESM 与 CommonJS 对照
| 维度 | ESM | CommonJS |
|---|---|---|
| 标准来源 | ECMAScript 标准 | Node.js 传统模块系统 |
| 核心语法 | import / export | require() / module.exports |
| 依赖结构 | 静态导入可在求值前分析 | require() 可在运行时调用 |
| 加载接口 | 静态导入与异步 import() | 传统 require() 同步 |
| 导出关系 | 语言级实时绑定 | 返回 module.exports 当前值 |
| 导入重新赋值 | 不允许 | 局部变量可重新赋值 |
顶层 this | undefined | Node.js CommonJS 中通常为 module.exports |
| 严格模式 | 自动启用 | 不自动全局启用 |
顶级 await | 支持 | 不支持 |
| 浏览器原生支持 | 支持 | 不原生支持 |
| Tree-shaking | 为静态分析提供基础 | 通常较难静态分析 |
| 文件类型 | .mjs 或 "type": "module" 范围中的 .js | .cjs 或 "type": "commonjs" 范围中的 .js |
| 循环依赖 | 实时绑定,可能遇到未初始化绑定 | 可能得到部分初始化的导出对象 |
表格描述的是主要语义,不意味着所有工具、运行时版本和构建输出都完全一致。
9.25 package 与 module 的区别
模块通常是可被加载器识别的单个代码单元。包通常是具有 package.json 的目录或发布归档。
一个包可以包含:
- 多个模块;
- TypeScript 类型声明;
- CSS;
- 图片;
- WebAssembly;
- 命令行程序;
- 构建脚本;
- 测试;
- 文档。
常见包管理器包括 npm、pnpm 和 Yarn。它们负责读取项目清单、解析版本范围、构建依赖图、下载包、校验完整性、生成锁文件、创建依赖布局、执行生命周期脚本、管理工作区和发布包。
包管理器不能保证第三方依赖安全、兼容或无缺陷。
9.26 package.json
package.json 是严格 JSON 文件,不允许注释、尾随逗号、单引号、函数或 undefined。
{
"name": "example-app",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run"
},
"dependencies": {
"react": "^19.0.0"
},
"devDependencies": {
"vite": "^7.0.0",
"vitest": "^3.0.0"
}
}
版本号只是示意,实际项目应根据当前依赖兼容性选择。
9.27 package.json 的基础字段
9.27.1 name 与 version
{
"name": "@example/math",
"version": "1.2.3"
}
发布到 Registry 的包名需要满足命名规则,并且在相应作用域内唯一。
9.27.2 private
{
"private": true
}
npm 会阻止该包被意外发布。应用和工作区根项目通常应设置为 true,前提是它本来不需要发布。
9.27.3 description、license 与项目链接
{
"description": "A small mathematics utility library.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/example/project.git"
},
"homepage": "https://example.com/project",
"bugs": {
"url": "https://github.com/example/project/issues"
}
}
许可证应使用明确的 SPDX 标识符,并与项目实际授权一致。
9.28 scripts
{
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run",
"lint": "eslint ."
}
}
执行:
npm run build
运行脚本时,包管理器通常把项目依赖中的可执行文件目录加入 PATH,因此脚本中可以直接写 vite。
9.28.1 pre 与 post 脚本
{
"scripts": {
"prebuild": "node scripts/prepare.js",
"build": "vite build",
"postbuild": "node scripts/report.js"
}
}
执行 npm run build 时会依次运行 prebuild、build 和 postbuild。
这种隐式链条可能降低可见性。复杂流水线可以在主脚本中显式组合,或使用专用任务工具。
9.28.2 生命周期脚本
npm 还定义 prepare、prepublishOnly、install、postinstall 等生命周期。
这些脚本的触发时机并不完全直观,且不同包管理器可能存在差异。公共包中的安装脚本具有较高安全风险和兼容成本,只有确实需要编译原生扩展、下载平台资源或完成必要安装工作时才应使用。
9.29 type、main、exports 与 imports
9.29.1 type
{
"type": "module"
}
决定 Node.js 如何解释该包范围内的 .js 文件。
9.29.2 main
{
"main": "./dist/index.js"
}
main 定义传统主入口,能力有限,但对旧工具具有兼容价值。
9.29.3 exports
{
"exports": {
".": "./dist/index.js",
"./math": "./dist/math.js",
"./package.json": "./package.json"
}
}
消费者:
import packageValue
from "example-package";
import { add }
from "example-package/math";
定义 "exports" 后,未公开的包内部路径通常不能再被外部导入。这建立了明确封装边界。
9.29.4 条件导出
{
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
}
}
条件顺序具有语义,应把更具体条件放在前面,把 "default" 放在后面。并非所有工具都采用完全相同的自定义条件集合,包作者应针对目标工具测试。
9.29.5 imports
{
"imports": {
"#config": "./src/config.js",
"#utils/*": "./src/utils/*.js"
}
}
包内部:
import configuration
from "#config";
"imports" 不会自动向包外消费者公开接口。
9.29.6 module 与 browser 字段
"module" 和 "browser" 是生态工具约定,不是 Node.js 与 ECMAScript 的通用标准入口字段。
现代包应优先使用 "exports",并在需要时保留 "main" 兼容。跨环境入口应通过条件导出和目标工具测试实现。
9.30 files、bin 与 engines
9.30.1 files
{
"files": [
"dist",
"README.md",
"LICENSE"
]
}
发布前应执行:
npm pack --dry-run
检查实际归档内容,避免遗漏构建产物或发布密钥、测试数据和临时文件。
9.30.2 bin
{
"bin": {
"example-cli": "./bin/cli.js"
}
}
命令行脚本通常需要:
#!/usr/bin/env node
并应正确设置退出码、stderr、--help、--version、Promise 拒绝和资源清理。
9.30.3 engines
{
"engines": {
"node": ">=22"
}
}
它表达期望的 Node.js 版本范围。npm 默认可能只发出警告,不能把 "engines" 当作绝对运行时阻止机制。
项目还可以通过 CI 矩阵、容器、.nvmrc、.node-version 或开发环境管理工具统一 Node.js 版本。
9.31 依赖分类
9.31.1 dependencies
{
"dependencies": {
"runtime-library": "^2.0.0"
}
}
放置包或应用运行时需要的依赖。
对于前端应用,依赖可能在构建时被打入静态产物,生产服务器未必需要安装完整 dependencies。对于 Node.js 服务,运行时通常需要相应依赖存在。分类应根据发布和部署模型决定,而不是机械理解为“线上一定安装”。
9.31.2 devDependencies
{
"devDependencies": {
"eslint": "^9.0.0",
"vite": "^7.0.0",
"vitest": "^3.0.0"
}
}
用于开发、测试、构建和文档工具。
不要把运行时会由包代码直接导入、且消费者没有义务安装的依赖错误放入 devDependencies。
9.31.3 peerDependencies
{
"peerDependencies": {
"react": ">=18 <20"
}
}
用于表达当前包需要与宿主项目中的某个库共同工作,例如框架插件、UI 组件库、构建工具插件和 lint 插件。
npm 7 及以后默认尝试安装 peer dependencies;npm 3 到 6 主要提供警告而不自动安装。不同包管理器的冲突处理也可能不同。
包作者应尽量提供合理宽的兼容范围,而不是无必要地锁死单个补丁版本。
9.31.4 peerDependenciesMeta
{
"peerDependencies": {
"optional-host": "^2.0.0"
},
"peerDependenciesMeta": {
"optional-host": {
"optional": true
}
}
}
包代码必须处理宿主未安装该依赖的情况。
9.31.5 optionalDependencies
{
"optionalDependencies": {
"platform-accelerator": "^1.0.0"
}
}
安装失败不会使整个安装必然失败。运行时代码需要提供后备路径。
不要把实际上必需的依赖标为可选来掩盖安装问题。
9.31.6 bundleDependencies
{
"bundleDependencies": [
"embedded-package"
]
}
发布归档时把指定依赖包含在包内。适用场景有限,例如需要单个离线归档的 CLI。它会增加归档大小和更新复杂度,不是普通库的默认选择。
9.31.7 overrides
npm 根项目可以使用:
{
"overrides": {
"transitive-package": "2.3.4"
}
}
用于替换传递依赖版本、应用安全修复或强制统一版本。
风险包括:
- 上游包未测试该版本;
- API 不兼容;
- 只在根项目有效;
- 隐藏真实依赖冲突;
- 更新上游后遗留无效覆盖。
overrides 应记录原因,并在上游正式修复后移除。Yarn 和 pnpm 提供类似但字段和语义可能不同的机制,不能把配置文件无条件跨包管理器复制。
9.32 语义化版本
Semantic Versioning 2.0.0 使用:
MAJOR.MINOR.PATCH
例如:
2.4.1
9.32.1 主版本号
破坏公开 API 兼容性的变更增加 MAJOR:
1.5.3 → 2.0.0
例如:
- 删除公开函数;
- 改变参数语义;
- 更改返回结构;
- 修改默认行为导致现有调用失效;
- 移除已公开的包子路径。
9.32.2 次版本号
向后兼容地增加功能,增加 MINOR:
1.5.3 → 1.6.0
9.32.3 修订号
向后兼容地修复错误,增加 PATCH:
1.5.3 → 1.5.4
9.32.4 0.y.z
SemVer 规定 0.y.z 表示初始开发阶段,公开 API 不应被视为稳定。
0.2.3
次版本变化可能包含破坏性变更:
0.2.3 → 0.3.0
不能把所有 0.x 包的 minor 更新都视为安全兼容升级。
9.32.5 预发布版本
1.0.0-alpha.1
1.0.0-beta.2
1.0.0-rc.1
预发布版本优先级低于对应正式版本:
1.0.0-rc.1 < 1.0.0
普通版本范围通常不会自动包含不满足明确预发布条件的版本。
9.32.6 构建元数据
1.0.0+build.20260719
构建元数据不影响版本优先级。
9.33 npm 版本范围
npm 使用 node-semver 语法表达版本范围,它建立在 SemVer 上,但提供更多范围写法。
9.33.1 精确版本
{
"dependencies": {
"package-name": "1.2.3"
}
}
9.33.2 比较范围
>=1.2.3 <2.0.0
9.33.3 x 范围
1.2.x
表示兼容的 1.2 补丁版本。
9.33.4 波浪号
~1.2.3
通常表示:
>=1.2.3 <1.3.0
允许补丁更新,不允许次版本更新。
9.33.5 插入符
^1.2.3
通常表示:
>=1.2.3 <2.0.0
允许不改变最左侧非零版本段的更新。
对于 0.x:
^0.2.3
通常表示:
>=0.2.3 <0.3.0
对于:
^0.0.3
通常只允许:
>=0.0.3 <0.0.4
因此,不能简单说 ^ 永远“锁定主版本”。
9.33.6 联合范围
^1.0.0 || ^2.0.0
表示支持两个主版本系列。
9.33.7 dist-tag
latest
next
beta
Registry dist-tag 是指向某个版本的可变标签,不是版本范围。它可以随着发布改变,不能用于要求长期固定版本的可重复环境。
9.34 版本范围的工程选择
应用项目:
- 锁文件固定实际安装版本;
package.json范围表达允许更新边界;- 可以根据更新策略选择
^、~或精确版本; - 每次更新仍需运行完整测试。
公共库:
dependencies应表达真正兼容范围;- peer dependency 范围不宜无意义地过窄;
- 不能依赖根项目锁文件替消费者固定依赖;
- 破坏公共 API 时应正确增加主版本;
- 发布前应测试支持范围中的代表版本。
精确固定所有库依赖并不一定更安全,因为它可能造成重复版本、安全补丁难以进入和 peer 冲突。
9.35 锁文件
常见锁文件:
- npm:
package-lock.json; - pnpm:
pnpm-lock.yaml; - Yarn:
yarn.lock。
锁文件记录解析后的依赖图,包括精确版本、来源、完整性校验、传递依赖、peer 解析信息、平台信息和包管理器特定元数据。
9.35.1 package.json 与锁文件的分工
package.json 表达允许范围和项目意图:
{
"dependencies": {
"library": "^2.1.0"
}
}
锁文件记录当前解析结果,例如:
library@2.4.3
执行普通 npm install 时:
- 如果锁文件版本满足
package.json范围,npm 使用锁文件结果; - 如果二者冲突,npm 重新解析并更新锁文件。
9.35.2 应提交锁文件
应用和服务应把锁文件提交到版本控制。公共库也通常应提交根项目锁文件,用于开发环境一致、CI 重现和审查依赖变化。
但 npm 发布包时不会把根 package-lock.json 作为消费者安装树的约束。消费者根据该包的 package.json 和自己的锁文件解析依赖。
9.35.3 锁文件不等于完整可重复构建
锁文件可以固定依赖解析结果,但无法单独保证产物逐字节一致。
差异仍可能来自:
- Node.js 和包管理器版本;
- 操作系统、CPU、libc;
- 可选依赖;
- 原生扩展编译器;
- 生命周期脚本;
- 环境变量;
- 系统时区和语言环境;
- 构建时间戳;
- 未锁定的系统工具;
- 外部网络资源;
- 构建工具的非确定性行为。
完整可重复构建还需要固定运行环境、工具链、配置和外部输入。
9.35.4 完整性校验
锁文件常包含包归档的完整性摘要,用于验证下载内容是否与解析记录一致。它有助于检测下载损坏和内容不匹配,但不能证明包本身没有恶意代码或安全缺陷。
9.36 npm install 与 npm ci
9.36.1 npm install
npm install
用于日常开发和依赖变更:
- 根据
package.json和锁文件安装; - 必要时更新锁文件;
- 可以添加新依赖;
- 可以调整依赖树。
添加运行时依赖:
npm install package-name
添加开发依赖:
npm install --save-dev package-name
9.36.2 npm ci
npm ci
适合 CI、部署和需要干净安装的环境。
主要特点:
- 要求存在
package-lock.json或npm-shrinkwrap.json; - 如果锁文件与
package.json不一致,直接失败; - 不修改
package.json; - 不更新锁文件;
- 安装前删除已有
node_modules; - 不能单独添加某个依赖。
如果生成锁文件时使用了会改变依赖树形状的配置,CI 应使用相同配置,通常通过提交 .npmrc 保持一致。
9.36.3 package-lock 与 npm-shrinkwrap
package-lock.json:
- 用于项目根;
- 应提交版本控制;
- 不随普通 npm 包发布;
- 消费者不会使用依赖包内部的 package-lock。
npm-shrinkwrap.json:
- 可以随包发布;
- 会约束从该包位置开始的依赖树;
- 主要适合通过发布流程交付的 CLI 或完整应用;
- 普通库通常不推荐使用。
如果根目录同时存在两者,npm 优先使用 npm-shrinkwrap.json。
9.37 不要混用锁文件
同一项目通常应选择一个包管理器和一个主锁文件。
同时长期维护多个锁文件会导致:
- 不同依赖解析结果;
- CI 与本地环境不一致;
- 自动更新工具修改不同文件;
- 合并冲突;
- 不清楚哪个文件是权威来源;
- 安装命令偶然切换布局。
迁移包管理器时,应明确迁移分支,生成新锁文件,删除旧锁文件,清理 node_modules,执行完整测试,并更新 CI、文档和部署脚本。
9.38 node_modules 与依赖提升
Node.js 传统包解析会从当前模块目录开始,沿父目录查找 node_modules。
最直接的依赖树可以映射为嵌套目录:
node_modules/
├── package-a/
│ └── node_modules/
│ └── shared-package/
└── package-b/
└── node_modules/
└── shared-package/
优点是依赖边界清楚、不同版本容易共存;问题是重复文件和目录较深。
npm 和部分 Yarn 配置会把满足条件的传递依赖提升到较高层:
node_modules/
├── package-a/
├── package-b/
└── shared-package/
提升能够减少重复,但结果受版本冲突、peer dependencies、包管理器版本、锁文件和工作区结构影响。不能根据肉眼看到的扁平目录推断逻辑依赖图。
可以使用:
npm ls --all
npm explain package-name
查看依赖来源。
9.39 幽灵依赖
如果传递依赖被提升到项目根,项目源码可能意外直接导入它:
import utility from "transitive-utility";
但根 package.json 中没有声明该包。
这称为幽灵依赖或未声明依赖。
风险:
- 上游移除该依赖后项目突然失败;
- 上游升级到不兼容版本;
- 不同包管理器布局下无法解析;
- 本地可运行但 CI 失败;
- 发布包漏掉真实运行时依赖;
- 依赖审计和许可证清单不准确。
原则是:
项目直接导入的包,应在项目自身的依赖清单中直接声明。
9.40 pnpm 的内容寻址存储
pnpm 使用内容寻址存储保存包文件,并通过硬链接与符号链接构建项目依赖结构。
简化结构:
node_modules/
├── package-a
└── .pnpm/
├── package-a@1.0.0/
│ └── node_modules/
│ ├── package-a/
│ └── dependency-b
└── dependency-b@2.0.0/
└── node_modules/
└── dependency-b/
优点:
- 相同包文件可在多个项目间复用存储;
- 不需要为每个逻辑依赖深度增加物理目录深度;
- 根
node_modules默认主要暴露直接依赖; - 未声明传递依赖更不容易被项目直接解析;
- 安装和重复项目磁盘利用率通常较好。
不能说 pnpm 在所有配置下彻底消除幽灵依赖。可见性还会受到 node-linker、提升配置、工作区和 peer dependency 布局影响。
现代 Node.js 能够处理 pnpm 的标准布局,但部分旧工具可能错误假设所有包都在根目录平铺,或无法正确处理符号链接。遇到问题时应优先升级工具或修复解析配置。
9.41 Yarn 的安装模型
Yarn 不同主要版本和项目配置可以采用传统 node_modules、现代链接器或 Plug’n’Play(PnP)。
PnP 不依赖传统 node_modules 树,而是通过解析映射描述包位置和依赖权限。
它可以提供更严格的依赖边界和确定的解析结构,但工具需要兼容 PnP API,部分旧工具或手写文件路径逻辑可能需要适配。
本章不把 npm、pnpm 或 Yarn 绝对评为最优解。选择应考虑团队熟悉度、生态兼容、工作区规模、磁盘和 CI 成本、依赖隔离、部署环境与现有锁文件。
9.42 包管理器一致性
项目应在文档和 CI 中明确包管理器及版本。不同包管理器版本可能改变锁文件格式、peer 解析、提升策略、生命周期脚本和工作区命令。
“使用同一个包管理器名称”仍不足以保证完全一致,关键项目应约束主版本或具体版本并在 CI 中验证。
9.43 工作区与 Monorepo
工作区允许一个仓库包含多个相互关联的包:
project/
├── package.json
├── packages/
│ ├── core/
│ │ └── package.json
│ ├── ui/
│ │ └── package.json
│ └── app/
│ └── package.json
└── package-lock.json
npm 根配置:
{
"private": true,
"workspaces": [
"packages/*"
]
}
优点:
- 本地包自动链接;
- 一个锁文件管理依赖图;
- 统一脚本和工具链;
- 跨包修改可以同一提交完成;
- 更容易进行整体重构。
风险:
- CI 范围扩大;
- 包之间形成隐式依赖;
- 发布顺序复杂;
- 版本策略需要统一;
- 根依赖提升可能掩盖未声明依赖;
- 构建缓存和任务拓扑更加复杂。
Monorepo 不是项目规模增大的必然选择。多个独立仓库在权限、发布周期和团队边界方面可能更加合适。
工作区包仍应通过正常依赖字段声明彼此关系。不同包管理器还支持 workspace: 等协议,具体语法和发布转换规则应查阅对应文档。
9.44 发布包
9.44.1 发布前检查
npm pack --dry-run
确认:
- 入口文件存在;
exports路径正确;- README 和 LICENSE 存在;
- 不包含密钥和临时文件;
- 构建产物完整;
- source map 策略明确;
- 依赖分类正确;
- 版本号已经更新;
- 测试通过;
- 包名和作用域正确。
9.44.2 npm publish
npm publish
作用域包公开发布可能需要:
npm publish --access public
npm Registry 中同一个包的同一个版本不能被覆盖重新发布。发现问题后应发布新版本。
9.44.3 publishConfig
{
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
它控制发布时的部分配置,适合防止作用域包误发到错误 Registry。
9.44.4 dist-tag
预发布版本可以使用:
npm publish --tag next
用户安装:
npm install package-name@next
dist-tag 可以移动,不应作为不可变构建标识。
9.45 包的公共 API
公共包一旦发布,以下内容都可能成为兼容性承诺:
- 导出名称和默认导出;
- 包子路径;
- 函数参数与返回值;
- 错误类型;
- 类和原型关系;
- TypeScript 类型;
- side effects;
- CSS 类名;
- CLI 参数;
- 环境变量;
- 文件路径。
使用 "exports" 可以限制用户导入内部文件:
{
"exports": {
".": "./dist/index.js",
"./testing": "./dist/testing.js"
}
}
不要让文档示例依赖未公开的 dist/internal.js 路径。
9.46 ESM 包示例
{
"name": "@example/math",
"version": "1.0.0",
"description": "Small mathematical utilities.",
"type": "module",
"exports": {
".": "./dist/index.js",
"./statistics": "./dist/statistics.js"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"sideEffects": false,
"engines": {
"node": ">=22"
}
}
发布前必须确认 dist/index.js 中的相对导入扩展名和目录结构在发布归档中有效。
9.47 双格式包示例
{
"name": "@example/library",
"version": "1.0.0",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
},
"main": "./dist/index.cjs",
"type": "module",
"files": [
"dist"
]
}
这种配置需要分别测试 ESM 和 CommonJS,并检查默认导出、命名导出、单例状态、错误类身份、子路径和类型声明是否一致。
如果没有明确兼容需求,只发布一种模块格式更加简单。
9.48 安装脚本与供应链风险
第三方包的生命周期脚本可能在安装过程中执行代码,例如编译原生扩展、下载二进制文件、修改文件或读取环境信息。因此,安装依赖不是纯粹下载静态文件。
代码审查应关注:
- 新增直接依赖;
- 锁文件新增的传递依赖;
- 包维护者和来源;
- 是否包含安装脚本;
- 包体积;
- 许可证;
- 最近发布历史;
- 是否真的需要该依赖。
npm 可以使用:
npm install --ignore-scripts
这会提高对安装脚本的控制,但部分依赖可能因此无法正确构建。更稳妥的流程是隔离安装、审查需要脚本的包,并在 CI 中固定配置。
npm audit 可以根据已知漏洞数据库检查依赖图,但不能发现尚未公开的漏洞、恶意代码、业务层不安全使用、错误配置或全部供应链攻击。
9.49 发布安全
包发布账户应使用强密码、双因素认证、最小权限、独立自动化令牌、受保护的 CI 环境和审核发布流程。
不要把长期 npm 令牌提交到 Git 仓库、.npmrc 模板、构建产物、日志或前端环境变量。
项目级 .npmrc 可以使用环境变量:
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
仍需确保 CI 不会把展开后的配置输出到日志或产物。
9.50 模块与依赖测试
模块测试应覆盖:
- ESM 导入;
- CommonJS 加载;
- 动态导入;
- 所有公开子路径;
- Node.js 最低支持版本;
- 浏览器构建;
- Tree-shaking;
- 顶级
await; - 循环依赖;
- 包发布归档;
- 无开发依赖的消费者安装;
- 工作区外独立安装。
仅在 Monorepo 中测试可能被提升依赖掩盖问题。发布前可以:
npm pack
然后在空目录安装生成的 .tgz,测试所有文档化入口。
9.51 常用命令
9.51.1 npm
npm install
npm ci
npm install package-name
npm install --save-dev package-name
npm uninstall package-name
npm update
npm outdated
npm ls --all
npm explain package-name
npm run build
npm test
npm pack --dry-run
npm publish
9.51.2 pnpm
pnpm install
pnpm add package-name
pnpm add --save-dev package-name
pnpm remove package-name
pnpm update
pnpm outdated
pnpm why package-name
pnpm build
pnpm test
pnpm pack
pnpm publish
9.51.3 Yarn
yarn install
yarn add package-name
yarn add --dev package-name
yarn remove package-name
yarn up package-name
yarn why package-name
yarn build
yarn test
yarn pack
yarn npm publish
具体命令可能随主要版本和项目配置变化,应以项目锁定的包管理器文档为准。
9.52 常见问题排查
9.52.1 Cannot use import statement outside a module
常见原因:
- Node.js 把
.js视为 CommonJS; - 缺少
"type": "module"; - 文件应使用
.mjs; - 测试工具未配置 ESM;
- 构建输出格式与运行环境不匹配。
9.52.2 require is not defined in ES module scope
ESM 没有普通 CommonJS require。可以改用静态 import、动态 import(),或在 Node.js 专用场景使用 createRequire()。
9.52.3 module、__filename 或 __dirname 不存在
ESM 不提供 CommonJS 的这些绑定。应使用 import.meta.url 和 URL API。Node.js 新版本可使用 import.meta.filename 和 import.meta.dirname,但它们不是浏览器通用接口。
9.52.4 ERR_PACKAGE_PATH_NOT_EXPORTED
包定义了 "exports",但当前导入路径没有公开。应使用包文档中的公共入口,而不是绕过到内部 dist 路径。
9.52.5 模块加载两次
检查:
- URL 查询参数;
- 大小写差异;
- 符号链接;
- 相对路径与包路径是否解析到不同位置;
- ESM 和 CommonJS 双入口;
- Monorepo 中是否安装了重复包副本;
- bundler alias;
- peer dependency 是否形成多实例。
9.52.6 Named export not found from CommonJS
CommonJS 命名导出检测有限。改为默认导入或命名空间导入,再显式读取属性。
9.52.7 本地成功,CI 无法找到包
检查:
- 是否导入了未声明传递依赖;
- 锁文件是否提交;
- CI 是否使用另一包管理器;
- 包管理器版本;
- 文件名大小写;
- 工作区提升是否掩盖缺失依赖;
- optional dependency 是否因平台未安装;
- 安装脚本是否被禁用。
9.53 模块设计原则
- 公共接口应小而稳定;
- 避免导出可被任意修改的共享状态;
- 减少导入即执行的副作用;
- 依赖方向应清晰;
- 循环依赖应被视为需要审查的设计信号;
- 不为单行代码机械拆模块;
- 库应通过
"exports"明确公共边界; - 跨环境包应分别测试浏览器、Node.js 和构建工具;
- 不依赖偶然的模块求值顺序;
- 顶级异步初始化应谨慎使用。
9.54 包管理原则
- 项目直接使用的依赖必须直接声明;
- 一个项目只维护一个权威锁文件;
- 在 CI 中使用干净、严格的锁文件安装;
- 明确包管理器和版本;
- 不无条件信任自动升级;
- 定期更新,但每次更新都运行测试;
- 尽量减少不必要依赖;
- 审查安装脚本和新增维护者;
- 公共库正确区分运行时、开发和 peer 依赖;
- 发布前测试实际包归档;
- 使用
"exports"定义公共边界; - 不依赖提升产生的幽灵依赖;
- 不把锁文件等同于完整可重复构建;
- 生产构建固定 Node.js、包管理器和构建环境;
- 凭据不进入仓库和产物。
9.55 完整示例:浏览器 ESM 应用
目录:
app/
├── index.html
└── src/
├── app.js
├── api.js
├── state.js
└── ui.js
state.js:
let users = [];
export function getUsers() {
return [...users];
}
export function setUsers(nextUsers) {
users = [...nextUsers];
}
api.js:
export async function fetchUsers() {
const response = await fetch(
"/api/users",
);
if (!response.ok) {
throw new Error(
`HTTP ${response.status}`,
);
}
return response.json();
}
ui.js:
export function renderUsers(
container,
users,
) {
container.replaceChildren();
const list = document.createElement("ul");
for (const user of users) {
const item = document.createElement("li");
item.textContent = user.name;
list.append(item);
}
container.append(list);
}
app.js:
import { fetchUsers } from "./api.js";
import { getUsers, setUsers } from "./state.js";
import { renderUsers } from "./ui.js";
async function startApplication() {
const container = document.querySelector("#app");
if (container === null) {
throw new Error(
"Missing #app container",
);
}
const users = await fetchUsers();
setUsers(users);
renderUsers(container, getUsers());
}
startApplication().catch(
(error) => {
console.error(error);
},
);
各模块不需要通过全局变量通信,依赖方向也可以从导入声明直接读出。
9.56 本章小结
本章系统介绍了 JavaScript 模块和包管理体系:
- 模块负责代码边界和依赖关系,包负责分发一组模块及资源;
- ESM 具有独立模块作用域、严格模式和顶层
this === undefined; - 同一已解析模块在同一加载环境中通常只实例化和求值一次;
- ESM 命名导出和导入使用语言级实时绑定;
- 导入绑定不可重新赋值,但导入对象内部仍可能可变;
- 模块命名空间对象不是普通可修改对象;
- 默认导出在内部表现为名为
default的导出; - 静态
import只能出现在模块顶层,说明符必须是字符串字面量; - ESM 加载包括获取、链接和求值等阶段;
- 循环依赖可以建立,但初始化前读取绑定仍可能产生错误;
- 浏览器模块脚本默认延迟执行,并使用严格 CORS 和 MIME 规则;
- 浏览器裸说明符需要 Import Map 或构建工具解析;
- 动态
import()返回 Promise,可用于条件加载和代码分割; import.meta.url提供当前模块 URL;- Import Attributes 使用
with语法,旧assert语法不应继续用于新代码; - 顶级
await挂起相关模块图求值,并把异步初始化成本传递给依赖方; - ESM 静态结构为 Tree-shaking 提供基础,但不能保证未使用代码一定被删除;
- CommonJS 使用
require()和module.exports,传统加载接口是同步的; exports只是module.exports的初始别名;- CommonJS
require()返回module.exports当前值,不应简单描述为永远复制静态快照; - CommonJS 循环依赖可能暴露部分初始化的导出对象;
- Node.js 通过
.mjs、.cjs和"type"判断文件模块类型; - Node.js ESM 相对导入通常要求完整扩展名;
- ESM 可以导入 CommonJS,但 CommonJS 命名导出检测具有局限;
- CommonJS 加载 ESM 时,动态
import()是较稳定的跨版本方式; - 双格式包可能产生双实例和对象身份问题;
package.json是严格 JSON,用于描述包元数据、脚本、入口和依赖;"exports"比"main"更适合定义现代包公共入口和子路径封装;"imports"用于包内部以#开头的说明符映射;"module"和"browser"是生态工具约定,不是 Node.js 通用标准入口;dependencies、devDependencies、peerDependencies和optionalDependencies表达不同责任;- npm 7 及以后默认尝试安装 peer dependencies,旧 npm 行为不同;
- SemVer 使用 MAJOR.MINOR.PATCH,但
0.y.z不表示稳定公共 API; - npm 的
^对0.x版本不会简单放宽到下一个主版本; - 锁文件固定依赖解析结果,但不足以单独保证整个构建逐字节可重复;
- 应把项目锁文件提交版本控制,并在 CI 中使用干净安装;
npm ci要求清单和锁文件一致,不会更新锁文件;package-lock.json不会作为普通依赖包的消费者约束,npm-shrinkwrap.json可以随包发布;- 同一项目不应长期混用多个权威锁文件;
- 依赖提升可以减少重复,但可能暴露幽灵依赖;
- 项目直接导入的包必须在自身依赖清单中直接声明;
- pnpm 使用内容寻址存储、硬链接和符号链接建立依赖布局,但严格性仍受配置影响;
- npm、pnpm 和 Yarn 各有不同布局和兼容权衡;
- 工作区简化多包协作,但也增加构建、发布和依赖边界复杂度;
- 发布前应使用
npm pack --dry-run检查真实归档; - 包的导出路径、错误类型、CLI 参数和类型声明都可能成为公共 API;
- 安装依赖可能执行第三方脚本,锁文件完整性校验也不能证明包本身安全;
- 模块应保持公共接口小、顶层副作用少、依赖方向清楚;
- 包管理应统一工具版本、审查锁文件并避免依赖偶然的目录提升。
参考资料
- javascript.info: Modules, introduction
- javascript.info: Export and Import
- javascript.info: Dynamic imports
- MDN JavaScript Guide: JavaScript modules
- MDN JavaScript Reference: import
- MDN JavaScript Reference: export
- MDN JavaScript Reference: import()
- MDN JavaScript Reference: import.meta
- MDN HTML: script type=“importmap”
- ECMAScript 2026 Language Specification: Modules
- Node.js Documentation: ECMAScript modules
- Node.js Documentation: CommonJS modules
- Node.js Documentation: Modules—Packages
- Node.js Documentation: node:module API
- npm Documentation: package.json
- npm Documentation: package-lock.json
- npm Documentation: npm-shrinkwrap.json
- npm Documentation: npm install
- npm Documentation: npm ci
- npm Documentation: Scripts
- npm Documentation: Workspaces
- Semantic Versioning 2.0.0
- npm node-semver
- pnpm Documentation: Symlinked node_modules structure
- pnpm Documentation: Motivation
- pnpm Documentation: Workspaces
- Yarn Documentation: Plug’n’Play
- Yarn Documentation: Workspaces