likes
comments
collection
share

从源码学 API 系列之 useCallback()

作者站长头像
站长
· 阅读数 19

API 签名

const cachedFn = useCallback(fn, dependencies)

用途

useCallback is a React Hook that lets you cache a function definition between re-renders

简单地直译为:“useCallback 是一个让你在组件重渲染期间缓存同一个函数定义的 hook 函数”。

「cache a function definition」这个说法显然是掩盖了它从更底层原理视角所看到真正用途。

useCallback 用途就是在多次的组件渲染之间对表达同一个语义的函数来「保持相同的引用」。从而在将这个函数传递给子组件的时候能够避免不必要的重渲染,提高组件的渲染性能。

原理

本小节,我们就来探讨一下,react 是如何实现在多个渲染期间对我们的「同一个函数」来保持同一个引用的。

上面,之所以给“同一个函数”打上一个斜括号,是因为,从底层原理来看,其实我们在组件中所声明的函数它在多次组件渲染之间并不是严格意义上的同一个函数,它们只是「语义上的同一个函数」。假如我们有以下代码:

function App(){
const [count, setCount] = useState(0)

const increaseCount = ()=> {
    setCount(c=> c + 1)
}

return (
    <>
        <span>{count}</span>
        <Child onAdd={increaseCount} />
    <>
)
}

假设现在 <App /> 先后渲染了两次,那我问你,<App /> 在第一次渲染和第二渲染的时候,传递给 <Child /> 组件 onAdd 属性的是同一个函数吗?

我也不卖关子。稍微对 js 这门语言在引擎层面的原理有点了解的人都知道,答案是否定的。上面例子中,我们传给<Child /> 组件 onAdd 属性的从底层原理上来说,它们分别是不同的函数实例的引用。故而,我们可以说第一次渲染的 increaseCount() 函数跟第二次渲染的increaseCount() 函数并不是同一个函数。

归根到底是因为,js 引擎在解析并执行「函数定义」(无论是通过普通的函数声明还是函数表达式来定义函数)代码的时候,它都会在内存中给这个函数定义代码创建一个函数实例。每一个函数实例都会有自己的上下文对象。最后,返回出去的值是这个函数实例的引用。同一个函数定义代码所创建的多个函数实例引用也是不同的,用 js 的全等号 “===” 去做比较将会返回false

其实,从「功能」的角度来看,上面的代码是一点问题都没有的。但是从「性能」角度来看,它是有问题的。

众所周知,react 在 render 阶段的 reconciliation 流程中决定是否要跳过某个组件的重渲染的条件就是该组件的 prevProps 对象和 nextProps 对象是否相等。比较这个两个对象的相等性所采用的算法是「浅比较」。这个算法有两个语义要素:

熟悉 Object.is() 背后所采用算法的人都知道,对于不同的引用,它的比较结果就是false。好啦, 那么问题就来了。因为先后两次的 props 对象的浅比较的结果为 false,上面的例子中,<Child />被迫要进行重渲染了。而我们看来,这样的重渲染是不必要的。这就是我上面所说的「性能问题」。

而这个性能问题就是useCallback 所要解决的问题。useCallback 是依靠「引用传递」来解决了这个问题。这个原理很简单:

  1. 首次,在内存中创建一个新函数实例,并把它的引用保存到上一层词法作用域中的某个变量中,然后返回这个引用;
  2. 随后,如果满足某些情况下,则原封不动地从上一层词法作用域中把引用取回来并返回出去;否则,重复步骤 1。

在不考虑依赖数组的情况下,我们可以根据这个原理实现自己的 useCallback()

let cached
function useCallback(fn){
    if(!cached){
        cached = fn
    }
    
    return cached
}

function App(){
    const [count, setCount] = useState(0)

    const increaseCount = useCallback(()=> {
        setCount(c=> c + 1)
    })

    return (
        <>
            <span>{count}</span>
            <Child onAdd={increaseCount} />
        <>
    )
}

