你有没有遇到过这些问题:
- 明明装了包,
import就报错,换成require又好了?- TypeScript 提示找不到类型声明,但包里明明有
.d.ts文件?- 发布了一个 npm 包,别人用的时候打包体积巨大,Tree Shaking 不生效?
main、module、exports、browser、types写了一堆,到底谁在生效?如果你也被这些问题折磨过,这篇文章就是为你写的。
一、先搞清一件事:模块系统的历史包袱
在讲入口字段之前,你必须理解一个前提 —— 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 老项目(require) | CJS |
Node.js 新项目(import) | ESM |
| 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 模块,浏览器端用 fetch 或 XMLHttpRequest。axios 就是这么干的。
高级用法 —— 模块替换:
{
"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"(默认) | CommonJS | CommonJS | ESModule |
"module" | ESModule | CommonJS | ESModule |
关键点:
.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 引入的官方方案,一个字段解决了 main、module、browser、types 四个字段干的事。
能力一:条件导出
根据不同的使用方式,返回不同的文件:
{
"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 文件
支持的条件关键字:
| 条件 | 含义 | 谁在用 |
|---|---|---|
types | TypeScript 类型声明 | TypeScript 编译器 |
import | ESM import 方式引入 | Node.js、打包工具 |
require | CJS require() 方式引入 | Node.js、打包工具 |
node | Node.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 条件导出) | 单入口即可 |
需要同时支持 require 和 import? | 提供 CJS + ESM 双格式 | 只提供一种 |
四、不同工具的解析优先级
你写了一堆入口字段,但最终谁在生效?这取决于"谁在消费你的包"。
Node.js(>= 16)
exports → main → index.js
- 如果有
exports,完全忽略main、module、browser - 如果没有
exports,读main - 如果没有
main,找index.js
Webpack 5
exports → browser → module → main
- 优先
exports - 然后看
browser(如果 target 是 web) - 再看
module(ESM 优先) - 最后
main
Vite / Rollup
exports → module → 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 }
如果使用者的项目中同时通过 import 和 require 引用了你的包(这在复杂项目中很常见),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 找不到类型声明。
排查步骤:
- 包有
types字段吗?指向的.d.ts文件存在吗? - 包有
exports吗?exports里有types条件吗? - 你的
tsconfig.json的moduleResolution是什么?如果是"node"(旧模式),改为"bundler"或"node16" - 如果都没问题,安装
@types/xxx
报错 4:Tree Shaking 不生效,打包体积大
排查步骤:
- 包有
module或exports.import入口吗?(必须是 ESM 格式) - 包设置了
"sideEffects": false吗? - 你是用
import { specific } from 'pkg'而不是import * as pkg from 'pkg'吗? - 检查是否有 barrel file(
index.ts里export * 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"
如果这篇文章帮你解开了心中的疑惑,点个赞让更多人看到吧。有问题欢迎在评论区讨论!