本文分为三部分,首先介绍 React 的工作流,让读者对 React 组件更新流程有宏观的认识。然后列出笔者总结的一系列优化技巧,并为稍复杂的优化技巧准备了 CodeSandbox 源码,以便读者实操体验。最后分享笔者使用 React Profiler 的一点心得,帮助读者更快定位性能瓶颈。
React 工作流
React 是声明式 UI 库,负责将 State 转换为页面结构(虚拟 DOM 结构)后,再转换成真实 DOM 结构,交给浏览器渲染。当 State 发生改变时,React 会先进行调和(Reconciliation)阶段,调和阶段结束后立刻进入提交(Commit)阶段,提交阶段结束后,新 State 对应的页面才被展示出来。
React 的调和阶段需要做两件事。1、计算出目标 State 对应的虚拟 DOM 结构。2、寻找「将虚拟 DOM 结构修改为目标虚拟 DOM 结构」的最优更新方案。 React 按照深度优先遍历虚拟 DOM 树的方式,在一个虚拟 DOM 上完成两件事的计算后,再计算下一个虚拟 DOM。第一件事主要是调用类组件的 render 方法或函数组件自身。第二件事为 React 内部实现的 Diff 算法,Diff 算法会记录虚拟 DOM 的更新方式(如:Update、Mount、Unmount),为提交阶段做准备。
React 的提交阶段也需要做两件事。1、将调和阶段记录的更新方案应用到 DOM 中。2、调用暴露给开发者的钩子方法,如:componentDidUpdate、useLayoutEffect 等。 提交阶段中这两件事的执行时机与调和阶段不同,在提交阶段 React 会先执行 1,等 1 完成后再执行 2。因此在子组件的 componentDidMount 方法中,可以执行
document.querySelector('.parentClass')
,拿到父组件渲染的 .parentClass
DOM 节点,尽管这时候父组件的 componentDidMount 方法还没有被执行。useLayoutEffect 的执行时机与 componentDidMount 相同,可参考线上代码进行验证。https://codesandbox.io/s/cdm-yu-commit-jieduanzhixingshunxu-fzu1w?file=/src/App.js
由于调和阶段的「Diff 过程」和提交阶段的「应用更新方案到 DOM」都属于 React 的内部实现,开发者能提供的优化能力有限,本文仅有一条优化技巧(列表项使用 key 属性[1])与它们有关。实际工程中大部分优化方式都集中在调和阶段的「计算目标虚拟 DOM 结构」过程,该过程是优化的重点,React 内部的 Fiber 架构和并发模式也是在减少该过程的耗时阻塞。对于提交阶段的「执行钩子函数」过程,开发者应保证钩子函数中的代码尽量轻量,避免耗时阻塞,相关的优化技巧参考本文的避免在 didMount、didUpdate 中更新组件 State[2]。
拓展知识
- 建议对 React 生命周期不熟悉的读者结合 React 组件的生命周期图。记得勾选该网站上的复选框。
- 因为理解事件循环后才知道页面会在什么时候被更新,所以推荐一个介绍事件循环的视频[4]。该视频中事件循环的伪代码如下图,非常清晰易懂。
定义 Render 过程
本文为了叙述方便, 将调和阶段中「计算目标虚拟 DOM 结构」过程称为 Render 过程 。触发 React 组件的 Render 过程目前有三种方式,分别为 forceUpdate、State 更新、父组件 Render 触发子组件 Render 过程。
优化技巧
本文将优化技巧分为三大类,分别为:
- 跳过不必要的组件更新。这类优化是在组件状态发生变更后,通过减少不必要的组件更新来实现,是本文优化技巧的主要部分。
- 提交阶段优化。这类优化的目的是减少提交阶段耗时,该分类中仅有一条优化技巧。
- 前端通用优化。这类优化在所有前端框架中都存在,本文的重点就在于将这些技巧应用在 React 组件中。
跳过不必要的组件更新
这类优化是在组件状态发生变更后,通过减少不必要的组件更新来实现,是本文优化技巧的主要部分。
1. PureComponent、React.memo
在 React 工作流中,如果只有父组件发生状态更新,即使父组件传给子组件的所有 Props 都没有修改,也会引起子组件的 Render 过程。从 React 的声明式设计理念来看,如果子组件的 Props 和 State 都没有改变,那么其生成的 DOM 结构和副作用也不应该发生改变。当子组件符合声明式设计理念时,就可以忽略子组件本次的 Render 过程。PureComponent 和 React.memo 就是应对这种场景的,PureComponent 是对类组件的 Props 和 State 进行浅比较,React.memo 是对函数组件的 Props 进行浅比较。
2. shouldComponentUpdate
在 React 刚开源的那段时期,数据不可变性还没有现在这样流行。当时 Flux 架构就使用的模块变量来维护 State,并在状态更新时直接修改该模块变量的属性值,而不是使用展开语法[5]生成新的对象引用。例如要往数组中添加一项数据时,当时的代码很可能是
state.push(item)
,而不是 const newState = [...state, item]
。这点可参考 Dan Abramov 在演讲 Redux 时[6]演示的 Flux 代码。在此背景下,当时的开发者经常使用 shouldComponentUpdate 来深比较 Props,只在 Props 有修改才执行组件的 Render 过程。如今由于数据不可变性和函数组件的流行,这样的优化场景已经不会再出现了。
接下来介绍另一种可以使用 shouldComponentUpdate 来优化的场景。在项目初始阶段,开发者往往图方便会给子组件传递一个大对象作为 Props,后面子组件想用啥就用啥。当大对象中某个「子组件未使用的属性」发生了更新,子组件也会触发 Render 过程。在这种场景下,通过实现子组件的 shouldComponentUpdate 方法,仅在「子组件使用的属性」发生改变时才返回
true
,便能避免子组件重新 Render。但使用 shouldComponentUpdate 优化第二个场景有两个弊端。
如果存在很多子孙组件,「找出所有子孙组件使用的属性」就会有很多工作量,也容易因为漏测导致 bug。
存在潜在的工程隐患。举例来说,假设组件结构如下。
<A data="{data}"> {/* B 组件只使用了 data.a 和 data.b */} <B data="{data}"> {/* C 组件只使用了 data.a */} <C data="{data}"></C> </B> </A> 复制代码
B 组件的 shouldComponentUpdate 中只比较了 data.a 和 data.b,目前是没任何问题的。之后开发者想在 C 组件中使用 data.c,假设项目中 data.a 和 data.c 是一起更新的,所以也没任何问题。但这份代码已经变得脆弱了,如果某次修改导致 data.a 和 data.c 不一起更新了,那么系统就会出问题。而且实际业务中代码往往更复杂,从 B 到 C 可能还有若干中间组件,这时就很难想到是 shouldComponentUpdate 引起的问题了。
拓展知识
- 第二个场景最好的解决方案是使用发布者订阅者模式,只是代码改动要稍多一些,可参考本文的优化技巧「发布者订阅者跳过中间组件 Render 过程[7]」。
- 第二个场景也可以在父子组件间增加中间组件,中间组件负责从父组件中选出子组件关心的属性,再传给子组件。相比于 shouldComponentUpdate 方法,会增加组件层级,但不会有第二个弊端。
- 本文中的跳过回调函数改变触发的 Render 过程[8]也可以用 shouldComponentUpdate 实现,因为回调函数并不参与组件的 Render 过程。
3. useMemo、useCallback 实现稳定的 Props 值
如果传给子组件的派生状态或函数,每次都是新的引用,那么 PureComponent 和 React.memo 优化就会失效。所以需要使用 useMemo 和 useCallback 来生成稳定值,并结合 PureComponent 或 React.memo 避免子组件重新 Render。
拓展知识
- useCallback 是「useMemo 的返回值为函数」时的特殊情况,是 React 提供的便捷方式。在React Server Hooks 代码[9] 中,useCallback 就是基于 useMemo 实现的。尽管 React Client Hooks 没有使用同一份代码,但 useCallback[10] 的代码逻辑和 useMemo[11] 的代码逻辑仍是一样的。
4. 发布者订阅者跳过中间组件 Render 过程
React 推荐将公共数据放在所有「需要该状态的组件」的公共祖先上,但将状态放在公共祖先上后,该状态就需要层层向下传递,直到传递给使用该状态的组件为止。
每次状态的更新都会涉及中间组件的 Render 过程,但中间组件并不关心该状态,它的 Render 过程只负责将该状态再传给子组件。在这种场景下可以将状态用发布者订阅者模式维护,只有关心该状态的组件才去订阅该状态,不再需要中间组件传递该状态。当状态更新时,发布者发布数据更新消息,只有订阅者组件才会触发 Render 过程,中间组件不再执行 Render 过程。
只要是发布者订阅者模式的库,都可以进行该优化。比如:redux、use-global-state、React.createContext 等。例子参考:发布者订阅者模式跳过中间组件的渲染阶段[12],本示例使用 React.createContext 进行实现。
import { useState, useEffect, createContext, useContext } from "react" const renderCntMap = {} const renderOnce = name => { return (renderCntMap[name] = (renderCntMap[name] || 0) + 1) } // 将需要公共访问的部分移动到 Context 中进行优化 // Context.Provider 就是发布者 // Context.Consumer 就是消费者 const ValueCtx = createContext() const CtxContainer = ({ children }) => { const [cnt, setCnt] = useState(0) useEffect(() => { const timer = window.setInterval(() => { setCnt(v => v + 1) }, 1000) return () => clearInterval(timer) }, [setCnt]) return <ValueCtx.Provider value={cnt}>{children}</ValueCtx.Provider> } function CompA({}) { const cnt = useContext(ValueCtx) // 组件内使用 cnt return <div>组件 CompA Render 次数:{renderOnce("CompA")}</div> } function CompB({}) { const cnt = useContext(ValueCtx) // 组件内使用 cnt return <div>组件 CompB Render 次数:{renderOnce("CompB")}</div> } function CompC({}) { return <div>组件 CompC Render 次数:{renderOnce("CompC")}</div> } export const PubSubCommunicate = () => { return ( <CtxContainer> <div> <h1>优化后场景</h1> <div> 将状态提升至最低公共祖先的上层,用 CtxContainer 将其内容包裹。 </div> <div style={{ marginTop: "20px" }}> 每次 Render 时,只有组件A和组件B会重新 Render 。 </div> <div style={{ marginTop: "40px" }}> 父组件 Render 次数:{renderOnce("parent")} </div> <CompA /> <CompB /> <CompC /> </div> </CtxContainer> ) } export default PubSubCommunicate 复制代码
5. 状态下放,缩小状态影响范围
如果一个状态只在某部分子树中使用,那么可以将这部分子树提取为组件,并将该状态移动到该组件内部。如下面的代码所示,虽然状态 color 只在
<input />
和 <p />
中使用,但 color 改变会引起 <ExpensiveTree />
重新 Render。import { useState } from "react" export default function App() { let [color, setColor] = useState("red") return ( <div> <input value={color} onChange={e => setColor(e.target.value)} /> <p style={{ color }}>Hello, world!</p> <ExpensiveTree /> </div> ) } function ExpensiveTree() { let now = performance.now() while (performance.now() - now < 100) { // Artificial delay -- do nothing for 100ms } return <p>I am a very slow component tree.</p> } 复制代码
通过将 color 状态、
<input />
和 <p />
提取到组件 Form 中,结果如下。export default function App() { return ( <> <Form /> <ExpensiveTree /> </> ) } function Form() { let [color, setColor] = useState("red") return ( <> <input value={color} onChange={e => setColor(e.target.value)} /> <p style={{ color }}>Hello, world!</p> </> ) } 复制代码
这样调整之后,color 改变就不会引起组件 App 和 ExpensiveTree 重新 Render 了。
如果对上面的场景进行扩展,在组件 App 的顶层和子树中都使用了状态 color ,但
<ExpensiveTree />
仍然不关心它,如下所示。import { useState } from "react" export default function App() { let [color, setColor] = useState("red") return ( <div style={{ color }}> <input value={color} onChange={e => setColor(e.target.value)} /> <ExpensiveTree /> <p style={{ color }}>Hello, world!</p> </div> ) } 复制代码
在这种场景中,我们仍然将 color 状态抽取到新组件中,并提供一个插槽来组合
<ExpensiveTree />
,如下所示。import { useState } from "react" export default function App() { return <ColorContainer expensiveTreeNode={<ExpensiveTree />}></ColorContainer> } function ColorContainer({ expensiveTreeNode }) { let [color, setColor] = useState("red") return ( <div style={{ color }}> <input value={color} onChange={e => setColor(e.target.value)} /> {expensiveTreeNode} <p style={{ color }}>Hello, world!</p> </div> ) } 复制代码
这样调整之后,color 改变就不会引起组件 App 和 ExpensiveTree 重新 Render 了。
该优化技巧来源于 before-you-memo[13],Dan 认为这种优化方式在 Server Component 场景下更有效,因为 <ExpensiveTree /> 可以在服务端执行。
6. 列表项使用 key 属性
当渲染列表项时,如果不给组件设置不相等的属性 key,就会收到如下报警。
相信很多开发者已经见过该报警成百上千次了,那 key 属性到底在优化了什么呢?举个 🌰,在不使用 key 时,组件两次 Render 的结果如下。
<!-- 前一次 Render 结果 --> <ul> <li>Duke</li> <li>Villanova</li> </ul> <!-- 新的 Render 结果 --> <ul> <li>Connecticut</li> <li>Duke</li> <li>Villanova</li> </ul> 复制代码
此时 React 的 Diff 算法会按照
<li>
出现的先后顺序进行比较,得出结果为需要更新前两个<li>
并创建内容为 Villanova 的li
,一共会执行两次 DOM 更新、一次 DOM 创建。如果加上 React 的 key 属性,两次 Render 结果如下。
<!-- 前一次 Render 结果 --> <ul> <li key="2015">Duke</li> <li key="2016">Villanova</li> </ul> <!-- 新的 Render 结果 --> <ul> <li key="2014">Connecticut</li> <li key="2015">Duke</li> <li key="2016">Villanova</li> </ul> 复制代码
React Diff 算法会把 key 值为 2015 的虚拟 DOM 进行比较,发现 key 为 2015 的虚拟 DOM 没有发生修改,不用更新。同样,key 值为 2016 的虚拟 DOM 也不需要更新。结果就只需要创建 key 值为 2014 的虚拟 DOM。相比于不使用 key 的代码,使用 key 节省了两次 DOM 更新操作。
如果把例子中的
<li>
换成自定义组件,并且自定义组件使用了 PureComponent 或 React.memo 优化。那么使用 key 属性就不只节省了 DOM 更新,还避免了组件的 Render 过程。React 官方推荐[14]将每项数据的 ID 作为组件的 key,以达到上述的优化目的。并且不推荐使用_每项的索引_作为 key,因为传索引作为 key 时,就会退化为不使用 key 时的代码。那么是否在所有列表渲染的场景下,使用 ID 都优于使用索引呢?
答案是否定的,在常见的分页列表中,第一页和第二页的列表项 ID 都是不同,假设每页展示三条数据,那么切换页面前后组件 Render 结果如下。
<!-- 第一页的列表项虚拟 DOM --> <li key="a">dataA</li> <li key="b">dataB</li> <li key="c">dataC</li> <!-- 切换到第二页后的虚拟 DOM --> <li key="d">dataD</li> <li key="e">dataE</li> <li key="f">dataF</li> 复制代码
切换到第二页后,由于所有
<li>
的 key 值不同,所以 Diff 算法会将第一页的所有 DOM 节点标记为删除,然后将第二页的所有 DOM 节点标记为新增。整个更新过程需要三次 DOM 删除、三次 DOM 创建。如果不使用 key,Diff 算法只会将三个 <li>
节点标记为更新,执行三次 DOM 更新。参考 Demo 没有添加、删除、排序功能的分页列表[15],使用 key 时每次翻页耗时约为 140ms,而不使用 key 仅为 70ms。尽管存在以上场景,React 官方仍然推荐使用 ID 作为每项的 key 值。其原因有两:
- 在列表中执行删除、插入、排序列表项的操作时,使用 ID 作为 key 将更高效。而翻页操作往往伴随着 API 请求,DOM 操作耗时远小于 API 请求耗时,是否使用 ID 在该场景下对用户体验影响不大。
- 使用 ID 做为 key 可以维护该 ID 对应的列表项组件的 State。举个例子,某表格中每列都有普通态和编辑态两个状态,起初所有列都是普通态,用户点击第一行第一列,使其进入编辑态。然后用户又拖拽第二行,将其移动到表格的第一行。如果开发者使用索引作为 key,那么第一行第一列的状态仍然为编辑态,而用户实际希望编辑的是第二行的数据,在用户看来就是不符合预期的。尽管这个问题可以通过将「是否处于编辑态」存放在数据项的数据中,利用 Props 来解决,但是使用 ID 作为 key 不是更香吗?