React 的 API 不算多。学会 useState、事件处理和列表渲染,很快就能写出一个可用的页面。难点在于换一种方式理解界面:组件在每次渲染时根据输入重新计算 UI,数据沿组件树向下流动,用户操作再通过事件把变化送回拥有数据的地方。
这篇教程写给有 JavaScript 基础、尤其是熟悉 Vue 的读者。正文从数据如何进入组件、如何发生变化讲起,再逐步讨论 Effect、性能和底层实现。文中的示例默认使用函数组件、TypeScript 和现代 JSX 转换。
0x00. Vue 与 React
1. 本质
Vue 与 React 面对的是同一个问题:根据数据声明界面,并在数据变化后更新 DOM。两者的分歧主要落在“框架如何知道哪里变了”。
Vue 通过 ref、reactive 等响应式 API 跟踪依赖。模板或渲染函数读取响应式数据时,Vue 会记录这次读取;数据变化后,框架便能为相关部分安排更新。模板编译器还会提前区分静态节点与动态绑定,进一步减少运行时工作。
React 的基本模型更接近一次普通的函数求值。State 更新后,React 会再次调用相关组件,得到一棵新的元素树,再通过协调过程找出需要提交到 DOM 的宿主节点。组件函数重新执行时,DOM 并不会随之整体重建;React 只提交两次渲染结果之间的必要变化。
可以用一个近似公式概括这套模型:
UI = f(props, state, context)
Vue 把响应式系统做成显式能力;React 默认重新计算组件,再借助协调、调度和编译期优化控制成本。脱离项目场景比较两条路线的高下没有多少意义。选型时,团队经验、生态和工程约束通常比细微的渲染差异更能决定开发成本。
2. 不再 ref() 和 reactive()
从 Vue 转到 React,人们很容易为 ref() 和 reactive() 寻找一一对应的 API。useState 同样保存状态,但它的读写语义与 Vue 的响应式容器不同。
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return (
<button type="button" onClick={() => setCount((value) => value + 1)}>
点击了 {count} 次
</button>
);
}
count 是本次渲染拿到的快照,setCount 则把更新排入队列,请求下一次渲染。执行 setCount 后,当前函数作用域里的 count 仍保持原值。useState 保存的正是这样一组“当前快照与更新入口”,并非一个省略了 .value 的 Vue Ref。
React 也有一个名为 useRef 的 Hook。Ref 可以保存 DOM 节点,或保存需要跨渲染保留的可变值;修改 ref.current 不会触发渲染。需要显示在页面上的数据通常放进 State,Ref 则留给命令式代码使用。
3. Vue Vine
Vue Vine 恰好说明,组件如何组织与框架如何响应数据变化是两个独立问题。Vine 允许在 .vine.ts 文件里用普通函数组织多个 Vue 组件,把逻辑、模板与样式放在相近的位置,减少编写小组件时的文件跳转。
这种组织方式会让 React 开发者感到熟悉,不过 Vine 仍然运行在 Vue 的响应式与模板编译体系之上。函数式文件没有改变 Vue 的更新机制,JSX 也无法概括 React 的全部。学习 React 时,更值得留意的是纯渲染、不可变更新和明确的数据所有权;这些原则放回 Vue 项目依然成立。
0x01. 前置知识
1. ECMAScript 6 之后的 JavaScript
React 应用大量使用现代 JavaScript。开始之前,至少要熟悉解构赋值、数组的 map 与 filter、展开语法、模板字符串、模块、Promise、async/await 和闭包。
const user = { id: 7, name: "Alex", roles: ["author", "editor"] };
const { name, roles } = user;
const labels = roles.map((role) => `${name}: ${role}`);
const nextUser = { ...user, roles: [...roles, "reviewer"] };
这段代码的关键在于引用与不可变更新。{ ...user } 只完成一层浅复制;修改嵌套对象时,变化路径上的每一层都要复制。React 会通过对象身份判断许多更新能否跳过,直接修改原对象会抹掉新旧值的边界,也让渲染行为变得难以追踪。
闭包同样绕不开。事件处理函数会捕获创建它的那次渲染中的 Props 和 State。回调读到旧值,是因为它仍持有旧渲染的快照,React 并没有延迟修改变量。
类、迭代器和生成器仍是 JavaScript 的组成部分,但学习现代 React 无须先掌握它们。Class Component 主要出现在旧项目中,新组件通常直接写成函数。
2. JSX
JSX 是 JavaScript 的语法扩展,用来把界面结构写在相关逻辑附近。它看起来像 HTML,编译后得到的则是描述界面的普通 JavaScript 对象,也就是 React 元素。
const greeting = <strong className="greeting">你好,{user.name}</strong>;
JSX 可以由 TypeScript、Babel、SWC 等工具转换。现代的 automatic JSX runtime 通常生成 jsx/jsxs 调用,也不要求每个文件都显式导入 React;React.createElement() 更多见于旧工具链。可以确定的是 JSX 最终会变成 JavaScript,至于由谁转换、生成什么调用,则取决于构建配置。
JSX 的规则不多:
- 一个表达式只能返回一个根节点;不需要额外 DOM 时使用 Fragment,也就是
<>...</>。 - 标签必须闭合,
<img />和<input />也不例外。 - DOM 属性大多使用小驼峰形式,例如
className、htmlFor和onClick。 - 小写标签被视为宿主元素,大写名称被视为自定义组件。
- 花括号中可以放 JavaScript 表达式,但不能直接放
if、for这类语句。
JSX 让一起变化的结构与逻辑靠得更近,但这并不鼓励把所有代码塞进一个文件。组件仍应按照数据归属和职责划分。
3. TypeScript
TypeScript 能在重构 Props、接口返回值和状态分支时提前发现错误。React 开发中常用到 type、interface、联合类型、泛型,以及 Pick<T, K>、Omit<T, K>、Partial<T> 和 Record<K, V> 等工具类型。
type UserCardProps = {
name: string;
role: "admin" | "member";
avatarUrl?: string;
};
export function UserCard({ name, role, avatarUrl }: UserCardProps) {
return (
<article>
{avatarUrl ? <img src={avatarUrl} alt="" /> : null}
<strong>{name}</strong>
<span>{role === "admin" ? "管理员" : "成员"}</span>
</article>
);
}
React.FC<UserCardProps> 是一种合法写法,却没有必要成为项目规定。直接标注参数更简洁,TypeScript 也能推导返回类型。确实需要约束返回值或统一公共签名时,再显式使用 ReactNode 等类型。
对于只表示一组固定值的业务状态,字符串字面量联合往往比 enum 更轻,序列化后也更直观:
type RequestStatus = "idle" | "pending" | "success" | "error";
0x02. 拥抱 React 思想
1. UI = f(props, state)
组件负责把输入映射为 UI。给定相同的 Props、State 和 Context,渲染结果也应相同。网络请求、订阅、写入 DOM 和修改外部变量都应留在渲染过程之外。
type PriceProps = {
amount: number;
currency: "CNY" | "USD";
};
function Price({ amount, currency }: PriceProps) {
const formatted = new Intl.NumberFormat("zh-CN", {
style: "currency",
currency,
}).format(amount);
return <data value={amount}>{formatted}</data>;
}
formatted 完全由 Props 决定,可以在渲染时直接计算。若再存一份 State,同一个值便有了两个需要保持同步的来源。
2. 脱离原生与 Vue 的思维
原生 DOM 编程常写成一连串命令:找到节点、修改文字、切换类名、禁用按钮。React 组件描述的是每种数据状态对应的页面结果。
function SubmitButton({ pending }: { pending: boolean }) {
return (
<button type="submit" disabled={pending}>
{pending ? "提交中..." : "提交"}
</button>
);
}
点击时只需更新 pending,文字和 disabled 属性会随下一次渲染一起变化。若一种变化能由 State 表达,便无须再查询节点、修改 textContent 或操作 classList。
从 Vue 转来时,尤其要留意“创建一个响应式对象,再到处修改字段”的习惯。在 React 中,每份 State 都应有明确的所有者;数据通过 Props 向下传递,变化意图通过事件回到所有者。
3. 数据为先
面对一张设计稿,沿着视觉边框拆组件往往为时过早。梳理页面需要的数据,分清原始数据、可推导数据和局部交互状态,组件边界随后会清晰许多。
以商品搜索页为例,服务端返回的 products 是原始数据,用户输入的 query 和 inStockOnly 是交互状态,过滤后的列表则是派生数据:
const visibleProducts = products.filter((product) => {
const matchesQuery = product.name
.toLocaleLowerCase()
.includes(query.toLocaleLowerCase());
return matchesQuery && (!inStockOnly || product.stock > 0);
});
visibleProducts 由现有输入完整决定,无须进入 State,渲染时计算即可。只有当数据量已经拖慢交互时,才需要考虑 useMemo。
4. 思考数据的流向
React 默认使用单向数据流:父组件通过 Props 向下传递数据,子组件通过回调报告发生了什么。回调名称应表达用户意图,例如 onQueryChange、onRemove;setParentValue 这类名字会把父组件的实现细节泄漏给子组件。
type SearchBoxProps = {
query: string;
onQueryChange: (nextQuery: string) => void;
};
function SearchBox({ query, onQueryChange }: SearchBoxProps) {
return (
<input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder="搜索商品"
/>
);
}
这是一个受控输入框:显示内容由 query 决定,输入事件只报告下一值。父组件始终持有这份数据。
5. 沿数据流自然地构建组件
一段 UI 有独立职责、同一数据结构需要重复渲染,或者某份局部状态只影响一个子树时,组件边界通常已经出现。完整的数据流会暴露这些边界,据此提取组件即可;过早建立多层抽象,反而会遮住数据的去向。
在商品页中,ProductPage 持有查询条件,SearchBox 负责输入,ProductList 负责列表,ProductRow 负责单项展示。数据一路向下,事件一路向上。每个组件从哪里取数据、又把事件交给谁,都可以顺着这条路线找到。
0x03. 构建纯粹的组件
1. 组件原型
最普通的 React 组件就是一个首字母大写的函数。它接收 Props,并返回 React 可以渲染的内容:元素、字符串、数字、数组、Fragment 或 null。
type BadgeProps = {
children: React.ReactNode;
tone?: "neutral" | "success";
};
function Badge({ children, tone = "neutral" }: BadgeProps) {
return <span data-tone={tone}>{children}</span>;
}
State 不会作为函数的第二个参数传入,函数组件要通过 Hook 读取自身状态。业务代码也不应直接调用组件函数;写成 <Badge /> 后,React 才能管理组件身份与对应的 Hook 状态。
2. Props
Props 是父组件传入的只读快照。子组件若要引发变化,应通过回调报告事件,再由数据所有者更新 State 并传入新的 Props。
children 也是普通 Prop,表示开始标签与结束标签之间的内容。默认值可以直接写在参数解构中。基础 UI 组件常会透传其余属性;业务组件则要明确筛选,避免把无效属性带到 DOM 上。
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
variant?: "solid" | "quiet";
};
function Button({ variant = "solid", type = "button", ...props }: ButtonProps) {
return <button type={type} data-variant={variant} {...props} />;
}
3. 条件渲染
简单条件可以使用提前返回、三元表达式或逻辑与。分支变多后,把判断移到 JSX 外,通常比嵌套表达式更容易读。
function Result({ error, data }: ResultProps) {
if (error) return <p role="alert">加载失败:{error.message}</p>;
if (!data) return <p>正在加载...</p>;
return <Report data={data} />;
}
count && <Badge /> 有一个容易忽略的细节:count 为 0 时,页面会渲染数字 0。左侧不一定是布尔值时,写成 count > 0 && <Badge /> 可以避开这个问题。
4. 列表渲染
列表通常通过 map 转成元素。每个同级元素都需要稳定且唯一的 key,React 才能在插入、删除和排序后把节点与组件状态对应到正确的数据项。
function TodoList({ todos }: { todos: Todo[] }) {
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
数据库主键或创建数据时生成的 ID 都适合作为 key。数组下标不适合会重排的列表,渲染期间调用 Math.random() 则会让 key 每次都变化。key 只供 React 判断身份,不会自动进入子组件的 Props;子组件需要 ID 时仍要单独传入 id。
5. 嗯,记得保持纯粹
组件渲染期间可以读取 Props、State 和 Context,也可以创建局部对象、执行纯计算。修改外部变量、改写传入对象、启动计时器和发送请求都会让渲染产生副作用,应放到事件或 Effect 中处理。
开发环境中的 Strict Mode 可能额外调用组件、State 初始化函数和更新函数,以暴露不纯逻辑。日志出现两次时,应检查渲染过程是否产生了副作用。纯组件无论调用一次还是两次,结果都应一致。
0x04. 组件的记忆:State
1. State 与响应式
普通局部变量无法充当组件记忆。组件重新渲染时,函数会再次执行,局部变量随之重新创建;修改普通变量也不会通知 React 更新页面。
useState 同时解决这两个问题:React 在组件身份对应的位置保存值,并提供一个触发更新的函数。
const [name, setName] = useState("");
State 应尽量精简,只保留无法从 Props 或其他 State 推导的数据。同一个事实保存两份,很容易失去同步。例如,selectedItem 和 selectedId 通常不必同时存在;保存 ID,再从列表中查找对象即可。
2. 快照
每次渲染都会拿到固定的 State 快照,事件处理函数也属于那次渲染。下面的代码不会改变当前回调中的 count:
function handleClick() {
setCount(count + 1);
console.log(count); // 仍然是本次渲染的值
}
因此,延迟回调经常会读到旧值。反过来看,一个事件开始执行后,其中的 Props 和 State 也不会在半路突然变化;整段回调始终基于同一次渲染。
3. 操作 State
下一状态依赖前一状态时,传入更新函数:
setCount((count) => count + 1);
React 会把同一事件中的更新加入队列并进行批处理。连续三次执行 setCount(count + 1),读取的都是同一个旧快照;改用函数式更新后,三个更新才会依次以上一次结果为输入。
对象和数组 State 应视为只读值,通过复制产生下一版本:
setProfile((profile) => ({
...profile,
address: {
...profile.address,
city: "杭州",
},
}));
setTodos((todos) =>
todos.map((todo) =>
todo.id === targetId ? { ...todo, completed: true } : todo,
),
);
直接执行 profile.address.city = "杭州" 再交回同一个对象,会破坏对象身份所表示的新旧边界。按身份比较、撤销记录和调试工具都会因此失去可靠前提。
4. 用 State 响应输入
复杂交互不适合用多组布尔值拼接状态。表单请求若同时拥有 isLoading、isSuccess 和 hasError,就可能出现多个值一起为 true。联合类型可以把状态限制在合法分支内:
type FormState =
| { status: "editing" }
| { status: "submitting" }
| { status: "success" }
| { status: "error"; message: string };
事件只负责在这些分支之间转换:输入发生时回到 editing,提交时进入 submitting,请求结束后进入 success 或 error。状态和事件一一对应后,DOM 如何显示便只是渲染结果。
5. 在组件间共享 State
两个组件需要保持同步时,可以把 State 移到最近的共同父组件,再通过 Props 分发。这个过程称为“提升 State”。
function TemperaturePanel() {
const [celsius, setCelsius] = useState(20);
return (
<>
<CelsiusInput value={celsius} onChange={setCelsius} />
<FahrenheitOutput value={(celsius * 9) / 5 + 32} />
</>
);
}
这里的唯一数据源是摄氏温度,华氏温度由它计算得出。若两个输入框分别保存 State,再用 Effect 互相同步,循环更新与精度偏差便很难避免。
State 还与组件在树中的位置绑定。相同类型的组件留在相同位置时会保留 State;类型或 key 改变后,React 会创建新的组件身份,并重置对应 State。表单需要随实体切换而整体重置时,一个随实体变化的 key 通常就能表达意图。
0x05. 更进一步的 State 管理
1. Context
Context 让深层组件读取上方 Provider 提供的值,适合主题、语言、当前用户和依赖注入等需要跨越多层传递的数据。
const ThemeContext = createContext<"light" | "dark">("light");
function App() {
const [theme, setTheme] = useState<"light" | "dark">("light");
return (
<ThemeContext value={theme}>
<Toolbar
onToggleTheme={() =>
setTheme((value) => (value === "light" ? "dark" : "light"))
}
/>
</ThemeContext>
);
}
Context 缩短了传递路径,却不会自动整理状态结构、更新逻辑或渲染性能。只经过一两层的数据继续使用 Props,来源通常更容易追踪。Provider 的值频繁变化时,所有消费者都可能重新渲染,因此不同变化频率、不同职责的数据应拆成独立 Context。
2. Reducer
多个 State 总是一起变化,或者更新分支已经散落到许多事件中时,可以用 useReducer 集中描述状态转换。Action 也会明确记录每次更新“发生了什么”。
type State = { count: number };
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset"; value: number };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
case "reset":
return { count: action.value };
}
}
Reducer 必须保持纯粹:接收当前状态与 Action,返回下一状态。发送请求和写入本地存储都应留在 Reducer 之外。TypeScript 的可辨识联合还能让 switch 的每个分支获得准确类型。
Reducer 与 Context 经常组合使用:前者集中更新规则,后者把状态和 dispatch 传到深层组件。这种组合适合更新较多的局部业务域;状态简单的页面继续使用 useState 即可。
3. Redux?爆金币了。
当状态跨越许多无关页面,更新链路还涉及中间件、时间旅行调试或严格的团队约定时,外部状态库可以把这些规则集中起来。Redux 拥有成熟的单向数据流和工具生态,同时也会引入额外概念与样板。现代项目通常直接使用 Redux Toolkit,省去早期 Redux 的大量模板代码。
引入之前,需要确认这份状态是否真的属于客户端全局,组件组合或 Context 是否已经足够,以及数据是否来自服务端。需要缓存、重试和重新验证的服务端数据,通常应交给 SWR、TanStack Query 或框架的数据层管理,复制进 Redux 只会增加一层同步。
状态库不会替你决定数据归属。把所有数据都放进全局 Store,会让组件之间形成更隐蔽的耦合。局部交互状态留在组件附近,确实出现跨页面共享需求时再提升。
0x06. 锋利的双刃剑:Hook
Hook 让函数组件接入 State、Context、Effect 等 React 能力。除 use 外,Hook 必须在组件或自定义 Hook 的顶层调用,不能放进条件、循环、事件回调或普通函数。稳定的调用顺序让 React 可以把每次 useState、useEffect 对应到同一条内部记录。React 19 的 use 可以出现在条件和循环中,但仍只能在组件或自定义 Hook 内调用,也不能放进 try/catch。
1. 使用 Ref 操作 DOM
聚焦、滚动、选区和媒体播放等命令无法只靠声明式 Props 表达,此时可以用 Ref 取得 DOM 节点:
function SearchForm() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<form>
<input ref={inputRef} type="search" />
<button type="button" onClick={() => inputRef.current?.focus()}>
聚焦搜索框
</button>
</form>
);
}
DOM Ref 应在事件或 Effect 中读写,渲染过程不要依赖 ref.current。节点的文字、类名或显隐仍然适合由 JSX 与 State 描述。
Ref 也能保存不参与渲染的数据,例如计时器 ID、上一次指针位置或第三方实例。如果某个值的变化需要显示在页面上,它就应进入 State。
2. 使用 Effect 与外部系统同步
Effect 负责让组件与 React 之外的系统保持同步,例如 WebSocket、浏览器事件、地图实例、原生媒体 API 或第三方组件。一段 Effect 通常同时包含连接与对称的清理:
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
依赖数组列出的是 Effect 读取的响应式值,不能拿来随意控制执行时机。代码读取了 roomId,依赖中就要包含 roomId。清理函数会在依赖变化、下一次连接建立之前执行,组件卸载时也会执行。
浏览器端可以直接在 Effect 中请求数据,但竞态、取消、缓存、服务端渲染和瀑布请求都要自行处理。框架或数据请求库通常已经覆盖了这些问题。
3. 你真需要用 Effect 吗?
如果没有外部系统,大多数情况都用不到 Effect。下面这些工作各有更直接的位置:
- 根据 Props 或 State 计算展示数据:直接在渲染期间计算。
- 响应一次点击或提交:放进对应事件处理函数。
- 通知父组件输入变化:在同一个事件里调用回调。
- 因实体变化而重置整个子树:使用稳定边界上的
key。 - 缓存昂贵计算:确认瓶颈后使用
useMemo,不要用 Effect 复制结果。
下面的写法会先渲染一次旧值,再由 Effect 触发第二次渲染,同时还要维护两份数据的同步:
// 不需要的 Effect
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
直接写成一行即可:
const fullName = `${firstName} ${lastName}`;
4. 移除对 Effect 的依赖
Lint 提示依赖缺失时,直接删掉依赖项只会掩盖问题。由用户操作触发的逻辑应移入事件,派生数据应回到渲染过程,订阅则保留在 Effect 中,并让建立与清理只读取必要值。
对象和函数在每次渲染时都会获得新身份,作为依赖时可能让 Effect 反复执行。对象可以在 Effect 内创建,与渲染无关的常量则可以移到组件外。缩小 Effect 的职责后,许多 useMemo 和 useCallback 也就没有必要了。
有时 Effect 需要读取最新值,却不希望这个值变化时重新建立外部连接。可以重新划分事件边界,或在支持的 React 版本中用 useEffectEvent 表达非响应式的 Effect 事件。用 Ref 隐藏所有依赖虽然能让 Lint 安静下来,却也隐藏了同步关系。
5. 使用 Memo 做性能优化
useMemo 缓存计算结果,useCallback 缓存函数身份,memo 则可以在 Props 未变化时跳过组件重新渲染。这些 API 只用于性能优化,代码不能依赖缓存来保证正确性。
const visibleRows = useMemo(() => filterRows(rows, query), [rows, query]);
filterRows 确实耗时,或者稳定身份能让下游的 memo、Effect 依赖减少工作时,这层缓存才有收益。缓存本身也要比较依赖、保存值,还会增加阅读成本。
React Compiler 可以在构建期自动加入许多等价的记忆化优化,从而减少手写 Memo。它无法修复不纯组件、错误的数据流或昂贵的业务算法。启用之前仍要检查构建链支持情况,并确认现有代码遵守 React 规则。
6. 你的优化,真有必要吗?没必要。
组件纯粹、State 靠近使用位置、结构清晰之后,再用 React DevTools Profiler 和浏览器性能工具测量。相比一次普通的组件函数调用,错误的全局状态、重复网络请求和巨大的列表 DOM 更容易造成可感知的卡顿。
常见的性能问题包括过早提升 State、把许多高频值塞进同一个 Context、Effect 形成更新链,以及一次渲染创建过多节点。此时应先修正数据所有权和组件边界,在每个函数外套缓存只会遮住问题。
一次有依据的优化应该能说清卡顿发生在什么交互、性能记录把时间花在了哪里,以及改动后指标改善了多少。缺少这些信息时,继续测量比添加缓存更有用。
7. 其他 Hooks
其余常用 Hook 可以按职责归类:
useReducer管理结构化状态转换,useContext读取跨层数据。useTransition和useDeferredValue降低非紧急更新的优先级,保持输入响应。useId生成适合可访问性属性的稳定 ID,不能用来生成列表 key。useSyncExternalStore以并发安全的方式订阅 React 外部 Store。- React 19 的
use可以在支持 Suspense 的边界中读取 Promise 或 Context,但数据源应提供稳定、可缓存的 Promise。
自定义 Hook 复用的是有状态逻辑,每次调用仍拥有独立状态,不会自动创建全局共享 State。名称必须以 use 开头,便于 Lint 和读者识别其中的 Hook 调用。
0x07. React 的底层
1. Compiler
JSX 编译器负责把 JSX 语法转换为 JavaScript;React Compiler 则分析组件与 Hook,自动对值和元素进行记忆化,尽量跳过无意义的重新计算。两者处在不同的编译阶段,也解决不同问题。
React Compiler 已经稳定,目前仍作为可选的构建期能力接入。它依赖代码遵守 React 规则:纯组件、不可变数据和正确的 Hook 调用顺序,都是静态分析成立的前提。Compiler 只优化 React 组件与 Hook 内的计算,也不会生成跨组件共享的通用缓存。
2. Fiber
React 元素是一次渲染得到的不可变描述,Fiber 是 React 在多次渲染之间维护的工作节点。每个 Fiber 对应组件树中的一个位置,记录组件类型、Props、State、子节点关系和待处理工作等信息。
Fiber 通过 child、sibling 和 return 等关系把树拆成可遍历的工作单元。React 使用当前树保存已提交界面,同时在工作树上计算下一候选结果。协调阶段找出变化,提交阶段再把必要修改应用到 DOM。
3. Scheduler
更新有不同的紧急程度。输入回显应该立刻响应,搜索结果或复杂图表则可以稍晚完成。React 按优先级组织更新,渲染阶段可以暂停、继续,也可以放弃已经过时的工作。
startTransition 不会缩短计算时间,它只是告诉 React 这次更新可以让位给更紧急的交互。渲染阶段允许中断,提交阶段必须一次完成,用户不会看到只更新了一半的组件树。
因此,组件函数可能已经执行,其结果却从未提交。React 有权重试或丢弃尚未提交的计算,渲染代码也就必须保持纯粹。
4. useState 的原理
从概念上看,函数组件对应的 Fiber 保存着一组按调用顺序排列的 Hook 记录。首次渲染时,useState 创建状态与更新队列;后续渲染时,React 按相同顺序找到原记录,再依次应用队列中的更新,得到本次渲染使用的 State。
setState 会把更新加入队列,标记相关 Fiber 有待处理工作,并安排一次渲染;当前函数里的变量不会随调用发生变化。React 根据更新优先级决定处理时机。若 Object.is 判断新旧值相同,后续工作还可能被跳过。
这套内部实现可能随版本变化,业务代码不应依赖其中细节。它仍能帮助我们理解两条公开规则:Hook 必须保持调用顺序;State 是渲染快照,Setter 只负责安排下一次更新。
0x08. 实际应用中的 React
1. Next.js
完整应用除了客户端组件,还需要路由、构建、数据获取、服务端渲染、缓存和部署约定。React 官方建议新应用从框架开始,Next.js 是生态中常见的选择。
在 Next.js App Router 中,组件默认运行在服务端。Server Component 可以直接读取服务端数据,并减少发送到浏览器的 JavaScript。只有需要 State、事件或浏览器 API 的交互边界才使用 "use client"。Client Component 的范围太大会增加客户端负担,拆得太碎又会让一次小交互跨越许多文件,边界应跟随实际交互划分。
Next.js 的路由、缓存和渲染模型也需要额外学习。对于已有独立 API 的纯客户端后台,Vite 加客户端路由可能更直接。部署方式与数据来源决定框架选择,流行程度不应成为唯一理由。
2. SWR 与 Alova.js
服务端状态要处理请求去重、缓存、失效、重试、分页、乐观更新和竞态,本地 UI State 很少面对这些问题。SWR 用较小的 API 实现 stale-while-revalidate 数据获取模型;Alova.js 提供的请求策略更多,也支持多个前端框架。
同一份远程数据不应同时复制到 useState、Context 和全局 Store。缓存由请求库管理,组件只保存筛选条件、选中项和弹窗状态等本地交互数据。在 Next.js 等全栈框架中,还要判断数据能否直接在服务端读取,许多请求无须再经过客户端 Hook。
选择请求库时,应检查它如何接入当前渲染模式,怎样定义缓存失效,是否妥善处理取消与竞态,以及 TypeScript 类型是否完整。API 数量和项目适配程度没有必然关系。
3. Zustand 与 Jotai
Zustand 和 Jotai 都提供了较轻的客户端共享状态方案。Zustand 以 Store 和 Selector 为中心,适合组织一组相关状态与动作;Jotai 通过 Atom 组合状态,适合细粒度依赖和自下而上的派生关系。
两者的起步代码都少于 Redux,但 Store 放在哪里、哪些状态允许全局修改、持久化数据如何迁移、服务端渲染如何隔离,仍需要明确约定。局部 State 足以覆盖大多数页面交互;出现跨页面共享、脱离组件树访问或复杂订阅后,再引入外部 Store。
回看全文,React 的开发顺序并不复杂:用纯组件描述 UI,用 Props 接收输入,用 State 保存最少的组件记忆,再沿单向数据流组织交互。与外部系统同步时使用 Effect,测到性能问题后再考虑 Memo;Context、Reducer 和状态库留给确实跨越组件边界的数据。
React 的 API 还会变化,数据所有权与纯渲染却很少过时。掌握这两点后,新的 Hook、Compiler 和框架能力都能放回同一套模型中理解。