一个同时支持浏览器和 Node.js 的 JavaScript 包,经常需要在两个环境里提供不同实现:Node 版本可以读文件、读环境变量,浏览器版本只能用 fetch、localStorage 和 Web Crypto。package.json 里的 browser 条件导出就是用来解决这个问题的机制。
这篇文章梳理 browser exports 的工作方式:它如何选择入口、为什么不能把 Node 代码自动变成浏览器代码,以及浏览器构建里出现 node:fs 报错时应该怎么排查。
1. 核心概念
browser exports 是 JavaScript 包为浏览器环境提供专用实现的一种条件导出机制。
包作者可以在 package.json 中声明:
{
"exports": {
".": {
"browser": "./dist/browser.js",
"node": "./dist/node.js",
"default": "./dist/index.js"
}
}
}使用者仍然写相同的导入代码:
import something from "some-package";解析工具会根据当前环境选择不同文件:
浏览器环境 → ./dist/browser.js
Node.js → ./dist/node.js
其他环境 → ./dist/index.js这种机制称为 Conditional Exports,条件导出。
2. 为什么需要浏览器专用入口
浏览器和 Node.js 提供的运行时能力不同。
Node.js 中可以使用:
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";还可能依赖:
process
Buffer
__dirname
__filename浏览器中通常没有这些 Node.js 能力,而是提供:
window
document
fetch
localStorage
Web Crypto API因此,一个同时支持浏览器和 Node.js 的包,可能需要维护两套实现:
node.js
├── 使用 fs 读取文件
├── 使用 process 读取环境变量
└── 使用 Node Crypto
browser.js
├── 使用 fetch 获取资源
├── 使用 Web Storage
└── 使用 Web Cryptobrowser exports 的本质是:
让同一个包、同一个
import,在不同运行环境中解析到不同实现。
3. 条件导出的职责划分
条件导出涉及两个参与方。
包作者:声明不同环境的实现
{
"exports": {
".": {
"browser": "./browser.js",
"node": "./node.js"
}
}
}包作者负责说明:
浏览器版本在哪里
Node.js 版本在哪里
默认版本在哪里解析工具:根据条件选择实现
构建工具或运行时会携带一组解析条件,例如:
browser
node
import
require
development
production
default然后按照 exports 中的声明选择匹配的入口。
完整过程可以理解为:
import "some-package"
↓
读取依赖的 package.json
↓
检查 exports 字段
↓
根据当前环境匹配条件
↓
加载对应文件4. browser 只是一个解析条件
browser 并不是特殊语法,也不会自动把 Node.js 代码转换成浏览器代码。
它只是一个条件名称:
{
"exports": {
".": {
"browser": "./browser.js"
}
}
}它表达的是:
当解析环境启用了
browser条件时,使用这个文件。
因此,真正生效需要同时满足:
包声明了 browser 分支
+
当前解析工具启用了 browser 条件只有一方是不够的。
5. 构建目标不等于自动兼容
即使最终代码要运行在浏览器中,也不代表所有 Node.js 代码都会自动变成浏览器代码。
例如依赖最终进入了这个文件:
import fs from "node:fs";
export function readConfig() {
return fs.readFileSync("./config.json", "utf-8");
}浏览器无法直接执行它。
正确的解决方式通常是由包提供浏览器实现:
export function readConfig() {
return localStorage.getItem("config");
}然后通过条件导出进行区分:
{
"exports": {
".": {
"browser": "./browser.js",
"node": "./node.js"
}
}
}所以:
环境识别
≠
代码转换环境识别只负责选择分支,不负责发明一个不存在的浏览器实现。
6. default 分支的作用
条件导出通常会提供 default:
{
"exports": {
".": {
"browser": "./browser.js",
"node": "./node.js",
"default": "./index.js"
}
}
}default 是兜底分支:
匹配 browser → browser.js
匹配 node → node.js
都未匹配 → default为了兼容不认识特定条件的工具,包通常应该提供 default。
7. 条件的顺序很重要
条件对象通常按照声明顺序进行匹配。
例如:
{
"exports": {
".": {
"browser": "./browser.js",
"default": "./index.js"
}
}
}这里会先尝试 browser,然后才进入 default。
如果把兜底条件放在前面:
{
"exports": {
".": {
"default": "./index.js",
"browser": "./browser.js"
}
}
}default 可能先被匹配,导致后面的 browser 分支没有机会生效。
因此应当遵循:
具体条件在前
兜底条件在后8. 条件可以嵌套
一个包可能既区分浏览器和 Node.js,又区分 ESM 和 CommonJS:
{
"exports": {
".": {
"browser": {
"import": "./dist/browser.mjs",
"require": "./dist/browser.cjs"
},
"node": {
"import": "./dist/node.mjs",
"require": "./dist/node.cjs"
},
"default": "./dist/index.js"
}
}
}可能的解析结果:
浏览器 + import → browser.mjs
浏览器 + require → browser.cjs
Node + import → node.mjs
Node + require → node.cjs这说明条件导出不仅能区分运行环境,还能区分模块系统、开发模式和生产模式。
9. exports.browser 与传统 browser 字段
它们目的相似,但不是同一种机制。
条件导出中的 browser
{
"exports": {
".": {
"browser": "./browser.js",
"node": "./node.js",
"default": "./index.js"
}
}
}特点:
属于
exports条件导出;可以组合
import、require等条件;可以限制包对外暴露的路径;
结构更明确、精细。
顶层 browser 字段
{
"main": "./node.js",
"browser": "./browser.js"
}还可以声明文件替换:
{
"browser": {
"./node.js": "./browser.js",
"fs": false
}
}它表达的可能是:
把某个文件替换为浏览器版本
把某个 Node 模块标记为不可用可以简单理解为:
exports.browser
→ 现代条件入口选择
顶层 browser 字段
→ 传统浏览器入口或模块替换机制实际项目中,两种形式都可能遇到。
10. Node 内置模块无法自动消失
假设一个浏览器项目间接依赖:
业务代码
↓
依赖 A
↓
依赖 B
↓
node:fs即使业务代码没有直接导入 fs,只要依赖链最终进入 Node.js 专用分支,浏览器构建仍然可能失败。
正确路径应该是:
业务代码
↓
依赖 A
↓
匹配 browser 条件
↓
进入浏览器实现
↓
不再引用 node:fs因此,排查浏览器构建中的 Node 模块问题时,重点不是只看业务源码,而是检查:
是哪个依赖引入了 Node 模块
该依赖是否提供 browser 分支
实际解析到了哪个入口
条件导出是否正确声明11. Polyfill 与浏览器实现的区别
Polyfill 是在浏览器中模拟部分 Node.js API。
例如:
Buffer → 可以由浏览器兼容库模拟
path → 部分能力可以用纯 JavaScript 实现
crypto → 部分能力可以映射到 Web Crypto
fs → 无法真正访问用户本地文件系统因此,优先级通常应该是:
真正的浏览器实现
↓
必要且合理的 Polyfill
↓
禁止不适合浏览器的 Node.js 模块不能简单认为所有 Node.js 模块都应该被 Polyfill。
12. 常见问题定位方法
当浏览器构建中出现以下模块时:
node:fs
node:path
node:crypto
stream
buffer
process可以按照这个顺序排查:
1. 找出是谁引入了该模块
2. 查看对应依赖的 package.json
3. 检查 exports 是否提供 browser 分支
4. 检查是否存在传统 browser 字段
5. 确认实际解析到了哪个文件
6. 判断是否错误进入了 Node.js 分支
7. 决定使用浏览器实现、替换依赖或添加 Polyfill不要一看到模块缺失就立即添加 Polyfill,因为真正的问题可能是:
本来应该加载浏览器版本,却错误加载了 Node.js 版本。
13. 完整心智模型
应用导入一个依赖
↓
解析依赖的 package.json
↓
读取 exports 或 browser 字段
↓
结合当前环境和模块类型
↓
匹配 browser / node / import / require 等条件
↓
选择具体入口文件
↓
入口文件进入后续构建或运行流程关键认识是:
包作者负责提供不同实现
解析工具负责选择实现
构建目标负责描述运行环境
Polyfill 只负责补充部分缺失能力一句话总结
browser exports是 JavaScript 包通过条件导出为浏览器提供专用入口的机制;它负责选择浏览器实现,而不是把 Node.js 代码自动转换成浏览器代码。