跳到主要内容

性能优化

跳过不必要的组件更新

1. PureComponent、React.memo

2. shouldComponentUpdate

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

如果传给子组件的派生状态或函数,每次都是新的引用,那么 PureComponent 和 React.memo 优化就会失效。所以需要使用 useMemo 和 useCallback 来生成稳定值,并结合 PureComponent 或 React.memo 避免子组件重新 Render。

一般场景:

  1. 内联的函数定义
  2. 内联的样式对象

4. 发布者订阅者跳过中间组件 Render 过程

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

如果一个状态只在某部分子树中使用,那么可以将这部分子树提取为组件,并将该状态移动到该组件内部。如下面的代码所示,虽然状态 color 只在 <input /><p /> 中使用,但 color 改变会引起 <ExpensiveTree /> 重新 Render。

import { useState } from 'react'

export default function App() {
const [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() {
return <p>I am a very slow component tree.</p>
}

通过将 color 状态、<input /><p /> 提取到组件 Form 中,结果如下。

export default function App() {
return (
<>
<Form />
<ExpensiveTree />
</>
)
}

function Form() {
const [color, setColor] = useState('red')
return (
<>
<input value={color} onChange={(e) => setColor(e.target.value)} />
<p style={{ color }}>Hello, world!</p>
</>
)
}

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

6. 列表项使用 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 更新。

尽管存在以上场景,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,而只需考虑当前场景中用到的值。

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

React 组件的 Props 可以分为两类。

  • 一类是在对组件 Render 有影响的属性,如:页面数据、getPopupContainer 和 renderProps 函数。
  • 另一类是组件 Render 后的回调函数,如:onClick、onVisibleChange

该优化思想应该通过 useMemo/React.memo 实现,且使用 useMemo 实现时也更容易理解。

9. Hooks 按需更新

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

export const useNormalDataHook = () => {
const [data, setData] = useState({ info: null, count: null })
useEffect(() => {
const timer = setInterval(() => {
setData((data) => ({
...data,
count: data.count + 1,
}))
}, 1000)

return () => {
clearInterval(timer)
}
})

return data
}

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

按需更新主要通过两步来实现:

  1. 根据调用方使用的数据进行依赖收集,Demo 中使用 Object.defineProperties 实现。
  2. 只在依赖发生改变时才触发组件更新。
export const useOnDemandDataHook = () => {
const setter = useState({})[1]
const forceUpdate = useCallback(() => setter({}), [setter])
const dependenciesRef = useRef({ info: false, count: false })
const dataRef = useRef({ info: null, count: 0 })
const dispatch = useCallback(
(payload) => {
dataRef.current = { ...dataRef.current, ...payload }
const needUpdate = Object.keys(payload).some(
(key) => dependenciesRef.current[key],
)
if (needUpdate) {
forceUpdate()
}
},
[forceUpdate],
)

useEffect(() => {
const timer = setInterval(() => {
dispatch({ count: dataRef.current.count + 1 })
}, 1000)

return () => {
clearInterval(timer)
}
}, [dispatch])

return useMemo(() => {
return Object.defineProperties(
{},
{
info: {
get: function () {
dependenciesRef.current.info = true
return dataRef.current.info
},
enumerable: true,
},
count: {
get: function () {
dependenciesRef.current.count = true
return dataRef.current.count
},
enumerable: true,
},
},
)
}, [])
}

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

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

这个技巧不仅仅适用于 didMountdidUpdate,还包括 willUnmountuseLayoutEffect 和特殊场景下的 useEffect(当父组件的 cDU/cDM 触发时,子组件的 useEffect 会同步调用)。

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

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

  1. 计算并更新组件的派生状态(Derived State)。在该场景中,类组件应使用 getDerivedStateFromProps 钩子方法代替,函数组件应使用函数调用时执行 setState的方式代替。使用上面两种方式后,React 会将新状态和派生状态在一次更新内完成。
  2. 根据 DOM 信息,修改组件状态。在该场景中,除非想办法不依赖 DOM 信息,否则两次更新过程是少不了的,就只能用其他优化技巧了。

前端通用优化

1. 组件按需挂载

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

懒加载

懒加载的实现是通过: Webpack 的动态导入 + React.lazy + suspense 方法

懒渲染

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

懒渲染的使用场景有:

  1. 页面中出现多次的组件,且组件渲染费时、或者组件中含有接口请求。如果渲染多个带有请求的组件,由于浏览器限制了同域名下并发请求的数量,就可能会阻塞可见区域内的其他组件中的请求,导致可见区域的内容被延迟展示。
  2. 需用户操作后才展示的组件。这点和懒加载一样,但懒渲染不用动态加载模块,不用考虑加载态和加载失败的兜底处理,实现上更简单。

虚拟列表

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

2. 批量更新

假设有如下组件代码,该组件在 getData() 的 API 请求结果返回后,分别更新了两个 State 。

function NormalComponent() {
const [list, setList] = useState(null)
const [info, setInfo] = useState(null)

useEffect(() => {
;(async () => {
const data = await getData()
setList(data.list)
setInfo(data.info)
})()
}, [])

return <div>非批量更新组件时 Render 次数:{renderOnce('normal')}</div>
}

该组件会在 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 回调中。修改后代码如下。
function BatchedComponent() {
const [list, setList] = useState(null)
const [info, setInfo] = useState(null)

useEffect(() => {
;(async () => {
const data = await getData()
unstable_batchedUpdates(() => {
setList(data.list)
setInfo(data.info)
})
})()
}, [])

return <div>批量更新组件时 Render 次数:{renderOnce('batched')}</div>
}

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

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

常见的场景是:页面弹出一个 Modal,当用户点击 Modal 中的确定按钮后,代码将执行两个操作。

  1. 关闭 Modal。
  2. 页面处理 Modal 传回的数据并展示给用户。

当 第二步 操作需要执行 500ms 时,用户会明显感觉到从点击按钮到 Modal 被关闭之间的延迟。

以下为一般的实现方式,将 slowHandle 函数作为用户点击按钮的回调函数。

const slowHandle = () => {
setShowInput(false)
setNumbers([...numbers, +inputValue].sort((a, b) => a - b))
}

slowHandle() 执行过程耗时长,用户点击按钮后会明显感觉到页面卡顿。如果让页面优先隐藏输入框,用户便能立刻感知到页面更新,不会有卡顿感。

实现优先级更新的要点是将耗时任务移动到下一个宏任务中执行,优先响应用户行为。

例如在该例中,将 setNumbers 移动到 setTimeout 的回调中,用户点击按钮后便能立即看到输入框被隐藏,不会感知到页面卡顿。优化后的代码如下

const fastHandle = () => {
// 优先响应用户行为
setShowInput(false)
// 将耗时任务移动到下一个宏任务执行
setTimeout(() => {
setNumbers([...numbers, +inputValue].sort((a, b) => a - b))
})
}

4. 缓存优化

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

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

其他方法

使用 React Fragments 避免额外标记

使用不可变数据

不可变数据并不能直接提升应用性能,但是能规避由于对象数据的改变,导致 React 渲染与期望不一致的问题。

另外也能简化 shouldComponentUpdate 的对比

优化场景分类总结

  1. 如果是因为存在不必要更新的组件进入了 Render 过程,则选择跳过不必要的组件更新进行优化。
  2. 如果是因为页面挂载了太多不可见的组件,则选择懒加载、懒渲染或虚拟列表进行优化。
  3. 如果是因为多次设置状态,引起了多次状态更新,则选择批量更新debounce、throttle 优化频繁触发的回调进行优化。
  4. 如果组件 Render 逻辑的确非常耗时,我们需要先定位到耗时代码,并判断能否通过缓存优化它。如果能,则选择缓存优化,否则选择按优先级更新,及时响应用户,将组件逻辑进行拆解,以便更快响应用户。

参考文章