换个角度结合高阶函数聊聊useMemo和useCallback

53次阅读

共计 4663 个字符,预计需要花费 12 分钟才能阅读完成。

关注 小贼先生,查看更多前端文章

Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。

useCallbackuseMemo 是其中的两个 hooks,本文旨在通过解决一个需求,结合高阶函数,深入理解 useCallbackuseMemo的用法和使用场景。
之所以会把这两个 hooks 放到一起说,是因为他们的主要作用都是性能优化,且使用 useMemo 可以实现useCallback

需求说明

先把需求拎出来说下,然后顺着需求往下捋 useCallbackuseMemo,这样更好理解为什么要使用这两个 hooks。

需求是:当鼠标在某个 dom 标签上移动的时候,记录鼠标的普通移动次数和加了防抖处理后的移动次数。[如图]:

技术储备

  • 本文主要介绍 useCallbackuseMemo,所以遇到 useState 时就不做特殊说明了,如果对 useState 还不了解,请参看官方文档。
  • 该需求需要用到防抖函数,为方便调试,先准备一个简单的防抖函数(一个高阶函数):
function debounce(func, delay = 1000) {
  let timer;

  function debounced(...args) {debounced.cancel();
    timer = setTimeout(() => {func.apply(this, args);
    }, delay);
  }

  debounced.cancel = function () {if (timer !== undefined) {clearTimeout(timer);
      timer = undefined;
    }
  }
  return debounced
}

不合格的解决方案

根据需求,写出来组件大致会是这样:

function Example() {const [count, setCount] = useState(0);
  const [bounceCount, setBounceCount] = useState(0);
  const debounceSetCount = debounce(setBounceCount);

  const handleMouseMove = () => {setCount(count + 1);
    debounceSetCount(bounceCount + 1);
  };

  return (<div onMouseMove={handleMouseMove}>
      <p> 普通移动次数: {count}</p>
      <p> 防抖处理后移动次数: {bounceCount}</p>
    </div>
  )
}

效果貌似是对的,在 debounced 里打印日志看下:

function debounce(func, delay = 1000) {
    // ... 省略其他代码
    timer = setTimeout(() => {
      // 在此处添加了一行打印代码
      console.log('run-do');
      func.apply(this, args);
    }, delay);
    // ... 省略其他代码
}

当鼠标在 div 标签上移动时,打印结果[如图]:

我们发现,当鼠标停止移动后,run-do被打印的次数,跟鼠标移动次数相同,这说明防抖功能并未生效。是哪里出问题了呢?

首先我们要清楚的是,使用 debounce 的目的是通过 debounce 返回一个 debounced 函数(注意:此处是 debounced,而不是debounce,下文同样要注意这个细节,否则意思就完全不对了),然后每次执行debounced 时,通过闭包内的 timer 清掉之前的setTimeout,达到一段时间不活动后执行任务的目的。

再来看看我们的 Example 组件,每次 Example 组件的更新渲染,都会通过 debounce(setBounceCount) 生成一个新的 debounceSetCount,也就是每次的更新渲染,debounceSetCount 都是指向不同的 debounced,不同的debounced 使用着不同的 timer,那么debounce 函数里的闭包就失去了意义,所以才会出现截图中的情况。

但是,为什么 bounceCount 的值看着像是进行过防抖处理一样呢?
那是 debounceSetCount(bounceCount + 1) 在多次执行时,因为 debounce 内的 setTimeout 使得 bounceCount 参数值是相同的,所以通过 run-do 的打印次数才把问题暴露了出来。

useCallback

我们使用 useCallback 修改下我们的组件:

function Example() {
  // ... 省略其他代码
  // 相比之前的 Example 组件,我们只是增加了 useCallback hook
  const debounceSetCount = React.useCallback(debounce(setBounceCount), []);
  // ... 省略其他代码
}

这时再用鼠标在 div 标签上移动时,效果跟我们的需求一致了,[如图]:

通过useCallback,我们貌似解决了之前存在的问题(其实这里面还有问题,我们后面会说到)。

那么,useCallback是怎么解决问题的呢?
看下 useCallback 的调用签名:

function useCallback<T extends (...args: any[]) => any>(callback: T, deps: ReadonlyArray<any>): T;

// 示例:const memoizedCallback = useCallback(() => {doSomething(a, b);
  },
  [a, b],
);

