React Hook 性能优化之 useCallback , useMemo , memo

4,726

前言

有很多同学已经在工作中用上了React Hook进行项目开发,相信大家也体会到了Hook的美妙之处,但是无论基于什么技术栈进行开发,性能方面总是大家关心的一个话题,本文将会介绍如何通过

  1. useCallback + memo 避免组件不必要重的重复渲染
  2. useMemo 避免组件在每次渲染时都进行高开销的计算 当然也会对 useCallback,useMemo的源码进行浅析。不足的地方希望大神能够指点一二~

优化前

定义一个子组件

const ChildComponent = () => {

  return (
    <Fragment>
      {console.log("ChildComponent Render")} {/* {1} */}
      <span>子组件</span>
    </Fragment>
  )

}
  • 我们这个组价相当简单,它没有接受任何属性,而我们在{1} [注]{1}对应代码后面注释号,下面不再赘述 的地方打印了 ChildComponent Render我们希望这个组件只在挂载的时候执行render函数,其他时候则无需执行,在工作中业务组件相对会复杂一些,不必要的render也是会影响性能的。

定义一个父组件

import React, { Fragment, useState } from "react";
import { Button, Tag, Divider } from "antd";

const ParentComponent = () => {

  const [count, setCount] = useState<number>(0); //{1}

  return (
    <Fragment>
      <h5>hooks 性能优化篇</h5>

      <Divider orientation="left">count</Divider>
      <Tag color="magenta">{count}</Tag>
      <Button type="primary" onClick={() => setCount((o: number) => o += 1)}>setCount</Button> {/* {2} */}

      <Divider orientation="left">子组件↓</Divider>
      <ChildComponent /> {/* {3} */}
    </Fragment>
  )
}
  • 以上,我们在ParentComponent组件中定义了一个count {1}, 它是一个useState , 在{2}改变了count的值使其+1 , 同时在{3}使用了上面定义的ChildComponent组件
  • 我们知道reactsetState之后组件会重新render,我们将在{2}setCount并查看ChildComponent组件的render函数是否执行。
  • 结果是显而易见的,随着ParentComponent组件setCount,ChildComponentrender函数也会被执行,这很不合理。对于这种场景来说,我们只希望ChildComponentrender函数在挂载的时候执行。下面我们开始优化。

优化前.gif

useCallback + memo 避免组件不必要重的重复渲染

首先我们需要介绍一下 memouseCallback的概念

memo

  • 使用过class组件进行开发的同学知道PureComponent,是的,memoPureComponent做的事情几乎是一样的
  • React.memo 为高阶组件,的组件在相同 props 的情况下渲染相同的结果,那么你可以通过将其包装在 React.memo 中调用,以此通过记忆组件渲染结果的方式来提高组件的性能表现。这意味着在这种情况下,React 将跳过渲染组件的操作并直接复用最近一次渲染的结果
  • 总结一下,通过React.memo包裹的组件props相同(注意这里是浅比较哦)的情况下,会复用最近一次执行的结果,真棒,React.memo帮我们缓存了组件

useCallback 当前还没到useCallback的场景,我们先介绍功能

  • 官方文档
    const memoizedCallback = useCallback(
      () => {
        doSomething(a, b);
      },
      [a, b],
    );

返回一个 memoized 回调函数。

把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。

  • 总结一下,useCallback帮我们缓存了函数,在依赖项没有变化的时候返回缓存的函数指针,而props涉及到复杂对象类型都是通过指针来传递的。

优化后

import React, { Fragment, useState, memo } from "react";
import { Button, Tag, Divider } from "antd";

// 父组件
const ParentComponent = () => {

  const [count, setCount] = useState<number>(0); //{1}

  return (
    <Fragment>
      <h5>hooks 性能优化篇</h5>

      <Divider orientation="left">count</Divider>
      <Tag color="magenta">{count}</Tag>
      <Button type="primary" onClick={() => setCount((o: number) => o += 1)}>setCount</Button> {/* {2} */}

      <Divider orientation="left">子组件↓</Divider>
      <ChildComponent /> {/* {3} */}
    </Fragment>
  )
}

// 子组件
 const ChildComponent = memo(() => { {/* + {2} */}
   return (
    <Fragment>
      {console.log("ChildComponent Render")} {/* {1} */}
      <span>子组件</span>
    </Fragment>
  )

})
  • 以上,我们在ChildComponent组件,新增了{2},使用React.memo对组件进行包裹,在ParentComponent组件 {2}setCount之后查看ChildComponent render函数是否执行,结果如下图,并不会重复执行

