各位同学,各位未来的前端架构师,大家下午好。
今天我们不谈 React 的 useState 怎么写,也不谈 Hooks 的依赖数组怎么填。今天我们要聊的是更“底层”、更“硬核”、也更“让人头秃”的话题:构建策略。
想象一下,你正在写一个 React 应用。你写了一行代码:import React from 'react'。这行代码看起来很优雅,很现代,很符合 ES6 规范。但是,当这行代码被构建工具打包,变成浏览器能读懂的 JavaScript 文件时,到底发生了什么?
React 作为一个庞大的库,它面临着一种尴尬的处境:它既要被 Node.js(服务器端)运行,又要被浏览器(客户端)运行。而 Node.js 和浏览器,在模块系统上简直是“老死不相往来”的冤家。
今天,我们就化身一名“构建侦探”,潜入 React 的源码仓库,看看它是如何利用 Rollup 这个瑞士军刀,配合各种插件,在 CommonJS (CJS) 和 ES Modules (ESM) 之间玩转“翻译游戏”的。
准备好了吗?系好安全带,我们开始这场代码的“变形记”。
第一章:模块系统的“罗密欧与朱丽叶”
在讲插件之前,我们得先搞清楚,为什么会有这种需求。这就像是因为语言不通而无法相爱的罗密欧与朱丽叶。
CommonJS (CJS) 是 Node.js 的原生模块系统。它的特点是:
- 同步加载:
require的时候,必须等文件下载完才能往下走。 - 动态性:
require可以在函数里调用,甚至参数可以是变量。 - 导出方式:
module.exports = xxx。
ESM (ES Modules) 是 JavaScript 的标准模块系统。
- 异步加载:
import是静态的,编译阶段就能确定依赖关系。 - 静态性:不能在函数里
import,必须写死路径。 - 导出方式:
export default xxx或export const xxx。
问题来了:
React 库本身是用 JavaScript (JS) 写的,但为了兼容 Node.js 环境(比如 Next.js、SSR),它需要导出 CJS 格式。但是,现代前端开发(Webpack, Vite, Rollup)都偏爱 ESM 格式,因为支持 Tree-shaking(摇树优化)。
所以,React 必须同时提供两套“面孔”:一套给 Node.js 用,一套给浏览器用。
React 选择的策略是:核心代码用 ES Modules (ESM) 编写,然后通过构建工具转换成 CJS 和 UMD。
第二章:Rollup —— 代码的“外科医生”
为什么不用 Webpack?虽然 Webpack 很强,但在处理库的构建时,Rollup 才是王者。Rollup 使用静态分析,能精确地知道哪些代码没有被用到,从而把它们剪掉。这对于 React 这样庞大的库来说,至关重要,因为剪掉没用的代码,能减少几十 KB 的体积。
React 的构建脚本里,核心就是 Rollup。
让我们先看一个最简单的 Rollup 配置文件示例,感受一下气氛:
// rollup.config.js
export default {
input: 'src/index.js', // 入口文件
output: [
{
file: 'dist/react.cjs.js', // 输出 CJS 文件
format: 'cjs', // 格式:CommonJS
exports: 'named', // 导出模式:命名导出
},
{
file: 'dist/react.esm.js', // 输出 ESM 文件
format: 'es', // 格式:ES Module
exports: 'named',
},
{
file: 'dist/react.umd.js', // 输出 UMD 文件(兼容旧浏览器和全局变量)
format: 'umd',
name: 'React', // 全局变量名
globals: {
// 如果 UMD 依赖了其他库,比如 'react-dom',这里需要指定全局变量
'react-dom': 'ReactDOM',
},
}
],
plugins: [
// 这里的插件是关键
]
};
注意看 output 部分。React 并没有把代码一股脑塞进一个文件里,而是生成了三个文件:react.cjs.js、react.esm.js 和 react.umd.js。这就是“多环境导出”的基础。
第三章:插件家族 —— 翻译官的团队
现在,代码从 index.js 进来,要变成 dist/react.cjs.js。这中间发生了什么?这就需要依赖 Rollup 的插件家族了。
React 的构建流程大致是这样的:
- Resolve (解析):找到文件在哪。
- Transform (转换):把 TypeScript 转成 JS,把 JSX 转成 JS,把 CJS 转成 ESM(如果需要的话)。
- Bundle (打包):把所有依赖打包在一起。
1. @rollup/plugin-node-resolve:地图导航员
这是第一个插件,它的任务是告诉 Rollup:“嘿,我要找 react-dom,它在哪里?”
当你在代码里写 import { useState } from 'react' 时,Rollup 不知道 react 在哪里。这个插件会去你的 node_modules 里翻箱倒柜,找到 react/package.json,然后告诉你:“找到了,在 /Users/you/node_modules/react/index.js。”
代码示例:
import resolve from '@rollup/plugin-node-resolve';
// ...
plugins: [
resolve({
browser: true, // 关键!告诉它优先找 browser 字段
extensions: ['.js', '.jsx', '.ts', '.tsx'],
}),
]
// ...
React 的 package.json 里有一个 browser 字段。这很关键,它告诉构建工具:“如果是浏览器环境,请使用这个文件;如果是 Node 环境,请使用那个文件。” 我们后面会细讲。
2. @rollup/plugin-commonjs:CJS 的克星
这是最复杂、最棘手的一个插件。为什么?
因为 React 的核心依赖(比如 scheduler、react-dom)很多都是用 CommonJS 写的。而我们的目标是生成 ESM。
如果直接把 module.exports = function() {} 变成 export function() {},那问题就大了!因为 CommonJS 里的 require 是动态的,而 ESM 的 import 是静态的。它们在作用域和导出时机上有巨大的差异。
@rollup/plugin-commonjs 做的事情非常像是一个“高明的翻译官”。它不仅仅是把语法改了,它还要处理以下情况:
- 循环依赖:A 依赖 B,B 又依赖 A。在 CJS 里这很容易(因为 require 是同步的),但在 ESM 里,导入必须在文件顶层。这个插件会智能地重构代码,把动态依赖变成静态导入。
- 默认导出 vs 命名导出:它会把
module.exports = { foo: 1 }转换成export const { foo } = module.exports。
代码示例(模拟转换过程):
假设我们有 CJS 代码 utils.js:
// utils.cjs
const utils = {
add(a, b) {
return a + b;
}
};
module.exports = utils;
Rollup 的 commonjs 插件处理后的 ESM 代码 utils.esm.js:
// utils.esm (转换后)
const utils = {
add(a, b) {
return a + b;
}
};
export { utils as default }; // 这里处理默认导出
React 的构建脚本里,它会针对不同的环境,决定是启用这个转换器,还是直接复用原始的 ESM 代码。
3. @rollup/plugin-sucrase 或 @rollup/plugin-typescript:TypeScript 的速递
React 是用 TypeScript 写的。但 TypeScript 编译出来的 .d.ts 类型定义文件不能直接在浏览器运行。我们需要 JS 文件。
这里通常使用 @rollup/plugin-sucrase,因为它比标准的 typescript 插件快得多(因为它跳过了一些不必要的检查,只做语法转换)。
它的作用很简单:把 .tsx 和 .ts 文件,瞬间变成浏览器能懂的 .js 文件,并且保持 ESM 的语法。
import typescript from '@rollup/plugin-typescript';
export default {
plugins: [
typescript({
declaration: true, // 同时生成 .d.ts 文件
declarationDir: './dist/types',
exclude: ['src/react-dom/server.tsx'], // 有些文件不需要编译,比如服务端专用的
})
]
}
第四章:package.json 的魔法 —— 指挥官的地图
前面我们讲了插件怎么把代码转换。那么,谁来告诉构建工具“我要 CJS 还是 ESM”呢?是 package.json。
在 React 的仓库里,package.json 是一个复杂的配置中心。它不仅仅是定义版本号,它定义了入口和映射关系。
1. main 字段:给 Node.js 的旧情人
"main": "dist/react.cjs.development.js"
这是传统的入口。当你在 Node.js 里 require('react') 时,Node.js 会读取这个字段。React 的构建脚本会确保这个文件是 CommonJS 格式。
2. module 字段:给现代浏览器的情人
"module": "dist/react.production.min.js"
这是 ESM 的入口。当你用现代打包工具(Webpack 4+, Vite, Rollup)构建应用时,它会优先读取这个字段。这意味着,React 会以 ESM 的形式被你的应用引用。
3. browser 字段:浏览器环境的优化
"browser": "dist/react.production.min.js"
这个字段非常有趣。它的目的是告诉 Webpack 或 Rollup:“嘿,如果你的构建目标环境是浏览器,请忽略 main,直接用 module。”
为什么?因为 main 可能包含一些 Node.js 专用的代码(比如 fs 模块)。通过 browser 字段,React 可以确保在浏览器端只加载必要的代码,避免在浏览器里报 fs is not defined 的错误。
4. exports 字段:现代的标准
在 React 的新版本中,exports 字段变得至关重要。它是一个对象,定义了不同路径下的不同导出。
{
"exports": {
".": {
"browser": "./dist/react.production.min.js",
"default": "./dist/react.cjs.development.js"
},
"./dom": {
"browser": "./dist/react-dom.production.min.js",
"default": "./dist/react-dom.cjs.development.js"
}
}
}
这行代码的意思是:
- 当你
import React from 'react'时,如果是浏览器环境,它指向react.production.min.js;如果是 Node 环境,它指向react.cjs.development.js。 - 当你
import { render } from 'react-dom'时,它也有专门的路径。
这就是“环境感知”的终极形态。它不再依赖打包工具的配置,而是直接在 package.json 里定好了规矩。
第五章:Side Effects(副作用) —— 安全网
在转换过程中,还有一个非常重要但容易被忽视的概念:sideEffects。
有些代码,虽然你看起来没用,但它在加载的时候会执行副作用,比如修改全局变量、注册插件、或者发送埋点数据。
如果在 package.json 里声明了 "sideEffects": false,Rollup 就可以大胆地进行 Tree-shaking,即使代码里没用到某个文件,它也可以把那个文件从 bundle 里删掉。
React 的 package.json 里通常会有类似这样的配置:
{
"sideEffects": [
"*.css",
"*.scss",
"@babel/runtime/regenerator"
]
}
这意味着:
- CSS 文件被导入时,不会被打包进去(因为浏览器不认识 CSS,需要单独处理)。
@babel/runtime里的 polyfill 代码被导入时,会被保留(因为它们有副作用)。
这个配置直接告诉构建工具:“别动这些文件,它们是有副作用的,动它们会导致 Bug。”
第六章:实战演练 —— 模拟 React 的构建
为了让大家更直观地理解,我们来模拟一个简化的 React 构建流程。
假设我们有一个简单的组件 Button.tsx:
// src/components/Button.tsx
import * as React from 'react';
// 这是一个有副作用的代码,比如引入样式
import './button.css';
export function Button(props: { label: string }) {
return <button>{props.label}</button>;
}
步骤 1:解析与转换
@rollup/plugin-typescript 把它变成 JS:
// dist/components/Button.js
import * as React from 'react';
import './button.css'; // 注意这里,它被保留了下来
export function Button(props) {
return React.createElement("button", null, props.label);
}
步骤 2:处理依赖
@rollup/plugin-node-resolve 找到了 React 的入口。假设我们构建的是 CJS 版本。
步骤 3:CommonJS 转换
因为我们要生成 CJS,Rollup 会调用 commonjs 插件。它会把顶层 import 转换成 require,把 export 转换成 module.exports。
转换后的 dist/components/Button.cjs.js:
// dist/components/Button.cjs.js
var React = require("react"); // 顶层 require
require("./button.css"); // 顶层 require
function Button(props) {
return React.createElement("button", null, props.label);
}
module.exports = {
Button: Button
};
转换后的 dist/components/Button.esm.js:
// dist/components/Button.esm.js
import * as React from "react"; // 顶层 import
import "./button.css"; // 顶层 import
export function Button(props) {
return React.createElement("button", null, props.label);
}
步骤 4:打包与 Tree-shaking
当你把这个 Button 导入到你的 React 应用中时,如果你的应用里只用了 Button,没有用其他组件。
- ESM 构建中:Rollup 看到你的代码只用了
Button,于是它会把Button函数从Button.js里提取出来,其他的空函数、无用的变量全部删掉,生成一个极小的 bundle。 - CJS 构建中:虽然也能 Tree-shaking,但效果通常不如 ESM 显著。
第七章:深入细节 —— SSR 的特殊处理
React 还有一个难点:服务端渲染 (SSR)。
在 Node.js 环境下,React 的某些代码(比如 scheduler 里的 setTimeout)需要被替换成 Node.js 的 setImmediate。如果直接打包进浏览器,就会报错。
React 是如何处理的?
在 package.json 里,通常会有针对不同环境的入口映射:
{
"main": "dist/react.cjs.js",
"module": "dist/react.esm.js",
"browser": "dist/react.production.min.js",
"react-server": "dist/react-server.edge.js" // 或者 "react-server/node"
}
构建脚本会根据当前构建的目标环境,选择不同的插件组合。
- 构建
react-server:它会使用@rollup/plugin-node-resolve,并且可能会使用一个特殊的插件,把浏览器专用的 API(如window)替换成 Node.js 的 API(如global)。
// 模拟构建时的替换逻辑
// src/react-dom/client.js
const isServer = typeof window === 'undefined';
if (isServer) {
// Node 环境
module.exports = require('./server.js');
} else {
// Browser 环境
module.exports = require('./browser.js');
}
这展示了 React 构建的灵活性:它不试图在一个文件里搞定所有事情,而是利用构建工具,根据环境生成不同的代码路径。
第八章:总结与展望
好了,同学们,今天的讲座就到这里。
我们回顾了一下 React 的构建策略:
- 架构上:它通过
package.json的main、module、browser和exports字段,为不同的环境(Node、Browser、Server)指明了不同的入口。 - 工具上:它依赖 Rollup 进行打包,利用 Tree-shaking 优化体积。
- 插件上:它巧妙地使用了 Resolve、CommonJS 和 TypeScript 插件,解决了 CJS 和 ESM 之间的语法鸿沟和作用域差异。
React 的构建策略之所以这么复杂,是因为它要同时照顾“旧情人”Node.js 和“新欢”浏览器。这种多环境兼容性是现代库开发必须掌握的技能。
给初学者的建议:
不要试图一开始就模仿 React 那么复杂的构建流程。先学会用 Vite 或 Webpack 构建一个简单的库。当你需要处理 SSR、Tree-shaking 和多环境导出时,再回过头来看 React 的源码,你会发现,那些看似复杂的配置,其实都是为了让代码在各种场景下都能“优雅地运行”。
最后,记住一点:好的构建策略,就像好的代码风格一样,是看不见的,但当你遇到性能瓶颈时,它就是那根救命稻草。
好了,下课!记得去试试用 Rollup 打包你自己的小项目!