文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

React 性能优化 :包括原理、技巧、Demo、工具使用

2024-12-03 03:38

关注

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 组件的生命周期图[3]阅读本文。记得勾选该网站上的复选框。
  2.   因为理解事件循环后才知道页面会在什么时候被更新,所以推荐一个介绍事件循环的视频[4]。该视频中事件循环的伪代码如下图,非常清晰易懂。

定义 Render 过程

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

优化技巧

本文将优化技巧分为三大类,分别为:

  1.  跳过不必要的组件更新。这类优化是在组件状态发生变更后,通过减少不必要的组件更新来实现,是本文优化技巧的主要部分。
  2.  提交阶段优化。这类优化的目的是减少提交阶段耗时,该分类中仅有一条优化技巧。
  3.  前端通用优化。这类优化在所有前端框架中都存在,本文的重点就在于将这些技巧应用在 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 优化第二个场景有两个弊端。

  1.  如果存在很多子孙组件,「找出所有子孙组件使用的属性」就会有很多工作量,也容易因为漏测导致 bug。
  2.  存在潜在的工程隐患。举例来说,假设组件结构如下。 
  1. <A data="{data}">  
  2.   {}  
  3.   <B data="{data}">  
  4.     {}  
  5.     <C data="{data}">C>  
  6.   B>  
  7. A>  
  8. 复制代码 

B 组件的 shouldComponentUpdate 中只比较了 data.a 和 data.b,目前是没任何问题的。之后开发者想在 C 组件中使用 data.c,假设项目中 data.a 和 data.c 是一起更新的,所以也没任何问题。但这份代码已经变得脆弱了,如果某次修改导致 data.a 和 data.c 不一起更新了,那么系统就会出问题。而且实际业务中代码往往更复杂,从 B 到 C 可能还有若干中间组件,这时就很难想到是 shouldComponentUpdate 引起的问题了。