对的,你没看错,useCallback() 的核心原理就是这么简单。只不过,相比这个演示版的 useCallback(), react 还要考虑其他方面的所遇到的问题。

关于函数实例引用的传递规则,react 官方文档在浅层面上讲得很清楚了。所以,这里我就不赘述了。这里是深入系列。我们从源码的角度看看,react 是怎么依托这个原理来实现 useCallback().

所有的 hook 函数都是有两个阶段的:

  • mount
  • update

这句话,在我的这个系列中不知道讲了多少遍了。所以,要想了解 useCallback() 的实现原理,我们首先要看它的 mount 阶段的真身 - mountCallback():

function mountCallback(callback, deps) {
    const hook = mountWorkInProgressHook();
    const nextDeps = deps === undefined ? null : deps;
    hook.memoizedState = [callback, nextDeps];
    return callback;
}

可以看得出,useCallback() 在第一次被调用的时候,react 其实就是把我们传入的函数实例的引用原封不动地存储在 hook 对象上,然后再把我们传给它的引用返回给我们,就是这么简单。可以说得上复杂的是背后的数据结构。调用完之后,跟 useCallback() 相关的数据结构是这样的:

从源码学 API 系列之 useCallback() 上图,我们可以看到 callback 的函数实例引用是存放在哪里的。

下面,我们来看看,在 update 阶段,useCallback() 是如何实现的 :

function updateCallback(callback, deps) {
    const hook = updateWorkInProgressHook();
    const nextDeps = deps === undefined ? null : deps;
    const prevState = hook.memoizedState;

    if (prevState !== null) {
      if (nextDeps !== null) {
        const prevDeps = prevState[1];

        if (areHookInputsEqual(nextDeps, prevDeps)) {
          return prevState[0];
        }
      }
    }

    hook.memoizedState = [callback, nextDeps];
    return callback;
}

其实这里的核心点是 updateWorkInProgressHook() 的函数实现。该函数主要是搭配是一个叫做 currentHook 的全局变量来在旧的 hook 链上找到当前的 hook 函数所对应的那个 hook 对象,然后对该 hook 对象进行「浅拷贝」:

function updateWorkInProgressHook() {
// ......
 const newHook = {
        memoizedState: currentHook.memoizedState,
        baseState: currentHook.baseState,
        baseQueue: currentHook.baseQueue,
        queue: currentHook.queue,
        next: null,
      };
  // .....
  workInProgressHook = workInProgressHook.next = hook;
  
  return workInProgressHook;
 }

从上面的代码我们可以看出,update 阶段,在 useCallback 中所访问到的 prevState[0] 其实就是 mount 阶段我们传给 useCallback 的函数实例引用。

接下来的关注点,就是一个 react 决定是否返回新函数实例还是上一次的函数实例引用的判断逻辑:

  • 如果同时满足下面条件的话,react 就返回上一次缓存下来的函数实例引用:
    • 上一次有传递依赖进来且它的值不为 null;
    • 这一次有传递依赖进来且它的值不为 null;
    • 这两次的依赖用 areHookInputsEqual() 算法(这个算法具体的实现在另外一篇文章《触摸 react 的命门 - 值的相等性比较(下篇)》进行深入讨论)进行比较,且比较结果为 true
  • 否则的话,就返回这次传入进来的函数实例引用,与此同时,更新hook.memoizedState 上面的缓存值。

从上面的源码分析,我们可以得知,useCallback 的原理其实就是:

  • 值的「作用域提升式」存储 - 函数实例引用是一个值,它是在组件函数作用域内创建的,但是最终是存储在它的外层作用域上 - fiber 节点。值的作用域提升式存储类似于「闭包」,都是词法作用域的嵌套。
  • 引用传递 - 函数是按引用进行传递的,react 利用引用的不变性来实现了 useCallback 的最终目的。

