人人都会AI编程

5.4 包加载机制:package.json 主入口、exports 字段、条件导出

更新时间:2026-07-10

在 5.3 节中,我们讨论了 CommonJS 的 require 和 ES Modules 的静态导入机制。但无论使用哪种模块系统,当 Node.js 遇到一个包(package)的引入,比如 require('lodash')import lodash from 'lodash',都需要知道这个包到底导出了什么文件。这个信息就定义在 package.json 中。随着 Node.js 模块体系的演进,包加载机制也从单一的 main 字段演进到了功能更强大的 exports 字段和条件导出。理解这套机制,对于发布一个优质 npm 包或者优雅地组织 Monorepo 项目都至关重要。

5.4.1 传统主入口:main 字段

在早期 Node.js 版本中,包的入口几乎完全由 package.json 中的 main 字段决定。当执行 require('some-package') 时,Node.js 的模块解析算法会去 node_modules/some-package 目录下寻找 package.json,并读取其中的 main 字段,把它作为包的入口文件。如果没有 main 字段,Node.js 会默认尝试读取目录下的 index.jsindex.node

// some-package/package.json
{
  "name": "some-package",
  "version": "1.0.0",
  "main": "lib/index.js"
}

上述配置让 require('some-package') 等价于 require('some-package/lib/index.js')。这种机制简单直接,在很长一段时间内工作得很好。

main 有明显的局限性:

  • 只能指定一个入口,无法区分 ESM 和 CommonJS,也无法为不同环境(Node.js / 浏览器)提供不同入口。
  • 无法对包的内部结构做约束:即使指定了 main,用户仍可以绕过它,通过 require('some-package/dist/internal') 直接访问包的内部模块,导致不稳定的 API 被依赖。
  • 无法声明多入口:希望提供 import from 'lodash/fp' 这样的子路径入口时,只能依靠文件目录结构,缺乏正式的规范约束。

于是,Node.js 12.7.0 引入了 exports 字段(并在后续版本中不断扩展),它从设计之初就是为了解决这些问题。

5.4.2 exports 字段:现代化包入口控制

exports 字段可以看作包的“公共 API 声明”。它在 package.json 中指定哪些文件可以被外部引用,并且可以针对不同的模块系统、运行环境给出不同的入口。一旦定义了 exports,用户就只能通过 exports 声明的路径访问包的内容,任何未声明的内部路径都将被阻止(即“封装”特性)。

基本用法——替换 main

{
  "name": "my-package",
  "version": "1.0.0",
  "exports": {
    ".": "./lib/index.js"
  }
}

这里的 "." 代表包的根入口,"./lib/index.js" 是相对于包根目录的路径。它的效果和 "main": "./lib/index.js" 类似,但加上了封装:现在用户只能通过 require('my-package') 获得 lib/index.js,而 require('my-package/lib/internal') 会直接报 ERR_PACKAGE_PATH_NOT_EXPORTED 错误。这非常有利于库的维护者稳定公开 API,防止内部实现被误用。

多入口声明:

如果包需要提供多个正式的入口点,可以像这样定义:

{
  "exports": {
    ".": "./lib/index.js",
    "./utils": "./lib/utils.js",
    "./styles/style.css": "./assets/style.css"
  }
}

用户可以使用 require('my-package/utils')require('my-package/styles/style.css'),而其他路径仍然不可访问。注意子路径导出时的 key 必须以 "./" 开头,即相对路径风格,但实际使用时只需写 my-package/utils,无需再写 my-package/./utils

双模块格式支持:

现代包经常需要同时支持 CommonJS(CJS)和 ES Modules(ESM),以便兼容旧项目和新工具链。exports 允许为同一个入口指定不同模块系统的文件:

{
  "exports": {
    ".": {
      "import": "./esm/index.mjs",
      "require": "./cjs/index.cjs"
    }
  }
}

当用户使用 import myPackage from 'my-package' 时,Node.js 会加载 ./esm/index.mjs;当使用 const myPackage = require('my-package') 时,则加载 ./cjs/index.cjs。这种明确的区分消除了通过文件后缀名(.mjs / .cjs)或 type 字段推断的歧义,是推荐的双模块打包方案。

5.4.3 条件导出:为运行环境与消费方定制

exports 的强大之处在于它支持 条件导出 —— 根据 Node.js 的环境条件选择最合适的入口。前面用到的 "import""require" 其实就是条件导出中的两种条件。除此之外,Node.js 还定义了一系列标准条件:

  • node:仅在 Node.js 环境中匹配。
  • browser:在浏览器打包环境中(如 webpack、rollup)通常会被识别。
  • import:用户使用 ES 模块导入时匹配。
  • require:用户使用 CommonJS 导入时匹配。
  • default:通配条件,当没有其他条件匹配时使用(通常放在最后)。
  • types:TypeScript 的类型声明文件入口。
  • node-addons:用于原生模块(.node 文件)分发。

条件导出可以嵌套,形成一个链式匹配。Node.js 在加载时从上到下检查条件数组,使用第一个与当前运行时环境匹配的条件。

