从源码到构建产物:错误码压缩、多格式构建与开发/生产环境的差异化处理
前置知识
- ›第 1-5 篇文章
- ›熟悉 JavaScript 打包工具(webpack、esbuild 或同类工具)
- ›了解 Babel 插件的基本原理
从源码到构建产物:错误码压缩、多格式构建与开发/生产环境的差异化处理
在本系列前几篇文章中,我们深入探讨了 Redux 的运行时代码和类型系统。现在来面对每位库作者都绕不开的问题:如何在保持产物体积精简、错误信息清晰可读的同时,让代码在各种 JavaScript 环境中都能正常运行?Redux 的答案是一套精密的构建流水线——它能生成四种不同的输出格式,在生产环境中将错误信息替换为数字编码,并对构建后的产物运行完整的测试套件。
本文是本系列的最后一篇,带你追溯从 src/index.ts 到 dist/ 目录的完整路径。
tsup 构建配置
Redux 使用 tsup——一个基于 esbuild 的零配置打包工具——通过单份配置文件生成全部四种输出格式:
flowchart TD
SRC["src/index.ts"] --> BABEL["Babel mangleErrors<br/>transform"]
BABEL --> ESBUILD["esbuild"]
ESBUILD --> ESM["redux.mjs<br/><i>ESM + sourcemap + .d.mts</i>"]
ESBUILD --> LEGACY["redux.legacy-esm.js<br/><i>ESM, ES2017 target</i>"]
ESBUILD --> BROWSER["redux.browser.mjs<br/><i>ESM, minified, NODE_ENV=production</i>"]
ESBUILD --> CJS["cjs/redux.cjs<br/><i>CommonJS</i>"]
defineConfig 的返回数组中定义了四个构建目标:
| 目标 | 格式 | 扩展名 | 特殊配置 |
|---|---|---|---|
| 标准 ESM | esm |
.mjs |
生成 .d.mts 类型声明,clean: true |
| 传统 ESM | esm |
.js |
target: ['es2017'],兼容 Webpack 4 |
| 浏览器 ESM | esm |
.mjs |
process.env.NODE_ENV 硬编码为 'production',并进行压缩 |
| CommonJS | cjs |
.cjs |
输出到 dist/cjs/ 子目录 |
四个构建目标共享同一套基础配置,均引入了 mangleErrorsTransform esbuild 插件——这也是整个构建流程中最有意思的部分。
错误码压缩:Babel 插件
Redux 借鉴了 React 的错误码机制。在生产环境中,"Actions must be plain objects" 这样的错误信息会被替换为数字编码,并附上一个可以查询完整信息的 URL。这样既能大幅压缩产物体积,又不损失调试能力。
整个流程如下:
esbuild 插件通过 onTransform 拦截 TypeScript 文件,使用 mangleErrorsPlugin 经由 Babel 处理,再将转换后的代码返回给 esbuild。也就是说,Babel 在 esbuild 之前运行——它是一个预处理步骤,而不是替代 esbuild。
Babel 插件本身位于 scripts/mangleErrors.mts:
scripts/mangleErrors.mts#L75-L185
flowchart TD
A["Visit ThrowStatement nodes"] --> B{"Is it 'throw new Error(...)'?"}
B -- No --> C["Skip"]
B -- Yes --> D["Extract error message string<br/>via evalToString"]
D --> E{"Already in errors.json?"}
E -- Yes --> F["Use existing index"]
E -- No --> G["Append to array,<br/>get new index"]
G --> H["Set changeInArray = true"]
F --> I{"minify mode?"}
H --> I
I -- Yes --> J["throw new Error(formatProdErrorMessage(N))"]
I -- No --> K["throw new Error(<br/>process.env.NODE_ENV === 'production'<br/>? formatProdErrorMessage(N)<br/>: 'original message'<br/>)"]
K --> L["Write updated errors.json<br/>in post() hook"]
J --> L
evalToString 辅助函数负责处理错误信息在 AST 中的各种表现形式——字符串字面量、+ 拼接的二进制表达式以及模板字面量——将它们统一归约为普通字符串以供查找:
scripts/mangleErrors.mts#L27-L50
错误码注册表是 errors.json——一个将数字索引映射到完整错误信息的简单对象:
共 18 个错误码。当插件以 minify: false(Redux 的当前配置)运行时,每个 throw new Error("message") 会变成:
throw new Error(
process.env.NODE_ENV === 'production'
? formatProdErrorMessage(7)
: "Actions must be plain objects..."
)
formatProdErrorMessage 则会生成一个 URL:
src/utils/formatProdErrorMessage.ts#L8-L13
在生产环境中,用户看到的是 "Minified Redux error #7; visit https://redux.js.org/Errors?code=7 for the full message",通过该 URL 可以查到完整的错误信息(包括动态值)。在开发环境中,用户则能直接看到原始的详细错误信息。
提示:
errors.json中的索引在每次构建间保持稳定——新增的错误只会追加,不会重新编号。这确保了错误码 URL 在 Redux 各版本间始终有效。如果你在构建类似机制的库,务必在分配新编码前先加载已有文件(如第 83-86 行所示),以防止索引发生变化。
开发/生产环境的代码差异化模式
错误码压缩是开发/生产环境差异化的一种形式,Redux 还采用了其他几种方式。
kindOf 工具函数是最典型的例子:
在开发环境中,kindOf 调用 miniKindOf——一个 38 行的函数,能返回丰富的类型描述,如 "date"、"error"、"WeakMap"、"Promise" 等。在生产环境中,则直接使用 typeof,只返回 "object"、"function"、"string" 这样的基础类型。
flowchart TD
A["kindOf(val)"] --> B{"NODE_ENV !== 'production'?"}
B -- Dev --> C["miniKindOf(val)"]
C --> D["'date', 'error', 'Map',<br/>'Promise', 'WeakSet', ..."]
B -- Prod --> E["typeof val"]
E --> F["'object', 'function',<br/>'string', ..."]
这意味着在开发环境中,错误信息会显示"Expected the root reducer to be a function. Instead, received: 'Promise'",而在生产环境中则是"...received: 'object'"。权衡非常清晰:开发时提供丰富的诊断信息,生产时保持代码精简。
warning 工具函数则通过抛出再捕获的技巧实现了调试器集成:
它先向 console.error 输出日志,然后抛出并立即捕获同一个错误。这看似多此一举,实则有其用意——开发者可以在浏览器 DevTools 中开启"在所有异常处暂停",从而精确定位到警告发生的位置。由于抛出的错误立刻被捕获,不会对正常执行流程产生任何影响。
四种输出格式与 Tree-Shaking
正如第 1 篇文章所述,Redux 提供四种输出格式。下面说明各格式存在的原因及其使用场景:
| 格式 | 文件 | package.json 字段 |
使用者 |
|---|---|---|---|
| ESM | dist/redux.mjs |
exports["."].import |
Vite、webpack 5+、Rollup、Node.js ESM |
| 传统 ESM | dist/redux.legacy-esm.js |
module |
Webpack 4(读取 module 字段,要求 .js 扩展名) |
| 浏览器 ESM | dist/redux.browser.mjs |
(不在 exports 中) | 直接通过 <script type="module"> 引入 |
| CJS | dist/cjs/redux.cjs |
exports["."].default、main |
Node.js require()、Jest、旧版工具链 |
package.json 中的 sideEffects: false(第 84 行)是启用 tree-shaking 的关键。它告知打包工具:任何未被使用的导出都可以安全移除。没有这个字段,打包工具会保守地假设导入任何模块都可能存在副作用(例如注册全局变量),从而阻止死代码消除。
flowchart TD
A["import { compose } from 'redux'"] --> B["Bundler reads package.json"]
B --> C["Selects redux.mjs via exports.import"]
C --> D["Sees sideEffects: false"]
D --> E["Tree-shakes everything except compose"]
E --> F["Final bundle includes only<br/>compose (~16 lines)"]
传统 ESM 格式明确以 ES2017 为目标(tsup.config.ts 第 71 行)——这确保了 Webpack 4 无需额外转译即可处理输出内容,因为 Webpack 4 对现代语法的支持并不完善。
针对构建产物的测试
Redux 质量保障的最后一道关卡,是对构建后的产物而非源码运行完整测试套件:
TEST_DIST 环境变量用于切换 redux 的导入别名:
- 不设置
TEST_DIST:测试从src/index.ts导入——速度快,适合开发阶段 - 设置
TEST_DIST:测试从node_modules/redux导入——即构建后的包,与实际使用者看到的完全一致
这能发现一类仅靠源码测试无法捕获的问题:
- 构建转换导致的行为变化(如错误码压缩)
- 导出映射问题(源码中导出的符号在构建后的入口点中缺失)
- 格式特有的问题(CJS 互操作异常、
.mjs扩展名缺失等) - Tree-shaking 异常(esbuild 错误地消除了不该消除的内容)
提示: 如果你在维护一个库,可以在 CI 中增加一个步骤:运行
npm pack,将打包产物安装到临时目录,再对其运行测试。Redux 的TEST_DIST方案以更低的成本实现了同样的目标——值得借鉴。
完整的构建流水线
从整体来看,源码到使用者手中的完整旅程如下:
flowchart TD
A["17 source files in src/"] --> B["tsup reads tsup.config.ts"]
B --> C["For each of 4 build targets..."]
C --> D["esbuild-extra intercepts .ts files"]
D --> E["Babel mangleErrors plugin<br/>replaces throw messages<br/>with conditional expressions"]
E --> F["esbuild compiles TS → JS"]
F --> G{"Browser target?"}
G -- Yes --> H["NODE_ENV hardcoded,<br/>minified"]
G -- No --> I["NODE_ENV checks preserved"]
H --> J["dist/redux.browser.mjs"]
I --> K["dist/redux.mjs<br/>dist/redux.legacy-esm.js<br/>dist/cjs/redux.cjs"]
K --> L["vitest with TEST_DIST<br/>validates built output"]
J --> L
系列回顾
历经六篇文章,我们完整地走过了 Redux 的整个代码库:
- 架构:17 个文件、9 个导出,刻意保持精简的 API 接口
- createStore:六个闭包变量、dispatch 循环、基于双 Map 的监听器快照机制
- combineReducers:创建时的校验、引用相等性优化,以及
compose - applyMiddleware:25 行代码、可变 dispatch 闭包技巧、中间件管道
- 类型系统:条件推断、
UnknownIfNonSpecific、可组合的StoreEnhancer泛型、类型级测试 - 构建系统:四种输出格式、错误码压缩、开发/生产环境代码拆分、产物测试
Redux 经久不衰的影响力,并不来自其体量——1,575 行代码着实精小——而在于其抽象的精准。闭包代替类,函数代替配置,组合代替继承。这些原则值得带入任何代码库。