上面这个原理用一个常见的术语来表达就是「缓存技术」。现在,我们也可以用这个视角来阐述一下 useCallback 的实现原理:

  • mount 阶段,useCallback 缓存了一个函数实例的引用;
  • update 阶段,根据 callback 的依赖是否已经发生变化来决定是否「续用缓存」还是「刷新缓存」。

useCallback 遇上闭包

useCallback 的闭包

众所周知,在使用 useCallback,如果 callback 内部消费了 state 或者 prop 的时候,我们需要把它们一一罗列在依赖数组里面。如果不这么做的话,就会出 bug。

为什么呢?深入了解过的人都知道,其实在 callback 里面去访问组件函数内部的 state 或者 prop 的时候,我们就产生了「闭包」。没错,就是 js 三座大山的「闭包」。我们可以看看下面的代码:

function App(){
    const [count, setCount] = useState(0)

    const increaseCount = useCallback(function addOne() {
        setCount(count + 1)
    })

    return (
        <>
            <span>{count}</span>
            <Child onAdd={increaseCount} />
        <>
    )
}

众所周知,产生闭包的三部曲是:

  1. 词法作用域的嵌套
  2. 里层词法作用域访问了外层词法作用域的一个或者多个变量
  3. 外层函数最终被调用

下面,我们来分析一下,useCallback() 的使用场景是如何命中闭包的三部曲:

  1. addOne() 函数定义了一个里层的词法作用域,App() 组件函数定义了一个外层的词法作用域。App() 词法作用域包住了 addOne() 的词法作用域;
  2. addOne() 函数的内部,我们访问了一个在外层作用域定义的 count 变量;
  3. App() 组件函数会在 render 阶段被 react 调用。

毫无疑问,useCallback()addOne callback 函数会产生闭包。

为什么需要依赖数组

如果我们完全不传入依赖的话(像上面的示例代码),那么useCallback()每一次在组件渲染的时候都会把我们传给它的那个全新函数实例引用原封不动地返回给我们(按引用传递),相等于没使用 useCallback() 的效果。这种用法显然不符合我们的初衷。

那假如我们传入一个空数组呢?会有什么结果啊:

function App(){
    const [count, setCount] = useState(0)

    const increaseCount = useCallback(function addOne() {
        setCount(count + 1)
    }, [])

    return (
        <>
            <span>{count}</span>
            <Child onAdd={increaseCount} />
        <>
    )
}

结果是 react 永远都会返回我们第一次创建的那个函数实例给我们。我们这么干会有什么后果?后果是就是无论 <App /> 组件怎么更新状态值,你的 callback 函数内部访问到的 count 变量值永远都是它第一次的值,即 0。这就是臭名昭著的闭包陷阱之一 - 「stale closure」。

而依赖数组就是用来解决 stale closure 问题的。在阐述依赖数组是如何解决 statle closure 问题之前,我们简单地复习一下「闭包的原理」:

  • 每一次函数被调用或者被实例化的时候都会创建一个作用域对象,该对象记录了函数在调用瞬间的各个变量的值

  • 不同的函数实例拥有不同的作用域对象

  • 如果一个函数内部出现了闭包,那么被嵌套函数的作用域对象就会关联到上级的作用域对象,从而形成一条用于追溯变量值的作用域链

下面,我们用图来分别表示一下下面两种情况在创建作用域链的情况:

  1. 在每次调用 App() 的时候都创建新的 addOne 函数实例;
  2. 在第一次调用 App() 的时候创建新的 addOne 函数实例,在第二次调用的时候,使用缓存的函数实例引用。

第一种情况:

从源码学 API 系列之 useCallback()

第二种情况:

从源码学 API 系列之 useCallback()

从上面的示意图,我们可以看出,在第二种情况中,因为没有创建新的函数实例而导致我们在调用 addOne() 的时候 js 引擎追溯的是第一条作用域链。这就是调用 addOne() 的时候,我们访问到的 count 变量值永远都是 0 的原因。

