Engineering

JavaScript 包中的 browser exports:浏览器与 Node 的打包差异

同一个 import 如何在浏览器和 Node.js 中解析到不同实现,以及为什么构建目标不等于自动兼容。

一个同时支持浏览器和 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 Crypto

browser 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 条件导出;

  • 可以组合 importrequire 等条件;

  • 可以限制包对外暴露的路径;

  • 结构更明确、精细。

顶层 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 代码自动转换成浏览器代码。

Back to Blog

Related Posts

View All Posts »