React16之Memo、useCallback、useMemo踩坑之旅

  • 作者:annualwu
  • 时间:2021-03-05
  • 366人已阅读
导语:本文带你正确解锁Memo、useCallback、useMemo的使用方式。

背景

react性能优化的一个主要方向就是减少组件重复渲染,避免没有必要的渲染以提升性能,而减少组件重复渲染的重要方式就是利用缓存。根据这个思路react推出了React.memo、hook函数useCallbackuseMemo等方法,但官方文档也提出不要滥用这些hook,不然很有可能适得其反,那具体怎么使用才能提高性能呢?

在开始之前先简单介绍下Memoization的概念,在密集型操作中通过将初始的操作结果‘缓存’起来,并在下一次操作中利用缓存来加速计算的技术。 换人话就是通过对象把函数每次执行的结果存起来,下次执行时先查找是否有执行过的值,有则直接返回结果。直接看一个简单的Memoization实现

const memo = function(func) {
    let cache = {};
    return function(key) {
        if(!cache(key)) {
            cache[key] = func.apply(this, arguments);
        }
        return cache[key];
    }
}
memo(testFunc)(arg);

通过闭包把函数参数作为key,结果存为value实现缓存。比如典型的斐波那契数列递归计算就可以使用该缓存方法优化其性能,这里不再赘述。

React.memo()

React.memo也是通过记忆组件渲染结果的方式来提高性能,memo是react16.6引入的新属性,通过浅比较(源码通过Object.is方法比较)当前依赖的props和下一个props是否相同来决定是否重新渲染;如果使用过类组件方式,就能知道memo其实就相当于class组件中的React.PureComponent,区别就在于memo用于函数组件,pureComponent用于类组件(pureComponent除了props还有state检查)。

举个栗子:

// app.js
import React, {useState} from 'react';
import Child from './components/Child';

const App: React.FC = () => {
  const [count,  setCount] = useState(0);
  const [subData, setSubData] = useState('haha');
  return (
    <div>
      <h1>I am Parent: 被点了{count}次</h1>
      <Child title={subData} />
      <button onClick={() => { setCount(count+1) }}>click</button>
    </div>
  )
}
export default App;

// Child
import React from 'react';

const Child: React.FC<{title: string,}> = ({title}) => {
    console.log('Child render....');
    return (
        <div style={{background: 'gray'}}>
            I am child: {title}
        </div>
    )
}
export default Child;

1.png

以上是一个非常简单且常见的父子组件的例子,父组件改变状态,Child组件所依赖的属性并没有更新,但是子组件每次都会重新渲染,当前例子中子组件内容较少,但如果子组件非常庞大,或者不能重复渲染的组件(比如天降彩蛋,随机飘落的彩蛋如果每次父组件都刷新,就会出现彩蛋突然”消失“的问题),就会造成性能或者体验问题。

如果在子组件上加上React.memo去缓存组件,就能避免子组件重复渲染的问题。

// child
const Child: React.FC<{title: string}> = ({title}) => {
    console.log('Child render....');
    return (
        <div style={{background: 'gray'}}>
            I am child: {title}
        </div>
    )
}
export default React.memo(Child);

2.png

可以看到,加上memo后除了初始化时渲染了子组件,后续父组件变更子组件并没有重新渲染了。

现在对上述例子做一个改造,通过child组件修改父组件的状态(场景:比如彩蛋点击后需要对父级操作)

// app.js
import React, {useState} from 'react';
import Child from './components/Child';

const App: React.FC = () => {
  const [count,  setCount] = useState(0);
  const [subData, setSubData] = useState('haha');
  return (
    <div>
      <h1>I am Parent: 被点了{count}次</h1>
      {/** 通过props给child组件传入依赖项*/}
      <Child title={subData} onChange={newCount => setCount(newCount)} />

      <button onClick={() => { setCount(count+1) }}>click</button>
    </div>
  )
}
export default App;

// Child
import React from 'react';