拓展知识

       1.   第二个场景最好的解决方案是使用发布者订阅者模式,只是代码改动要稍多一些,可参考本文的优化技巧「发布者订阅者跳过中间组件 Render 过程[7]」。

        2.  第二个场景也可以在父子组件间增加中间组件,中间组件负责从父组件中选出子组件关心的属性,再传给子组件。相比于 shouldComponentUpdate 方法,会增加组件层级,但不会有第二个弊端。

        3.  本文中的跳过回调函数改变触发的 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 进行实现。 

  1. import { useState, useEffect, createContext, useContext } from "react"  
  2. const renderCntMap = {}  
  3. const renderOnce = name => {  
  4.   return (renderCntMap[name] = (renderCntMap[name] || 0) + 1)  
  5.  
  6. // 将需要公共访问的部分移动到 Context 中进行优化  
  7. // Context.Provider 就是发布者  
  8. // Context.Consumer 就是消费者  
  9. const ValueCtx = createContext()  
  10. const CtxContainer = ({ children }) => {  
  11.   const [cnt, setCnt] = useState(0)  
  12.   useEffect(() => {  
  13.     const timer = window.setInterval(() => {  
  14.       setCnt(v => v + 1)  
  15.     }, 1000)  
  16.     return () => clearInterval(timer)  
  17.   }, [setCnt])  
  18.   return <ValueCtx.Provider value={cnt}>{children}ValueCtx.Provider>  
  19.  
  20. function CompA({}) {  
  21.   const cnt = useContext(ValueCtx)  
  22.   // 组件内使用 cnt  
  23.   return <div>组件 CompA Render 次数:{renderOnce("CompA")}div>  
  24.  
  25. function CompB({}) {  
  26.   const cnt = useContext(ValueCtx)  
  27.   // 组件内使用 cnt  
  28.   return <div>组件 CompB Render 次数:{renderOnce("CompB")}div>  
  29.  
  30. function CompC({}) {  
  31.   return <div>组件 CompC Render 次数:{renderOnce("CompC")}div> 
  32.  
  33. export const PubSubCommunicate = () => {  
  34.   return (  
  35.     <CtxContainer>  
  36.       <div>  
  37.         <h1>优化后场景h1>  
  38.         <div>  
  39.           将状态提升至最低公共祖先的上层,用 CtxContainer 将其内容包裹。  
  40.         div>  
  41.         <div style={{ marginTop: "20px" }}>  
  42.           每次 Render 时,只有组件A和组件B会重新 Render 。  
  43.         div>  
  44.         <div style={{ marginTop: "40px" }}>  
  45.           父组件 Render 次数:{renderOnce("parent")}  
  46.         div>  
  47.         <CompA />  
  48.         <CompB />  
  49.         <CompC />  
  50.       div>  
  51.     CtxContainer>  
  52.   )  
  53.   
  54. export default PubSubCommunicate  
  55. 复制代码 

5. 状态下放,缩小状态影响范围

如果一个状态只在某部分子树中使用,那么可以将这部分子树提取为组件,并将该状态移动到该组件内部。如下面的代码所示,虽然状态 color 只在

中使用,但 color 改变会引起 重新 Render。 

  1. import { useState } from "react"  
  2. export default function App() {  
  3.   let [color, setColor] = useState("red")  
  4.   return (  
  5.     <div>  
  6.       <input value={color} onChange={e => setColor(e.target.value)} />  
  7.       <p style={{ color }}>Hello, world!p>  
  8.       <ExpensiveTree />  
  9.     div>  
  10.   )  
  11.   
  12. function ExpensiveTree() {  
  13.   let now = performance.now()  
  14.   while (performance.now() - now < 100) {  
  15.     // Artificial delay -- do nothing for 100ms  
  16.   }  
  17.   return <p>I am a very slow component tree.p>  
  18.  
  19. 复制代码 

通过将 color 状态、

提取到组件 Form 中,结果如下。 

  1. export default function App() {  
  2.   return (  
  3.     <>  
  4.       <Form />  
  5.       <ExpensiveTree />  
  6.     >  
  7.   )  
  8.  
  9. function Form() {  
  10.   let [color, setColor] = useState("red")  
  11.   return (  
  12.     <>  
  13.       <input value={color} onChange={e => setColor(e.target.value)} />  
  14.       <p style={{ color }}>Hello, world!p>  
  15.     >  
  16.   )  
  17.  
  18. 复制代码 

这样调整之后,color 改变就不会引起组件 App 和 ExpensiveTree 重新 Render 了。

如果对上面的场景进行扩展,在组件 App 的顶层和子树中都使用了状态 color ,但 仍然不关心它,如下所示。 

  1. import { useState } from "react"  
  2. export default function App() {  
  3.   let [color, setColor] = useState("red")  
  4.   return (  
  5.     <div style={{ color }}> 
  6.       <input value={color} onChange={e => setColor(e.target.value)} />  
  7.       <ExpensiveTree />  
  8.       <p style={{ color }}>Hello, world!p>  
  9.     div>  
  10.   )  
  11.  
  12. 复制代码 

在这种场景中,我们仍然将 color 状态抽取到新组件中,并提供一个插槽来组合 ,如下所示。 

  1. import { useState } from "react"  
  2. export default function App() {  
  3.   return <ColorContainer expensiveTreeNode={<ExpensiveTree />}>ColorContainer>  
  4.  
  5. function ColorContainer({ expensiveTreeNode }) {  
  6.   let [color, setColor] = useState("red")  
  7.   return (  
  8.     <div style={{ color }}>  
  9.       <input value={color} onChange={e => setColor(e.target.value)} />  
  10.       {expensiveTreeNode}  
  11.       <p style={{ color }}>Hello, world!p>  
  12.     div>  
  13.   )  
  14.  
  15. 复制代码 

这样调整之后,color 改变就不会引起组件 App 和 ExpensiveTree 重新 Render 了。

该优化技巧来源于 before-you-memo[13],Dan 认为这种优化方式在 Server Component 场景下更有效,因为 可以在服务端执行。

6. 列表项使用 key 属性

当渲染列表项时,如果不给组件设置不相等的属性 key,就会收到如下报警。

相信很多开发者已经见过该报警成百上千次了,那 key 属性到底在优化了什么呢?举个 🌰,在不使用 key 时,组件两次 Render 的结果如下。 

  1.   
  2. <ul>  
  3.   <li>Dukeli>  
  4.   <li>Villanovali>  
  5. ul>  
  6.   
  7. <ul>  
  8.   <li>Connecticutli>  
  9.   <li>Dukeli>  
  10.   <li>Villanovali>  
  11. ul>  
  12. 复制代码 

此时 React 的 Diff 算法会按照

  • 出现的先后顺序进行比较,得出结果为需要更新前两个
  • 并创建内容为 Villanova 的li,一共会执行两次 DOM 更新、一次 DOM 创建。

    如果加上 React 的 key 属性,两次 Render 结果如下。 

    1.   
    2. <ul>  
    3.   <li key="2015">Dukeli>  
    4.   <li key="2016">Villanovali>  
    5. ul>  
    6.   
    7. <ul>  
    8.   <li key="2014">Connecticutli>  
    9.   <li key="2015">Dukeli>  
    10.   <li key="2016">Villanovali> 
    11. ul>  
    12. 复制代码 

    React Diff 算法会把 key 值为 2015 的虚拟 DOM 进行比较,发现 key 为 2015 的虚拟 DOM 没有发生修改,不用更新。同样,key 值为 2016 的虚拟 DOM 也不需要更新。结果就只需要创建 key 值为 2014 的虚拟 DOM。相比于不使用 key 的代码,使用 key 节省了两次 DOM 更新操作。

    如果把例子中的

  • 换成自定义组件,并且自定义组件使用了 PureComponent 或 React.memo 优化。那么使用 key 属性就不只节省了 DOM 更新,还避免了组件的 Render 过程。

    React 官方推荐[14]将每项数据的 ID 作为组件的 key,以达到上述的优化目的。并且不推荐使用_每项的索引_作为 key,因为传索引作为 key 时,就会退化为不使用 key 时的代码。那么是否在所有列表渲染的场景下,使用 ID 都优于使用索引呢?

    答案是否定的,在常见的分页列表中,第一页和第二页的列表项 ID 都是不同,假设每页展示三条数据,那么切换页面前后组件 Render 结果如下。 

    1.   
    2. <li key="a">dataAli>  
    3. <li key="b">dataBli>  
    4. <li key="c">dataCli>  
    5.   
    6. <li key="d">dataDli>  
    7. <li key="e">dataEli>  
    8. <li key="f">dataFli>  
    9. 复制代码 

    切换到第二页后,由于所有

  • 的 key 值不同,所以 Diff 算法会将第一页的所有 DOM 节点标记为删除,然后将第二页的所有 DOM 节点标记为新增。整个更新过程需要三次 DOM 删除、三次 DOM 创建。如果不使用 key,Diff 算法只会将三个
  • 节点标记为更新,执行三次 DOM 更新。参考 Demo 没有添加、删除、排序功能的分页列表[15],使用 key 时每次翻页耗时约为 140ms,而不使用 key 仅为 70ms。

    尽管存在以上场景,React 官方仍然推荐使用 ID 作为每项的 key 值。其原因有两:

        1.  在列表中执行删除、插入、排序列表项的操作时,使用 ID 作为 key 将更高效。而翻页操作往往伴随着 API 请求,DOM 操作耗时远小于 API 请求耗时,是否使用 ID 在该场景下对用户体验影响不大。

        2.  使用 ID 做为 key 可以维护该 ID 对应的列表项组件的 State。举个例子,某表格中每列都有普通态和编辑态两个状态,起初所有列都是普通态,用户点击第一行第一列,使其进入编辑态。然后用户又拖拽第二行,将其移动到表格的第一行。如果开发者使用索引作为 key,那么第一行第一列的状态仍然为编辑态,而用户实际希望编辑的是第二行的数据,在用户看来就是不符合预期的。尽管这个问题可以通过将「是否处于编辑态」存放在数据项的数据中,利用 Props 来解决,但是使用 ID 作为 key 不是更香吗?

    7. useMemo 返回虚拟 DOM

    利用 useMemo 可以缓存计算结果的特点,如果 useMemo 返回的是组件的虚拟 DOM,则将在 useMemo 依赖不变时,跳过组件的 Render 阶段。该方式与 React.memo 类似,但与 React.memo 相比有以下优势:

    1.  更方便。React.memo 需要对组件进行一次包装,生成新的组件。而 useMemo 只需在存在性能瓶颈的地方使用,不用修改组件。
    2.  更灵活。useMemo 不用考虑组件的所有 Props,而只需考虑当前场景中用到的值,也可使用 useDeepCompareMemo[16] 对用到的值进行深比较。

    例子参考:useMemo 跳过组件 Render 过程[17]。该例子中,父组件状态更新后,不使用 useMemo 的子组件会执行 Render 过程,而使用 useMemo 的子组件不会执行。 

    1. import { useEffect, useMemo, useState } from "react"  
    2. import "./styles.css"  
    3. const renderCntMap = {}  
    4. function Comp({ name }) {  
    5.   renderCntMap[name] = (renderCntMap[name] || 0) + 1  
    6.   return (  
    7.     <div>  
    8.       组件「{name}」 Render 次数:{renderCntMap[name]}  
    9.     div>  
    10.   )  
    11.   
    12. export default function App() {  
    13.   const setCnt = useState(0)[1]  
    14.   useEffect(() => {  
    15.     const timer = window.setInterval(() => {  
    16.       setCnt(v => v + 1)  
    17.     }, 1000)  
    18.     return () => clearInterval(timer)  
    19.   }, [setCnt])   
    20.   const comp = useMemo(() => {  
    21.     return <Comp name="使用 useMemo 作为 children" />  
    22.   }, [])  
    23.   return (  
    24.     <div className="App">  
    25.       <Comp name="直接作为 children" />  
    26.       {comp}  
    27.     div>  
    28.   ) 
    29.  
    30. 复制代码 

    8. 跳过回调函数改变触发的 Render 过程

    React 组件的 Props 可以分为两类。a) 一类是在对组件 Render 有影响的属性,如:页面数据、getPopupContainer[18] 和 renderProps 函数。b) 另一类是组件 Render 后的回调函数,如:onClick、onVisibleChange[19]。b) 类属性并不参与到组件的 Render 过程,因为可以对 b) 类属性进行优化。当 b)类属性发生改变时,不触发组件的重新 Render ,而是在回调触发时调用最新的回调函数。

    Dan Abramov 在 A Complete Guide to useEffect[20] 文章中认为,每次 Render 都有自己的事件回调是一件很酷的特性。但该特性要求每次回调函数改变就触发组件的重新 Render ,这在性能优化过程中是可以取舍的。

    例子参考:跳过回调函数改变触发的 Render 过程[21]。Demo 中通过拦截子组件的 Props 实现,仅仅是因为笔者比较懒不想改了,这种实现方式也能开阔读者视野吧。实际上该优化思想应该通过 useMemo/React.memo 实现,且使用 useMemo 实现时也更容易理解。 

    1. import { Children, cloneElement, memo, useEffect, useRef } from "react"  
    2. import { useDeepCompareMemo } from "use-deep-compare"  
    3. import omit from "lodash.omit"  
    4. let renderCnt = 0  
    5. export function SkipNotRenderProps({ children, skips }) {  
    6.   if (!skips) {  
    7.     // 默认跳过所有回调函数  
    8.     skips = prop => prop.startsWith("on")  
    9.   }  
    10.   const child = Children.only(children)  
    11.   const childchildProps = child.props  
    12.   const propsRef = useRef({})  
    13.   const nextSkippedPropsRef = useRef({})  
    14.   Object.keys(childProps)  
    15.     .filter(it => skips(it))  
    16.     .forEach(key => {  
    17.       // 代理函数只会生成一次,其值始终不变  
    18.       nextSkippedPropsRef.current[key] =  
    19.         nextSkippedPropsRef.current[key] ||  
    20.         function skipNonRenderPropsProxy(...args) {  
    21.           propsRef.current[key].apply(this, args)  
    22.         }  
    23.     })  
    24.   useEffect(() => {  
    25.     propsRef.current = childProps  
    26.   })  
    27.   // 这里使用 useMemo 优化技巧  
    28.   // 除去回调函数,其他属性改变生成新的 React.Element  
    29.   return useShallowCompareMemo(() => {  
    30.     return cloneElement(child, {  
    31.       ...child.props,  
    32.       ...nextSkippedPropsRef.current,  
    33.     })  
    34.   }, [omit(childProps, Object.keys(nextSkippedPropsRef.current))])  
    35.  
    36. // SkipNotRenderPropsComp 组件内容和 Normal 内容一样  
    37. export function SkipNotRenderPropsComp({ onClick }) {  
    38.   return (  
    39.     <div className="case">  
    40.       <div className="caseHeader">  
    41.         跳过『与 Render 无关的 Props』改变触发的重新 Render  
    42.       div>  
    43.       Render 次数为:{++renderCnt}  
    44.       <div>  
    45.         <button onClick={onClick} style={{ color: "blue" }}>  
    46.           点我回调,回调弹出值为 1000(优化成功)  
    47.         button>  
    48.       div>  
    49.     div>  
    50.   )  
    51.  
    52. export default SkipNotRenderPropsComp  
    53. 复制代码 

    9. Hooks 按需更新

    如果自定义 Hook 暴露多个状态,而调用方只关心某一个状态,那么其他状态改变就不应该触发组件重新 Render。 

    1. export const useNormalDataHook = () => {  
    2.   const [data, setData] = useState({ info: null, count: null })  
    3.   useEffect(() => {  
    4.     const timer = setInterval(() => {  
    5.       setData(data => ({  
    6.         ...data,  
    7.         count: data.count + 1,  
    8.       }))  
    9.     }, 1000)  
    10.     return () => {  
    11.       clearInterval(timer)  
    12.     }  
    13.   })  
    14.   return data  
    15.  
    16. 复制代码 

    如上所示,useNormalDataHook 暴露了两个状态 info 和 count 给调用方,如果调用方只关心 info 字段,那么 count 改变就没必要触发调用方组件 Render。

    按需更新主要通过两步来实现,参考Hooks 按需更新[22]

    1.  根据调用方使用的数据进行依赖收集,Demo 中使用 Object.defineProperties 实现。
    2.  只在依赖发生改变时才触发组件更新。

    10. 动画库直接修改 DOM 属性

    这个优化在业务中应该用不上,但还是非常值得学习的,将来可以应用到组件库中。参考 react-spring[23] 的动画实现,当一个动画启动后,每次动画属性改变不会引起组件重新 Render ,而是直接修改了 dom 上相关属性值。

    例子演示:CodeSandbox 在线 Demo[24] 

    1. import React, { useState } from "react"  
    2. import { useSpring, animated as a } from "react-spring"  
    3. import "./styles.css"  
    4. let renderCnt = 0  
    5. export function Card() {  
    6.   const [flipped, set] = useState(false)  
    7.   const { transform, opacity } = useSpring({  
    8.     opacity: flipped ? 1 : 0,  
    9.     transform: `perspective(600px) rotateX(${flipped ? 180 : 0}deg)`,  
    10.     config: { mass: 5, tension: 500, friction: 80 },  
    11.   })  
    12.   // 尽管 opacity 和 transform 的值在动画期间一直变化  
    13.   // 但是并没有组件的重新 Render  
    14.   return (  
    15.     <div onClick={() => set(state => !state)}>  
    16.       <div style={{ position: "fixed", top: 10, left: 10 }}>  
    17.         Render 次数:{++renderCnt}  
    18.       div>  
    19.       <a.div  
    20.         class="c back"  
    21.         style={{ opacity: opacity.interpolate(o => 1 - o), transform }}  
    22.       />  
    23.       <a.div  
    24.         class="c front"  
    25.         style={{  
    26.           opacity,  
    27.           transform: transform.interpolate(t => `${t} rotateX(180deg)`),  
    28.         }}  
    29.       />  
    30.     div>  
    31.   )  
    32.  
    33. export default Card  
    34. 复制代码 

    提交阶段优化

    这类优化的目的是减少提交阶段耗时,该分类中仅有一条优化技巧。

    1. 避免在 didMount、didUpdate 中更新组件 State

    这个技巧不仅仅适用于 didMount、didUpdate,还包括 willUnmount、useLayoutEffect 和特殊场景下的 useEffect(当父组件的 cDU/cDM 触发时,子组件的 useEffect 会同步调用),本文为叙述方便将他们统称为「提交阶段钩子」。

    React 工作流[25]提交阶段的第二步就是执行提交阶段钩子,它们的执行会阻塞浏览器更新页面。如果在提交阶段钩子函数中更新组件 State,会再次触发组件的更新流程,造成两倍耗时。

    一般在提交阶段的钩子中更新组件状态的场景有:

        1.  计算并更新组件的派生状态(Derived State)。在该场景中,类组件应使用 getDerivedStateFromProps[26] 钩子方法代替,函数组件应使用函数调用时执行 setState[27]的方式代替。使用上面两种方式后,React 会将新状态和派生状态在一次更新内完成。

        2.  根据 DOM 信息,修改组件状态。在该场景中,除非想办法不依赖 DOM 信息,否则两次更新过程是少不了的,就只能用其他优化技巧了。

    use-swr 的源码[28]就使用了该优化技巧。当某个接口存在缓存数据时,use-swr 会先使用该接口的缓存数据,并在 requestIdleCallback 时再重新发起请求,获取最新数据。如果 use-swr 不做该优化的话,就会在 useLayoutEffect 中触发重新验证并设置 isValidating 状态为 true[29],引起组件的更新流程,造成性能损失。

    前端通用优化

    这类优化在所有前端框架中都存在,本文的重点就在于将这些技巧应用在 React 组件中。

    1. 组件按需挂载

    组件按需挂载优化又可以分为懒加载、懒渲染和虚拟列表三类。

    懒加载

    在 SPA 中,懒加载优化一般用于从一个路由跳转到另一个路由。还可用于用户操作后才展示的复杂组件,比如点击按钮后展示的弹窗模块(有时候弹窗就是一个复杂页面 😌)。在这些场景下,结合 Code Split 收益较高。

    懒加载的实现是通过 Webpack 的动态导入和 React.lazy 方法,

    参考例子 lazy-loading[30]。实现懒加载优化时,不仅要考虑加载态,还需要对加载失败进行容错处理。 

    1. import { lazy, Suspense, Component } from "react"  
    2. import "./styles.css"  
    3. // 对加载失败进行容错处理  
    4. class ErrorBoundary extends Component {  
    5.   constructor(props) {  
    6.     super(props)  
    7.     this.state = { hasError: false }  
    8.   }  
    9.   static getDerivedStateFromError(error) {  
    10.     return { hasError: true }  
    11.   }  
    12.   render() {  
    13.     if (this.state.hasError) {  
    14.       return <h1>这里处理出错场景h1>  
    15.     }  
    16.     return this.props.children  
    17.   }  
    18.  
    19. const Comp = lazy(() => {  
    20.   return new Promise((resolve, reject) => {  
    21.     setTimeout(() => {  
    22.       if (Math.random() > 0.5) {  
    23.         reject(new Error("模拟网络出错"))  
    24.       } else {  
    25.         resolve(import("./Component"))  
    26.       }  
    27.     }, 2000)  
    28.   })  
    29. })  
    30. export default function App() {  
    31.   return (  
    32.     <div className="App">  
    33.       <div style={{ marginBottom: 20 }}>  
    34.         实现懒加载优化时,不仅要考虑加载态,还需要对加载失败进行容错处理。 
    35.       div>  
    36.       <ErrorBoundary>  
    37.         <Suspense fallback="Loading...">  
    38.           <Comp />  
    39.         Suspense>  
    40.       ErrorBoundary>  
    41.     div>  
    42.   )  
    43.  
    44. 复制代码 

    懒渲染

    懒渲染指当组件进入或即将进入可视区域时才渲染组件。常见的组件 Modal/Drawer 等,当 visible 属性为 true 时才渲染组件内容,也可以认为是懒渲染的一种实现。

    懒渲染的使用场景有:

        1.  页面中出现多次的组件,且组件渲染费时、或者组件中含有接口请求。如果渲染多个带有请求的组件,由于浏览器限制了同域名下并发请求的数量,就可能会阻塞可见区域内的其他组件中的请求,导致可见区域的内容被延迟展示。

        2.  需用户操作后才展示的组件。这点和懒加载一样,但懒渲染不用动态加载模块,不用考虑加载态和加载失败的兜底处理,实现上更简单。

    懒渲染的实现中判断组件是否出现在可视区域内是通过 react-visibility-observer[31] 进行监听。

    例子参考:懒渲染[32] 

    1. import { useState, useEffect } from "react"  
    2. import VisibilityObserver, {  
    3.   useVisibilityObserver,  
    4. } from "react-visibility-observer"  
    5. const VisibilityObserverChildren = ({ callback, children }) => {  
    6.   const { isVisible } = useVisibilityObserver()  
    7.   useEffect(() => {  
    8.     callback(isVisible)  
    9.   }, [callback, isVisible])  
    10.   return <>{children}>  
    11.  
    12. export const LazyRender = () => {  
    13.   const [isRendered, setIsRendered] = useState(false)  
    14.   if (!isRendered) {  
    15.     return (  
    16.       <VisibilityObserver rootMargin={"0px 0px 0px 0px"}>  
    17.         <VisibilityObserverChildren 
    18.            callback={isVisible => {  
    19.             if (isVisible) {  
    20.               setIsRendered(true)  
    21.             }  
    22.           }}  
    23.         >  
    24.           <span />  
    25.         VisibilityObserverChildren>  
    26.       VisibilityObserver>  
    27.     )  
    28.   }  
    29.   console.log("滚动到可视区域才渲染")  
    30.   return <div>我是 LazyRender 组件div>  
    31.  
    32. export default LazyRender 
    33. 复制代码 

    虚拟列表

    虚拟列表是懒渲染的一种特殊场景。虚拟列表的组件有 react-window[33] 和 react-virtualized,它们都是同一个作者开发的。react-window 是 react-virtualized 的轻量版本,其 API 和文档更加友好。所以新项目中推荐使用 react-window,而不是使用 Star 更多的 react-virtualized。

    使用 react-window 很简单,只需要计算每项的高度即可。下面代码中每一项的高度是 35px。

    例子参考:官方示例[34] 

    1. import { FixedSizeList as List } from "react-window"  
    2. const Row = ({ index, style }) => <div style={style}>Row {index}div>  
    3. const Example = () => (  
    4.   <List  
    5.     height={150}  
    6.     itemCount={1000}  
    7.     itemSize={35} // 每项的高度为 35  
    8.     width={300}  
    9.   >  
    10.     {Row}  
    11.   List>  
    12.  
    13. 复制代码 

    如果每项的高度是变化的,可给 itemSize 参数传一个函数。

    对于这个优化点,笔者遇到一个真实案例。在公司的招聘项目中,通过下拉菜单可查看某个候选人的所有投递记录。平常这个列表也就几十条,但后来用户反馈『下拉菜单点击后要很久才能展示出投递列表』。该问题的原因就是这个候选人在我们系统中有上千条投递,一次性展示上千条投递导致页面卡住了。所以在开发过程中,遇到接口返回的是所有数据时,需提前预防这类 bug,使用虚拟列表优化。

    2. 批量更新

    我们先回忆一道前几年的 React 面试常考题,React 类组件中 setState 是同步的还是异步的?如果对类组件不熟悉也没关系,可以将 setState 理解为 useState 的第二个返回值。

    balabala...

    答案是:在 React 管理的事件回调和生命周期中,setState 是异步的,而其他时候 setState 都是同步的。这个问题根本原因就是 React 在自己管理的事件回调和生命周期中,对于 setState 是批量更新的,而在其他时候是立即更新的。读者可参考线上示例 setState 同步还是异步[35],并自行验证。

    批量更新 setState 时,多次执行 setState 只会触发一次 Render 过程。相反在立即更新 setState 时,每次 setState 都会触发一次 Render 过程,就存在性能影响。

    假设有如下组件代码,该组件在 getData() 的 API 请求结果返回后,分别更新了两个 State 。线上代码实操参考:batchUpdates 批量更新[36]。 

    1. function NormalComponent() {  
    2.   const [list, setList] = useState(null)  
    3.   const [info, setInfo] = useState(null)  
    4.   useEffect(() => {  
    5.     ;(async () => {  
    6.       const data = await getData()  
    7.       setList(data.list)  
    8.       setInfo(data.info)  
    9.     })()  
    10.   }, [])  
    11.   return <div>非批量更新组件时 Render 次数:{renderOnce("normal")}div>  
    12.  
    13. 复制代码 

    该组件会在 setList(data.list) 后触发组件的 Render 过程,然后在 setInfo(data.info) 后再次触发 Render 过程,造成性能损失。遇到该问题,开发者有两种实现批量更新的方式来解决该问题:

        1.  将多个 State 合并为单个 State。例如使用 `const [data, setData] = useState({ list: null, info: null })` 替代 list 和 info 两个 State。

        2.  使用 React 官方提供的 unstable\_batchedUpdates 方法,将多次 setState 封装到 unstable\_batchedUpdates 回调中。修改后代码如下。 

    1. function BatchedComponent() {  
    2.   const [list, setList] = useState(null)  
    3.   const [info, setInfo] = useState(null)  
    4.   useEffect(() => {  
    5.     ;(async () => {  
    6.       const data = await getData()  
    7.       unstable_batchedUpdates(() => {  
    8.         setList(data.list)  
    9.         setInfo(data.info)  
    10.       })  
    11.     })()  
    12.   }, [])  
    13.   return <div>批量更新组件时 Render 次数:{renderOnce("batched")}div>  
    14.  
    15. 复制代码 

    拓展知识

            1.  推荐阅读为什么 setState 是异步的?[37]

            2.  为什么面试官不会问“函数组件中的 setState 是同步的还是异步的?”?因为函数组件中生成的函数是通过闭包引用了 state,而不是通过 this.state 的方式引用 state,所以函数组件的处理函数中 state 一定是旧值,不可能是新值。可以说函数组件已经将这个问题屏蔽掉了,所以面试官也就不会问了。可参考线上示例[38]。

            3.  根据官方文档[39],在 React 并发模式中,将默认以批量更新方式执行 setState。到那时候,也可能就不需要这个优化了。

    3. 按优先级更新,及时响应用户

    优先级更新是批量更新的逆向操作,其思想是:优先响应用户行为,再完成耗时操作。

    常见的场景是:页面弹出一个 Modal,当用户点击 Modal 中的确定按钮后,代码将执行两个操作。a) 关闭 Modal。b) 页面处理 Modal 传回的数据并展示给用户。当 b) 操作需要执行 500ms 时,用户会明显感觉到从点击按钮到 Modal 被关闭之间的延迟。

    例子参考:CodeSandbox 在线 Demo[40]。在该例子中,用户添加一个整数后,页面要隐藏输入框,并将新添加的整数加入到整数列表,将列表排序后再展示。以下为一般的实现方式,将 slowHandle 函数作为用户点击按钮的回调函数。 

    1. const slowHandle = () => {  
    2.   setShowInput(false)  
    3.   setNumbers([...numbers, +inputValue].sort((a, b) => a - b))  
    4.  
    5. 复制代码 

    slowHandle() 执行过程耗时长,用户点击按钮后会明显感觉到页面卡顿。如果让页面优先隐藏输入框,用户便能立刻感知到页面更新,不会有卡顿感。实现优先级更新的要点是将耗时任务移动到下一个宏任务中执行,优先响应用户行为。 例如在该例中,将 setNumbers 移动到 setTimeout 的回调中,用户点击按钮后便能立即看到输入框被隐藏,不会感知到页面卡顿。优化后的代码如下。 

    1. const fastHandle = () => {  
    2.   // 优先响应用户行为  
    3.   setShowInput(false)  
    4.   // 将耗时任务移动到下一个宏任务执行  
    5.   setTimeout(() => {  
    6.     setNumbers([...numbers, +inputValue].sort((a, b) => a - b))  
    7.   })  
    8.  
    9. 复制代码 

    4. 缓存优化

    缓存优化往往是最简单有效的优化方式,在 React 组件中常用 useMemo 缓存上次计算的结果。当 useMemo 的依赖未发生改变时,就不会触发重新计算。一般用在「计算派生状态的代码」非常耗时的场景中,如:遍历大列表做统计信息。

    拓展知识

    1.   React 官方并不保证 useMemo 一定会进行缓存,所以可能在依赖不改变时,仍然执行重新计算。参考 How to memoize calculations[41]
    2.   useMemo 只能缓存最近一次函数执行的结果,如果想缓存更多次函数执行的结果,可使用 memoizee[42]。

    5. debounce、throttle 优化频繁触发的回调

    在搜索组件中,当 input 中内容修改时就触发搜索回调。当组件能很快处理搜索结果时,用户不会感觉到输入延迟。但实际场景中,中后台应用的列表页非常复杂,组件对搜索结果的 Render 会造成页面卡顿,明显影响到用户的输入体验。

    在搜索场景中一般使用 useDebounce[43] + useEffect 的方式获取数据。

    例子参考:debounce-search[44]。 

    1. import { useState, useEffect } from "react"  
    2. import { useDebounce } from "use-debounce"  
    3. export default function App() {  
    4.   const [text, setText] = useState("Hello")  
    5.   const [debouncedValue] = useDebounce(text, 300)  
    6.   useEffect(() => {  
    7.     // 根据 debouncedValue 进行搜索 
    8.   }, [debouncedValue])  
    9.   return (  
    10.     <div>  
    11.       <input  
    12.         defaultValue={"Hello"}  
    13.         onChange={e => {  
    14.           setText(e.target.value)  
    15.         }}  
    16.       /> 
    17.        <p>Actual value: {text}p>  
    18.       <p>Debounce value: {debouncedValue}p>  
    19.     div>  
    20.   )  
    21.  
    22. 复制代码 

    为什么搜索场景中是使用 debounce,而不是 throttle 呢?

    throttle 是 debounce 的特殊场景,throttle 给 debounce 传了 maxWait 参数,可参考 useThrottleCallback[45]。在搜索场景中,只需响应用户最后一次输入,无需响应用户的中间输入值,debounce 更适合使用在该场景中。而 throttle 更适合需要实时响应用户的场景中更适合,如通过拖拽调整尺寸或通过拖拽进行放大缩小(如:window 的 resize 事件)。实时响应用户操作场景中,如果回调耗时小,甚至可以用 requestAnimationFrame 代替 throttle。

    React Profiler 定位 Render 过程瓶颈

    React Profiler 是 React 官方提供的性能审查工具,本文只介绍笔者的使用心得,详细的使用手册请移步官网文档[46]。

    Profiler 只记录了 Render 过程耗时

    通过 React Profiler,开发者可以查看组件 Render 过程耗时,但无法知晓提交阶段的耗时。尽管 Profiler 面板中有 Committed at 字段,但这个字段是相对于录制开始时间,根本没有意义。所以提醒读者不要通过 Profiler 定位非 Render 过程的性能瓶颈问题。

    通过在 React v16 版本上进行实验,同时开启 Chrome 的 Performance 和 React Profiler 统计。如下图,在 Performance 面板中,调和阶段和提交阶段耗时分别为 642ms 和 300ms,而 Profiler 面板中只显示了 642ms,没有提交阶段的耗时信息。

    拓展知识

    1.    React 在 v17 版本后已移除 User Timing 统计功能,具体原因可参考 PR#18417[47]。
    2.    在 v17 版本上,笔者也通过测试代码[48]验证了 Profiler 中的统计信息并不包含提交阶段,有兴趣的读者可以看看。

    开启「记录组件更新原因」

    点击面板上的齿轮,然后勾选「Record why each component rendered while profiling.」,如下图。

    然后点击面板中的虚拟 DOM 节点,右侧便会展示该组件重新 Render 的原因。

    定位产生本次 Render 过程原因

    由于 React 的批量更新(Batch Update)机制,产生一次 Render 过程可能涉及到很多个组件的状态更新。那么如何定位是哪些组件状态更新导致的呢?

    在 Profiler 面板左侧的虚拟 DOM 树结构中,从上到下审查每个发生了渲染的(不会灰色的)组件。如果组件是由于 State 或 Hook 改变触发了 Render 过程,那它就是我们要找的组件,如下图。

    结语

    笔者是从年前开始写这篇文章,到发布时已经写了一个月了,期间断断续续将自己这几年对 React 的理解加入到文章中,然后调整措辞和丰富示例,最后终于在周四前完成(周四是我定的 deadline 😌)。

    既然自己付出了那么多努力,那就希望它能成为一份优秀的 React 优化手册吧。 

     

  • 来源:前端大全内容投诉

    免责声明:

    ① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

    ② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

    软考中级精品资料免费领

    • 历年真题答案解析
    • 备考技巧名师总结
    • 高频考点精准押题
    • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

      难度     813人已做
      查看
    • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

      难度     354人已做
      查看
    • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

      难度     318人已做
      查看
    • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

      难度     435人已做
      查看
    • 2024年上半年系统架构设计师考试综合知识真题

      难度     224人已做
      查看

    相关文章

    发现更多好内容

    猜你喜欢

    AI推送时光机
    位置:首页-资讯-后端开发
    咦!没有更多了?去看看其它编程学习网 内容吧
    首页课程
    资料下载
    问答资讯