React 是一个用于构建用户界面的 JavaScript 库。它的核心思想是:用组件描述 UI,用状态描述变化,再由 React 负责把状态变化反映到宿主环境中。
React 本身主要负责组件、元素、Hooks 和更新模型;浏览器 DOM 的挂载与更新由 react-dom 负责,React Native 则有另一套宿主实现。因此,“React 只负责视图”是一个便于入门的概括,更准确地说,React 负责 UI 的声明式描述和更新协调。
新项目可以使用 Vite 创建:
npm create vite@latest react-demo -- --template react
cd react-demo
npm install
npm run dev
典型的依赖关系如下:
react 组件、元素、Hooks、更新模型
react-dom 将 React 树挂载到浏览器 DOM
一次更新可以抽象成以下过程:
状态或属性变化
|
v
触发更新调度
|
v
Render 阶段:调用组件,计算新的 React 元素树
|
v
比较新旧树,得到需要执行的变更
|
v
Commit 阶段:修改 DOM、执行 ref 和 effect 等副作用
Render 阶段应尽量保持纯粹,因为在并发渲染下它可能被暂停、重做或放弃。真正修改 DOM 的工作属于 Commit 阶段。
JSX 是 JavaScript 的语法扩展,允许我们用接近 HTML 的形式描述 UI。它不是字符串模板,也不是 HTML 文件,而是最终会被编译为 JavaScript 表达式。
// JSX 描述页面结构,编译后会生成 React 元素。
const element = (
<h1 className="title" style={{ color: "red" }}>
React
</h1>
);
在经典 JSX 转换下,它大致等价于:
// 经典 JSX 转换通过 createElement 生成元素对象。
const element = React.createElement(
"h1",
{ className: "title", style: { color: "red" } },
"React"
);
现代 JSX 转换通常使用 react/jsx-runtime,不一定需要在每个文件顶部显式导入 React。JSX 最终会产生 React 元素。
元素是描述 UI 的普通 JavaScript 数据。它记录类型、属性和子节点,React 根据这些描述创建或更新宿主节点。
{
type: "h1",
props: {
className: "title",
style: { color: "red" },
children: "React"
}
}
元素是不可变的描述对象。调用 setState 并不是修改旧元素,而是让 React 重新调用相关组件,得到一棵新的元素树,再协调两棵树之间的差异。
createElement 的核心思路createElement 的核心过程可以概括为:
const REACT_ELEMENT = Symbol.for("react.element");
function createElement(type, config, ...children) {
// props 保存节点或组件渲染所需的数据。
const props = { ...config };
props.children = children.length <= 1 ? children[0] : children;
return {
// React 通过这个标识确认对象是 React 元素。
$$typeof: REACT_ELEMENT,
type,
key: config?.key ?? null,
ref: config?.ref ?? null,
props
};
}
除了生成元素对象,React 还会处理保留属性、默认属性、开发环境校验和不同类型的 children。组件最终返回的是一份可被 React 识别的 UI 描述。
每次渲染中的 state 都是该次渲染的快照:
function Counter() {
const [number, setNumber] = useState(0);
function handleClick() {
// 三次调用都读取当前渲染中的同一个 number 快照。
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}
return <button onClick={handleClick}>{number}</button>;
}
上面的三次更新都读取同一个 number,因此通常只得到 1。如果下一次状态依赖上一次状态,应使用函数式更新:
// 函数式更新会在处理队列时读取最新状态。
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);
批量更新是指 React 将同一时机内的多个状态更新合并处理,减少重复渲染。React 18 之后,批处理范围扩展到了 Promise、定时器和原生事件等场景;flushSync 可以在确有必要时强制同步提交,但不应作为普通更新手段。
批处理的核心过程可以表达为:
const updateQueue = {
// 事件处理期间收集更新,事件结束后统一刷新。
isBatching: false,
updaters: new Set(),
flush() {
this.isBatching = false;
// 每个 updater 对应一个需要重新计算的组件。
for (const updater of this.updaters) updater.updateComponent();
this.updaters.clear();
}
};
class Updater {
constructor(instance) {
this.instance = instance;
this.pendingStates = [];
}
enqueue(partialState) {
// 更新先进入队列,不直接修改已经显示的 DOM。
this.pendingStates.push(partialState);
if (updateQueue.isBatching) {
updateQueue.updaters.add(this);
} else {
this.updateComponent();
}
}
updateComponent() {
// 合并队列中的状态,并安排组件重新渲染。
}
}
批量更新的基本逻辑是先把更新放入队列,再由调度器统一处理;Fiber 更新队列还会根据更新优先级安排具体的渲染时机。
React 为事件提供统一的事件对象和传播行为。早期 React 主要通过在根节点或 document 上注册少量监听器,再根据事件目标向上查找 React 处理函数来实现委托;现代 React 的具体委托节点和事件细节已经发生变化,因此不应把“必然挂在 document 上”当作当前实现的结论。
事件委托的核心过程可以表达为:
function addEvent(dom, eventName, handler) {
// 节点保存最新处理函数,派发事件时读取它。
const store = dom._reactHandlers || (dom._reactHandlers = {});
store[eventName] = handler;
const nativeType = eventName.slice(2).toLowerCase();
if (!delegatedEvents.has(nativeType)) {
delegatedEvents.add(nativeType);
document.addEventListener(nativeType, dispatchEvent);
}
}
const delegatedEvents = new Set();
function dispatchEvent(nativeEvent) {
// 同一事件中的多个更新可以在这里统一处理。
updateQueue.isBatching = true;
const eventName = `on${nativeEvent.type}`;
let node = nativeEvent.target;
while (node) {
// 从事件目标向父节点查找处理函数,模拟冒泡过程。
const handler = node._reactHandlers?.[eventName];
if (handler) handler(nativeEvent);
if (nativeEvent.cancelBubble) break;
node = node.parentNode;
}
updateQueue.flush();
}
这里要注意三个概念:target 是最初触发事件的节点,currentTarget 是当前正在执行处理函数的节点,事件冒泡是从内到外传播。实际项目中应使用 React 提供的事件对象,不要依赖内部字段。
类组件的生命周期可以按“挂载、更新、卸载”理解。componentDidMount、componentDidUpdate 和 componentWillUnmount 分别适合处理挂载完成、更新完成和卸载清理。函数组件则通常使用 Hooks 表达同样的同步关系。
class Counter extends React.Component {
// 类组件的状态保存在实例上,setState 会触发新的渲染。
state = { number: 0 };
componentDidMount() {
// 组件首次提交到 DOM 后执行。
document.title = `点击了 ${this.state.number} 次`;
}
componentDidUpdate() {
// 组件更新提交完成后同步页面标题。
document.title = `点击了 ${this.state.number} 次`;
}
componentWillUnmount() {
// 清理订阅、定时器和外部资源
}
render() {
// render 只负责根据当前 state 返回元素描述。
return (
<button onClick={() => this.setState(({ number }) => ({ number: number + 1 }))}>
{this.state.number}
</button>
);
}
}
shouldComponentUpdate 可以阻止一次更新,但不应随意返回条件表达式来做实验性优化。需要优化时,应明确比较 props/state,或使用 PureComponent、memo,并通过性能分析确认收益。
早期 React 的递归协调过程一旦开始就难以暂停。组件树较大或更新复杂时,主线程可能长时间被占用,造成输入和动画卡顿。
Fiber 可以看作一种可暂停的工作单元,也是一棵带有更多运行时信息的链表式树。一个 Fiber 通常包含:
type 当前节点类型
key 节点身份
pendingProps / memoizedProps
memoizedState
child 第一个子节点
sibling 下一个兄弟节点
return 父节点
alternate 当前树与工作树之间的对应节点
flags 需要执行的副作用标记
Render 阶段会从根 Fiber 开始,调用函数组件或类组件,协调子节点,建立新的工作树,并标记插入、更新、删除等操作。这个阶段可以被打断,因此组件 render 函数不应执行网络请求、修改 DOM 或写入不可逆的外部状态。
Commit 阶段把 Render 阶段的结果一次性应用到宿主环境。DOM 变更、ref 更新和 effect 的安排都与这个阶段有关。useLayoutEffect 会在浏览器绘制前执行,适合读取布局并同步修正;useEffect 通常在浏览器绘制后执行,适合订阅、请求和日志等不需要阻塞绘制的副作用。
React 不会对两棵任意树做通用的最优编辑距离计算,而是基于一些启发式规则降低复杂度:
key 用于标识同级节点的身份,帮助 React 判断插入、删除和移动。key 必须稳定、唯一且与数据身份对应:
items.map(item => <Row key={item.id} item={item} />)
不建议使用数组下标作为 key,尤其是列表会插入、删除或排序时。错误的 key 会导致组件状态跟错数据。key 只参与 React 的协调,不会自动出现在组件的 props 中。
列表协调的核心过程可以抽象为:
function reconcileChildren(oldChildren, newChildren) {
// 先按 key 建立索引,便于在新列表中查找可复用的旧节点。
const oldByKey = new Map(
oldChildren.map((child, index) => [child.key ?? index, child])
);
const operations = [];
let lastPlacedIndex = 0;
newChildren.forEach((nextChild, nextIndex) => {
const key = nextChild.key ?? nextIndex;
const oldChild = oldByKey.get(key);
if (!oldChild) {
// 新节点没有对应的旧节点,需要插入 DOM。
operations.push({ type: "INSERT", child: nextChild, index: nextIndex });
return;
}
// 找到相同 key 后复用节点,只更新它的内容或位置。
operations.push({ type: "UPDATE", oldChild, child: nextChild });
if (oldChild.index < lastPlacedIndex) {
// 旧位置落后于已处理节点,说明该节点需要向前移动。
operations.push({ type: "MOVE", oldChild, index: nextIndex });
}
lastPlacedIndex = Math.max(lastPlacedIndex, oldChild.index);
oldByKey.delete(key);
});
for (const child of oldByKey.values()) operations.push({ type: "DELETE", child });
return operations;
}
Fiber 协调会结合元素类型、key、旧 Fiber、flags 和宿主配置完成更新。
Context 用于在组件树中跨层传递相对稳定的共享数据,例如主题、当前用户或语言。它不是通用状态管理器,也不能替代所有 props 设计。
// Provider 为子树提供主题值,Toolbar 不需要通过 props 接收它。
const ThemeContext = createContext("light");
function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar() {
const theme = useContext(ThemeContext);
return <button className={theme}>保存</button>;
}
Context 的核心模型是:创建一个 context 对象,Provider 为当前子树提供值,Consumer 或 useContext 读取距离自己最近的 Provider 值。当 Provider 的 value 发生变化时,订阅它的消费者会重新渲染。因此应注意 value 的引用稳定性:
const value = useMemo(() => ({ state, dispatch }), [state, dispatch]);
return <CounterContext.Provider value={value}>{children}</CounterContext.Provider>;
Context 的基本数据结构可以表示为:
function createContext(defaultValue) {
// context 保存默认值,并通过 Provider/Consumer 暴露读写边界。
const context = { _currentValue: defaultValue };
context.Provider = { $$typeof: "provider", _context: context };
context.Consumer = { $$typeof: "consumer", _context: context };
return context;
}
Provider 嵌套时,读取值的组件会沿组件树找到最近的 Provider;Provider 更新后,React 会传播变化并安排消费者重新渲染。
React 通常按调用顺序把每个 Hook 与当前 Fiber 关联起来:第一次调用对应第一个槽位,第二次调用对应第二个槽位。因此以下写法会让槽位错位:
// 错误示例
if (enabled) useState(0);
// 条件变化后,下面的 Hook 可能读取到错误的状态槽位。
useEffect(() => {});
Hooks 必须在函数组件或自定义 Hook 的顶层调用,不能放在条件、循环、事件处理函数或普通函数中。
useState 与 useReducer可以把 useState 理解为一个特殊的 reducer:
function basicStateReducer(state, action) {
return typeof action === "function" ? action(state) : action;
}
useReducer 需要保存状态槽位,并让 dispatch 把 action 放入下一次更新:
// 普通 action 会交给 reducer,根据当前状态计算下一状态。
function useReducer(reducer, initialState) {
// hookIndex 保证每次渲染按相同顺序访问同一个状态槽位。
const index = hookIndex++;
if (!(index in hookStates)) hookStates[index] = initialState;
function dispatch(action) {
// reducer 根据旧状态和 action 计算新状态,再安排组件更新。
hookStates[index] = reducer(hookStates[index], action);
scheduleUpdate();
}
return [hookStates[index], dispatch];
}
在 Fiber 中,状态更新会进入对应 Hook 的更新队列,并由调度器按照优先级处理。
useMemo 与 useCallbackuseMemo 缓存计算结果,useCallback 缓存函数本身。它们不是“阻止组件渲染”的开关,只有在计算昂贵、子组件使用 memo 且引用稳定确实有收益时才值得使用。
依赖项使用 Object.is 进行比较,依赖数组本身也必须与 Hook 调用保持稳定:
const requirements = useMemo(() => computeRequirements(product), [product]);
// 只有 product.id 改变时才创建新的提交函数。
const handleSubmit = useCallback(
details => post(`/product/${product.id}`, details),
[product.id]
);
依赖比较和缓存过程可以表示为:
function areEqual(nextDeps, prevDeps) {
// 只有依赖数量相同且每一项都保持 Object.is 相等时才复用缓存。
return nextDeps?.length === prevDeps?.length &&
nextDeps.every((value, index) => Object.is(value, prevDeps[index]));
}
function useMemo(factory, deps) {
// 每个 useMemo 都通过自己的索引保存缓存值和依赖项。
const index = hookIndex++;
const previous = hookStates[index];
if (previous && areEqual(deps, previous.deps)) return previous.value;
const value = factory();
hookStates[index] = { value, deps };
return value;
}
function useCallback(callback, deps) {
return useMemo(() => callback, deps);
}
useContextuseContext(Context) 读取最近 Provider 的值,并订阅该值的变化:
function useContext(context) {
// 当前值由最近的 Provider 提供。
return context._currentValue;
}
读取 Context 只是消费值;Provider 更新时,React 还会沿 Fiber 关系传播变化并安排消费者重新渲染。
useEffect 与 useLayoutEffectEffect 用于把 React 的声明式渲染与外部系统连接起来,例如订阅、定时器、手动 DOM API 或网络连接。Effect 应返回清理函数:
// Effect 建立与房间的连接,返回的函数负责清理旧连接。
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
依赖数组的含义:不传表示每次提交后运行,传 [] 表示只依赖初次挂载时捕获的值,传具体依赖表示依赖变化后重新运行。开发环境严格模式可能额外执行一次 setup/cleanup,以帮助发现清理不完整的问题。
useLayoutEffect 在浏览器绘制前运行,适合测量布局和避免闪烁;它会阻塞绘制,不能无条件替代 useEffect。
Effect 的基本调度过程可以表示为:
function useEffect(callback, deps) {
// 依赖未变化时保留原 effect,不重复建立外部连接。
const index = hookIndex++;
const previous = hookStates[index];
if (previous && areEqual(deps, previous.deps)) return;
previous?.cleanup?.();
// 把副作用安排到渲染提交之后执行。
setTimeout(() => {
const cleanup = callback();
hookStates[index] = { cleanup, deps };
});
}
Effect 会在提交阶段按照依赖关系建立和清理外部同步关系。
路由的本质是把 URL 映射为组件树,并在 URL 改变时触发更新。一个最小路由器通常需要四件事:读取当前位置、匹配路由、导航而不刷新页面、监听前进后退。
function navigate(to) {
// pushState 修改 URL,但不会自动触发 React 组件更新。
window.history.pushState({}, "", to);
// 手动派发事件,让订阅当前位置的组件重新读取 URL。
window.dispatchEvent(new PopStateEvent("popstate"));
}
pushState 会修改地址栏但不会自动触发 popstate,所以自定义路由器需要主动通知订阅者。浏览器前进、后退时则会触发 popstate。
function useLocation() {
// 用字符串保存当前位置,确保 URL 改变时 state 引用也会改变。
const readLocation = () =>
window.location.pathname + window.location.search + window.location.hash;
const [location, setLocation] = React.useState(readLocation);
React.useEffect(() => {
// 浏览器前进或后退时,重新读取地址并触发渲染。
const onPopState = () => setLocation(readLocation());
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
return location;
}
function Router({ routes }) {
const location = useLocation();
const pathname = location.split(/[?#]/, 1)[0];
// 找到匹配路径后渲染对应页面,找不到则显示 404。
const route = routes.find(item => match(item.path, pathname));
return route ? route.element : <NotFound />;
}
function Link({ to, children }) {
return (
<a href={to} onClick={event => {
// 保留浏览器的新标签页、快捷键和辅助点击行为。
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
// 单页导航只更新 URL 和 React 状态,不重新加载文档。
navigate(to);
}}>
{children}
</a>
);
}
完整路由系统还会支持动态参数、嵌套路由、查询参数、懒加载、数据加载、错误边界、滚动恢复和服务端部署回退。
使用 History API 的单页应用在 /users/1 刷新时,服务器可能把它当作真实文件路径。如果服务器没有将未知路径回退到 index.html,就会返回 404。部署时必须配置静态服务器回退规则;如果无法配置,也可以考虑 Hash 路由,但它会牺牲更自然的 URL 体验。
createRoot 到 commitRoot浏览器应用的一次更新可以沿着下面的主链路阅读:
createRoot
-> updateContainer
-> scheduleUpdateOnFiber
-> ensureRootIsScheduled
-> performConcurrentWorkOnRoot
-> renderRootConcurrent
-> workLoopConcurrent
-> beginWork / completeWork
-> commitRoot
updateContainer 会把新的 React 元素树包装成一个更新对象,放进根 Fiber 的更新队列,再请求调度器安排工作。Render 阶段通过 beginWork 向下处理节点,通过 completeWork 向上完成节点;Commit 阶段则根据 Fiber 上的 flags 执行插入、删除和属性更新。
阅读源码时可以重点追踪三个问题:
React 通常同时维护两棵 Fiber 树:已经提交到屏幕的 current 树,以及正在构建的 workInProgress 树。两个节点通过 alternate 互相指向。
current root <---- alternate ----> workInProgress root
| |
已显示的树 正在计算的树
如果 Render 阶段被打断,用户仍然看到旧的 current 树;只有 Commit 成功后,workInProgress 才会成为新的 current。这就是 React 能够在不破坏当前界面的前提下暂停和重做工作的基础。
Fiber 的 child、sibling、return 结构把递归树转换成可迭代的工作链:
beginWork(child)
-> child 的 sibling
-> 没有 sibling 时返回 return
-> completeWork(parent)
React 使用 Lane 表示更新优先级。Lane 本质上是一组二进制位,因此可以同时记录多个待处理优先级,并通过位运算合并、筛选和标记已完成工作。
可以把它理解成两类页面变更:点击反馈和输入响应属于必须及时完成的紧急变更,搜索结果或复杂列表计算属于可以延后完成的非紧急变更。紧急更新可以打断非紧急更新,非紧急更新完成后再提交。
const [isPending, startTransition] = useTransition();
function handleChange(event) {
setText(event.target.value); // 紧急:保证输入框立即响应
startTransition(() => {
setFilteredItems(expensiveFilter(event.target.value));
});
}
并发渲染不是多线程,也不意味着 JavaScript 可以同时执行两段代码。它主要依靠可暂停的工作循环和优先级调度,把长任务拆成多个可让出主线程的工作单元。
函数组件的 Hooks 状态并不是简单挂在函数对象上,而是挂在当前 Fiber 的 memoizedState 链表上:
Fiber.memoizedState
-> Hook(useState)
-> Hook(useEffect)
-> Hook(useMemo)
一个 Hook 节点通常会保存 memoizedState、基础状态、更新队列和 next 指针。组件每次执行时,Dispatcher 按调用顺序依次读取这些节点,因此 Hooks 不能写进条件分支。
setState 也不是立即改值,而是创建一个更新节点放入队列。更新节点可能包含普通值,也可能包含函数:
setCount(1);
setCount(n => n + 1);
React 会在处理队列时依次计算结果。函数式更新读取队列当时的最新状态,所以在异步回调和多次连续更新中更加可靠。
当组件在 Render 阶段遇到尚未完成的数据或懒加载模块时,可以暂时“挂起”。最近的 Suspense Boundary 会显示 fallback,待资源完成后 React 再安排一次重试。
<Suspense fallback={<Loading />}>
<LazyPanel />
</Suspense>
Suspense 不是一个普通的 try/catch,它与 Fiber 的异常路径、重试队列、Lane 和 Commit 逻辑共同工作。它的价值在于:加载中的局部子树可以拥有自己的占位 UI,而不必阻塞整个页面。
服务端渲染先在服务器生成 HTML,浏览器显示 HTML 后,客户端通过 hydrateRoot 为已有 DOM 接管事件和交互:
// 服务端已经生成 HTML 时,客户端使用 hydrateRoot 接管现有 DOM。
hydrateRoot(document.getElementById("root"), <App />);
Hydration 要求服务端和客户端首次渲染结果尽量一致。随机数、当前时间、仅浏览器存在的对象和不同的环境变量,都可能造成 hydration mismatch。
Server Components 是另一层能力:部分组件只在服务器执行,客户端接收序列化后的结果和必要的交互边界。它们不等同于 SSR,也不等同于把所有组件都变成异步组件。需要 state、effect 或浏览器 API 的部分仍然需要客户端组件。
Ref 用于保存不参与渲染的数据或访问宿主节点:
const inputRef = useRef(null);
function focusInput() {
inputRef.current?.focus();
}
Portal 允许把子节点渲染到当前 DOM 层级之外,例如模态框容器,但它仍然属于原来的 React 树,事件冒泡和 Context 关系仍按 React 树处理。
Error Boundary 用于捕获子树渲染、生命周期和构造过程中的错误,并显示降级 UI。它不能捕获事件处理函数中的错误,也不能替代业务层的错误处理。
如果组件直接在 Render 阶段读取一个可变的外部 store,并在并发渲染期间 store 发生变化,就可能出现同一屏 UI 读取到不同版本数据的问题,这类问题通常称为 tearing。
useSyncExternalStore 提供了订阅、读取快照和一致性检查的标准契约。实现 Redux、状态库或浏览器外部数据订阅时,应优先遵循这一接口,而不是在 effect 中手写一个不完整的订阅方案。
传统性能优化经常依赖 memo、useMemo 和 useCallback 稳定引用。React Compiler 试图在编译阶段分析组件和依赖关系,自动插入适当的缓存,从而减少部分手动记忆化代码。
这并不意味着可以忽略组件设计。纯函数组件、稳定的 key、合理的状态拆分和正确的 Effect 依赖仍然是基础。编译器也不能修复渲染阶段副作用、错误的状态建模或过大的组件边界。
use 与乐观更新React 19 增加了一些围绕异步操作的能力。理解它们时,可以把服务器请求看成网页上的一次异步操作:用户先看到操作反馈,React 再负责处理等待、成功和失败状态。
useOptimistic 适合显示暂时的乐观结果,例如评论发送后先出现在列表中。它不是复制一份永久 state,而是在权威数据之上叠加临时更新;服务器结果回来后,临时状态会重新基于最新数据计算。
function CommentForm({ comments, sendComment }) {
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(current, comment) => [...current, { ...comment, sending: true }]
);
async function submit(formData) {
const text = formData.get("text");
addOptimisticComment({ id: crypto.randomUUID(), text });
await sendComment(text);
}
return (
<form action={submit}>
<input name="text" />
<button>发送</button>
{optimisticComments.map(comment => (
<p key={comment.id}>{comment.text}</p>
))}
</form>
);
}
use 可以在组件中读取 Promise 或 Context。读取尚未完成的 Promise 时,会与 Suspense 配合;这仍然不是普通的 await,因为组件 render 必须保持 React 能够重做和恢复的模型。
这些 API 的共同点是:异步任务的生命周期被纳入 React 的更新和调度模型,而不是在事件函数里手动维护大量 loading 标志。
下面用“商品搜索页面”贯穿整个过程。把 React 想成一个网页编辑器:页面上已经显示的内容是一张当前快照,用户的输入会产生新的页面快照,React 负责计算两张快照之间的变化,并把变化应用到浏览器。
用户输入 产生一次页面变更请求
事件系统 接收输入并找到对应处理函数
state 更新 把变更请求放入更新队列
Fiber 保存页面各部分的工作状态
Render 计算下一张页面快照
Diff 找出两张快照的差异
Commit 把差异应用到浏览器
Effect 与标题、网络等外部系统同步
function SearchPage({ products }) {
const [keyword, setKeyword] = useState("");
const [visibleProducts, setVisibleProducts] = useState(products);
function handleChange(event) {
const nextKeyword = event.target.value;
setKeyword(nextKeyword);
setVisibleProducts(
products.filter(product => product.name.includes(nextKeyword))
);
}
return (
<>
<input value={keyword} onChange={handleChange} />
<ProductList products={visibleProducts} />
</>
);
}
浏览器先产生原生事件,React 的事件系统找到 onChange 处理函数。处理函数执行 setKeyword 和 setVisibleProducts,这两次调用不是立即改 DOM,而是向对应 Fiber 的更新队列中放入两条变更记录。React 会在合适的时机统一处理这些记录。
假设当前渲染得到的 keyword 是 "r",那么这个事件处理函数闭包中看到的就是 "r"。调用 setter 后,当前函数不会突然变成下一次渲染的函数。
setKeyword(keyword + "a");
console.log(keyword); // 仍然是本次渲染的旧值
这就像编辑器打开了一份文档:本次编辑开始时,编辑器手里有一份确定的内容快照。用户输入新文字后,编辑器会根据新内容生成下一份快照,而不是在计算过程中把旧快照改写一半。这样每次渲染都能得到稳定、可预测的结果。
React 会在合适的时机处理多个更新。对于同一个 state,队列可能是:
setCount(1);
setCount(n => n + 1);
setCount(n => n + 1);
处理时大致是:
初始状态 0
应用值更新 -> 1
应用函数更新 -> 2
应用函数更新 -> 3
最终状态 -> 3
普通值更新表示“把状态设置为某个固定值”,函数式更新表示“拿当前最新状态继续计算”。前者依赖创建这条记录时的快照,后者依赖处理队列时的最新结果。因此在异步回调、连续更新和 Transition 中,函数式更新通常更可靠。
React 不会边调用组件边修改用户已经看到的 DOM。它会基于当前树复制或复用 Fiber,生成 workInProgress 树。
current:浏览器当前使用的页面结构
workInProgress:React 正在计算的下一份页面结构
组件函数会在 Render 阶段被调用,得到新的元素:
<ProductList products={nextProducts} />
此时 React 只是计算“页面应该是什么样子”,并不保证这次计算一定会提交。如果更高优先级的输入到来,或者子树暂时无法完成,这份页面快照可能被暂停、丢弃或重新计算。浏览器仍然保持上一份已经提交的页面。
旧树和新树不是简单地从根节点全部重建。React 会先判断身份:
同一位置、同一类型、同一 key 尝试复用
类型不同 删除旧节点,创建新节点
列表 key 相同 认为是同一个逻辑节点
例如:
old: [<Row key="a" />, <Row key="b" />]
new: [<Row key="b" />, <Row key="a" />]
React 会尽量移动已有节点,而不是销毁两个 Row 再创建两个新组件。key 就像商品记录中的商品编号,它代表商品本身;数组下标只是商品当前排在第几行。当搜索结果重新排序时,商品编号仍然不变,React 就能把原来的行与对应商品关联起来。
当整棵 workInProgress 树准备好后,React 进入 Commit 阶段,把 flags 转换成宿主环境操作:
Placement 插入节点
Update 更新属性和文本
Deletion 删除节点并执行卸载清理
这就是为什么用户通常不会看到“只改了一半”的 React 树。Render 可以被打断,Commit 则负责把已经准备好的结果应用到浏览器,使页面从一个完整状态切换到另一个完整状态。
DOM 更新完成后,React 才会处理 ref 和 Effect。比如:
useEffect(() => {
document.title = `搜索:${keyword}`;
}, [keyword]);
修改页面标题不是计算 UI 所必需的工作,它属于与浏览器外部系统同步的副作用。Effect 的 cleanup 可以理解为“解除上一份同步关系”:依赖变化前或组件卸载时,先断开旧连接、清除旧定时器,再建立新的连接。
如果商品列表非常大,输入框本身必须马上响应,而过滤列表可以稍后完成:
const [isPending, startTransition] = useTransition();
function handleChange(event) {
const value = event.target.value;
setKeyword(value);
startTransition(() => {
setVisibleProducts(filterProducts(products, value));
});
}
输入框更新必须及时完成,列表过滤则可以稍后完成。React 可以先提交输入框,让用户继续输入,再推进列表更新。这里的“稍后”仍然发生在同一个 JavaScript 主线程,只是列表更新被赋予了较低优先级。
一次交互的完整过程可以记成:
事件触发
-> 更新进入 Fiber 队列
-> Scheduler 选择优先级
-> Render 构造 workInProgress
-> Diff 标记 flags
-> Commit 修改 DOM
-> Layout Effect 和 Passive Effect 完成外部同步
如果源码阅读时迷路,可以回到这条链路,判断当前函数负责的是接收变更、计算下一份页面、寻找差异,还是把差异应用到浏览器。
学习和实践可以按照以下顺序展开:
阅读源码时要区分三个层次:公开 API 的行为、协调器的抽象模型、具体版本的内部实现。源码会持续演进,但这些抽象关系相对稳定。
setState 或 state setter 是请求更新,不会改变当前 render 中已经捕获的变量。key 用于节点身份,不是普通业务属性。useMemo 和 useCallback 是性能优化工具,不是保证语义正确的必需品。React 的核心并不是“把数据变成 DOM”这么简单,而是建立了一套声明式更新模型:组件产生元素,Fiber 保存工作状态,Render 阶段计算变化,Commit 阶段提交副作用,调度器决定工作何时以及以什么优先级推进。
理解这条主线之后,再去看事件系统、Diff、Context、Hooks 和路由,就不容易陷入零散 API 的记忆。源码阅读的目标也不是背下每个函数,而是知道一个用户操作发生后,更新如何进入队列、如何被协调、何时写入 DOM,以及哪些代码必须保持纯粹。