const Child: React.FC<{ title: string, onChange: Function }> = ({ title,onChange }) => {
  console.log('Child render....');
  return (
  <div style={{ background: 'gray' }} onClick={() => onChange(100)}>
    I am child: {title}
  </div>
  )
};
export default React.memo(Child);

3.png

因为引入了依赖项,并且改变了状态值,所以子组件又重复渲染了,而这次的改变项是callback函数,父组件的重新渲染,导致重新创建了新的callback函数,要保持这两个函数引用,就要用到useCallback

useCallback

使用

// app.js
import React, {useState, useCallback} from 'react';
import Child from './components/Child';

const App: React.FC = () => {
  const [count,  setCount] = useState(0);
  const [subData, setSubData] = useState('haha');
  return (
    <div>
      <h1>I am Parent: 被点了{count}次</h1>
      {/* <Child title={subData} onChange={(newCount) => setCount(newCount)} /> */}
      {/** 使用useCallback缓存inlie函数*/}
      <Child title={subData} onChange={useCallback(newCount => setCount(newCount), [])} />

      <button onClick={() => { setCount(count+1) }}>click</button>
    </div>
  )
}
export default App;

4.png

加上useCallback以后可以看到子组件没有再重复渲染了,这又是什么原因呢?

概念

关于useCallback的概念,官方文档给出的解释是

把内联回调函数以及依赖项作为参数传入,并且返回一个memoized回调函数的方法

关于memoized,文章最开始已经解释了,就是所谓的缓存;

问题

  • 1、那useCallback到底怎么实现和使用的?
  • 2、为什么说很多情况下单独使用useCallback不仅不会带来性能提升,反而会影响?

歪个楼,关于Fiber,为了方便看懂源码,照例先简单介绍一下React Fiber的概念:Fiber是一个比线程还小的控制粒度,ReactV16的一个重大改变就是使用了React Fiber来代替之前的Stack reconciler用以解决渲染造成的卡顿原因。
Stack reconciler是通过自顶向下的同步方式来处理任务,整个过程不会中断,若过程中时间较长,浏览器主线程被阻断,就会出现卡顿现象。为解决这个问题,引入了React Fiber的概念,它的主要原理就是将一个任务分割成多个片段,每个片段执行完以后,可以给其他任务执行的机会,线程不会被独占。
React Fiber在执行过程中分为两个阶段,如下图(摘自网络),阶段1render/reconciliation阶段构建Fiber树(和virtual Dom类似,本质是链表),执行多次,可随时被高优先级的任务打断;阶段2Commit阶段,这个阶段只会执行一次不会被打断。

5.png

有了以上概念,再来看useCallback的源码(基于v16.14):

源码分析

