Read OSS

从源码到构建产物:错误码压缩、多格式构建与开发/生产环境的差异化处理

中级

前置知识

  • 第 1-5 篇文章
  • 熟悉 JavaScript 打包工具(webpack、esbuild 或同类工具)
  • 了解 Babel 插件的基本原理

从源码到构建产物:错误码压缩、多格式构建与开发/生产环境的差异化处理

在本系列前几篇文章中,我们深入探讨了 Redux 的运行时代码和类型系统。现在来面对每位库作者都绕不开的问题:如何在保持产物体积精简、错误信息清晰可读的同时,让代码在各种 JavaScript 环境中都能正常运行?Redux 的答案是一套精密的构建流水线——它能生成四种不同的输出格式,在生产环境中将错误信息替换为数字编码,并对构建后的产物运行完整的测试套件。

本文是本系列的最后一篇,带你追溯从 src/index.tsdist/ 目录的完整路径。

tsup 构建配置

Redux 使用 tsup——一个基于 esbuild 的零配置打包工具——通过单份配置文件生成全部四种输出格式:

tsup.config.ts#L47-L96

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。这样既能大幅压缩产物体积,又不损失调试能力。

整个流程如下:

tsup.config.ts#L12-L45

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——一个将数字索引映射到完整错误信息的简单对象:

errors.json#L1-L20

共 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 工具函数是最典型的例子:

src/utils/kindOf.ts#L62-L70

在开发环境中,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 工具函数则通过抛出再捕获的技巧实现了调试器集成:

src/utils/warning.ts#L6-L18

它先向 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["."].defaultmain 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 质量保障的最后一道关卡,是对构建后的产物而非源码运行完整测试套件:

vitest.config.mts#L1-L17

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 的整个代码库:

  1. 架构:17 个文件、9 个导出,刻意保持精简的 API 接口
  2. createStore:六个闭包变量、dispatch 循环、基于双 Map 的监听器快照机制
  3. combineReducers:创建时的校验、引用相等性优化,以及 compose
  4. applyMiddleware:25 行代码、可变 dispatch 闭包技巧、中间件管道
  5. 类型系统:条件推断、UnknownIfNonSpecific、可组合的 StoreEnhancer 泛型、类型级测试
  6. 构建系统:四种输出格式、错误码压缩、开发/生产环境代码拆分、产物测试

Redux 经久不衰的影响力,并不来自其体量——1,575 行代码着实精小——而在于其抽象的精准。闭包代替类,函数代替配置,组合代替继承。这些原则值得带入任何代码库。