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

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

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

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


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

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

CommonJS(CJS)

js 复制代码
// 导出
module.exports = { add, subtract }
// 或
exports.add = function() {}

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

ES Module(ESM)

js 复制代码
// 导出
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 --- 最古老的入口

json 复制代码
{
  "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 --- 打包工具的"私下约定"

json 复制代码
{
  "module": "dist/index.esm.js"
}

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

为什么需要它?

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

json 复制代码
{
  "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 --- 浏览器专用入口

json 复制代码
{
  "browser": "dist/index.browser.js"
}

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

典型例子:

json 复制代码
{
  "main": "dist/index.node.js",
  "browser": "dist/index.browser.js"
}

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

高级用法 ------ 模块替换:

json 复制代码
{
  "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 类型入口

json 复制代码
{
  "types": "dist/index.d.ts"
}

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

没有这个字段会怎样?

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

arduino 复制代码
Could not find a declaration file for module 'xxx'.

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


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

json 复制代码
{
  "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 就会报错:

javascript 复制代码
SyntaxError: Unexpected token 'export'

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


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

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

json 复制代码
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.mjs"
    }
  }
}

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

能力一:条件导出

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

json 复制代码
{
  "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 兜底条件 所有

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

json 复制代码
{
  "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 可能匹配到其他条件就停了,导致找不到类型。

能力二:子路径导出

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

json 复制代码
{
  "exports": {
    ".": "./dist/index.mjs",
    "./utils": "./dist/utils.mjs",
    "./hooks": "./dist/hooks.mjs",
    "./styles": "./dist/styles.css"
  }
}

使用方式:

js 复制代码
import { debounce } from 'your-package/utils'
import { useAuth } from 'your-package/hooks'
import 'your-package/styles'

通配符导出:

json 复制代码
{
  "exports": {
    ".": "./dist/index.mjs",
    "./components/*": "./dist/components/*/index.mjs",
    "./icons/*": "./dist/icons/*.mjs"
  }
}
js 复制代码
import Button from 'your-package/components/Button'
import StarIcon from 'your-package/icons/Star'

能力三:封装隔离

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

json 复制代码
{
  "exports": {
    ".": "./dist/index.mjs",
    "./utils": "./dist/utils.mjs"
  }
}
js 复制代码
// ✅ 可以用
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 / 服务端)

css 复制代码
my-cli/
├── src/
│   ├── index.js
│   └── utils.js
├── package.json
└── README.md

不需要打包。

原因:

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

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

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

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

场景二:前端 UI 组件库

css 复制代码
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
json 复制代码
{
  "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 那种)

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

json 复制代码
{
  "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"。

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

json 复制代码
{
  "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

不需要打包。

json 复制代码
{
  "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)

css 复制代码
exports  →  main  →  index.js
  • 如果有 exports完全忽略 mainmodulebrowser
  • 如果没有 exports,读 main
  • 如果没有 main,找 index.js

Webpack 5

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

Vite / Rollup

java 复制代码
exports  →  module  →  main
  • Vite 基于 Rollup,天然偏好 ESM
  • 不读 browser 字段(通过 Vite 自己的 resolve.conditions 处理)

TypeScript

css 复制代码
exports["types"]  →  types  →  typings  →  main 对应的 .d.ts

需要 tsconfig.json 配合:

json 复制代码
{
  "compilerOptions": {
    "moduleResolution": "bundler"    // 或 "node16" / "nodenext"
  }
}

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

优先级总览图

java 复制代码
               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(双包风险)

问题是什么?

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

js 复制代码
// 你的包
let count = 0
export function increment() { count++ }
export function getCount() { return count }

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

解决方案一:ESM Wrapper

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

js 复制代码
// dist/index.cjs  ← 真正的实现
module.exports = { increment, getCount }

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

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

解决方案二:无状态设计

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


六、实战配置模板

模板一:TypeScript 工具函数库

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

json 复制代码
{
  "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

json 复制代码
{
  "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 组件库

json 复制代码
{
  "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 包(不打包)

json 复制代码
{
  "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 工具

json 复制代码
{
  "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

css 复制代码
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

csharp 复制代码
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

lua 复制代码
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 一大堆)导致的连锁引入

八、总结

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

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

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

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

相关推荐
●VON2 小时前
Flutter 入门指南:从基础组件到状态管理核心机制
前端·学习·flutter·von
gCode Teacher 格码致知2 小时前
Javascript提高:JavaScript Promise 超通俗解释-由Deepseek产生
开发语言·javascript
踩着两条虫2 小时前
VTJ.PRO 在线应用开发平台概览
前端·vue.js·人工智能
西西学代码2 小时前
Flutter---SingleChildScrollView
前端·javascript·flutter
ZTLJQ2 小时前
构建现代Web应用:Python全栈框架完全解析
前端·数据库·python
风123456789~2 小时前
【架构专栏】第2章 计算机系统基础知识 1/3
笔记·架构
前端付豪2 小时前
实现代码块复制和会话搜索
前端·人工智能·后端
英俊潇洒美少年2 小时前
Vue reactive 底层 Proxy 完整流程(依赖收集 + 触发更新)
前端·javascript·vue.js
周万宁.FoBJ2 小时前
vue源码讲解之 effect解析 (仅包含在effect中使用reacitve情况)
前端·javascript·vue.js