useCallback hook定义:

    // mount阶段
  HooksDispatcherOnMountInDEV = {
    useCallback: function (callback, deps) {
      currentHookNameInDev = 'useCallback';
      //...
      return mountCallback(callback, deps);
    },
    //....
  };
  // update阶段
  HooksDispatcherOnUpdateInDEV = {
    useCallback: function (callback, deps) {
      currentHookNameInDev = 'useCallback';
      //....
      return updateCallback(callback, deps);
    },

mount阶段: mountCallback

// 入参:callback:内联函数, deps:依赖变更项
function mountCallback(callback, deps) {
  // 创建hook对象拼接在链表上
  var hook = mountWorkInProgressHook();
  // 获取更新依赖
  var nextDeps = deps === undefined ? null : deps;
  // 通过将传入函数和依赖存入memoizedState缓存中
  hook.memoizedState = [callback, nextDeps];
  // 返回callback
  return callback;
}

// 创建hook链表
function mountWorkInProgressHook() {
  var hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null
  };
  if (workInProgressHook === null) {
    currentlyRenderingFiber$1.memoizedState = workInProgressHook = hook;
  } else {
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

updata阶段: updateMemo

function updateCallback(callback, deps) {
  // 也是创建对应的hook对象
  var hook = updateWorkInProgressHook();
  // 获取更新依赖
  var nextDeps = deps === undefined ? null : deps;
  // 获取上一次的数组值
  var prevState = hook.memoizedState;
  if (prevState !== null) {
    // 依赖不为空,则浅比较,无变化返回上一次的值
    if (nextDeps !== null) {
      var prevDeps = prevState[1];

      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  // 无上一次状态直接存入缓存
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

// 浅比较
function areHookInputsEqual(nextDeps, prevDeps) {
  // ...
  for (var i = 0; i < prevDeps.length &amp;&amp; i < nextDeps.length; i++) {
    // Object.is()
    if (objectIs(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

结论

关于第一个问题,上面的源码分析已经讲解得很细致了,第二个问题,为什么滥用useCallback有时候会适得其反?因为单从组件上看,inline函数是一定会创建的(上面的callback内联函数),每次函数的创建都需要占用内存,而useCallback的目的就是为了缓存inline函数,而无意义的创建和内部每次的浅比较都是会消耗些许性能的。因此如果传入useCallback的第二个参数是一个经常变更的state,那么callback也就无法缓存的。所以useCallback的使用一定要分清使用场景才能达到效果。

useMemo

我们再变化一下原来那个例子,

// app.js
import React, {useState, useCallback} from 'react';
import Child from './components/Child';

const App: React.FC = () => {
  const [count,  setCount] = useState(0);
  const [subData, setSubData] = useState('haha');
  return (
    <div>
      <h1>I am Parent: 被点了{count}次</h1>
      {/* <Child title={subData} onChange={useCallback(newCount => setCount(newCount), [])} /> */}
      {/* 通过props给child组件传入一个对象数据 */}
      <Child title={{name: subData, age: 1}} onChange={useCallback(newCount => setCount(newCount), [])} />
      <button onClick={() => { setCount(count+1) }}>click</button>
    </div>
  )
}
export default App;

6.png

发现子组件又重复渲染了。。父组件在更新其他状态的时候,子组件的对象属性也发生了变更,于是子组件又重新渲染了,这时候就可以使用useMemo这个hook函数。

使用

// app.js
import React, {useState, useCallback, useMemo} from 'react';
import Child from './components/Child';

const App: React.FC = () => {
  const [count,  setCount] = useState(0);
  const [subData, setSubData] = useState('haha');
  return (
    <div>
      <h1>I am Parent: 被点了{count}次</h1>
      {/* 通过props给child组件传入一个对象数据 */}
      {/*<Child title={{name: subData, age: 1}} onChange={useCallback(newCount => setCount(newCount), [])} /> */}
      {/* 添加useMemo来缓存这个数据,只有依赖变更才会重新创建 */}
      <Child title={useMemo(() => ({name: subData, age: 1}), [subData])} onChange={useCallback((newCount) => setCount(newCount), [])} />

      <button onClick={() => { setCount(count+1) }}>click</button>
    </div>
  )
}
export default App;

7.png

源码分析

和useCall的实现方法基本一致,这里只列出一些不同的地方

  // mount阶段,hook声明
  HooksDispatcherOnMountInDEV = {
    useMemo: function (create, deps) {
      //...
      try {
        return mountMemo(create, deps);
      } finally {
        //...
      }
    },
    //....
  };

  function mountMemo(nextCreate, deps) {
    var hook = mountWorkInProgressHook();
    var nextDeps = deps === undefined ? null : deps;
    // 把传入的callback执行的结果返回
    var nextValue = nextCreate();
    hook.memoizedState = [nextValue, nextDeps];
    return nextValue;
  }

看到主要区别了吗,useMemo把“创建”函数和依赖项数组作为参数传入,把执行结果加入缓存并返回。而useCallback只是缓存函数而不调用。也可以理解为useMemo是值对依赖项是否有依赖的缓存,useCallBack是函数对依赖项的缓存。从本质上分清二者的区别才能更清楚地感受这两个方法带来的优化。

总结

以上是关于Memo、useCallback、useMemo个人的一些使用总结和理解,性能优化是前端开发一个很重要的方向,但所有优化都是有代价的,正如官网所说,这只是性能优化的手段而不能当做语义上的保证,真实环境中需要用到这些方法来提升性能才去使用它而不是无目的的滥用。

Top