npm 包入口指南:package.json 中的 main、module、exports

31 阅读11分钟

你有没有遇到过这些问题:

  • 明明装了包,import 就报错,换成 require 又好了?
  • TypeScript 提示找不到类型声明,但包里明明有 .d.ts 文件?
  • 发布了一个 npm 包,别人用的时候打包体积巨大,Tree Shaking 不生效?
  • mainmoduleexportsbrowsertypes 写了一堆,到底谁在生效?

如果你也被这些问题折磨过,这篇文章就是为你写的。


一、先搞清一件事:模块系统的历史包袱

在讲入口字段之前,你必须理解一个前提 —— JavaScript 有两套模块系统,而且它们互不兼容

CommonJS(CJS)

// 导出
module.exports = { add, subtract }
// 或
exports.add = function() {}

// 导入
const { add } = require('lodash')
  • Node.js 原生支持(从诞生起就有)
  • 同步加载,不适合浏览器
  • 文件后缀:.js(在 type: "commonjs" 下)或 .cjs

ES Module(ESM)

// 导出
export function add() {}
export default subtract

// 导入
import { add } from 'lodash-es'
  • ECMAScript 官方标准
  • 静态分析,支持 Tree Shaking
  • Node.js 12+ 开始支持
  • 文件后缀:.js(在 type: "module" 下)或 .mjs

矛盾的根源

一个 npm 包的使用者可能是:

使用场景期望的模块格式
Node.js 老项目(requireCJS
Node.js 新项目(importESM
Webpack / Vite 前端项目ESM(优先)或 CJS
浏览器直接 <script type="module">ESM
SSR(Nuxt / Next.js)CJS 或 ESM

一个包要服务这么多场景,只用一个入口文件显然不够。 这就是为什么 package.json 需要这么多入口字段。


二、入口字段逐个击破

2.1 main — 最古老的入口

{
  "main": "dist/index.js"
}

历史地位: 这是 package.json 中最早的入口字段,Node.js 从一开始就读它。

行为: 当别人写 require('your-package')import 'your-package' 时,Node.js 会去找 main 字段指向的文件。

注意:

  • 如果不写 main,Node.js 默认找包根目录下的 index.js
  • main 指向的文件格式应该和 type 字段一致(后面会讲)
  • 在有 exports 字段的情况下,main 只是作为兜底存在

一句话: main 是给 require() 用的,通常指向 CJS 格式的文件。


2.2 module — 打包工具的"私下约定"

{
  "module": "dist/index.esm.js"
}

重要:这不是 Node.js 官方标准。 它是 Rollup 在 2015 年提出的一个社区约定,后来 Webpack 也支持了。

为什么需要它?

假设你写了一个工具库,你想同时提供 CJS 和 ESM 两种格式:

{
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js"
}

打包工具(Webpack、Rollup、Vite)看到 module 字段就会优先使用 ESM 版本,因为 ESM 支持静态分析Tree Shaking。而 Node.js 直接运行时会忽略 module,走 main 拿到 CJS 版本。

一句话: module 是给 Webpack / Rollup / Vite 这些打包工具看的 ESM 入口。


2.3 browser — 浏览器专用入口

{
  "browser": "dist/index.browser.js"
}

使用场景: 你的包在 Node.js 和浏览器中需要不同的实现。

典型例子:

{
  "main": "dist/index.node.js",
  "browser": "dist/index.browser.js"
}

比如一个 HTTP 请求库,Node 端用 http 模块,浏览器端用 fetchXMLHttpRequestaxios 就是这么干的。

高级用法 —— 模块替换:

{
  "browser": {
    "./lib/ws.js": "./lib/ws-browser.js",
    "fs": false,
    "path": false
  }
}
  • "./lib/ws.js": "./lib/ws-browser.js" → 替换特定文件
  • "fs": false → 在浏览器端将 fs 模块替换为空对象

Webpack 在构建 target: 'web' 时会读取这个字段。

一句话: browser 是给浏览器环境用的入口,解决 Node vs 浏览器 API 差异。


2.4 types / typings — TypeScript 类型入口

{
  "types": "dist/index.d.ts"
}

作用: 告诉 TypeScript 编译器去哪里找类型声明文件。

没有这个字段会怎样?

TypeScript 会尝试找 main 字段指向的文件,把 .js 替换为 .d.ts。比如 main: "dist/index.js" → 找 dist/index.d.ts。找不到就报那个烦人的错误:

Could not find a declaration file for module 'xxx'.

types vs typings 完全等价,推荐用 types(更简短)。


2.5 type — 模块系统的"开关"

{
  "type": "module"
}

这个字段不是入口,而是一个全局开关,决定了 Node.js 怎么理解 .js 文件:

type 的值.js 文件被视为.cjs 文件.mjs 文件
"commonjs"(默认)CommonJSCommonJSESModule
"module"ESModuleCommonJSESModule

关键点:

  • .cjs 永远是 CommonJS,不管 type 怎么设
  • .mjs 永远是 ESModule,不管 type 怎么设
  • .js 的身份取决于 type 字段

一个容易踩的坑:

你在 package.json 里写了 "type": "module",然后你的 .eslintrc.js 配置文件用了 module.exports = {},Node.js 就会报错:

SyntaxError: Unexpected token 'export'

因为 Node.js 把 .js 当 ESM 处理了,但 module.exports 是 CJS 语法。解决办法:把配置文件改名为 .eslintrc.cjs


2.6 exports — 终极解决方案(重点!)

如果你只想记住一个字段,那就记住 exports

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

exports 是 Node.js 12.11 引入的官方方案,一个字段解决了 mainmodulebrowsertypes 四个字段干的事

能力一:条件导出

根据不同的使用方式,返回不同的文件:

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

当使用者写 import pkg from 'your-package' → 走 import 条件,拿到 ESM 文件
当使用者写 const pkg = require('your-package') → 走 require 条件,拿到 CJS 文件

支持的条件关键字:

条件含义谁在用
typesTypeScript 类型声明TypeScript 编译器
importESM import 方式引入Node.js、打包工具
requireCJS require() 方式引入Node.js、打包工具
nodeNode.js 环境Node.js
browser浏览器环境打包工具
development开发环境部分打包工具
production生产环境部分打包工具
default兜底条件所有

条件匹配规则:从上到下,命中第一个就停。 所以顺序很重要:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",    // ← 必须第一个!
      "node": {
        "import": "./dist/node.mjs",
        "require": "./dist/node.cjs"
      },
      "browser": "./dist/browser.mjs",
      "default": "./dist/index.mjs"     // ← 兜底放最后
    }
  }
}

