16个React 性能优化方法 | 包括原理、技巧、Demo、工具使用
16个React 性能优化方法 | 包括原理、技巧、Demo、工具使用
性能优化的三个方面:
1、前端通用优化。这类优化在所有前端框架中都存在,重点就在于如何将这些技巧应用在 React 组件中。
2、减少不必要的组件更新。这类优化是在组件状态发生变更后,通过减少不必要的组件更新来实现,对应到 React 中就是:减少渲染的节点 、降低组件渲染的复杂度、充分利用缓存避免重新渲染(利用缓存可以考虑使用PureComponent、React.memo、hook函数useCallback、useMemo等方法)
PureComponent 是对类组件的 Props 和 State 进行浅比较;React.memo 是对函数组件的 Props 进行浅比较
3、提交阶段优化。这类优化的目的是减少提交阶段耗时。
前端通用优化
这类优化在所有前端框架中都存在,本文的重点就在于将这些技巧应用在 React 组件中。
1、组件按需加载
组件按需加载优化又可以分为:懒加载、懒渲染、虚拟列表 三类。
懒加载
在 SPA 中,懒加载优化一般用于从一个路由跳转到另一个路由。还可用于用户操作后才展示的复杂组件,比如点击按钮后展示的弹窗模块。在这些场景下,可以结合 Code Split 实现。
懒加载的实现主要是通过 Webpack 的动态导入和 React.lazy
方法。注意,实现懒加载优化时,不仅要考虑加载态,还需要对加载失败进行容错处理。
import { lazy, Suspense, Component } from "react"
import "./styles.css"
// 对加载失败进行容错处理
class ErrorBoundary extends Component {
constructor(props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error) {
return { hasError: true }
}
render() {
if (this.state.hasError) {
return <h1>这里处理出错场景</h1>
}
return this.props.children
}
}
const Comp = lazy(() => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.5) {
reject(new Error("模拟网络出错"))
} else {
resolve(import("./Component"))
}
}, 2000)
})
})
export default function App() {
return (
<div className="App">
<div style={{ marginBottom: 20 }}>
实现懒加载优化时,不仅要考虑加载态,还需要对加载失败进行容错处理。
</div>
<ErrorBoundary>
<Suspense fallback="Loading...">
<Comp />
</Suspense>
</ErrorBoundary>
</div>
)
}
懒渲染
懒渲染指当组件进入或即将进入可视区域时才渲染组件。常见的组件 Modal/Drawer 等,当 visible 属性为 true 时才渲染组件内容,也可以认为是懒渲染的一种实现。
懒渲染的使用场景有:
- 页面中出现多次的组件,且组件渲染费时、或者组件中含有接口请求。如果渲染多个带有请求的组件,由于浏览器限制了同域名下并发请求的数量,就可能会阻塞可见区域内的其他组件中的请求,导致可见区域的内容被延迟展示。
- 需用户操作后才展示的组件。这点和懒加载一样,但懒渲染不用动态加载模块,不用考虑加载态和加载失败的兜底处理,实现上更简单。
判断组件是否出现在可视区域内是通过 react-visibility-observer 进行监听。
import { useState, useEffect } from "react"
import VisibilityObserver, {
useVisibilityObserver,
} from "react-visibility-observer"
const VisibilityObserverChildren = ({ callback, children }) => {
const { isVisible } = useVisibilityObserver()
useEffect(() => {
callback(isVisible)
}, [callback, isVisible])
return <>{children}</>
}
export const LazyRender = () => {
const [isRendered, setIsRendered] = useState(false)
if (!isRendered) {
return (
<VisibilityObserver rootMargin={"0px 0px 0px 0px"}>
<VisibilityObserverChildren
callback={isVisible => {
if (isVisible) {
setIsRendered(true)
}
}}
>
<span />
</VisibilityObserverChildren>
</VisibilityObserver>
)
}
console.log("滚动到可视区域才渲染")
return <div>我是 LazyRender 组件</div>
}
export default LazyRender
虚拟列表
虚拟列表是懒渲染的一种特殊场景。实现虚拟列表的组件有 react-window 和 react-virtualized。react-window 是 react-virtualized 的轻量版本,其 API 和文档更加友好。新项目中推荐使用 react-window。
使用 react-window 很简单,只需要计算每项的高度即可。如果每项的高度是变化的,可给 itemSize 参数传一个函数。
import { FixedSizeList as List } from "react-window"
const Row = ({ index, style }) => <div style={style}>Row {index}</div>
const Example = () => (
<List
height={150}
itemCount={1000}
itemSize={35} // 每项的高度为 35
width={300}
>
{Row}
</List>
)
2、批量更新
在React18中会有并发模式,在并发模式中,将默认以批量更新方式执行 setState。到那时候,或许就不需要这个优化了。
3、按优先级更新,及时响应用户
优先级更新是批量更新的逆向操作,其思想是:优先响应用户行为,再完成耗时操作。
常见的场景是:页面弹出一个 Modal,当用户点击 Modal 中的确定按钮后,代码将执行两个操作。a) 关闭 Modal。b) 页面处理 Modal 传回的数据并展示给用户。当 b) 操作需要执行 500ms 时,用户会明显感觉到从点击按钮到 Modal 被关闭之间的延迟。
4、利用debounce、throttle 避免重复回调
在搜索组件中,当 input 中内容修改时就触发搜索回调。当组件能很快处理搜索结果时,用户不会感觉到输入延迟。但实际场景中,中后台应用的列表页非常复杂,组件对搜索结果的 Render 会造成页面卡顿,明显影响到用户的输入体验。
在搜索场景中一般使用 useDebounce + useEffect 的方式获取数据。
例子参考:debounce-search。
import { useState, useEffect } from "react"
import { useDebounce } from "use-debounce"
export default function App() {
const [text, setText] = useState("Hello")
const [debouncedValue] = useDebounce(text, 300)
useEffect(() => {
// 根据 debouncedValue 进行搜索
}, [debouncedValue])
return (
<div>
<input
defaultValue={"Hello"}
onChange={e => {
setText(e.target.value)
}}
/>
<p>Actual value: {text}</p>
<p>Debounce value: {debouncedValue}</p>
</div>
)
}
为什么搜索场景中是使用 debounce,而不是 throttle 呢?
throttle 是 debounce 的特殊场景,throttle 给 debounce 传了 maxWait 参数,可参考 useThrottleCallback。在搜索场景中,只需响应用户最后一次输入,无需响应用户的中间输入值,debounce 更适合使用在该场景中。而 throttle 更适合需要实时响应用户的场景中更适合,如通过拖拽调整尺寸或通过拖拽进行放大缩小(如:window 的 resize 事件)。实时响应用户操作场景中,如果回调耗时小,甚至可以用 requestAnimationFrame 代替 throttle。
5、缓存优化
缓存优化往往是最简单有效的优化方式,在 React 组件中常用 useMemo 缓存上次计算的结果。当 useMemo 的依赖未发生改变时,就不会触发重新计算。一般用在「计算派生状态的代码」非常耗时的场景中,如:遍历大列表做统计信息。
- React 官方并不保证 useMemo 一定会进行缓存,所以可能在依赖不改变时,仍然执行重新计算。参考 How to memoize calculations
- useMemo 只能缓存最近一次函数执行的结果,如果想缓存更多次函数执行的结果,可使用 memoizee。
跳过不必要的组件更新
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,并在状态更新时直接修改该模块变量的属性值,而不是使用展开语法生成新的对象引用。例如要往数组中添加一项数据时,当时的代码很可能是 state.push(item)
,而不是 const newState = [...state, item]
。这点可参考 Dan Abramov 在演讲 Redux 时演示的 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.c */}
<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 过程」。
第二个场景也可以在父子组件间增加中间组件,中间组件负责从父组件中选出子组件关心的属性,再传给子组件。相比于 shouldComponentUpdate 方法,会增加组件层级,但不会有第二个弊端。
本文中的跳过回调函数改变触发的 Render 过程也可以用 shouldComponentUpdate 实现,因为回调函数并不参与组件的 Render 过程。
3、useMemo、useCallback 实现稳定的 Props 值
如果传给子组件的派生状态或函数,每次都是新的引用,那么 PureComponent 和 React.memo 优化就会失效。所以需要使用 useMemo 和 useCallback 来生成稳定值,并结合 PureComponent 或 React.memo 避免子组件重新 Render。
拓展知识
useCallback 是「useMemo 的返回值为函数」时的特殊情况,是 React 提供的便捷方式。在 React Server Hooks 代码 中,useCallback 就是基于 useMemo 实现的。尽管 React Client Hooks 没有使用同一份代码,但 useCallback 的代码逻辑和 useMemo 的代码逻辑仍是一样的。
4、发布者订阅者跳过中间组件 Render 过程
React 推荐将公共数据放在所有「需要该状态的组件」的公共祖先上,但将状态放在公共祖先上后,该状态就需要层层向下传递,直到传递给使用该状态的组件为止。
每次状态的更新都会涉及中间组件的 Render 过程,但中间组件并不关心该状态,它的 Render 过程只负责将该状态再传给子组件。在这种场景下可以将状态用发布者订阅者模式维护,只有关心该状态的组件才去订阅该状态,不再需要中间组件传递该状态。当状态更新时,发布者发布数据更新消息,只有订阅者组件才会触发 Render 过程,中间组件不再执行 Render 过程。
只要是发布者订阅者模式的库,都可以进行该优化。比如:redux、use-global-state、React.createContext 等。例子参考:发布者订阅者模式跳过中间组件的渲染阶段,本示例使用 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={{