到这里,useCallback hook 的依赖数组的作用已经很清晰了。它的作用在前后依赖发生改变的时候重新对 callback 函数定义进行实例化来创建一个新的作用域对象。新的下层作用域对象总是能「链接」到最新的上层作用域对象,所以它总能访问到最新的闭包变量值。通过这样,react 间接地「刷新」了 callback 函数内部所使用的闭包变量的值。

如果没有依赖数组,那么我们就无法比较;如果无法比较,我们就无法知道闭包变量值是否已经发生变化;如果无法知道闭包变量值是否已经发生变化,我们就无法知道是否应该创建新的函数实例。不能正确地创建新的函数实例,这就导致了 stale closure 问题。

以上,就是在 react 中,为什么需要依赖数组的终极原因。

遭遇性能困境

弄白了「为什么需要依赖数组」后,于是乎, callback 函数用到什么闭包变量,我们就乖乖地把它罗列在依赖数组里面。我们似乎就可以高枕无忧了,是这样的吗?

不是的。如果你在乎 react 的渲染性能的话,那么,很快你就会遭遇一个性能困境。想象一下,你有这个业务场景:你有一个很重的组件 <SuperHeavyComponnent>,它在你的 <App> 组件里面被使用了。<SuperHeavyComponnent> 接受一个叫 onSubmit 的 prop,它最终是由 <SuperHeavyComponnent> 组件来触发的。它的作用是提交 <App> 组件的表单数据到远程服务器。

鉴于 <SuperHeavyComponnent> 太重了,熟悉 react 渲染性能优化的你很快就写下了下面的代码:

const MemoziedSuperHeavyComponnent = React.memo(SuperHeavyComponnent);

function App(){
    const [formVal, setFormVal] = useState('')

    const handleSubmit = useCallback(()=>{
        console.log('formVal:', formVal)
    }, [formVal])

    return (
        <>
            <input 
                onChange={(e)=>{ setFormVal(e.target.value) }} 
                value={formVal} 
            />
            <MemoziedSuperHeavyComponnent   onSubmit ={handleSubmit} />
        </>
    )
}

为了让 <SuperHeavyComponnent> 跳过不必要被动渲染,你使用了经典配方: React.memo() + useCallback()。但是,很快你就会发现了问题 - 在输入框里面输入内容的时候,<SuperHeavyComponnent> 还是进行了重渲染。

为什么?

下面我们来分析一下。为了保证 handleSubmit 引用的不变性,我们必须用 useCallback()去包裹;但是因为 callback 函数里面消费了闭包变量 formVal,为了保证能访问到该变量最新值,我们必须把 formVal 设置为依赖。但是,这样一来,每次组件渲染的时候,handleSubmit 就是一个全新的引用。这让我们之前做的工作(React.memo())前功尽废了。

我们确实来到了一个「鱼和熊掌不能兼得」的性能困境:

  • 鱼 : 保持 callback 引用的唯一性/稳定性
  • 熊掌:保证在 callback 内部能访问到闭包变量的最新值

假如,我们的 callback 函数不访问闭包变量,我们就不会遭遇这个困境。但是一旦访问了,目前来看,「鱼」和「熊掌」是强烈冲突的,两者是「有我没你,有你没我」的境况。

难道真的没办法走出这个困境,兼得「鱼」和「熊掌」吗?

走出困境

办法有是有的,就是得花点心思。这些心思都是建立在能正确理解「闭包」和「作用域链」之上的。其实解决方案的思路就是:

  • 把对闭包变量的访问转移到 callback 的外部作用域,并在外面去解决 stale closure 问题,并把访问到闭包变量的函数实例引用存储起来;
  • useCallback 的 callback 函数内部一律不访问闭包变量,成为一个调用上面所提的那个函数实例的容器。

