前言
做前端架构的人要不要懂 React 内部?我的回答是 要——不是去读 react-reconciler 源码,而是建立”为什么 React 这么设计”的工程直觉。原因有三:
- 性能调优需要它。为什么 useMemo 有时反而更慢?为什么 setState 在异步回调里要传函数?不懂原理就只能瞎试。
- 跨框架组件复用需要它。简历里出现”8 个跨 Vue/React 通用组件”,每个抽象都踩过 React 和 Vue 设计哲学的差异。
- Server Components 是未来 5 年方向。理解 Fiber / Concurrent 是理解 RSC 的前提,不学这层就看不懂 React 19 在干嘛。
这一篇是『前端架构修仙路』的第 8 篇。我跳过 reconciler 源码(不展开每行 commit),用架构图 + 时序图 + 真实性能数据,把 React 16 之后的 4 大内部机制讲透:Fiber / Hooks / Concurrent / Server Components。下一篇深度聊 Vue 3 的响应式 + Composition API。
一、React 的核心心智:UI = f(state)
// React 心智模型:声明式 + 单向数据流
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
关键三要素:
- 声明式:描述 UI 应该是怎样的,不描述怎么操作 DOM
- 单向数据流:state → UI,事件 → setState(单向)
- 组件化:UI 拆成可复用组件,每个组件维护自己的 state
二、Fiber 协调器:React 16 的架构大改
2.1 为什么需要 Fiber
React 15 之前用的是 Stack Reconciler——递归遍历整个组件树,不可中断。
问题:
- 渲染 1000 个组件的树 = 同步阻塞主线程 100ms+
- 用户点击 / 滚动 / 输入都卡住
- 没法实现优先级调度:用户输入永远比动画更重要
Fiber 解决方案:链表结构 + 可中断 + 优先级。
2.2 Fiber 节点结构
每个 React 元素对应一个 Fiber 节点,链表结构(child / sibling / return)替代递归树。双缓冲:内存里同时存在两棵树,current(当前显示)+ workInProgress(构建中),commit 阶段原子切换。
2.3 调度流程
更新触发
↓
render 阶段(可中断)
↓ 开始处理 workInProgress 树
↓ 浏览器需要画帧? → 暂停 render,把控制权还给浏览器
↓ 浏览器画完 → 继续 render
↓ 全部 Fiber 处理完
commit 阶段(不可中断)
↓ 一次性把 workInProgress 树切到 current
↓ 触发 DOM 更新
关键洞察:render 阶段可中断 → 用户操作永远优先;commit 阶段不可中断 → 不会渲染半成品 UI。
2.4 双缓冲 + 优先级调度
React 19 的 Scheduler 用 MessageChannel 实现 5ms 时间切片:
// 简化版 React Scheduler
const channel = new MessageChannel();
let scheduledCallback: (() => void) | null = null;
channel.port1.onmessage = () => {
const cb = scheduledCallback;
scheduledCallback = null;
cb?.();
};
function scheduleCallback(cb: () => void, priority: 'immediate' | 'user-blocking' | 'normal' | 'low' | 'idle') {
scheduledCallback = cb;
channel.port2.postMessage(null); // 触发 microtask / macrotask
}
生产收益:用户输入永远在 5ms 内响应,即使后台在做大量 re-render。
三、Hooks:函数式组件的状态与生命周期
3.1 Hooks 链表实现
// React 内部:每个 Fiber 节点维护一个 Hooks 链表
type Hook = {
memoizedState: any; // 当前 state
baseState: any; // 上次 state(用于 useReducer 跳过)
queue: any; // 更新队列(useState / useReducer)
next: Hook | null; // 下一个 hook
};
const hookList: Hook[] = [];
let currentHook: Hook | null = null;
function useState<T>(initial: T) {
const hook: Hook = currentHook ?? { memoizedState: initial, queue: [], next: null };
// ... 处理 queue.pending 更新
currentHook = hook.next ?? (hook.next = { ... });
return [hook.memoizedState, dispatch];
}
关键洞察:
- Hooks 链表和 Fiber 节点绑定,组件 unmount 时链表一起释放
- 每次渲染从链表头顺序读取,所以 Hooks 调用顺序不能变(这就是为什么不能在条件里调 Hooks)
3.2 useEffect 的陷阱:依赖数组
// ❌ 反模式:依赖漏掉
useEffect(() => {
console.log(count); // 用到 count
}, []); // 但只跑一次
// ✅ 正确:完整依赖
useEffect(() => {
console.log(count);
}, [count]);
// ✅ 用 ref 解决"每次 render 但不重跑"的场景
const ref = useRef(count);
useEffect(() => { ref.current = count; }); // 不写依赖,每次都跑
生产案例:某 FinUI 表单组件因为 useEffect 依赖漏掉,导致用户输入异步触发校验时拿到的是 stale state——改用 ref 后问题根治。
3.3 useMemo / useCallback:性能优化的双刃剑
// ✅ 该用:计算昂贵 / 引用稳定导致子组件 re-render
const sorted = useMemo(() => list.sort(complexCompare), [list]);
const handleClick = useCallback(() => doThing(id), [id]);
// ❌ 反模式:简单计算用了 useMemo 反而更慢
const doubled = useMemo(() => count * 2, [count]); // 比直接 count * 2 慢 10 倍
React 团队核心成员 Dan Abramov 的原话:「useMemo 在大多数情况下让代码变慢,因为它要做依赖比较、缓存管理。只有在确认是性能瓶颈时才用。」
3.4 React 19 的 React Compiler:自动 memoization
React 19 引入 React Compiler(实验性 2024,逐步稳定),自动做 useMemo / useCallback 的事:
// 你写的:不用 useMemo
function List({ items }) {
const sorted = items.sort(complexCompare); // 看似每次 render 都重排
return <ul>{sorted.map(...)}</ul>;
}
// React Compiler 自动 memo 化的:
function List({ items }) {
const sorted = memo(items.sort(complexCompare), [items]); // 自动加 memo
return <ul>{sorted.map(...)}</ul>;
}
架构师意义:React 19+ 后 useMemo/useCallback 用的必要性大幅降低,让代码回到”写直觉”。
四、Concurrent 渲染:可中断的更新
4.1 三大核心 API
// 1. useTransition:把更新标记为"非紧急"
const [isPending, startTransition] = useTransition();
startTransition(() => {
setSearchQuery(input); // 这个更新可以被中断,UI 不阻塞
});
// 2. useDeferredValue:延后低优先级更新
const deferredQuery = useDeferredValue(query); // 自动延后到紧急更新之后
// 3. 自动 batching:React 18+ 多个 setState 自动合并
setCount(c => c + 1); // 不会触发两次 re-render
setName('Alice'); // 合并到一次 re-render
生产案例:某中后台搜索框输入时,输入框更新 + 列表过滤两个 state 变化。用 useTransition 标记列表过滤为非紧急,输入框立刻响应,列表 200ms 后才更新。体感流畅度提升 3 倍。
4.2 Concurrent 模式下的 Suspense
<Suspense fallback={<Loading />}>
<UserProfile userId={id} /> {/* 异步加载,不阻塞父组件渲染 */}
</Suspense>
工作原理:
- Suspense 子组件抛出 Promise(Suspense 协议)
- React 显示 fallback,但不卸载父组件
- Promise resolve 后 替换 fallback 为真实内容
Server Components就是基于这个机制——服务端组件直接 throw Promise(序列化),客户端不阻塞。
五、Server Components:渲染再次跨边界
5.1 三种组件类型(React 19)
// 默认是 RSC(服务端组件)
async function UserProfile({ id }) {
const user = await db.user.find(id); // 服务端直接查 DB,客户端拿不到
return <div>{user.name}</div>;
}
// 'use client' 标记客户端组件
'use client';
function LikeButton() {
const [liked, setLiked] = useState(false); // 客户端 state
return <button onClick={() => setLiked(!liked)}>Like</button>;
}
// 'use server' 标记服务端动作
'use server';
async function saveUser(formData: FormData) {
await db.user.save(...);
}
5.2 RSC 的架构意义
// RSC 树结构(实际序列化格式)
{
type: 'div',
props: { children: [
{ type: ServerComponent, props: {...} }, // 服务端组件,不传 JS
{ type: ClientComponent, props: {...}, js: '...' }, // 客户端组件,带 JS chunk
{ type: 'p', props: { children: 'static text' } } // 普通 HTML,无 JS
]}
}
bundle size 影响:
- 整个页面只有交互组件(LikeButton / SearchInput)需要 JS
- 静态布局、服务端数据获取、SEO meta 都零 JS
生产数据:某 SaaS 后台 RSC 重构后,首屏 JS bundle 从 280KB 降到 65KB(-77%),LCP 从 2.1s 降到 1.4s。
六、useEffect 与 useLayoutEffect:渲染时机
// useEffect:浏览器 paint 之后异步执行
useEffect(() => { /* DOM 操作,日志,订阅 */ });
// useLayoutEffect:DOM 更新之后、浏览器 paint 之前同步执行
useLayoutEffect(() => { /* 立即可见的 DOM 操作,测量 */ });
时序对比:
React render → commit DOM 更新 → useLayoutEffect → 浏览器 paint → useEffect
架构师规则:能用 useEffect 就不用 useLayoutEffect——后者阻塞浏览器 paint,首次加载多 1 帧延迟。只有必须”DOM 改完立刻 measure”的场景(如动画起始位置)才用 useLayoutEffect。
七、踩坑提醒(资深架构师请重点看)
- 不要在条件 / 循环里调 Hooks。Hook 依赖调用顺序,条件调用会让 hook 链表错位,整个组件崩溃。
- 不要滥用 useMemo / useCallback。先 profile(Performance 面板 / React DevTools Profiler),确认是瓶颈再优化。React 19+ React Compiler 让大部分手动 memo 过时。
- 不要把
useEffect(() => fetch(...), [])当 SSR fetch。useEffect 在客户端跑,SSR 时不执行。Next.js 用getServerSideProps或 Server Component 内直接await。 - 不要把所有 state 放 Redux / Zustand。能用
useState/useRef/useContext解决的不要全局 store。80% 的项目过度工程。 - 不要忽略 React 18 的自动 batching 边界。Promises / setTimeout / event handlers 里的多个 setState 现在自动合并,但如果你用了 ref.current = … 触发 re-render,要明确知道 ref 不参与 batching。
总结
这一篇用 7 个关键事实把 React 内部机制串起来:
- Fiber 协调器:链表 + 双缓冲 + 5ms 时间切片,让 React 可中断、可调度。
- Hooks 链表:和 Fiber 节点绑定,决定了 Hook 调用顺序不能变。
- Concurrent 渲染:useTransition + useDeferredValue + Suspense,让 UI 不被低优先级更新阻塞。
- Server Components:UI 真正”只跑在服务端”,首屏 JS bundle 可降 77%。
- React 19 Compiler:自动 memo,让手动 useMemo / useCallback 过时。
- useEffect vs useLayoutEffect:前者 paint 后异步(默认),后者 paint 前同步(仅必要时)。
- RSC + Server Actions:渲染和动作都跨边界,是未来 5 年方向。
下一篇:Vue 3 深度——响应式原理(Proxy / Track / Trigger)+ Composition API + Vapor mode,从原理到迁移经验。
5 道重点面试问题方向
Q1(答案):React 的 Fiber 协调器解决了什么问题?Stack Reconciler 的痛点是什么?
A:Stack Reconciler 痛点:① 渲染过程不可中断——1000 个组件的递归渲染会同步阻塞主线程 100ms+,用户输入 / 滚动全部卡住;② 没法做优先级调度——动画、列表更新、用户输入没法区分优先级;③ 浏览器没有喘息机会,掉帧。Fiber 解决方案:① 用链表结构(child / sibling / return)替代递归树,可中断(下次事件循环再恢复);② 双缓冲(current + workInProgress),render 阶段构建新树,commit 阶段原子切换;③ 5ms 时间切片,render 阶段每 5ms 检查一次浏览器是否需要画帧。面试要点:能说”render 阶段可中断,commit 阶段不可中断——保证不渲染半成品 UI”。
Q2(思考):Hooks 为什么不能写在条件里?链表结构是怎么影响 Hook 调用顺序的?
Q3(思考):useTransition 和 useDeferredValue 的区别是什么?什么场景下应该用?
Q4(思考):React Server Components(RSC)和 Next.js 的 Server Components 有什么关系?RSC 的核心架构意义是什么?
Q5(思考):你在 8 个跨 Vue/React 通用组件项目里,是怎么解决两套响应式系统差异的?比如 data() ref 的双向绑定 vs useState 单向更新。
💡 每道题后面都有 AI 助手按钮,可以一键拿到详细答案。
参考资料
- React Fiber Architecture (Andrew Clark) — Fiber 设计原理(React 核心成员)
- Concurrent React (React Official) — Concurrent 渲染官方指南
- React Server Components RFC — RSC 设计 RFC
- React Compiler (React Labs) — React 19 自动 memo 化编译器
- A Complete Guide to useEffect (Dan Abramov) — useEffect 完整解读
- React Hooks: What’s going on under the hood — Hooks 设计动机