阅读 4720

useCallback/useMemo 的使用误区

在编写 React Hook 代码时,useCallbackuseMemo时常令人感到困惑。尽管我们知道他们的功能都是做缓存并优化性能,但是又会担心因为使用方法不正确导致负优化。本文将阐述useCallbackuseMemo在开发中常见的使用方式和误区,并结合源码剖析原因,知其然并知其所以然。

1.useCallback

1.1 不要滥用useCallback

考察如下示例:

import React from 'react';

function Comp() {
    const onClick = () => {
        console.log('打印');
    }
    
    return <div onClick={onClick}>Comp组件</div>
}
复制代码

Comp组件自身触发刷新或作为子组件跟随父组件刷新时,我们注意到onClick会被重新赋值。为了"提升性能",使用useCallback包裹onClick以达到缓存的目的:

import React, { useCallback } from 'react';

function Comp() {
    const onClick = useCallback(() => {
        console.log('打印');
    }, []);
    
    return <div onClick={onClick}>Comp组件</div>
}
复制代码

那么问题来了,性能到底有没有获得提升?答案是非但没有,反而不如以前了;我们改写代码的逻辑结构之后,原因就会非常清晰:

import React, { useCallback } from 'react';

function Comp() {
    const onClick = () => {
        console.log('打印');
    };
    
    const memoOnClick = useCallback(onClick, []);
    
    return <div onClick={memoOnClick}>Comp组件</div>
}
复制代码

每一行多余代码的执行都产生消耗,哪怕这消耗只是 CPU 的一丁点热量。官方文档指出,无需担心创建函数会导致性能问题,所以使用useCallback来改造该场景下的组件,我们并未获得任何收益(函数还是会被创建),反而其带来的成本让组件负重(需要对比依赖是否发生变化),useCallback用的越多,负重越多。站在 javascript 的角度,当组件刷新时,未被useCallback包裹的方法将被垃圾回收并重新定义,但被useCallback所制造的闭包将保持对回调函数和依赖项的引用。

1.2 useCallback的正确使用方法

产生误区的原因是useCallback的设计初衷并非解决组件内部函数多次创建的问题,而是减少子组件的不必要重复渲染。实际上在 React 体系下,优化思路主要有两种:

  • 1.减少重新 render 的次数。因为 React 最耗费性能的就是调和过程(reconciliation),只要不 render 就不会触发 reconciliation。
  • 2.减少计算量,这个自然不必多说。

所以考察如下场景:

import React, { useState } from 'react';

function Comp() {
    const [dataA, setDataA] = useState(0);
    const [dataB, setDataB] = useState(0);

    const onClickA = () => {
        setDataA(o => o + 1);
    };
    
    const onClickB = () => {
        setDataB(o => o + 1);
    }
    
    return <div>
        <Cheap onClick={onClickA}>组件Cheap:{dataA}</div>
        <Expensive onClick={onClickB}>组件Expensive:{dataB}</Expensive>
    </div>
}
复制代码

Expensive是一个渲染成本非常高的组件,但点击Cheap组件也会导致Expensive重新渲染,即使dataB并未发生改变。原因就是onClickB被重新定义,导致 React 在 diff 新旧组件时,判定组件发生了变化。这时候useCabllbackmemo就发挥了作用:

import React, { useState, memo, useCallback } from 'react';

function Expensive({ onClick, name }) {
  console.log('Expensive渲染');
  return <div onClick={onClick}>{name}</div>
}

const MemoExpensive = memo(Expensive);

function Cheap({ onClick, name }) {
  console.log('cheap渲染');
  return <div onClick={onClick}>{name}</div>
}

export default function Comp() {
    const [dataA, setDataA] = useState(0);
    const [dataB, setDataB] = useState(0);

    const onClickA = () => {
        setDataA(o => o + 1);
    };
    
    const onClickB = useCallback(() => {
        setDataB(o => o + 1);
    }, []);
    
    return <div>
        <Cheap onClick={onClickA} name={`组件Cheap:${dataA}`}/>
        <MemoExpensive onClick={onClickB} name={`组件Expensive:${dataB}`} />
    </div>
}
复制代码

memo是 React v16.6.0 新增的方法,与 PureComponent 类似,前者负责 Function Component 的优化,后者负责 Class Component。它们都会对传入组件的新旧数据进行浅比较,如果相同则不会触发渲染。

所以useCallback保证了onClickB不发生变化,此时点击Cheap组件不会触发Expensive组件的刷新,只有点击Expensive组件才会触发。在实现减少不必要渲染的优化过程中,useCallbackmemo是一对利器。运行示例代码

1.3 延伸

useCallback源码如下:

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

// 更新阶段
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)) {
                // 如果相等,返回旧的 callback
                return prevState[0];
            }
        }
    }
  
    hook.memoizedState = [callback, nextDeps];
    return callback;
}
复制代码

核心逻辑就是比较deps是否发生变化,如果有变化则返回新的callback函数,否则返回原函数。其中比较方法areHookInputsEqual内部实际调用了 React 的is工具方法:

// 排除以下两种特殊情况:
// +0 === -0  // true
// NaN === NaN // false

function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y);
  );
}
复制代码

2.useMemo

2.1 不要滥用useMemo

import React, { useMemo } from 'react';

function Comp() {
    const v = 0;
    const memoV = useMemo(() => v, []);
    
    return <div>{memoV}</div>;
}
复制代码

创建memoV的开销是没有必要的,原因与第一节提到的相同。只有当创建行为本身会产生高昂的开销(比如计算上千次才会生成变量值),才有必要使用useMemo,当然这种场景少之又少。

2.2 useMemo的正确使用方法

前文我们提到,优化 React 组件性能的两个主要思路之一是减少计算量,这也是useMemo的用武之地:

import React, { useMemo } from 'react';

function Comp({ a, b }) {
    const v = 0;
    const calculate = (a, b) => {
        // ...  complex calculation
        return c;
    }
    
    const memoV = useMemo((a, b) => v, [a, b]);
    
    return <div>{memoV}</div>;
}
复制代码

3.总结

React Hook 对团队的协作一致性要求非常高,useCallbackuseMemo这一对方法就是很好的示例,更复杂的场景还有对useRef、自定义 Hook 的使用等等。从经验上来看,团队在进行 Hook 编码时需要特别加强 code review,否则很容易出现难以定位的 bug 或性能问题。当前 Hook 的各类方法还不完善,推特上争论也很多,期待 React 后续版本提供出更成熟易用的方案。

文章分类
前端