根据上面的思路,我们可以分为下面几个步骤来做:

  1. 首先,创建一个 <App > 组件函数作用域之外创建一个变量 cachedCallback,用于保存刷新闭包变量的那个函数的实例引用;
  2. 在每一次 <App > 组件函数重渲染的时候,我们要重新创建一个函数实例,以便链接到最新的作用域链上。与此同时,把这个函数的实例引用保存在 cachedCallback 变量上;
  3. 最后,用 useCallback 实现一个不变的引用 - callback 只是作为一个壳,在函数体内去调用那个用 cachedCallback 保存下来的函数实例,由它来做真正要做的事。

最终完整的代码如下:

import * as React from 'react';
const { useState, useCallback, useRef, useEffect, memo} = React

const SuperHeavyComponent = ({ onSubmit }: {onSubmit: ()=>void})=>{
  return (
    <>
    <div >{Math.random()}</div>
    <button onClick={onSubmit}>提交表单</button>
    </>
  )
}

const MemoziedSuperHeavyComponnent = memo(SuperHeavyComponent);

let cachedCallback

export default function App(){
  const [formVal, setFormVal] = useState('')

  cachedCallback = ()=>{
    console.log('formVal:', formVal)
  }

  const handleSubmit = useCallback(()=>{
    cachedCallback()
  }, [])

  return (
      <>
          <input 
              onChange={(e)=>{ setFormVal(e.target.value) }} 
              value={formVal} 
          />
          <MemoziedSuperHeavyComponnent   onSubmit ={handleSubmit} />
      </>
  )
}

只是简单地调整了一下代码的位置,我们就把这个问题解决了。对,就是这么神奇。现在我们是鱼和熊掌都兼得了:

  • 保证了 handleSubmit 变量保存的是一个稳定不变的引用。这种情况下,React.memo 达到了我们期望的效果;
  • 保证了调用 handleSubmit() 函数时候,代码能访问到最新的 formVal 值。

最后,深入了解过 hook 原理的人可能知道,这里,我们用 useEffect() + useRef 来改造一下上面的代码也是很合适的:

import * as React from 'react';
const { useState, useCallback, useRef, useEffect, memo} = React

const SuperHeavyComponent = ({ onSubmit }: {onSubmit: ()=>void})=>{
  return (
    <>
    <div >{Math.random()}</div>
    <button onClick={onSubmit}>提交表单</button>
    </>
  )
}

const MemoziedSuperHeavyComponnent = memo(SuperHeavyComponent);
  
export default function App(){
  const [formVal, setFormVal] = useState('')
  const callbackRef = useRef<()=> void>()

  const handleSubmit = useCallback(()=>{
    callbackRef?.current()
  }, [])

  useEffect(()=>{
    // 在这里,通过在每一次在 re-render 之后都创建新的函数实例来刷新闭包变量
    callbackRef.current = ()=>{ 
      console.log('formVal:', formVal)
    }
  })

  return (
      <>
          <input 
              onChange={(e)=>{ setFormVal(e.target.value) }} 
              value={formVal} 
          />
          <MemoziedSuperHeavyComponnent   onSubmit ={handleSubmit} />
      </>
  )
}

再厉害的读者可能会发现,其实这里还有我们的优化空间。那就是,我们可以把这些逻辑提取出来,放在一个自定义的 hook 函数里面:

const useCallbackWithoutDeps = (fn)=>{
  const callbackRef = useRef<()=> void>()

  useEffect(()=>{
    callbackRef.current = fn
  })

  return useCallback(()=>{
    callbackRef?.current()
  }, [])
}

最后是这样用:

export default function App(){
  const [formVal, setFormVal] = useState('')

  const handleSubmit = useCallbackWithoutDeps(()=>{
    console.log('formVal:', formVal)
  })

  return (
      <>
          <input 
              onChange={(e)=>{ setFormVal(e.target.value) }} 
              value={formVal} 
          />
          <MemoziedSuperHeavyComponnent   onSubmit ={handleSubmit} />
      </>
  )
}

到这里,你是不是感觉很神奇呢?想自己去把玩一下?点这里吧