TypeScript 的 types 条件必须放在最前面! 否则 TS 可能匹配到其他条件就停了,导致找不到类型。

能力二:子路径导出

不需要暴露整个包,可以精确控制哪些路径可以被外部引用:

{
  "exports": {
    ".": "./dist/index.mjs",
    "./utils": "./dist/utils.mjs",
    "./hooks": "./dist/hooks.mjs",
    "./styles": "./dist/styles.css"
  }
}

使用方式:

import { debounce } from 'your-package/utils'
import { useAuth } from 'your-package/hooks'
import 'your-package/styles'

通配符导出:

{
  "exports": {
    ".": "./dist/index.mjs",
    "./components/*": "./dist/components/*/index.mjs",
    "./icons/*": "./dist/icons/*.mjs"
  }
}
import Button from 'your-package/components/Button'
import StarIcon from 'your-package/icons/Star'

能力三:封装隔离

一旦声明了 exports未列出的路径就无法被外部访问

{
  "exports": {
    ".": "./dist/index.mjs",
    "./utils": "./dist/utils.mjs"
  }
}
// ✅ 可以用
import pkg from 'your-package'
import { foo } from 'your-package/utils'

// ❌ 报错!未在 exports 中声明
import internal from 'your-package/dist/internal.mjs'
import helper from 'your-package/src/helper.js'

这是一个非常重要的特性 —— 保护内部实现细节,防止使用者依赖你的私有 API


三、到底什么时候需要打包?什么时候不需要?

这可能是最让人困惑的问题了。同样是写 npm 包,有的包 dist/ 目录里放着打包好的文件,有的包直接发布源码。到底怎么选?

场景一:纯 Node.js 工具包(CLI / 服务端)

my-cli/
├── src/
│   ├── index.js
│   └── utils.js
├── package.json
└── README.md

不需要打包。

原因:

  • Node.js 直接运行 JS 文件,不需要打包
  • 没有浏览器兼容性问题
  • 不需要 Tree Shaking(Node.js 用不到)
  • 发布源码即可
{
  "main": "src/index.js",
  "type": "module",
  "files": ["src"]
}

但如果用了 TypeScript,需要编译(不是打包):

{
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "build": "tsc"
  }
}

这里用 tsc 只是把 .ts.js一对一转换,不是打包。

场景二:前端 UI 组件库

my-ui/
├── src/
│   ├── Button/
│   ├── Modal/
│   └── index.ts
├── dist/
│   ├── index.mjs      ← ESM
│   ├── index.cjs       ← CJS
│   ├── index.d.ts      ← 类型
│   └── style.css       ← 样式
└── package.json

