React 基础体系 · 第 1/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
React 的学习不能只按 API 顺序记忆。一个组件为什么重新渲染、状态为什么像“快照”、key 为什么不能随便写、服务端组件为什么不能调用浏览器 API,以及生产环境为什么需要区分构建配置、静态资源、CSP 和回滚,这些问题都指向同一个核心:
React 用声明式组件描述 UI;状态变化触发新的 UI 计算;React 再把新旧结果协调为尽可能小的宿主环境更新。
这条主线可以分为四层:
- 语言与类型层:JavaScript、JSX、TypeScript。
- React 核心层:元素、组件、渲染、Fiber、Reconciliation、
key。 - 状态与并发层:Hooks、状态快照、批处理、Reducer、Transition、Suspense。
- 应用与交付层:客户端/服务端边界、Server Components、SSR、框架路由、CSP、监控、灰度和回滚。
一、先建立 React 的基本心智模型
1. 元素、组件和真实 DOM 不是同一个概念
下面三者需要严格区分:
- React 元素(element):描述“希望看到什么”的普通 JavaScript 对象。
- React 组件(component):接收输入并返回元素的函数或类。
- 宿主实例(host instance):浏览器中的真实 DOM 节点,例如
HTMLButtonElement。
例如:
function Greeting({ name }: { name: string }) {
return <h1 className="title">Hello, {name}</h1>;
}
const element = <Greeting name="Ada" />;
<Greeting name="Ada" /> 在 JSX 转换后类似于:
const element = React.createElement(Greeting, { name: "Ada" });
在现代 JSX 转换中,编译器通常会生成 jsx、jsxs 等运行时调用,而不一定显式引用 React.createElement。具体生成形式由编译器配置决定,但语义仍然是创建一个描述 UI 的元素对象。
元素不是 DOM:
console.log(element);
// 类似:
// {
// type: Greeting,
// props: { name: "Ada" }
// }
调用 root.render(element) 后,React 才会根据元素树创建或更新 DOM。
2. JSX 是 JavaScript 表达式语法的扩展
JSX 允许把类似 HTML 的结构写进 JavaScript/TypeScript。它有几个重要规则:
function Profile({ user }: { user: { name: string; avatarUrl?: string } }) {
return (
<section>
<h2>{user.name}</h2>
{user.avatarUrl ? <img src={user.avatarUrl} alt="" /> : null}
</section>
);
}
- 使用
{}插入 JavaScript 表达式。 - 组件名必须以大写开头,
<Profile />才会被当成组件;<profile />会被视为原生标签。 - 返回多个兄弟节点时需要父节点或 Fragment。
className对应 DOM 的class属性。- JSX 中的条件表达式必须返回元素、字符串、数字、数组或
null等可渲染值。
一个常见陷阱是:
{count && <p>有消息</p>}
当 count 为 0 时,React 会渲染数字 0,因为 0 && expression 的结果是 0。若意图是严格的布尔判断,应写成:
{count > 0 && <p>有消息</p>}
3. 渲染分为 Render 和 Commit
React 更新 UI 时,可以抽象为两个阶段:
状态或属性变化
↓
Render:计算新的元素树,确定哪些组件需要重新计算
↓
Commit:把确定的变更提交给 DOM 或其他宿主环境
↓
浏览器绘制
Render 阶段的工作是计算,理想情况下应当是纯的:
function Price({ amount }: { amount: number }) {
const display = amount.toFixed(2);
return <span>{display}</span>;
}
组件函数可能在 Render 阶段执行多次,因此不应在其中做不可逆副作用:
function BadComponent() {
localStorage.setItem("visited", "1"); // 不应放在渲染期间
return <div>Page</div>;
}
副作用是“读取或修改 React 外部系统”的行为,例如:
- 修改 DOM;
- 订阅 WebSocket;
- 启动定时器;
- 写入
localStorage; - 发起不由框架数据层管理的请求。
这类逻辑通常应放在事件处理函数或 Effect 中:
import { useEffect } from "react";
function VisitMarker() {
useEffect(() => {
localStorage.setItem("visited", "1");
}, []);
return <div>Page</div>;
}
useEffect 在组件提交到 DOM 后运行。它不是“让组件初始化一次”的通用语法,而是用来同步 React 状态与外部系统。若只是根据已有 props 计算值,应直接计算,而不是用 Effect 再复制一份状态。
// 不必要的 Effect
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// 更直接
const fullName = `${firstName} ${lastName}`;
二、Fiber、Reconciliation 和 key
1. Fiber 是 React 的内部工作单元
Fiber 是 React 内部表示组件树中一个节点及其工作状态的数据结构。一个 Fiber 通常会关联:
- 当前元素的类型;
key;- props;
- hooks 链表;
- 父、子、兄弟节点关系;
- 上一次提交的状态;
- 本次更新的优先级和待处理工作;
- 对宿主节点的引用。
Fiber 不是公开业务 API,也不应直接依赖其字段。理解 Fiber 的价值在于理解 React 为什么能把更新拆分、暂停、恢复,并在 Commit 阶段统一提交。
在现代 React 中,Render 工作可以被调度。低优先级更新可能暂时让位给用户输入,但 Commit 不能被任意拆成导致 UI 半更新的状态。这里的“可中断”主要描述 Render 计算,而不是已经提交到 DOM 的一半变更。
2. Reconciliation 是新旧元素树的协调
Reconciliation 可译为“协调”:React 比较上一次 Render 产生的元素树和这一次产生的元素树,判断哪些节点可以复用、哪些需要卸载、哪些需要创建。
一个简化的同层比较规则是:
- 类型不同,通常认为是不同节点;
- 类型相同,复用节点并更新 props;
- 列表中的同类型节点通过
key建立稳定身份; - 子树变化后继续递归比较。
例如:
function App({ loggedIn }: { loggedIn: boolean }) {
return loggedIn ? <Dashboard /> : <Login />;
}
<Dashboard /> 和 <Login /> 的类型不同,因此切换时 React 通常会卸载一个组件并挂载另一个组件。组件内部状态不会跨越这两个不同类型的节点自动保留。
相反:
function App({ name }: { name: string }) {
return <Profile name={name} />;
}
前后都是 Profile 类型,React 通常复用这个组件实例,只更新 name。
3. key 是身份,不是性能装饰
列表渲染时:
function TodoList({ todos }: { todos: { id: string; text: string }[] }) {
return (
<ul>
{todos.map(todo => (
<TodoRow key={todo.id} todo={todo} />
))}
</ul>
);
}
key 告诉 React:同一列表范围内,某个数据项在不同 Render 之间的稳定身份是什么。
考虑一个带输入框的列表:
function Row({ item }: { item: { id: string; name: string } }) {
const [draft, setDraft] = useState(item.name);
return <input value={draft} onChange={e => setDraft(e.target.value)} />;
}
初始数据:
[
{ id: "a", name: "A" },
{ id: "b", name: "B" }
]
用户把第二行改成 "B edited",随后在首部插入 X。
使用索引作为 key
items.map((item, index) => (
<Row key={index} item={item} />
))
旧身份与新身份可能变成:
旧:key 0 -> A,key 1 -> B
新:key 0 -> X,key 1 -> A,key 2 -> B
React 会认为 key 0 还是原来的 Row,于是原本属于 A 的本地状态被复用给 X;原本属于 B 的状态被复用给 A。结果可能表现为输入框内容跟着错误的数据项移动。
使用稳定 ID
旧:key a -> A,key b -> B
新:key x -> X,key a -> A,key b -> B
React 能识别出:
x是新节点;a仍是 A;b仍是 B。
因此行组件的本地状态跟随数据身份,而不是跟随数组位置。
key 的约束是:
- 在同一个父列表范围内唯一;
- 在多次 Render 间稳定;
- 不应每次 Render 都随机生成;
- 不应在数据身份稳定时使用数组索引。
错误示例:
items.map(item => <Row key={Math.random()} item={item} />);
每次 Render 都得到新 key,React 会把每一行当成新组件,导致状态丢失、Effect 反复清理和重建,并增加不必要的工作。
需要注意,key 不会自动出现在组件 props 中:
function Row(props: { key: string }) {
// props.key 不能依赖为 React 的 key
}
如果组件需要业务 ID,应显式传递:
<Row key={item.id} itemId={item.id} />
4. 通过改变 key 有意重置状态
key 不只用于数组,也可以用于控制组件身份:
function Editor({ documentId }: { documentId: string }) {
return <DocumentForm key={documentId} documentId={documentId} />;
}
当 documentId 改变时,DocumentForm 被视为新组件,其本地状态和 Effect 会重新开始。这是有意重置状态的可靠方式,但不应把它当作修复状态同步问题的默认手段。
三、React 状态:快照、更新队列与批处理
1. State 是“下一次 Render 的输入”
组件函数中的 state 变量不是一个会被当前函数执行过程实时修改的普通变量,而是当前 Render 的快照。
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
return <button onClick={handleClick}>{count}</button>;
}
当按钮点击时,这次事件处理函数闭包中的 count 是 0。三个调用实际都在请求:
setCount(1)
setCount(1)
setCount(1)
因此最终通常是 1,不是 3。
如果下一次状态依赖上一次状态,应使用函数式更新:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
}
return <button onClick={handleClick}>{count}</button>;
}
React 会按队列顺序计算:
初始值:0
第一次:c = 0,结果 1
第二次:c = 1,结果 2
第三次:c = 2,结果 3
这两个写法的差异不是语法风格,而是依赖关系不同:
setCount(count + 1):根据当前 Render 的快照计算;setCount(c => c + 1):根据更新队列中前一个结果计算。
2. 批处理减少提交次数,但不改变快照语义
React 会把同一时机内的多个状态更新合并处理,这称为 batching。React 18 以后,现代根 API 下,批处理范围不只局限于 React 事件,也通常覆盖 Promise、定时器等异步回调。
function Example() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
}
return (
<button onClick={handleClick}>
{count} / {String(flag)}
</button>
);
}
这通常会导致一次新的 Render 和一次 Commit,而不是每个 setState 都立即改 DOM。
批处理不等于同步修改变量:
function Example() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // 仍是当前 Render 的值
}
return <button onClick={handleClick}>{count}</button>;
}
如果必须在 DOM 提交后读取布局信息,flushSync 可以强制同步提交,但它是特殊工具,不是常规状态更新方式:
import { flushSync } from "react-dom";
flushSync(() => {
setOpen(true);
});
// 此处可读取已经提交的 DOM,但会破坏部分调度空间
使用它会增加同步渲染成本,尤其在大型树或频繁交互中可能造成卡顿。
3. State 的身份取决于树中的位置和 key
下面两个组件各自维护独立状态:
function App() {
return (
<>
<Counter />
<Counter />
</>
);
}
它们函数代码相同,但位于不同的树位置,因此状态独立。
如果组件类型和位置保持不变,普通 props 更新不会自动重置 state:
function ContactEditor({ contact }: { contact: { id: string; name: string } }) {
const [name, setName] = useState(contact.name);
return <input value={name} onChange={e => setName(e.target.value)} />;
}
切换 contact 后,name 不会自动变成新联系人名称,因为 state 属于这个位置上的 ContactEditor,而不是自动属于某个 prop 值。若编辑器确实应随联系人切换而重置,使用:
<ContactEditor key={contact.id} contact={contact} />
若不应重置,则应设计显式同步策略,例如只在用户尚未编辑时更新草稿,而不是无条件用 Effect 覆盖用户输入。
4. 状态应保持不可变更新
React 通常通过引用变化识别对象或数组是否需要进一步处理。不要直接修改已有 state:
// 错误
user.name = "Ada";
setUser(user);
应创建新对象:
setUser(previous => ({
...previous,
name: "Ada",
}));
数组同理:
setItems(previous =>
previous.map(item =>
item.id === id ? { ...item, done: true } : item
)
);
不可变更新的因果关系是:旧 Render 使用旧对象快照,新 Render 接收新对象引用;这让数据流可追踪,也避免已经执行的闭包观察到“对象内容被偷偷改写”的不一致状态。
四、Hooks:规则、生命周期和选择方式
1. Hook 是把 React 能力接入函数组件的协议
常用 Hooks 可以按目的理解:
useState:保存局部状态;useReducer:用事件和纯 Reducer 管理状态转移;useEffect:同步外部系统;useLayoutEffect:在浏览器绘制前同步执行布局相关副作用;useRef:保存跨 Render 的可变引用,修改不会触发 Render;useMemo:缓存计算结果;useCallback:缓存函数引用;useContext:读取上下文;useTransition、useDeferredValue:表达非紧急更新;useId:生成适合 SSR/CSR 对齐的标识符;useSyncExternalStore:以符合 React 并发模型的方式订阅外部 store。
Hook 的核心规则是:
- 只能在组件函数或自定义 Hook 的顶层调用;
- 不能放在条件、循环、嵌套函数或事件处理器中;
- 每次 Render 必须以相同顺序调用同一组 Hooks。
错误示例:
function Panel({ enabled }: { enabled: boolean }) {
if (enabled) {
const [value] = useState(0); // 错误:调用顺序会变化
}
return null;
}
React 内部需要依靠稳定的调用顺序把当前 Hook 与上一次 Hook 状态对应起来。第一次 Render 如果顺序是:
useState -> useEffect -> useRef
下一次变成:
useEffect -> useState -> useRef
React 就无法可靠判断哪个状态属于哪个 Hook。ESLint 的 react-hooks 规则应作为工程检查的一部分,但规则的根本原因是运行时状态寻址依赖调用顺序。
2. useEffect 的正确边界
Effect 用于“同步外部系统”,例如订阅:
import { useEffect, useState } from "react";
function OnlineStatus({ roomId }: { roomId: string }) {
const [online, setOnline] = useState(false);
useEffect(() => {
const connection = createConnection(roomId);
function handleStatus(status: "online" | "offline") {
setOnline(status === "online");
}
connection.on("status", handleStatus);
connection.connect();
return () => {
connection.off("status", handleStatus);
connection.disconnect();
};
}, [roomId]);
return <span>{online ? "在线" : "离线"}</span>;
}
完整生命周期是:
首次提交
↓
运行 Effect
↓
roomId 变化或组件卸载
↓
运行 cleanup
↓
运行新的 Effect
Cleanup 不是只在卸载时执行。依赖变化时,旧 Effect 必须先清理,再建立新订阅。开发模式下的 Strict Mode 还可能故意执行额外的 setup/cleanup 检查,以发现不对称副作用;因此 Effect 必须可重复建立和清理。
请求也需要处理竞态:
function User({ userId }: { userId: string }) {
const [state, setState] = useState<
{ status: "loading" } |
{ status: "success"; user: UserData } |
{ status: "error"; error: Error }
>({ status: "loading" });
useEffect(() => {
const controller = new AbortController();
setState({ status: "loading" });
fetch(`/api/users/${encodeURIComponent(userId)}`, {
signal: controller.signal,
})
.then(async response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json() as Promise<UserData>;
})
.then(user => setState({ status: "success", user }))
.catch(error => {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
setState({
status: "error",
error: error instanceof Error ? error : new Error(String(error)),
});
});
return () => controller.abort();
}, [userId]);
if (state.status === "loading") return <p>加载中</p>;
if (state.status === "error") return <p>{state.error.message}</p>;
return <p>{state.user.name}</p>;
}
当 userId 从 A 变为 B 时,A 请求被取消。即使某些环境无法及时取消底层网络请求,cleanup 也应阻止旧请求结果覆盖新状态。真实项目还应考虑重试、超时、缓存和错误边界;仅依赖一个 Effect 并不自动得到完整的数据获取架构。
3. useRef、useMemo 和 useCallback 的区别
useRef 适合保存不参与渲染的可变值:
const inputRef = useRef<HTMLInputElement>(null);
function focusInput() {
inputRef.current?.focus();
}
return (
<>
<input ref={inputRef} />
<button onClick={focusInput}>聚焦</button>
</>
);
ref.current 改变不会触发组件重新渲染,因此不能用它替代需要显示在 UI 中的 state。
useMemo 缓存计算结果:
const visibleTodos = useMemo(
() => todos.filter(todo => !todo.done),
[todos]
);
useCallback 缓存函数引用:
const handleSelect = useCallback(
(id: string) => onSelect(id),
[onSelect]
);
它们不是“让任何组件都更快”的开关。只有当计算确实昂贵、或引用稳定性对子组件/Effect 有意义时才有合理收益。缓存本身也有依赖维护成本;依赖写错会产生陈旧闭包或错误结果。
五、用 useReducer 建模复杂状态
当状态变化由多个明确事件驱动,useReducer 比多个相互耦合的 useState 更容易推理。
type State = {
status: "idle" | "submitting" | "success" | "error";
error?: string;
};
type Action =
| { type: "submit" }
| { type: "success" }
| { type: "failure"; message: string }
| { type: "reset" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "submit":
return { status: "submitting" };
case "success":
return { status: "success" };
case "failure":
return { status: "error", error: action.message };
case "reset":
return { status: "idle" };
default: {
const exhaustive: never = action;
return exhaustive;
}
}
}
组件和副作用分离:
function SaveButton() {
const [state, dispatch] = useReducer(reducer, { status: "idle" });
async function handleSave() {
dispatch({ type: "submit" });
try {
const response = await fetch("/api/save", { method: "POST" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
dispatch({ type: "success" });
} catch (error) {
dispatch({
type: "failure",
message: error instanceof Error ? error.message : "保存失败",
});
}
}
return (
<button disabled={state.status === "submitting"} onClick={handleSave}>
{state.status === "submitting" ? "保存中" : "保存"}
</button>
);
}
这里有三个不同层次:
- Reducer:纯函数,只负责
state + action -> nextState; - 事件处理器:启动异步操作;
- UI:根据状态渲染不同结果。
Reducer 不应直接发请求、写 DOM 或读取当前时间。纯 Reducer 便于测试状态转移:
idle + submit -> submitting
submitting + success -> success
submitting + failure -> error
error + reset -> idle
TypeScript 的判别联合让新增 Action 后可以在编译期发现未处理分支,但它不能替代运行时校验。来自网络的 JSON 仍应通过 Zod 等运行时 schema 或手写校验验证。
六、并发渲染、Transition、Suspense 与错误边界
1. 紧急更新和非紧急更新
用户输入通常是紧急更新,搜索结果列表可以是非紧急更新。useTransition 用于标记后者:
import { useState, useTransition } from "react";
function Search({ allItems }: { allItems: string[] }) {
const [query, setQuery] = useState("");
const [items, setItems] = useState(allItems);
const [isPending, startTransition] = useTransition();
function handleChange(value: string) {
setQuery(value);
startTransition(() => {
const next = allItems.filter(item =>
item.toLowerCase().includes(value.toLowerCase())
);
setItems(next);
});
}
return (
<>
<input value={query} onChange={e => handleChange(e.target.value)} />
{isPending && <small>更新结果中…</small>}
<ul>{items.map(item => <li key={item}>{item}</li>)}</ul>
</>
);
}
startTransition 不会让计算本身变快,也不会取消业务请求。它表达的是更新优先级:在结果更新尚未完成时,React 可以优先保证输入响应。
如果更新被延迟,组件必须能接受中间状态。不能假设每个 Render 都会立即 Commit,也不能把 Render 中的临时变量当作已提交事实。
2. Suspense 是声明式等待边界
Suspense 的作用是:当子树暂时无法完成渲染时显示 fallback。它不是任意 Promise 的通用错误处理器,具体哪些数据源支持 Suspense 取决于 React 集成和框架。
import { Suspense } from "react";
function Page() {
return (
<Suspense fallback={<p>加载页面中…</p>}>
<ProductDetails />
</Suspense>
);
}
错误和等待是两条不同路径:
子树挂起 -> Suspense fallback
子树抛出错误 -> Error Boundary
错误边界目前通常通过类组件实现,或由框架提供:
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ error: Error | null }
> {
state = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
reportError(error, info);
}
render() {
if (this.state.error) {
return <p>页面加载失败,请重试。</p>;
}
return this.props.children;
}
}
错误边界不能捕获所有错误,例如事件处理器中的异常、服务端渲染错误和边界自身的错误需要分别处理。生产系统应同时记录错误、请求 ID、路由和构建版本,但不要把 access token 或个人敏感数据写入日志。
七、TypeScript:让组件契约和状态转移可验证
1. Props 应描述输入契约
type ButtonProps = {
variant?: "primary" | "secondary";
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
};
function Button({
variant = "primary",
disabled = false,
onClick,
children,
}: ButtonProps) {
return (
<button
className={`button button-${variant}`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
React.ReactNode 表示可以被 React 渲染的内容;若组件只接受单个元素,可以使用更窄的类型。不要为了省事把所有 props 写成 any,否则组件之间的边界失去检查价值。
DOM 事件应使用具体类型:
function NameInput() {
const [name, setName] = useState("");
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setName(event.target.value);
}
return <input value={name} onChange={handleChange} />;
}
2. 使用判别联合表达互斥状态
不要把异步状态写成多个可互相矛盾的布尔值:
type BadState = {
loading: boolean;
success: boolean;
error?: string;
};
这种类型允许 loading: true 且 success: true。更可靠的表达是:
type LoadState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
使用时 TypeScript 会根据 status 缩小类型:
function Result({ state }: { state: LoadState<UserData> }) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <p>加载中</p>;
case "success":
return <p>{state.data.name}</p>;
case "error":
return <p>{state.error.message}</p>;
}
}
TypeScript 只在编译期检查类型。API 返回的 JSON 在运行时仍可能缺字段、类型错误或受到恶意构造,因此网络边界不能只靠 TypeScript 接口保护。
八、客户端组件、服务端渲染和服务端组件
1. 三个概念必须分开
客户端渲染(CSR):浏览器下载 JavaScript 后,调用 createRoot 创建 UI。
import { createRoot } from "react-dom/client";
import App from "./App";
const container = document.getElementById("root");
if (!container) throw new Error("Missing #root");
createRoot(container).render(<App />);
服务端渲染(SSR):服务器生成初始 HTML,浏览器先看到内容,再通过 hydration 接管已有标记。
import { hydrateRoot } from "react-dom/client";
import App from "./App";
hydrateRoot(document.getElementById("root")!, <App />);
hydrateRoot 的前提是服务端 HTML 与客户端首次 Render 能够匹配。随机数、当前时间、仅浏览器存在的 API、不同 locale 的格式化结果,都可能导致 hydration mismatch:
// 容易产生不一致
function Bad() {
return <p>{Math.random()}</p>;
}
应把依赖浏览器或非确定性环境的内容延后到客户端 Effect,或由服务器把确定值传给客户端。
Server Components(服务端组件):组件在服务器环境执行,输出一种供 React 客户端处理的组件树/数据协议,而不是把该组件的 JavaScript 代码发送到浏览器执行。它是组件模型和传输协议能力,不等同于传统 SSR HTML。
2. Server Components 的边界
在支持 RSC 的框架中,通常可以这样组织:
// app/products/page.tsx
// 具体文件约定由框架决定;此处表示服务端组件
import ProductList from "./ProductList";
import FilterPanel from "./FilterPanel";
export default async function Page() {
const response = await fetch("https://api.example.com/products", {
cache: "no-store",
});
if (!response.ok) {
throw new Error(`Product API failed: ${response.status}`);
}
const products = (await response.json()) as Product[];
return (
<>
<FilterPanel />
<ProductList products={products} />
</>
);
}
// FilterPanel.tsx
"use client";
import { useState } from "react";
export default function FilterPanel() {
const [query, setQuery] = useState("");
return (
<input
value={query}
onChange={event => setQuery(event.target.value)}
placeholder="筛选商品"
/>
);
}
"use client" 是支持该约定的框架/工具链识别的模块边界,表示该模块需要进入客户端模块图。它不是 React 核心中可以在任意环境单独开启 RSC 的通用开关。
服务端组件通常不能:
- 调用
useState、useEffect等客户端交互 Hooks; - 访问
window、document、localStorage; - 注册浏览器事件;
- 把任意不可序列化的对象传给客户端组件。
客户端组件可以作为服务端组件的子树,但一旦进入客户端边界,该子树及其依赖会进入客户端 JavaScript 构建。应把交互所需的最小部分放在客户端边界内。
3. 服务端组件不是“把服务器代码发给浏览器”
服务端组件中的数据库访问、私有凭证和文件系统操作不会因为组件被引用就自动暴露给浏览器;但必须信任并正确配置框架的 RSC 构建与传输链路。不要把服务端组件直接当作安全边界:
- 用户输入仍需服务端鉴权和校验;
- 返回给客户端的 props 不能包含秘密;
- 日志、错误信息和序列化数据都可能泄露敏感内容;
- 公开的环境变量与服务端私密变量必须由构建系统明确区分。
服务端组件的优势主要是减少不必要的客户端代码、让数据获取靠近服务器资源,以及把交互边界缩小;代价是需要理解序列化、缓存、请求生命周期和框架约定。
4. React 19 的 Action 相关能力
React 19 引入了面向异步表单和 Action 的能力,例如 useActionState、useOptimistic 以及表单 Action 集成。它们在支持相应 React DOM/框架能力的环境中使用,不能简单等同于任意 API 路由或数据库事务。
一个客户端表单的示意:
"use client";
import { useActionState } from "react";
type FormState = {
ok: boolean;
message: string;
};
async function submit(
previous: FormState,
formData: FormData
): Promise<FormState> {
const email = String(formData.get("email") ?? "").trim();
if (!/^[^@]+@[^@]+$/.test(email)) {
return { ok: false, message: "邮箱格式不正确" };
}
const response = await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!response.ok) {
return { ok: false, message: "提交失败" };
}
return { ok: true, message: "订阅成功" };
}
export function SubscribeForm() {
const [state, formAction, pending] = useActionState(submit, {
ok: false,
message: "",
});
return (
<form action={formAction}>
<input name="email" type="email" required />
<button disabled={pending}>
{pending ? "提交中…" : "订阅"}
</button>
{state.message && <p>{state.message}</p>}
</form>
);
}
这里的 Action 负责把表单输入转换为状态结果,但鉴权、CSRF 防护、限流、数据库唯一约束和幂等性仍必须在服务端实现。客户端禁用按钮不能防止重复请求,服务端应使用请求 ID、业务唯一键或数据库约束保证重复提交的安全性。
九、从入口到生产应用的架构分层
一个可维护的 React 应用通常可按数据流拆分:
路由/服务端数据获取
↓
页面组合组件
↓
领域组件与展示组件
↓
客户端交互组件
↓
浏览器事件、缓存、外部 store
↓
API / Server Action / 后端服务
关键原则不是目录名称,而是边界:
- 页面层负责路由参数、服务端数据和错误/加载边界;
- 领域组件负责业务状态和业务事件;
- 展示组件尽量保持纯函数;
- 客户端状态只保存真正需要在浏览器交互期间持有的数据;
- 服务器数据不应无条件复制成多份本地 state;
- API 返回值在边界处校验;
- 权限判断必须在服务端重复执行。
1. 路由和数据加载
主流框架会提供文件路由、布局、错误页、加载页和服务端数据获取能力,但这些不是 React 核心本身。React 负责组件模型、渲染和状态;框架负责路由、打包、RSC/SSR 集成、缓存策略和部署适配。
无论使用何种框架,都应明确:
请求进入
↓
路由匹配
↓
鉴权与参数校验
↓
读取数据/调用后端
↓
服务端组件或 SSR 生成结果
↓
浏览器 hydration
↓
客户端交互接管
错误路径也要设计:
参数错误 -> 400
未认证 -> 401
无权限 -> 403
资源不存在 -> 404
上游或数据库失败 -> 5xx + 错误边界/降级页
客户端事件失败 -> 组件内错误状态或重试
不要把所有异常都渲染成“服务器错误”。用户需要可操作的提示,而日志需要保留真实错误和关联上下文。
十、生产交付:配置、静态资源和 CSP
1. 构建时配置和运行时配置不同
前端静态 JavaScript 一旦发送到浏览器,里面的值就不再是秘密。以下内容不能放入客户端环境变量:
- 数据库密码;
- 私有 API token;
- 签名密钥;
- 内部服务地址中包含的敏感凭证。
常见配置分为:
构建时配置:决定代码如何被打包,例如公开 API base URL
运行时服务端配置:服务器启动后读取,例如数据库连接串
客户端公开配置:明确允许浏览器看到,例如公开站点 ID
不同框架对环境变量前缀、注入时间和部署方式的约定不同,必须以具体框架文档和构建产物验证为准。安全检查不能只看变量名;应检查最终 JavaScript、Source Map 和响应内容中是否出现秘密。
2. 静态资源和缓存
带内容哈希的资源名可以支持长期缓存:
app.8f3a1c.js
styles.12ab90.css
如果文件内容改变,哈希也改变,因此可以发送:
Cache-Control: public, max-age=31536000, immutable
但入口 HTML 通常不能无限期缓存,否则用户可能继续拿到引用旧资源的 HTML。常见策略是:
index.html:
Cache-Control: no-cache
app.<hash>.js:
Cache-Control: public, max-age=31536000, immutable
发布流程必须保证 HTML 引用的资源已经上传完成。错误顺序可能是:
先发布新 HTML
↓
CDN 尚未拿到新 JS
↓
浏览器请求 404
↓
白屏
更安全的顺序通常是先上传带哈希的静态资源,再切换 HTML 或版本指针,并验证 CDN 命中结果。
3. CSP 的基本因果
Content Security Policy(CSP)通过响应头限制浏览器允许加载和执行的资源。例如:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-随机值';
style-src 'self' 'unsafe-inline';
img-src 'self' https: data:;
connect-src 'self' https://api.example.com;
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
实际策略必须根据框架产物、内联脚本、第三方分析和 CDN 逐项收紧。unsafe-inline 会削弱脚本防护,不能为了快速消除报错而无条件加入 script-src。
部署 CSP 时应经过:
- 先使用
Content-Security-Policy-Report-Only收集违规报告; - 区分真实资源依赖和误报;
- 去掉不必要的来源;
- 再切换为强制策略;
- 验证登录、SSR、hydration、图片、API 请求和错误上报。
CSP 不能替代输出转义、服务端鉴权和依赖安全更新;它是纵深防御的一层。
十一、灰度、监控和回滚
1. 灰度发布必须有稳定的版本标识
一次灰度请求至少应能关联:
用户/设备标识
→ 灰度规则
→ 应用版本
→ 静态资源版本
→ API 版本
→ 日志和指标
不要只按请求随机切流而不记录结果,否则出现问题时无法判断用户实际拿到哪个版本。灰度规则还要避免同一用户在不同请求间频繁跳版本;通常使用稳定哈希或持久化分组。
2. 监控不只是捕获异常
React 生产监控应覆盖不同失败层:
- 构建失败:CI 中的 TypeScript、测试、Lint 和打包错误;
- 加载失败:JS 404、模块加载错误、CDN 故障;
- 渲染失败:错误边界捕获的组件异常;
- hydration 失败:服务端和客户端输出不一致;
- 交互失败:请求失败、Action 失败、超时;
- 性能问题:首次内容、交互延迟、长任务、页面转场;
- 业务问题:支付失败率、保存成功率、登录成功率。
错误事件应包含版本、路由、浏览器、请求 ID 和脱敏后的用户上下文。例如资源加载失败时,单纯记录“白屏”无法区分是发布错误、CDN 缓存错误还是浏览器扩展拦截。
3. 回滚要验证,而不是只执行命令
一个可验证的回滚流程是:
发现异常
↓
停止继续扩散灰度
↓
确认当前版本与影响范围
↓
切换到上一稳定版本
↓
验证入口 HTML、JS 资源、API 兼容性
↓
观察错误率和核心业务指标
↓
保留失败版本日志,定位根因
回滚静态资源时尤其要注意缓存。若旧 HTML 引用的哈希资源已经被删除,即使 HTML 回滚也可能继续 404。因此带哈希资源通常应保留多个发布周期,不能发布后立即清理。
如果数据库迁移不可逆,应用回滚还可能因为旧代码无法读取新 schema 而失败。生产发布应优先采用向后兼容的迁移顺序:
先增加兼容字段/接口
↓
发布能同时读旧新结构的应用
↓
迁移数据
↓
切换读写路径
↓
确认无旧版本实例后再删除旧字段
这属于应用与后端协同的发布问题,不能仅靠 React 层解决。
十二、一个完整的学习与验证顺序
阶段一:先掌握 JavaScript 和 TypeScript 前置能力
必须能够独立理解:
- 函数、闭包和词法作用域;
- Promise、
async/await和异常传播; - 数组不可变操作;
- 模块导入导出;
- TypeScript 泛型、联合类型、类型缩小;
- DOM 事件和 HTTP 基础。
React 的状态快照本质上会放大闭包语义;如果不了解闭包,就容易误以为 setState 应立即改变当前变量。
阶段二:掌握渲染模型
依次验证:
- JSX 如何转换为元素描述;
render如何创建或更新宿主节点;- Render 与 Commit 的区别;
- 同类型节点如何复用;
- 类型变化如何导致卸载与挂载;
key如何决定列表项身份;- Strict Mode 为什么会帮助发现副作用问题。
可以通过一个带输入框的可插入列表实验 key:
- 先用索引作为 key;
- 在首部插入一项;
- 观察输入状态错位;
- 再改用稳定 ID;
- 比较组件身份变化。
这个实验比背诵“不要用 index key”更能说明因果。
阶段三:掌握状态和 Hooks
依次实现:
- 计数器:比较直接更新和函数式更新;
- 表单:理解受控组件;
- Reducer 表单:建模提交、成功、失败;
- WebSocket 订阅:练习 Effect cleanup;
- 可取消请求:处理依赖变化和竞态;
useRef聚焦 DOM;useTransition区分输入和列表更新;- Error Boundary 和 Suspense 边界。
每个练习都应观察三个问题:
什么触发了 Render?
哪些值属于当前 Render 快照?
哪些操作发生在 Commit 之后?
阶段四:理解 TypeScript 与数据边界
将组件 props、Reducer Action 和异步状态全部类型化;然后故意让 API 返回错误数据,验证编译期类型无法保护运行时边界,再加入运行时校验。
这一步可以避免一种危险误解:写了 interface User 并不意味着服务器真的返回了符合接口的对象。
阶段五:进入 SSR、Server Components 和框架
先理解 CSR 与 SSR 的区别,再学习:
createRoot和hydrateRoot;- hydration 一致性;
- 服务端数据获取;
- 客户端模块边界;
- Server Components 的序列化和代码发送范围;
- 框架的路由、缓存、错误页和部署模型;
- React 19 的表单 Action、
useActionState和乐观更新能力。
框架 API 的文件名和配置会变化,React 核心的渲染、状态和组件身份模型不会因此消失。遇到框架特性时,应先判断它属于:
React 核心 API
React DOM API
RSC/框架集成能力
构建工具能力
部署平台能力
这样能避免把框架约定误认为所有 React 项目都支持的规范。
阶段六:以生产故障验证架构
至少演练以下故障:
- 发布 HTML 早于静态资源;
- CDN 缓存了旧入口;
- hydration 因时间和随机数不一致;
- API 超时和重复提交;
- CSP 阻止了必要脚本;
- 灰度版本错误率上升;
- 回滚后旧代码与新数据库 schema 不兼容;
- 错误边界显示降级页但监控没有收到事件。
学习路线的终点不是“记住更多 Hook”,而是能够从一次点击开始,解释它如何进入事件处理器、产生更新队列、触发新的 Render、经过协调和 Commit 到达 DOM;也能够从一次生产白屏反向追踪到版本、资源、边界、网络和回滚路径。
React 的核心抽象可以最后压缩成一条可验证的链:
输入(props、state、context、外部 store)
↓
纯的组件计算
↓
元素树
↓
Fiber 工作与 Reconciliation
↓
Commit 到宿主环境
↓
Effect/事件/外部系统反馈
↓
下一次输入
掌握这条链,才能在 React 19、TypeScript、SSR、Server Components 或具体框架发生版本变化时,继续判断哪些行为是 React 的规范保证,哪些只是当前实现,哪些是应用层需要自行承担的工程取舍。
完整学习目录
一、核心模型
- React 项目工具链:Vite、TypeScript、Lint、环境变量和构建
- React JSX 与渲染模型:元素、Fiber、Reconciliation 和 Key
- React 组件与 Props:纯渲染、组合、Children 和 API 契约
二、Hooks 与交互
- React State 与 Hooks:useState、useReducer、批处理和状态快照
- React Effect 完整指南:同步外部系统、依赖、清理和竞态
- React Ref 与 DOM:useRef、forwardRef、测量、焦点和命令式边界
- React 表单工程:受控组件、校验、异步提交、错误与性能
三、状态与数据
- React Context 与 Reducer:跨层状态、更新边界和可测试设计
- React Router 完整指南:嵌套路由、Loader、Action、错误与权限
- React 异步数据:请求取消、缓存、并发、Suspense 和错误恢复
- React 状态管理选型:Redux Toolkit、Zustand、服务端缓存和边界
四、工程质量
- React 与 TypeScript:Props、事件、泛型组件、Ref 和联合类型
- React 测试体系:Testing Library、Vitest、用户行为和端到端测试
- React 性能优化:Profiler、Memo、更新边界、长列表和 Bundle
- React 并发渲染:Transition、Deferred Value、Suspense 和一致性
- React 可访问性:语义、键盘、焦点、ARIA、Portal 和动态内容
- React 前端安全:XSS、dangerouslySetInnerHTML、URL、Token 和依赖
- React 错误边界与可观测性:异常、日志、Web Vitals 和发布诊断
五、架构与服务端
- React 组件库与设计系统:组合 API、Token、主题和版本治理
- React Server Components:服务端边界、序列化、缓存和客户端交互
- Next.js 应用架构:路由、渲染、数据、缓存、Server Actions 和部署
- React 生产交付:配置、静态资源、CSP、灰度、监控和回滚
一、核心模型
- React 事件系统:合成事件、传播、优先级、闭包和原生事件
- React 状态建模:最小状态、派生值、归一化和所有权
- React Reducer 深入:Action、不可变更新、初始化和状态机
- React 自定义 Hook:契约、组合、副作用、稳定性和测试
- React Memo、useMemo 与 useCallback:引用、成本和正确边界
- React useLayoutEffect 与 useInsertionEffect:时机、测量和闪烁
- React Ref 与 useImperativeHandle:焦点、测量和命令式 API
- React Portal:事件、焦点、层级、可访问性和弹窗架构
- React lazy 与 Suspense:代码加载、边界、错误和用户体验
- React useSyncExternalStore:外部状态、一致快照、订阅和 SSR
- React 身份与 useId:Key、DOM ID、水合和列表稳定性
二、Hooks 与交互
- React 表单与 Action:原生提交、状态、乐观更新和错误
- React Hook Form:字段注册、Schema、动态表单和性能
- React 文件上传:拖拽、分片、进度、取消、重试和预览
- React 拖拽交互:排序、跨容器、触摸、键盘和状态一致性
- React 表格与虚拟列表:列模型、窗口、动态高度和交互
- React 样式工程:CSS Modules、CSS-in-JS、原子 CSS 和主题
- React 动画:CSS、Transition、Motion、布局动画和减少动态
- React 与 Web Components:属性、事件、Ref、类型和互操作
三、状态与数据
- React Router 数据路由:Loader、Action、Revalidation 和错误边界
- React 登录与权限:路由、组件、Token 刷新、403 和状态恢复
- TanStack Query:缓存键、失效、乐观更新、分页和离线
- Redux Toolkit 深入:Slice、Thunk、Listener、RTK Query 和测试
- Zustand 状态管理:Store、Selector、订阅、持久化和 SSR
- React 实时数据:WebSocket、SSE、重连、心跳和缓存同步
四、工程质量
- React 单元测试:纯函数、Hook、时间、网络和稳定断言
- React 组件集成测试:用户行为、异步 UI、Provider 和边界
- React 端到端测试:Playwright、登录态、网络、并行和失败证据
- React 视觉回归:截图基线、字体、动画、阈值和审阅
- React Storybook:Story、交互测试、文档、主题和评审
- React DevTools 与 Profiler:提交、更新来源、火焰图和诊断
- React Bundle 分析:Tree Shaking、分包、预加载和性能预算
- React PWA:Service Worker、缓存更新、离线和安装体验
- React 国际化:消息目录、Locale、日期数字、路由和回退
五、架构与服务端
- React Monorepo:Workspace、共享包、构建图、版本和边界
- React 组件库发布:构建、类型、样式、Tree Shaking 和版本
- React 微前端:路由、状态、样式、依赖隔离和迁移取舍
- React 流式 SSR:Shell、Suspense、错误、缓存和断流恢复
- React 水合诊断:不一致、事件绑定、客户端边界和调试
- React CI 质量流水线:类型、Lint、测试、构建、预览和制品
- React 静态交付:CDN、缓存、路由回退、压缩和回滚
- Next.js 路由与布局:Segment、并行路由、拦截和错误边界
- Next.js 数据与缓存:请求记忆、Data Cache、Revalidation 和标签
- Next.js Server Actions:序列化、认证、校验、重放和渐进增强
- Next.js Node 与 Edge Runtime:API、限制、依赖和部署选择
- Next.js SEO:Metadata、结构化数据、Canonical、站点地图和 OG 图
四、工程质量
系列导航与关联阅读
- 下一篇:React 项目工具链:Vite、TypeScript、Lint、环境变量和构建
- 延伸:React JSX 与渲染模型:元素、Fiber、Reconciliation 和 Key
- 延伸:React State 与 Hooks:useState、useReducer、批处理和状态快照
- 延伸:React 生产交付:配置、静态资源、CSP、灰度、监控和回滚
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论