优化后.gif

丰富一下业务场景

import React, { Fragment, useState, memo } from "react";
import { Button, Tag, Divider } from "antd";


const ParentComponent = () => {

  const [count, setCount] = useState<number>(0); //{1}

  const [random, setRandom] = useState<number>(0); // { + 4}

  function childFn() { // {+6}

  }
  return (
    <Fragment>
      <h5>hooks 性能优化篇</h5>

      <Divider orientation="left">count</Divider>
      <Tag color="magenta">{count}</Tag>
      <Button type="primary" onClick={() => setCount((o: number) => o += 1)}>setCount</Button> {/* {2} */}

      <Divider orientation="left">random</Divider>
      <Tag color="cyan">{random}</Tag>
      <Button type="ghost" onClick={() => setRandom(Math.floor(Math.random() * 10 + 1))}>setRandom</Button>  {/* + {5} */}

      <Divider orientation="left">子组件↓</Divider> {/* {3} */}
      <ChildComponent state={random} fn={childFn} /> {/* + {7} */}
    </Fragment>
  )
}


interface ChildProps {
  state: number,
  fn: Function
}
const ChildComponent = memo((props: ChildProps) => {
  const { state } = props;
  return (
    <Fragment>
      {console.log("ChildComponent Render")} {/* {1} */}
      <span>子组件</span>
      <Tag color="magenta">{state}</Tag>
    </Fragment>
  )

})
  • 我们丰富一下业务场景,在ParentComponent新增了{4} {+6}分别为一个随机数,一个函数作为ChildComponentprops,并且在{5}修改了随机数的值,现在我们看下ChildComponent render函数的执行情况
  • 理想情况是只有在random变化的时候ChildComponent render函数执行,而结果并不是这样,在我们修改count的时候ChildComponent render函数也会执行,结果如下图

丰富场景优化前.gif

丰富业务场景后优化

  • 造成上面的问题是因为我们在ChildComponentprops中增加了函数fn,上面说过props在 传递函数的时候是传递指针,而随着ParentComponentsetCount函数childFn会被重新声明,指针也会相应更新,这时候大家应该想到useCallback
  • 现在我们将ParentComponent中的childFnuseCallback进行缓存
import React, { Fragment, useState, memo, useCallback } from "react";
import { Button, Tag, Divider } from "antd";


const ParentComponent = () => {

  const [count, setCount] = useState<number>(0); //{1}

  const [random, setRandom] = useState<number>(0); // {4}

  const memoizedFn = useCallback(childFn, []); // {+ 8}

  function childFn() { // {6}

  }
  return (
    <Fragment>
      <h5>hooks 性能优化篇</h5>

      <Divider orientation="left">count</Divider>
      <Tag color="magenta">{count}</Tag>
      <Button type="primary" onClick={() => setCount((o: number) => o += 1)}>setCount</Button> {/* {2} */}

      <Divider orientation="left">random</Divider>
      <Tag color="cyan">{random}</Tag>
      <Button type="ghost" onClick={() => setRandom(Math.floor(Math.random() * 10 + 1))}>setRandom</Button>  {/* {5} */}

      <Divider orientation="left">子组件↓</Divider> {/* {3} */}
      <ChildComponent state={random} fn={memoizedFn} /> {/* {+ 7} */}

    </Fragment>
  )
}


interface ChildProps {
  state: number,
  fn: Function
}
const ChildComponent = memo((props: ChildProps) => {
  const { state } = props;
  return (
    <Fragment>
      {console.log("ChildComponent Render")} {/* {1} */}
      <span>子组件</span>
      <Tag color="magenta">{state}</Tag>
    </Fragment>
  )

})

  • 如上,我们在ParentComponent组件中新增了{8},将函数用useCallback进行了缓存,并且更新了{7} 的用法

  • 结果如下图,是我们期望的结果吗?

丰富业务场景优化后.gif

  • 上图,我们看到只有在setRandom的时候ChildComponent render会执行,现在,我们完成了组件不必要重的重复渲染的优化

useMemo 避免组件在每次渲染时都进行高开销的计算

优化前

import React, { Fragment, useState } from "react";
import { Button, Divider } from "antd";