需要打包。

原因:

  • 使用者的打包工具需要 ESM 格式做 Tree Shaking
  • 需要编译 TypeScript / JSX / Vue SFC
  • 需要处理 CSS / Less / Sass
  • 可能需要同时提供 CJS 和 ESM
{
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./style.css": "./dist/style.css"
  },
  "sideEffects": ["*.css"],
  "files": ["dist"]
}

场景三:工具函数库(lodash 那种)

需要打包,而且最好提供多种格式。

{
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  },
  "sideEffects": false,
  "files": ["dist"]
}

sideEffects: false 至关重要 —— 它告诉打包工具"这个包里所有模块都没有副作用,可以放心 Tree Shaking"。

场景四:全栈框架的插件/中间件

{
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "node": {
        "import": "./dist/node.mjs",
        "require": "./dist/node.cjs"
      },
      "browser": "./dist/browser.mjs",
      "default": "./dist/index.mjs"
    }
  }
}

Node 端和浏览器端实现不同,需要条件导出区分。

场景五:只发布类型声明(纯 .d.ts 包)

比如 @types/node@types/lodash

不需要打包。

{
  "types": "index.d.ts",
  "files": ["*.d.ts", "**/*.d.ts"]
}

决策速查表

问题是 →否 →
用了 TypeScript?至少需要 tsc 编译可以直接发布源码
用了 JSX / Vue SFC / Sass?需要打包/编译
需要 Tree Shaking?必须提供 ESM 格式只提供 CJS 也行
Node 和浏览器行为不同?需要多入口(exports 条件导出)单入口即可
需要同时支持 requireimport提供 CJS + ESM 双格式只提供一种

四、不同工具的解析优先级

你写了一堆入口字段,但最终谁在生效?这取决于"谁在消费你的包"。

Node.js(>= 16)

exports  →  main  →  index.js
  • 如果有 exports完全忽略 mainmodulebrowser
  • 如果没有 exports,读 main
  • 如果没有 main,找 index.js

Webpack 5

exports  →  browser  →  module  →  main
  • 优先 exports
  • 然后看 browser(如果 target 是 web)
  • 再看 module(ESM 优先)
  • 最后 main

Vite / Rollup

exportsmodule  →  main
  • Vite 基于 Rollup,天然偏好 ESM
  • 不读 browser 字段(通过 Vite 自己的 resolve.conditions 处理)

TypeScript

exports["types"]  →  types  →  typings  →  main 对应的 .d.ts

需要 tsconfig.json 配合:

{
  "compilerOptions": {
    "moduleResolution": "bundler"    // 或 "node16" / "nodenext"
  }
}

注意: 如果 moduleResolution 还是 "node"(旧模式),TypeScript 不会读 exports 字段!这是很多人类型丢失的根本原因。

优先级总览图

               Node.js         Webpack 5        Vite/Rollup      TypeScript
               ───────         ─────────        ──────────       ──────────
最高优先级 →    exports         exports          exports          exports.types
               │               │                │                │
               │               browser          module           types/typings
               │               │                │                │
               main            module           main             main→.d.ts
               │               │
               index.js        main

五、Dual Package 的陷阱(CJS + ESM 双格式)

同时提供 CJS 和 ESM 是好事,但有一个隐藏的大坑:Dual Package Hazard(双包风险)

问题是什么?

假设你的包导出了一个单例:

// 你的包
let count = 0
export function increment() { count++ }
export function getCount() { return count }

如果使用者的项目中同时通过 importrequire 引用了你的包(这在复杂项目中很常见),Node.js 会加载两份代码 —— ESM 一份,CJS 一份。两份代码各自维护自己的 count,状态不共享,产生诡异的 bug。

解决方案一:ESM Wrapper

只打包一份 CJS,ESM 入口只是一个转发:

// dist/index.cjs  ← 真正的实现
module.exports = { increment, getCount }

// dist/index.mjs  ← 只是一个 wrapper
import cjs from './index.cjs'
export const { increment, getCount } = cjs
{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

这样 ESM 和 CJS 用的是同一份代码,状态一致。

解决方案二:无状态设计

如果你的包本身是纯函数、无状态的(大部分工具函数库都是),那就不用担心,直接双格式打包即可。


六、实战配置模板

模板一:TypeScript 工具函数库

打包工具推荐 tsup(基于 esbuild,零配置):

{
  "name": "my-utils",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.cjs",
  "module": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  },
  "files": ["dist"],
  "sideEffects": false,
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts --clean",
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch"
  },
  "devDependencies": {
    "tsup": "^8.0.0",
    "typescript": "^5.0.0"
  }
}

