背景
在寻常的页面的业务开发中,不太容易遇到很明显的性能问题,但一旦遇到性能问题,却是难以解决的。本文就来介绍一些性能优化的手段来提升性能,优化用户的使用体验。
问题
- 如何衡量性能?性能好坏仅仅是用户的主观感受吗?
- 性能优化的切入点是什么?是否会过度优化带来更高的心智负担?
性能问题来源
哪种性能问题才是需要去解决的?
- 用户反馈的,操作后带来页面卡顿,无法进行后续的操作,需要等待
- 开发过程中,测试功能时,明显感觉到操作的卡顿,例如:点击按钮反应慢,输入框输入后内容出现慢
定位问题
如何知道哪段代码是特别消耗性能的,如何通过浏览器控制台快速定位耗时操作?
Performance 面板
浏览器的这个面板,用来检测性能。 通常是因为一个操作,比如:按钮点击,输入框输入时,会出现卡顿的情况,所以我们需要用这个面板来检测这个操作的耗时时长,以及耗时代码。
问题举例
re-render 带来不必要的计算
tsx
import React from "react";
function fib(n: number): number {
if (n === 0) return 0;
if (n === 1) return 1;
return fib(n - 1) + fib(n - 2);
}
function Child({ text, count }: { text: string, count: number }) {
const fibCount = fib(count);
return (<>
<h2>{text}</h2>
<h3>{fibCount}</h3>
</>);
}
export default () => {
const [text, setText] = React.useState("");
const [count, setCount] = React.useState(37);
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={() => setCount(count + 1)}>+1</button>
<Child text={text} count={count} />
</div>
)
}
由使用测试可以知道,输入框的输入改变很卡顿,这时候在不看大量代码的前提下,先使用前文讲到的 Performance 面板来测试这个操作(行为,动作)的耗时情况。
- 点击录制,操作,等待页面响应完毕
- 由图可知,操作出现了很多长任务 Task ,在 Summary 面板也可以看到,Scripting 脚本执行时间是很长的,通常来说,Scripting 脚本执行时间,是我们是可以通过优化代码来控制的。
- 查看 Bottom-Up 面板,Call Tree 面板,或者将 Task 往下滚动查看。可以看到,执行时间长的函数是 fib (注意:如果不在开发阶段,可能由于代码混淆,导致这里的名称被混淆,导致无法在代码中找到它),我们去代码中定位它。
- 看代码可知,fib 是一个函数,它的执行时机是当 Child 组件 re-render 时,就会执行,如此我们需要去从本次的操作出发,去判断 Child 组件为什么会 re-render ,以及是否应该执行 fib 函数。
- 操作是输入框的改变,输入框的改变带来 text 这个 state 更新,state 更新触发当前组件的 re-render ,当前组件 re-render ,子组件在不使用(memo 或 类组件的 PureComponent)的前提下,也一定会 re-render ,所以 Child 组件会 re-render 。Child 组件依赖 text 的改变,所以在输入框改变时,Child re-render 是应该的,而 fib 函数的执行是不应该的,因为 fib 函数的结果值只依赖于 count ,我们可以借助 useMemo 来将 fib 函数执行的返回值给缓存起来,从而达到 text 改变引起的 Child 组件 re-render ,fib 函数不再执行。
结论:使用 useMemo 后,再去通过面板测试性能。
tsx
function Child({ text, count }: { text: string, count: number }) {
const fibCount = useMemo(() => fib(count), [count]);
return (<>
<h2>{text}</h2>
<h3>{fibCount}</h3>
</>);
}
如图所示,之前的长任务 Task 不存在了,Summary 面板中的 Scripting 脚本的执行时间也很明显的变短了。
useMemo 应该多使用
通常来说,组件的 re-render 的次数是不可预期的,引发 re-render 的外界因素很多,父组件的 re-render ,全局状态管理(Store)的状态改变等。 对于一些会因为 re-render 带来的复杂计算时,尽可能还是使用 useMemo 来处理这个复杂计算,仅仅当依赖值改变时,才会重新计算,从而提升性能。
常见误区:useCallback 的滥用
函数分为声明(声明与定义的区别不讨论)与执行,react 组件 re-render 时,无论是否使用 useCallback ,函数都会声明,区别在于是使用新声明的函数,还是使用之前缓存的函数。 所以,我们不应该每个函数都是用 useCallback ,只有必要时再去使用 useCallback ,例如:性能优化时,依赖的组件需要保证传入的函数不变化。
减少不必要的 re-render
tsx
import React, { useState } from "react"
function Child1() {
return <h1>Child1</h1>
}
function Child2() {
const now = Date.now();
while (Date.now() - now < 1000) { }
return <h1>Child2</h1>
}
function Child3() {
return <h1>Child3</h1>
}
export default () => {
const [text, setText] = useState('');
return <>
<input value={text} onChange={(e) => setText(e.target.value)} />
<Child1 />
<Child2 />
<Child3 />
</>
}
同样的操作,输入框的改变会使页面变得卡顿。
- 使用 Performance 面板来检测操作性能。可以看到,Child2 耗时比较严重,去代码中找 Child2 。
- 从代码得知,Child2 是一个组件,它的重复执行即它进行了 re-render 。
- 从操作出发,判断 Child2 为什么会 re-render ;text 这个 state 的改变,导致当前组件 re-render ,当前组件 re-render ,会使子组件 Child2 re-render 。是否可以使 Child2 不进行 re-render 呢,它不依赖于任何 props ,即它不需要 re-render 已经可以保持最新的了。
- 使用 memo 函数包裹 Child2 组件,memo 在不传入第二个参数的情况下会对 props 进行浅层次的比较,若 props 的浅层次没有发生改变,该组件就不会 re-render。更新代码,重新测试。很显然,打印不再执行,Child2 不再 re-render ,操作也不卡顿了。
jsx
const Child2 = memo(function () {
console.log('Child2 re-render')
const now = Date.now();
while (Date.now() - now < 1000) { }
return <h1>Child2</h1>
})
- 升级一下问题,若 Child2 存在一个函数 props 呢?按照前面说的,memo 会对 props 进行浅层次的判断,若函数变化了,依旧会重新 re-render 。这时候如果希望函数不改变,就可以使用 useCallback 来保证函数的引用不改变,从而 Child2 不 re-render 。
tsx
const Child2 = memo(function ({ onClick }: { onClick: () => void }) {
console.log('Child2 re-render')
const now = Date.now();
while (Date.now() - now < 1000) { }
return <>
<h1>Child2</h1>
<button onClick={onClick}>---</button>
</>
})
export default () => {
const [text, setText] = useState('');
const handleClick = useCallback(() => {
console.log('click')
}, []);
return <>
<input value={text} onChange={(e) => setText(e.target.value)} />
<Child1 />
<Child2 onClick={handleClick} />
<Child3 />
</>
}
- 继续升级一下问题,如果 handleClick 依赖于本次更新的变量呢?即在 handleClick 里需要使用 re-render 后的 text 变量,又该如何处理呢?
tsx
const handleClick = useCallback(() => {
console.log('click', text)
}, [text]);
- 在不使用第三方的情况下,可以使用 useRef 创建的变量来存储一下 text ,useRef 生成的引用是一直不变的,改变的是 它 的current ,这样在函数不变时,仍然可以拿到 re-render 后的 text 。不过在这种情况下,更推荐使用 ahooks 的 useMemoizedFn ,它可以保证返回值函数 re-render 后仍然是同一个引用,这样传入的函数就不会改变引用了,有点类似于 类组件 的实例方法,引用是一直不变的。
tsx
const [text, setText] = useState('');
const textRef = useRef(text);
textRef.current = text;
const handleClick = useCallback(() => {
console.log('click', textRef.current)
}, []);
memo
不要过度使用 memo ,在未遇到性能问题时,用 memo 会做 props 浅层次的比对,浅层次的比较过程仍然是有性能消耗的。 若使用了 memo ,就要尽可能的保证 props 不改变,除非这次改变是在你的预期之中的。
批处理 batch
tsx
import React from "react";
import { useEffect, useState } from "react"
function useMouseDown() {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
function handleMouseDown(e) {
setX(e.clientX);
setY(e.clientY);
}
useEffect(() => {
window.addEventListener('click', handleMouseDown);
return () => {
window.removeEventListener('click', handleMouseDown);
}
}, []);
return {
x,
y
}
}
function Child() {
const now = Date.now();
while (Date.now() - now < 1000) { }
return <h1>Child2</h1>
}
export default () => {
const { x, y } = useMouseDown();
console.log('render Batch2', x, y);
return <>
<h1>x: {x}</h1>
<h1>y: {y}</h1>
<Child />
</>
}
由控制台的打印可知,在 window 上点击时,会触发两次 re-render ,为什么会触发两次 re-render 呢?由打印的 x 和 y 值可以知道,第一次打印还是上一次的 y 值,说明两次 setState 分别执行了一次 re-render ,setX 和 setY 各执行了一次 re-render ,那么是否执行几次 setState 就会触发几次 re-render 呢?补充一下 demo。
useMouseDown 新增一个 reset 方法, 父组件新增一个 button 来执行它。完整代码如下:
tsx
import React from "react";
import { useEffect, useState } from "react"
function useMouseDown() {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
function handleMouseDown(e) {
setX(e.clientX);
setY(e.clientY);
}
useEffect(() => {
window.addEventListener('click', handleMouseDown);
return () => {
window.removeEventListener('click', handleMouseDown);
}
}, []);
function reset() {
setX(0);
setY(0);
}
return {
x,
y,
reset
}
}
function Child() {
const now = Date.now();
while (Date.now() - now < 1000) { }
return <h1>Child2</h1>
}
export default () => {
const { x, y, reset } = useMouseDown();
console.log('render Batch2', x, y);
return <>
<h1>x: {x}</h1>
<h1>y: {y}</h1>
<button onClick={(e) => {
e.stopPropagation();
reset()
}} >重置</button>
<Child />
</>
}
按钮点击以后,setState 也执行了两次,但打印只有一次,只有一次 re-render ,为什么只触发一次 re-render 呢?显然并不是执行几次 setState 就触发几次 re-render 。
React16 批处理
在React16版本及以前,React 会对所有React内部触发的事件监听函数中的更新(比如onClick函数)做批处理,如果是绕过react组件,如addEventListener,或者异步调用如异步请求或者setTimeout等,不会进行批处理。
这句话的解释与我们实际测试的情况也是符合的,执行 window 的点击事件监听用的是 addEventListener ,执行几次 setState 就会触发几次 re-render ;而 button 的 onClick 是 react 内部处理了的事件监听函数,会进行批处理合并。
如何解决?
unstable_batchedUpdates
这个 api 很少使用,且从名称上来看,它是不稳定的(unstable),按照官方解释,并非是不稳定的,而是认为这个问题应该由 react 自身来解决,现在最新的 React18 不会有这个问题了,默认都会进行批处理;batchedUpdates 批量更新,只要用这个 api 就可以实现批处理。将 handleMouseDown 重写如下:
tsx
import { unstable_batchedUpdates } from 'react-dom';
function handleMouseDown(e) {
unstable_batchedUpdates(() => {
setX(e.clientX);
setY(e.clientY);
})
}
就可以解决问题了。它减少了一次 re-render ,性能上也会有所提升。