const Admin = () => {

  const [, setRandom] = useState<number>(0); //{1}

  function getState() {  //{2}

    console.log("getState run"); //{3}

    let temp = 0;
    for (let index = 0; index < 1000; index++) {
      temp += index;
    }

    return temp;
  }

  const computeValue = getState(); //{4}
  console.log("computeValue", computeValue)//{5}

  return (
    <Fragment>
      Admin
      <Divider orientation="left">random</Divider>
      <Button type="ghost" onClick={() => setRandom(Math.floor(Math.random() * 10 + 1))}>setRandom</Button>  {/* {6} */}
    </Fragment>
  )

}
  • 以上,我们定义了组件Admin, 在{1}定义了setRandom,并且在 {6}执行了setRandom,我们需要让组件重新render, 重点在方法{2} , 我们在方法{2}中比喻了大数据量的计算,并且在{4}调用了方法{2}, 需要验证的是{3}是否会被重复打印
  • 结果如下图,每次setRandom {3}都会被打印,这不合理,这个时候我们并不希望方法{2}被执行,这里造成了大数据量的重复计算

callback优化前.gif

useMemo 定义

  • 官方文档
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

返回一个 memoized 值。

把“创建”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算。

记住,传入 useMemo 的函数会在渲染期间执行。请不要在这个函数内部执行与渲染无关的操作,诸如副作用这类的操作属于 useEffect 的适用范畴,而不是 useMemo

如果没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值。

  • 总结一下,useMemo帮我们缓存了某个值,比如组件中某个数组/对象需要通过大量计算得到,而这个值依赖于某一个state,我们希望只在依赖的state改变之后计算而不是任意state改变之后都会计算,这无疑会造成性能上的问题。

优化后

const Admin = () => {

  const [, setRandom] = useState<number>(0); //{1}

  const memoizedValue = useMemo(() => getState(), []) // {7}

  function getState() {  //{2}

    console.log("getState run"); //{3}

    let temp = 0;
    for (let index = 0; index < 1000; index++) {
      temp += index;
    }

    return temp;
  }

  const computeValue = memoizedValue; //{4}
  console.log("computeValue", computeValue)//{5}

  return (
    <Fragment>
      Admin
      <Divider orientation="left">random</Divider>
      <Button type="ghost" onClick={() => setRandom(Math.floor(Math.random() * 10 + 1))}>setRandom</Button>  {/* {6} */}
    </Fragment>
  )

}
  • 以上我们新增了{7}缓存了函数{2}的返回值,并且在{4}更新了用法,优化结果如下图

usememo优化后.gif

  • 可以看到,方法{2}中的大数据量的计算只会在第一次挂载的时候执行,后续使用了useMemo缓存的值,我们完成了避免组件在每次渲染时都进行高开销的计算的优化(当然用useEffect也是可以做到的,看具体场景)

源码浅析

[注] useCallback , useMemo 源码都在react项目中 /packages/react-reconciler/src/ReactFiberHooks.new.js文件中,以下不在赘述。

useCallback

// mount阶段就是获取传入的回调函数和依赖数组,保存到hook的memorizedState中,然后返回回调函数
function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

// update 阶段
function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = updateWorkInProgressHook();
  // 从hook的memorizedState中获取上次保存的值[callback,deps]
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps: Array<mixed> | null = prevState[1];
      // 比较新的deps和之前的deps是否相等
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // 如果相等,返回memorized的callback
        return prevState[0];
      }
    }
  }
  // 如果deps发生变化,更新hooks的memorizedState,并返回最新的callback
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

useMemo

// mount阶段, 执行创建函数获得返回值
// 保存到hook的memorizedState中[nextValue, nextDeps]
function mountMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}


// update 阶段
function updateMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  const hook = updateWorkInProgressHook();
  // 获取新的deps
  const nextDeps = deps === undefined ? null : deps;
  // 从memorizedState中获取上次保存的值
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    // Assume these are defined. If they're not, areHookInputsEqual will warn.
    if (nextDeps !== null) {
      // 比较新的deps和就的deps是否相等,如果两个值相等,返回旧的创建函数的返回值
      const prevDeps: Array<mixed> | null = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }

  // 如果dependents发生改变,hook中保存新的返回值和deps,并返回新的创建函数的返回值
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

最后祝大家中秋节快乐呀!

00FA5227.gif