PhysChen.com
主页
物理
笔记 科普 研究
教学
IB 课程
编程
笔记 项目
随笔
所感 所思
摄影
Shenzhen Portrait Cats Others Wuhan Japan
关于
主页
物理
笔记 科普 研究
编程
笔记 项目
摄影
Shenzhen Portrait Cats Others Wuhan Japan
教学
IB 课程
随笔
所感 所思
关于
文章目录
    JavaScript 9. 模块系统与包管理 陈华的个人主页

    文章信息

    • 标题: JavaScript 9. 模块系统与包管理
    • 发布时间: 2026 年 7 月 19 日
    • 来源: https://physchen.com/zh-Hans/programming/notes/javascript-modules-and-package-management/
    • 摘要: 系统介绍 ES Modules、CommonJS、浏览器与 Node.js 的模块解析、动态导入、顶级 await、package.json、语义化版本、锁文件、依赖布局、工作区与包发布。

    目录

      JavaScript 9. 模块系统与包管理

      发布于 2026 年 7 月 19 日
      • JavaScript 基础
      • JavaScript
      • ESM
      • Node.js
      • Package Management

      随着程序规模增大⁠,把所有代码放在一个文件或全局作用域中会产生明显问题⁠:标识符容易冲突⁠,文件之间的依赖关系不清晰⁠,初始化顺序难以控制⁠,公共接口与内部实现无法区分⁠,测试⁠、构建和代码复用也会变得困难⁠。

      模块系统负责把程序拆分为具有明确输入和输出的代码单元⁠;包管理系统负责描述⁠、获取⁠、安装⁠、解析和更新可复用的软件包及其依赖⁠。

      二者相关⁠,但不是同一概念⁠:

      • 模块通常对应一个源文件或一个可独立加载的代码单元⁠;
      • 包通常是由 package.json 描述的目录或发布归档⁠,可以包含多个模块⁠、资源文件和命令行程序⁠;
      • 包管理器负责安装包⁠,模块加载器负责在运行时解析 import⁠、require() 等模块请求⁠;
      • 构建工具可以分析和转换模块⁠,也可以把多个模块打包为少量部署文件⁠。

      现代 JavaScript 主要涉及两种模块系统⁠:

      1. ECMAScript Modules(⁠ESM⁠)⁠:由 ECMAScript 标准定义⁠,浏览器⁠、Node.js 和其他现代运行时共同支持⁠;
      2. CommonJS(⁠CJS⁠)⁠:Node.js 早期采用的模块系统⁠,以 require() 和 module.exports 为核心⁠。

      本章首先介绍 ESM 的语言语义⁠,再讨论浏览器和 Node.js 的宿主解析规则⁠,随后介绍 CommonJS⁠、两种模块系统的互操作以及包管理机制⁠。

      9.1 模块的基本目标

      一个良好的模块通常应满足以下要求⁠:

      1. 只公开调用者真正需要的接口⁠;
      2. 隐藏内部实现细节⁠;
      3. 明确声明外部依赖⁠;
      4. 避免通过全局变量隐式通信⁠;
      5. 可以被独立测试⁠;
      6. 初始化副作用尽量少⁠;
      7. 不依赖偶然的文件加载顺序⁠;
      8. 保持循环依赖可控⁠。

      例如⁠,可以把计算逻辑放在独立模块中⁠:

      // 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 加载分为三个主要阶段⁠:

      1. 解析与获取⁠:根据模块说明符定位并获取依赖⁠;
      2. 实例化与链接⁠:创建模块环境⁠、解析导入和导出绑定⁠;
      3. 求值⁠:执行模块顶层代码⁠。

      这个模型有助于理解⁠:

      • 为什么导入绑定在模块代码求值前已经建立⁠;
      • 为什么循环依赖可以建立双向绑定⁠;
      • 为什么某些循环依赖会在初始化前访问时报错⁠;
      • 为什么顶级 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 对照

      维度ESMCommonJS
      标准来源ECMAScript 标准Node.js 传统模块系统
      核心语法import / exportrequire() / module.exports
      依赖结构静态导入可在求值前分析require() 可在运行时调用
      加载接口静态导入与异步 import()传统 require() 同步
      导出关系语言级实时绑定返回 module.exports 当前值
      导入重新赋值不允许局部变量可重新赋值
      顶层 thisundefinedNode.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 模块设计原则

      1. 公共接口应小而稳定⁠;
      2. 避免导出可被任意修改的共享状态⁠;
      3. 减少导入即执行的副作用⁠;
      4. 依赖方向应清晰⁠;
      5. 循环依赖应被视为需要审查的设计信号⁠;
      6. 不为单行代码机械拆模块⁠;
      7. 库应通过 "exports" 明确公共边界⁠;
      8. 跨环境包应分别测试浏览器⁠、Node.js 和构建工具⁠;
      9. 不依赖偶然的模块求值顺序⁠;
      10. 顶级异步初始化应谨慎使用⁠。

      9.54 包管理原则

      1. 项目直接使用的依赖必须直接声明⁠;
      2. 一个项目只维护一个权威锁文件⁠;
      3. 在 CI 中使用干净⁠、严格的锁文件安装⁠;
      4. 明确包管理器和版本⁠;
      5. 不无条件信任自动升级⁠;
      6. 定期更新⁠,但每次更新都运行测试⁠;
      7. 尽量减少不必要依赖⁠;
      8. 审查安装脚本和新增维护者⁠;
      9. 公共库正确区分运行时⁠、开发和 peer 依赖⁠;
      10. 发布前测试实际包归档⁠;
      11. 使用 "exports" 定义公共边界⁠;
      12. 不依赖提升产生的幽灵依赖⁠;
      13. 不把锁文件等同于完整可重复构建⁠;
      14. 生产构建固定 Node.js⁠、包管理器和构建环境⁠;
      15. 凭据不进入仓库和产物⁠。

      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 模块和包管理体系⁠:

      1. 模块负责代码边界和依赖关系⁠,包负责分发一组模块及资源⁠;
      2. ESM 具有独立模块作用域⁠、严格模式和顶层 this === undefined⁠;
      3. 同一已解析模块在同一加载环境中通常只实例化和求值一次⁠;
      4. ESM 命名导出和导入使用语言级实时绑定⁠;
      5. 导入绑定不可重新赋值⁠,但导入对象内部仍可能可变⁠;
      6. 模块命名空间对象不是普通可修改对象⁠;
      7. 默认导出在内部表现为名为 default 的导出⁠;
      8. 静态 import 只能出现在模块顶层⁠,说明符必须是字符串字面量⁠;
      9. ESM 加载包括获取⁠、链接和求值等阶段⁠;
      10. 循环依赖可以建立⁠,但初始化前读取绑定仍可能产生错误⁠;
      11. 浏览器模块脚本默认延迟执行⁠,并使用严格 CORS 和 MIME 规则⁠;
      12. 浏览器裸说明符需要 Import Map 或构建工具解析⁠;
      13. 动态 import() 返回 Promise⁠,可用于条件加载和代码分割⁠;
      14. import.meta.url 提供当前模块 URL⁠;
      15. Import Attributes 使用 with 语法⁠,旧 assert 语法不应继续用于新代码⁠;
      16. 顶级 await 挂起相关模块图求值⁠,并把异步初始化成本传递给依赖方⁠;
      17. ESM 静态结构为 Tree-shaking 提供基础⁠,但不能保证未使用代码一定被删除⁠;
      18. CommonJS 使用 require() 和 module.exports⁠,传统加载接口是同步的⁠;
      19. exports 只是 module.exports 的初始别名⁠;
      20. CommonJS require() 返回 module.exports 当前值⁠,不应简单描述为永远复制静态快照⁠;
      21. CommonJS 循环依赖可能暴露部分初始化的导出对象⁠;
      22. Node.js 通过 .mjs⁠、.cjs 和 "type" 判断文件模块类型⁠;
      23. Node.js ESM 相对导入通常要求完整扩展名⁠;
      24. ESM 可以导入 CommonJS⁠,但 CommonJS 命名导出检测具有局限⁠;
      25. CommonJS 加载 ESM 时⁠,动态 import() 是较稳定的跨版本方式⁠;
      26. 双格式包可能产生双实例和对象身份问题⁠;
      27. package.json 是严格 JSON⁠,用于描述包元数据⁠、脚本⁠、入口和依赖⁠;
      28. "exports" 比 "main" 更适合定义现代包公共入口和子路径封装⁠;
      29. "imports" 用于包内部以 # 开头的说明符映射⁠;
      30. "module" 和 "browser" 是生态工具约定⁠,不是 Node.js 通用标准入口⁠;
      31. dependencies⁠、devDependencies⁠、peerDependencies 和 optionalDependencies 表达不同责任⁠;
      32. npm 7 及以后默认尝试安装 peer dependencies⁠,旧 npm 行为不同⁠;
      33. SemVer 使用 MAJOR.MINOR.PATCH⁠,但 0.y.z 不表示稳定公共 API⁠;
      34. npm 的 ^ 对 0.x 版本不会简单放宽到下一个主版本⁠;
      35. 锁文件固定依赖解析结果⁠,但不足以单独保证整个构建逐字节可重复⁠;
      36. 应把项目锁文件提交版本控制⁠,并在 CI 中使用干净安装⁠;
      37. npm ci 要求清单和锁文件一致⁠,不会更新锁文件⁠;
      38. package-lock.json 不会作为普通依赖包的消费者约束⁠,npm-shrinkwrap.json 可以随包发布⁠;
      39. 同一项目不应长期混用多个权威锁文件⁠;
      40. 依赖提升可以减少重复⁠,但可能暴露幽灵依赖⁠;
      41. 项目直接导入的包必须在自身依赖清单中直接声明⁠;
      42. pnpm 使用内容寻址存储⁠、硬链接和符号链接建立依赖布局⁠,但严格性仍受配置影响⁠;
      43. npm⁠、pnpm 和 Yarn 各有不同布局和兼容权衡⁠;
      44. 工作区简化多包协作⁠,但也增加构建⁠、发布和依赖边界复杂度⁠;
      45. 发布前应使用 npm pack --dry-run 检查真实归档⁠;
      46. 包的导出路径⁠、错误类型⁠、CLI 参数和类型声明都可能成为公共 API⁠;
      47. 安装依赖可能执行第三方脚本⁠,锁文件完整性校验也不能证明包本身安全⁠;
      48. 模块应保持公共接口小⁠、顶层副作用少⁠、依赖方向清楚⁠;
      49. 包管理应统一工具版本⁠、审查锁文件并避免依赖偶然的目录提升⁠。

      参考资料

      1. javascript.info: Modules, introduction
      2. javascript.info: Export and Import
      3. javascript.info: Dynamic imports
      4. MDN JavaScript Guide: JavaScript modules
      5. MDN JavaScript Reference: import
      6. MDN JavaScript Reference: export
      7. MDN JavaScript Reference: import()
      8. MDN JavaScript Reference: import.meta
      9. MDN HTML: script type=“⁠importmap⁠”
      10. ECMAScript 2026 Language Specification: Modules
      11. Node.js Documentation: ECMAScript modules
      12. Node.js Documentation: CommonJS modules
      13. Node.js Documentation: Modules—Packages
      14. Node.js Documentation: node:module API
      15. npm Documentation: package.json
      16. npm Documentation: package-lock.json
      17. npm Documentation: npm-shrinkwrap.json
      18. npm Documentation: npm install
      19. npm Documentation: npm ci
      20. npm Documentation: Scripts
      21. npm Documentation: Workspaces
      22. Semantic Versioning 2.0.0
      23. npm node-semver
      24. pnpm Documentation: Symlinked node_modules structure
      25. pnpm Documentation: Motivation
      26. pnpm Documentation: Workspaces
      27. Yarn Documentation: Plug⁠’n⁠’Play
      28. Yarn Documentation: Workspaces
      上一篇 JavaScript 8. 错误处理与异常流控制 2026 年 7 月 19 日 下一篇
      © 2026 CHEN Hua All rights reserved
      闽ICP备2026003335号 · 粤公网安备44030002014022号
      © Hua Chen / PhysChen.com