React 性能优化

背景

在寻常的页面的业务开发中,不太容易遇到很明显的性能问题,但一旦遇到性能问题,却是难以解决的。本文就来介绍一些性能优化的手段来提升性能,优化用户的使用体验。

问题

  1. 如何衡量性能?性能好坏仅仅是用户的主观感受吗?
  2. 性能优化的切入点是什么?是否会过度优化带来更高的心智负担?

性能问题来源

哪种性能问题才是需要去解决的?

  1. 用户反馈的,操作后带来页面卡顿,无法进行后续的操作,需要等待
  2. 开发过程中,测试功能时,明显感觉到操作的卡顿,例如:点击按钮反应慢,输入框输入后内容出现慢

定位问题

如何知道哪段代码是特别消耗性能的,如何通过浏览器控制台快速定位耗时操作?

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 面板来测试这个操作(行为,动作)的耗时情况。

  1. 点击录制,操作,等待页面响应完毕
  1. 由图可知,操作出现了很多长任务 Task ,在 Summary 面板也可以看到,Scripting 脚本执行时间是很长的,通常来说,Scripting 脚本执行时间,是我们是可以通过优化代码来控制的。
  2. 查看 Bottom-Up 面板,Call Tree 面板,或者将 Task 往下滚动查看。可以看到,执行时间长的函数是 fib (注意:如果不在开发阶段,可能由于代码混淆,导致这里的名称被混淆,导致无法在代码中找到它),我们去代码中定位它。
  1. 看代码可知,fib 是一个函数,它的执行时机是当 Child 组件 re-render 时,就会执行,如此我们需要去从本次的操作出发,去判断 Child 组件为什么会 re-render ,以及是否应该执行 fib 函数。
  1. 操作是输入框的改变,输入框的改变带来 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 />
  </>
}

同样的操作,输入框的改变会使页面变得卡顿。

  1. 使用 Performance 面板来检测操作性能。可以看到,Child2 耗时比较严重,去代码中找 Child2 。
  1. 从代码得知,Child2 是一个组件,它的重复执行即它进行了 re-render 。
  1. 从操作出发,判断 Child2 为什么会 re-render ;text 这个 state 的改变,导致当前组件 re-render ,当前组件 re-render ,会使子组件 Child2 re-render 。是否可以使 Child2 不进行 re-render 呢,它不依赖于任何 props ,即它不需要 re-render 已经可以保持最新的了。
  2. 使用 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>
})
  1. 升级一下问题,若 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 />
  </>
}
  1. 继续升级一下问题,如果 handleClick 依赖于本次更新的变量呢?即在 handleClick 里需要使用 re-render 后的 text 变量,又该如何处理呢?
tsx 复制代码
  const handleClick = useCallback(() => {
    console.log('click', text)
  }, [text]);
  1. 在不使用第三方的情况下,可以使用 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 ,性能上也会有所提升。

相关推荐
GISer_Jing2 分钟前
React基础知识回顾详解
前端·react.js·前端框架
键.5 分钟前
react-bn-面试
javascript·react.js·ecmascript
GISer_Jing11 小时前
React中useState()钩子和函数式组件底层渲染流程详解
前端·react.js·前端框架
渔阳节度使14 小时前
React
前端·react.js·前端框架
m0_5287238120 小时前
在React中使用redux
前端·javascript·react.js
小乌龟快跑20 小时前
React Hooks 隔离机制和自定义 Hooks
react.js·面试
m0_528723811 天前
redex快速体验
react.js
桦说编程2 天前
CompletableFuture 超时功能有大坑!使用不当直接生产事故!
java·性能优化·函数式编程·并发编程
Loong_DQX2 天前
【react+redux】 react使用redux相关内容
前端·react.js·前端框架
GISer_Jing2 天前
react redux监测值的变化
前端·javascript·react.js