React 很快,对吧?我第一次听到这句话时差点笑出声——毕竟,在浏览器里又引入一层虚拟 DOM,还要做 Diff,听起来就像是往引擎里加了一罐蜂蜜,指望它跑得更顺。可后来我闭嘴了。不是因为它真的快,而是因为它的“快”藏得很深,深到如果咱们不把 Fiber 那一套调度逻辑翻个底朝天,你根本不知道它快在哪。
好了,不绕圈子。咱们直接切到 React 的心脏:reconciliation。这词的官方翻译是“协调”,但我更愿意叫它“算账”——React 需要算清楚,当状态变了,UI 该怎么跟着变。最蠢的办法是啥?把整个 DOM 树拆了重画。但真实 DOM 操作贵得要死,所以 React 搞了个轻量级的虚拟 DOM,先在内存里算完这笔账,再一把头把差异打到真实 DOM 上。听起来很完美?等等,这账怎么算才是关键。传统的 O(n³) 算法?得了吧,React 用的是启发式 O(n),做两个假设:不同类型的元素会生成不同的树;开发者能通过 key prop 暗示哪些子元素保持稳定。就这两条,把复杂度直接砍到线性。我第一次看这算法时,拍桌子说了句“妙啊”,因为它在工程上做到了极致权衡——牺牲一点理论最优,换来可维护的复杂度,这才是扎到骨子里的实用主义。

虚拟 DOM 不是银弹?—— 重新审视 Diff 的本质
很多人把 React 的性能归功于虚拟 DOM,其实这是最大的误解。虚拟 DOM 本身并不会让操作变快,它只是给了 React 一个在内存里记账的机会。真正的加速来自于两个维度:减少不必要的 DOM 操作和把这些操作批量化。你想想,React 每一次 setState 都会触发一次 reconciliation 吗?不是。在 React 18 之前,setState 在事件处理函数里是批量的,但在 setTimeout 或 async 函数里就会变成“一次 setState 一次 render”,这坑我踩过好多次,后面再骂。先看一组数据,来自我去年压测的一个中型 Dashboard 应用:
- 传统 jQuery 手动操作 DOM:2000 条数据列表更新耗时 8.3 秒,页面完全卡死。
- React(无优化,key 用 index):2.1 秒。
- React(正确使用 key + shouldComponentUpdate/PureComponent):0.6 秒。
你看,差距主要来自 diff 的效率——如果你把 key 设成循环的 index,React 就瞎了,因为它会认为每个子元素都变了,得重画。这就是那两条假设的威力,也是 React 工程之美的核心:用极简的约束换来性能的跃进。不过话说回来,O(n) 的 diff 在很多场景下还是慢,尤其是动画、输入框这类需要即时反馈的地方。要是你列表里有 10000 个节点,哪怕虚拟 DOM 算得快,最后把差异提交到真实 DOM 时还是会掉帧。怎么办?React 的解答是 Fiber。

Fiber 如何让 React “时间旅行”:并发模式下的调度艺术

Fiber 不是新架构吗?其实它从 16 就来了,但大部分人对它的理解停留在“能把更新切成小片,不让主线程卡死”。对,但不全对。Fiber 的核心是 协程式调度——它给每个工作单元加上“暂停”和“恢复”的能力。在 React 16 之前,reconciliation 是一个同步递归过程,一旦开始,就得一口气跑完,主线程被霸占,页面就“假死”。而 Fiber 把组件树变成了一个单链表(对,不是树结构了,是 Fiber 节点构成的链,每个节点存着 child、sibling、return 的引用),这样就可以用 while 循环来遍历,每次循环后检查还剩多少时间。我最初看这个实现的时候,心里就一个念头:这不就是把遍历逻辑从递归变成了可中断的迭代吗?但就这么一改,整个协调过程就被赋予“时间片”的概念——每 5ms(默认)让出主线程,回去看有没有更高优先级的活儿,比如响应用户点击。
这种设计的工程美感在于,它没有引入多线程的复杂性(浏览器里 JS 是单线程),而是利用协作式多任务,把控制权交给了调度器。调度器(Scheduler)会根据任务的优先级(过期时间)来决定接下来执行哪个工作单元。高优先级的更新(比如点击动画)会比数据拉取导致的更新更早被处理,甚至可以打断低优先级的渲染——对,就是并发渲染,React 18 的 Concurrent Mode 就是在这层皮上加了更多调度策略。说实话,我头一次在 DEMO 里看到 useTransition 把一次慢查询导致的界面卡顿化解于无形时,确实有点激动。那感觉就像你在高速上堵车,突然有辆救护车能从应急车道穿过去,而其他车还在排队——把紧急更新和过渡更新区分开,这正是 React 设计哲学的又一次胜利。
那些年我们踩过的坑:三个必知的核心陷阱与解法

光吹不练假把式。React 落地时的坑,每一个都能让你半夜爬起来改代码。我挑三个最常见的,都是血泪换来的经验。
陷阱一:状态更新的“批处理幻觉”
早先在 class 组件里,合成事件和生命周期中的 setState 是批量的,但在异步回调里不是。这导致我有次在 setInterval 里更新计时器,发现组件疯狂重绘,CPU 飙到百分百。React 18 之后,自动批处理覆盖了所有场景,但如果你还在维护老代码,或者用了某些绕过 React 的事件(如 addEventListener 直接加的原生事件),批处理依然会失效。解决办法分两步:要么尽快升级到 18 开启默认批处理,要么在需要的地方用 ReactDOM.unstable_batchedUpdates 手动包裹。更彻底的,函数组件里直接拥抱 useReducer,状态逻辑更内聚,依赖也更清晰。
陷阱二:useEffect 依赖缺失与闭包陈旧问题
这个话题能吵一上午。useEffect 的闭包陷阱简直无处不在:你在 effect 里用到了某个 state 或 prop,却没填进依赖数组,导致读取到旧值。更恶心的是,有时候你故意不想加依赖,比如只想在挂载时执行一次,但 React 的 lint 规则会给你警告。我的解法是:第一,启用 react-hooks/exhaustive-deps 规则,把警告当错误处理;第二,如果真的需要稳定引用,用 useRef 来存最新值,在 effect 里读 ref.current;第三,对于复杂的依赖关系,抽取成自定义 Hook,或用 useReducer + context 把更新和读取解耦。别跟 lint 对着干,除非你很清楚自己在干嘛。
陷阱三:key 的误用与性能反模式
前面提过,key 用 index 是灾难,尤其在列表顺序会动态改变的时候。但更隐蔽的坑是 key 在兄弟节点间不唯一,或者用随机数做 key——每次渲染都换 key,React 会认为这是一个全新的组件,导致状态丢失和 DOM 重建。正确姿势:使用稳定且唯一的 ID,比如后端返回的数据 ID。如果实在没有,可以用 crypto.randomUUID() 在数据创建时生成一次,存进对象。还有一点,避免在 map 里使用内联函数或对象字面量,它们每次都是新引用,会导致子组件不必要的重渲染,记得配合 React.memo 与 useMemo 来缓存。
写到这儿,我忽然想起有一次给一个 legacy 项目做性能优化,只改了 key 和拆了几个 context,首屏时间从 5 秒掉到 1.5 秒。技术说穿了并不神秘,但把这几个点做到极致,才是区分“会用 React”和“懂 React”的分野。