logo

匠人学习平台在线直播操作指南

Java教程

最全的React性能优化知识锦集(1)
最全的React性能优化知识锦集(1)

最全的React性能优化知识锦集(1)

本文分为三部分,首先介绍 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]
拓展知识
  1. 建议对 React 生命周期不熟悉的读者结合 React 组件的生命周期图。记得勾选该网站上的复选框。
  1. 因为理解事件循环后才知道页面会在什么时候被更新,所以推荐一个介绍事件循环的视频[4]。该视频中事件循环的伪代码如下图,非常清晰易懂。
notion image

定义 Render 过程

本文为了叙述方便, 将调和阶段中「计算目标虚拟 DOM 结构」过程称为 Render 过程 。触发 React 组件的 Render 过程目前有三种方式,分别为 forceUpdate、State 更新、父组件 Render 触发子组件 Render 过程。

优化技巧

本文将优化技巧分为三大类,分别为:
  1. 跳过不必要的组件更新。这类优化是在组件状态发生变更后,通过减少不必要的组件更新来实现,是本文优化技巧的主要部分。
  1. 提交阶段优化。这类优化的目的是减少提交阶段耗时,该分类中仅有一条优化技巧。
  1. 前端通用优化。这类优化在所有前端框架中都存在,本文的重点就在于将这些技巧应用在 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 代码。
notion image
在此背景下,当时的开发者经常使用 shouldComponentUpdate 来深比较 Props,只在 Props 有修改才执行组件的 Render 过程。如今由于数据不可变性和函数组件的流行,这样的优化场景已经不会再出现了。
接下来介绍另一种可以使用 shouldComponentUpdate 来优化的场景。在项目初始阶段,开发者往往图方便会给子组件传递一个大对象作为 Props,后面子组件想用啥就用啥。当大对象中某个「子组件未使用的属性」发生了更新,子组件也会触发 Render 过程。在这种场景下,通过实现子组件的 shouldComponentUpdate 方法,仅在「子组件使用的属性」发生改变时才返回 true,便能避免子组件重新 Render。
但使用 shouldComponentUpdate 优化第二个场景有两个弊端。
  1. 如果存在很多子孙组件,「找出所有子孙组件使用的属性」就会有很多工作量,也容易因为漏测导致 bug。
  1. 存在潜在的工程隐患。举例来说,假设组件结构如下。
<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 引起的问题了。
拓展知识
  1. 第二个场景最好的解决方案是使用发布者订阅者模式,只是代码改动要稍多一些,可参考本文的优化技巧「发布者订阅者跳过中间组件 Render 过程[7]」。
  1. 第二个场景也可以在父子组件间增加中间组件,中间组件负责从父组件中选出子组件关心的属性,再传给子组件。相比于 shouldComponentUpdate 方法,会增加组件层级,但不会有第二个弊端。
  1. 本文中的跳过回调函数改变触发的 Render 过程[8]也可以用 shouldComponentUpdate 实现,因为回调函数并不参与组件的 Render 过程。

3. useMemo、useCallback 实现稳定的 Props 值

如果传给子组件的派生状态或函数,每次都是新的引用,那么 PureComponent 和 React.memo 优化就会失效。所以需要使用 useMemo 和 useCallback 来生成稳定值,并结合 PureComponent 或 React.memo 避免子组件重新 Render。
拓展知识
  1. 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,就会收到如下报警。
notion image
相信很多开发者已经见过该报警成百上千次了,那 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 值。其原因有两:
  1. 在列表中执行删除、插入、排序列表项的操作时,使用 ID 作为 key 将更高效。而翻页操作往往伴随着 API 请求,DOM 操作耗时远小于 API 请求耗时,是否使用 ID 在该场景下对用户体验影响不大。
  1. 使用 ID 做为 key 可以维护该 ID 对应的列表项组件的 State。举个例子,某表格中每列都有普通态和编辑态两个状态,起初所有列都是普通态,用户点击第一行第一列,使其进入编辑态。然后用户又拖拽第二行,将其移动到表格的第一行。如果开发者使用索引作为 key,那么第一行第一列的状态仍然为编辑态,而用户实际希望编辑的是第二行的数据,在用户看来就是不符合预期的。尽管这个问题可以通过将「是否处于编辑态」存放在数据项的数据中,利用 Props 来解决,但是使用 ID 作为 key 不是更香吗?
 
logo

Follow Us

linkedinfacebooktwitterinstagramweiboyoutubebilibilitiktokxigua

We Accept

/image/layout/pay-paypal.png/image/layout/pay-visa.png/image/layout/pay-master-card.png/image/layout/pay-airwallex.png/image/layout/pay-alipay.png

地址

Level 10b, 144 Edward Street, Brisbane CBD(Headquarter)
Level 2, 171 La Trobe St, Melbourne VIC 3000
四川省成都市武侯区桂溪街道天府大道中段500号D5东方希望天祥广场B座45A13号
Business Hub, 155 Waymouth St, Adelaide SA 5000

Disclaimer

footer-disclaimerfooter-disclaimer

JR Academy acknowledges Traditional Owners of Country throughout Australia and recognises the continuing connection to lands, waters and communities. We pay our respect to Aboriginal and Torres Strait Islander cultures; and to Elders past and present. Aboriginal and Torres Strait Islander peoples should be aware that this website may contain images or names of people who have since passed away.

匠人学院网站上的所有内容,包括课程材料、徽标和匠人学院网站上提供的信息,均受澳大利亚政府知识产权法的保护。严禁未经授权使用、销售、分发、复制或修改。违规行为可能会导致法律诉讼。通过访问我们的网站,您同意尊重我们的知识产权。 JR Academy Pty Ltd 保留所有权利,包括专利、商标和版权。任何侵权行为都将受到法律追究。查看用户协议

© 2017-2025 JR Academy Pty Ltd. All rights reserved.

ABN 26621887572