模板二:Vue 组件库

打包工具推荐 Vite Library Mode

{
  "name": "my-components",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.umd.js",
  "module": "dist/index.es.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.es.js",
      "require": "./dist/index.umd.js"
    },
    "./style.css": "./dist/style.css"
  },
  "files": ["dist"],
  "sideEffects": ["*.css"],
  "peerDependencies": {
    "vue": "^3.3.0"
  },
  "scripts": {
    "build": "vite build"
  }
}

模板三:React 组件库

{
  "name": "my-react-ui",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.cjs",
  "module": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./styles": "./dist/styles.css"
  },
  "files": ["dist"],
  "sideEffects": ["*.css"],
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  },
  "peerDependenciesMeta": {
    "react-dom": { "optional": true }
  }
}

模板四:纯 Node.js 包(不打包)

{
  "name": "my-server-lib",
  "version": "1.0.0",
  "type": "module",
  "main": "src/index.js",
  "types": "src/index.d.ts",
  "exports": {
    ".": {
      "types": "./src/index.d.ts",
      "default": "./src/index.js"
    },
    "./middleware": {
      "types": "./src/middleware.d.ts",
      "default": "./src/middleware.js"
    }
  },
  "files": ["src"],
  "engines": {
    "node": ">=18.0.0"
  }
}

模板五:CLI 工具

{
  "name": "my-cli",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "mycli": "./bin/cli.js"
  },
  "files": ["bin", "src"],
  "engines": {
    "node": ">=18.0.0"
  }
}

CLI 工具通常不需要别人 import,所以连 main 都不需要写。


七、常见报错排查指南

报错 1:ERR_REQUIRE_ESM

Error [ERR_REQUIRE_ESM]: require() of ES Module not supported

原因: 你用 require() 引入了一个 "type": "module" 的包。

解决:

  • 改用 import(推荐)
  • 或者用 await import('the-package')(动态导入)
  • 或者在你的项目中也设置 "type": "module"

报错 2:ERR_PACKAGE_PATH_NOT_EXPORTED

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './lib/foo' is not defined by "exports"

原因: 包设置了 exports,但你访问的路径不在 exports 的声明里。

解决:

  • 只使用包 exports 中声明的路径
  • 如果你是包作者,把遗漏的路径加到 exports

报错 3:Could not find a declaration file for module

Could not find a declaration file for module 'xxx'.
'xxx' implicitly has an 'any' type.

原因: TypeScript 找不到类型声明。

排查步骤:

  1. 包有 types 字段吗?指向的 .d.ts 文件存在吗?
  2. 包有 exports 吗?exports 里有 types 条件吗?
  3. 你的 tsconfig.jsonmoduleResolution 是什么?如果是 "node"(旧模式),改为 "bundler""node16"
  4. 如果都没问题,安装 @types/xxx

报错 4:Tree Shaking 不生效,打包体积大

排查步骤:

  1. 包有 moduleexports.import 入口吗?(必须是 ESM 格式)
  2. 包设置了 "sideEffects": false 吗?
  3. 你是用 import { specific } from 'pkg' 而不是 import * as pkg from 'pkg' 吗?
  4. 检查是否有 barrel file(index.tsexport * from 一大堆)导致的连锁引入

八、总结

一张决策流程图帮你选择正确的配置:

你的包是什么类型?
│
├── CLI 工具
│   └── 只需要 bin,不需要 main
│
├── 纯 Node.js 库
│   ├── 用 JS 写的 → 不需要打包,直接发布源码
│   └── 用 TS 写的 → tsc 编译,发布 dist
│
├── 前端组件库
│   └── 需要打包(Vite / tsup / Rollup)
│       ├── 提供 CJS + ESM 双格式
│       ├── 设置 exports 条件导出
│       ├── 设置 sideEffects
│       └── peerDependencies 声明框架依赖
│
└── 工具函数库
    └── 需要打包
        ├── 提供 CJS + ESM 双格式
        ├── sideEffects: false(关键!)
        └── exports 条件导出

无论哪种类型,如今的最佳实践是:
✅ 始终写 exports(现代标准)
✅ 保留 main + module 做向后兼容
✅ types 条件放在 exports 的第一个
✅ moduleResolution 用 "bundler""node16"

如果这篇文章帮你解开了心中的疑惑,点个赞让更多人看到吧。有问题欢迎在评论区讨论!