通过 useCallback 的签名可以知道,useCallback第一个参数是一个函数,返回一个 memoized 回调函数,如上面代码中的 memoizedCallback。useCallback的第二个参数是依赖 (deps),当依赖改变时才更新 memoizedCallback,也就是在依赖未改变时(或空数组无依赖时),memoizedCallback 总是指向同一个函数,也就是指向同一块内存区域。当把 memoizedCallbac 当作 props 传递给子组件时,子组件就可以通过shouldComponentUpdate 等手段避免不必要的更新。

Example 组件首次渲染时,debounceSetCount的值是 debounce(setBounceCount) 的执行结果,因为通过 useCallback 生成 debounceSetCount 时,传入的依赖是空数组,所以 Example 组件在下一次渲染时,debounceSetCount会忽略 debounce(setBounceCount) 的执行结果,总是返回 Example 第一次渲染时 useCallback 缓存的结果,也就是说 debounce(setBounceCount) 的执行结果通过 useCallback 缓存了下来,解决了 debounceSetCountExample每次渲染时总是指向不同 debounced 的问题。

我们上面说过,这里面其实还有一个问题,那就是每次 Example 组件更新的时候,debounce函数都会执行一次,通过上面的分析我们知道,这是一次无用的执行,如果此处的 debounce 函数里有大量的计算的话,就会很影响性能。

useMemo

看下使用 useMemo 如何解决这个问题呢:

function Example() {const [count, setCount] = useState(0);
  const [bounceCount, setBounceCount] = useState(0);
  const debounceSetCount = React.useMemo(() => debounce(setBounceCount), []);

  const handleMouseMove = () => {setCount(count + 1);
    debounceSetCount(bounceCount + 1);
  };

  return (<div onMouseMove={handleMouseMove} >
      <p> 普通移动次数: {count}</p>
      <p> 防抖处理后移动次数: {bounceCount}</p>
    </div>
  )
}

现在,每次 Example 更新渲染时,debounceSetCount都是指向同一块内存,而且 debounce 只会执行一次,我们的需求完成了,我们的问题也都得到了解决。

小贼先生 - 文章原址

useMemo是怎么做到的呢?
看下 useMemo 的调用签名:

function useMemo<T>(factory: () => T, deps: ReadonlyArray<any> | undefined): T;

// 示例:const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

通过 useMemo 的签名可以知道,useMemo第一个参数是一个 factory 函数,该函数的返回结果会通过 useMemo 缓存下来,只有当 useMemo 的依赖 (deps) 改变时才重新执行 factory 函数,memoizedValue 才会被重新计算。也就是在依赖未改变时(或空数组无依赖时),memoizedValue 总是返回通过 useMemo 缓存的值。

看到这里,相信细心的你也已经发现了,useCallback(fn, deps) 其实相当于 useMemo(() => fn, deps),所以在最开始我们说:使用 useMemo 完全可以实现useCallback

特别注意

React 官方有这么一句话:
你可以把 useMemo 作为性能优化的手段,但不要把它当成语义上的保证。将来,React 可能会选择“遗忘”以前的一些 memoized 值,并在下次渲染时重新计算它们,比如为离屏组件释放内存。先编写在没有 useMemo 的情况下也可以执行的代码 —— 之后再在你的代码中添加 useMemo,以达到优化性能的目的。 查看原文

显然,我们的代码中,如果去掉 useMemo 是会出问题的,对此,可能有人会想,改装下 debounce 防抖函数就可以了,例如:

function debounce(func, ...args) {if (func.timeId !== undefined) {clearTimeout(func.timeId);
    func.timeId = undefined;
  }

  func.timeId = setTimeout(() => {func(...args);
  }, 200);
}

// 使用 useCallback
function Example() {
  // ... 省略其他代码
  const debounceSetCount = React.useCallback((...args) => {debounce(setBounceCount, ...args);
  }, []);
  // ... 省略其他代码
}

// 不使用 useCallback
function Example() {
  // ... 省略其他代码
  const debounceSetCount = changeCount => debounce(setBounceCount, changeCount);
  // ... 省略其他代码
}

貌似去掉了 useMemo 也能实现我们的需求,但显然,这是一种非常将就的解决方案,一旦遇到像修改前的 debounce 这样的高阶函数就束手无策了。
那么,如果不使用useMemo,你有什么好的解决方案呢,欢迎留言讨论。

关注 小贼先生,查看更多前端文章

参考文档

  • 官方文档

正文完
 0