{
  "exports": {
    ".": {
      "node": {
        "import": "./node-esm/index.mjs",
        "require": "./node-cjs/index.cjs"
      },
      "browser": "./browser/index.js",
      "default": "./fallback/index.js"
    }
  }
}

上述配置的工作方式为:

  • 在 Node.js 环境中,如果是 import 加载,走 ./node-esm/index.mjs;如果是 require,走 ./node-cjs/index.cjs
  • 在浏览器打包工具中(通常声明 browser 条件),加载 ./browser/index.js
  • 其他环境或不支持条件的旧版本运行时会回退到 ./fallback/index.js

这种方式让包的作者可以精确控制每个环境下的代码分发,例如浏览器版本可以去掉 Node.js 的 fs 引用,Node 版本可能包含 Buffer 处理等。

TypeScript 类型支持:

对于 TypeScript 项目,条件导出也可以配合 types 条件指定类型声明文件。

{
  "exports": {
    ".": {
      "import": "./esm/index.mjs",
      "require": "./cjs/index.cjs",
      "types": "./types/index.d.ts"
    }
  }
}

当 TypeScript 解析模块时,会优先使用 types 指向的声明文件,从而获得准确的类型提示。

条件出口的嵌套与简写:

条件导出允许简写形式:如果只需要区分 importrequire,可以写成:

{
  "exports": {
    ".": {
      "import": "./esm/index.mjs",
      "require": "./cjs/index.cjs"
    }
  }
}

这等价于把 "import""require" 作为顶层条件。此外,如果只提供一个文件而不区分,可以使用字符串简写:

{
  "exports": "./lib/index.js"
}

它代表包根入口为 ./lib/index.js,适用于单一模块格式的包。

5.4.4 exportsmain 的共存与迁移

当一个包同时定义了 mainexports只有 exports 会生效(Node.js 12.7.0+)。这其实是一种特性:如果用上了 exports,它应该成为入口定义的唯一来源,避免混淆。但在支持旧版本 Node.js 的情况下,可以保留 main 作为向后兼容手段:

{
  "main": "./cjs/index.cjs",
  "exports": {
    "import": "./esm/index.mjs",
    "require": "./cjs/index.cjs"
  }
}

对于不支持 exports 的旧版 Node.js(<12.7.0),会忽略 exports 而回退到 main,这样用户仍然能使用 CommonJS 入口。对于现代 Node.js,则会严格按照 exports 解析。这是一种平滑过渡的方案。

5.4.5 子路径导出的使用限制与注意事项

封装特性固然好,但也带来一些需要注意的细节:

  • 路径必须以 "./" 开头exports 中的键必须是类似 ".""./sub" 的格式,值必须是相对路径(以 "./" 开头)。不能使用绝对路径或网络路径。
  • 不能映射到外部包exports 只能指向包内的文件,不能将 "./lodash" 重定向到 lodash 这个包,这种需求应使用第三方别名工具或 TypeScript 的 paths 映射。
  • 导出的是文件还是目录:如果导出的是一个目录,Node.js 会寻找该目录下的 index 文件(具体规则取决于模块类型),但建议始终明确到具体文件,避免依赖隐式索引。
  • 封装可能导致测试困难:当包测试需要访问内部模块时,需要考虑是否开放内部入口或使用其他方式注入。通常建议对测试工具开放 internal 子路径。
  • TypeScript 的支持:TypeScript 需要额外配置 typestypesVersions 字段来配合条件导出,确保类型检查能跟随导出配置。

5.4.6 实用技巧:用 exports 组织 Monorepo 包

在 pnpm workspace 或 Lerna 管理的 Monorepo 中,exports 字段可以很好地定义每个子包的外部接口。例如,一个工具库包 @repo/utils 可以这样规划:

// packages/utils/package.json
{
  "name": "@repo/utils",
  "exports": {
    ".": "./src/index.ts",
    "./string": "./src/string/index.ts",
    "./date": "./src/date/index.ts"
  }
}

开发阶段直接指向 TypeScript 源文件,配合构建工具(如 tsup 或 unbuild)处理成 CommonJS 和 ES Modules 的产物,并在发布版本时通过条件导出切换:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

这使得外部引用 import { formatDate } from '@repo/utils/date' 既类型安全,又按需加载。内部包之间的引用也可以利用这一机制清晰地管理依赖。

5.4.7 小结

包加载机制的演进反映了 Node.js 模块系统的成熟过程:

  • main 是单一入口的简单方案,至今仍作为兜底。
  • exports 提供了封装、多入口、条件导出等现代化能力,是当今发布 npm 包的核心标准。
  • 条件导出让同一个包能够优雅地适配 Node.js、浏览器、打包工具、TypeScript 等不同消费方,大幅提升了包的灵活性和可维护性。

在制定一个包的导出策略时,应该优先使用 exports 而非 main,并且充分利用条件导出为包的使用者提供清晰的、环境适配的最佳入口。这不仅是技术能力的体现,也是现代 npm 生态对包开发者的基本期望。