React 实现原理
用通俗方式理解 React 的核心实现原理。
React 的核心可以简单理解成一句话:状态变化后,React 重新计算 UI,然后把真正需要变化的部分更新到页面上。
开发时我们写的是组件和状态,React 内部做的是“计算差异”和“更新 DOM”。
从组件到页面
我们写的 React 组件本质上是一个函数:
function UserCard({ name }: { name: string }) {
return <div>{name}</div>;
}这个函数返回的 JSX 看起来像 HTML,但它不是直接的 DOM。
JSX 会被编译成 JavaScript 对象,大概可以理解成:
{
type: "div",
props: {
children: "Alice"
}
}也就是说,React 先拿到的是一棵“用 JavaScript 对象描述的 UI 树”。
Virtual DOM
Virtual DOM 可以理解成真实 DOM 的一份轻量描述。
真实 DOM 是浏览器里的页面节点,操作它相对昂贵。Virtual DOM 是普通 JavaScript 对象,React 可以先在内存里计算它。
流程大致是:
- 组件执行,生成新的 Virtual DOM。
- React 拿新的 Virtual DOM 和旧的 Virtual DOM 做比较。
- 找出真正变化的地方。
- 只更新对应的真实 DOM。
这就是为什么我们写 React 时通常只关心状态,不需要手动操作 DOM。
状态驱动更新
React 页面更新通常从 state 变化开始。
const [count, setCount] = useState(0);
function increment() {
setCount((value) => value + 1);
}调用 setCount 后,React 不会让你手动改按钮文字,而是重新执行组件函数:
return <button>{count}</button>;新的 count 会生成新的 UI 描述,React 再决定页面上哪些地方需要更新。
所以 React 的思路是:
UI = f(state)
状态变了,UI 就重新计算。
Diff
Diff 是 React 比较新旧 UI 树的过程。
比如旧 UI:
<div>
<span>Alice</span>
</div>新 UI:
<div>
<span>Bob</span>
</div>React 会发现结构没变,只是文本从 Alice 变成了 Bob,于是只更新文本内容。
如果组件类型变了:
<UserCard />变成:
<OrderCard />React 通常会认为这是两种不同的组件,旧组件卸载,新组件重新创建。
key 的作用
列表渲染时,key 用来帮助 React 判断“哪个元素是谁”。
users.map((user) => (
<UserItem key={user.id} user={user} />
));如果没有稳定的 key,React 很难准确判断列表项的增删移动,可能导致错误复用组件状态。
不推荐用数组下标做 key:
users.map((user, index) => (
<UserItem key={index} user={user} />
));当列表顺序变化时,index 会变得不稳定。更推荐使用业务 ID。
Fiber
Fiber 是 React 内部的任务调度结构。
可以把它理解成 React 给每个组件节点建立的一份“工作记录”。这份记录里会保存:
- 当前组件是什么。
- 当前 props 和 state。
- 子节点、父节点、兄弟节点关系。
- 这次更新需要做什么。
为什么需要 Fiber?
因为页面更新可能很大,如果 React 一口气把所有更新都做完,浏览器可能会卡住。Fiber 让 React 可以把更新拆成很多小任务,根据优先级安排执行。
比如:
- 用户输入、点击:优先级高。
- 大列表渲染:优先级低一些。
- 不紧急的页面内容:可以稍后处理。
这也是 React 能支持并发渲染的基础。
Render 和 Commit
React 更新大致分两个阶段:
Render 阶段
Render 阶段负责计算。
React 会执行组件,生成新的 UI 描述,比较差异,整理出需要更新的内容。
这个阶段可以被暂停、丢弃或重新开始,因为它还没有真正改 DOM。
Commit 阶段
Commit 阶段负责提交。
React 会把 Render 阶段算出来的变化真正应用到 DOM 上,并执行一些副作用。
比如:
- 插入 DOM。
- 更新文本。
- 设置属性。
- 执行
useLayoutEffect。 - 安排
useEffect。
可以简单记:
- Render:算出要改什么。
- Commit:真正改页面。
Hooks 为什么要按顺序调用
React Hooks 依赖调用顺序来保存状态。
function Counter() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Alice");
}React 内部不是靠变量名识别这两个 state,而是靠“第一次 Hook、第二次 Hook”这样的顺序。
所以 Hooks 不能写在条件语句里:
if (show) {
const [count, setCount] = useState(0);
}如果某次渲染执行了这个 Hook,另一次渲染没执行,Hook 顺序就乱了,React 就不知道哪个状态对应哪个 Hook。
正确做法是:Hooks 永远放在组件顶层调用。
useEffect 什么时候执行
useEffect 用来处理渲染之后的副作用。
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);它通常在页面更新到浏览器之后执行。
适合放:
- 请求数据。
- 订阅事件。
- 设置定时器。
- 手动操作非 React 管理的外部系统。
如果一个值可以直接通过 props 或 state 算出来,通常不需要放进 useEffect。
为什么 setState 后拿不到最新值
React 的状态更新不是立即同步改变量,而是安排一次更新。
setCount(count + 1);
console.log(count);这里打印的 count 可能还是旧值,因为当前这次渲染里的 count 已经固定了。
如果下一次状态依赖上一次状态,推荐使用函数写法:
setCount((value) => value + 1);这样 React 会基于最新状态计算。
批处理
React 会把多次状态更新合并处理,减少重复渲染。
setCount((value) => value + 1);
setName("Bob");
setVisible(true);这些更新通常会被合并成一次渲染,而不是每调用一次 setState 就渲染一次。
这能减少性能浪费。
React.memo、useMemo、useCallback 的位置
这几个 API 都和“减少不必要计算或渲染”有关。
React.memo:缓存组件,props 没变就跳过子组件渲染。useMemo:缓存计算结果。useCallback:缓存函数引用。
它们不是必须默认使用的工具。项目里应该先保证组件结构清楚,再根据实际性能问题使用。
总结
React 的实现可以理解成:组件返回 UI 描述,状态变化后生成新的 UI 描述,React 通过 Diff 找出变化,再通过 Fiber 调度更新,最后在 Commit 阶段把变化应用到真实 DOM。开发时我们写组件和状态,React 负责把状态变化高效地同步到页面。