使用 React hooks 的一些正确姿势

1,000 阅读20分钟

背景

React Hooks 是 React 16.8 引入的新特性,允许我们在不使用 Class 的前提下使用 state 和其他特性。React Hooks 要解决的问题是状态共享,是继 render-props 和 higher-order components 之后的第三种状态逻辑复用方案,不会产生 JSX 嵌套地狱问题。

但是在实际的使用过程中,很多人对 hooks 的使用和理解有许多不对的地方,没有正确的掌握某些细节,以至于经常会出现下面的这些问题:

  • 如何正确地在useEffect里请求数据?

  • 我应该把函数当做effect的依赖吗?

  • 为什么有时候会出现无限重复请求的问题?

  • 为什么有时候在effect里拿到的是旧的state或prop?

  • useCallback 和 useEffect 到底在什么时候使用?

本文会深入讲解 hooks 的一些常见用法帮你明白上面问题的答案。

FC 组件的渲染

在我们讨论 hooks 之前,我们需要先讨论一下渲染(rendering)。

每一次渲染都有自己的 Props and State

我们来看一个计数器组件Counter:

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

  return (
    <div>
      <p>You clicked {count} times</p>      
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

这个组件看起来很简单,它的工作分为两部分:

  1. 初始渲染的时候 使用 useState 的初始值,count 为 0,高亮的部分显示为:You clicked {count} times。

  2. 每次点击 button 的时候,count 值加一,高亮的部分会实时更新 count 的最新值。

我们开始思考,高亮的代码究竟是什么意思呢?count 会“监听”状态的变化并自动更新吗?它是不是数据绑定或者类似于 Vue 中的 watcher 或 proxy 的概念呢?

但事实上 count 只是个数字,不是上面那些神奇的概念, Counter 组件在第一次渲染的时候,从useState()拿到count的初始值0,当点击 button 会调用 setCount 方法,此时 React 会重新渲染 Counter 组件,每一次渲染都能拿到独立的count 状态,这个状态值是函数中的一个常量。

Count 的值在每次渲染中由 React 提供,当 setCount 的时候,React 会带着一个不同的 count 值再次调用组件,然后React会更新DOM以保持和渲染输出一致。就像下面这样:

// 第一次渲染
function Counter() {
  const count = 0; // Returned by useState()  
  // ...
  <p>You clicked {count} times</p>
  // ...
}

// 第二次渲染
function Counter() {
  const count = 1; // Returned by useState()  
  // ...
  <p>You clicked {count} times</p>
  // ...
}

现在我们知道了 Hooks 中的变量是这样的,那么事件处理函数呢?

每一次渲染都有自己的事件处理函数

我们来看下面的这个例子。它在三秒后会alert点击次数count:

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

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>
  );
}

如果我按照下面的步骤去操作:

  • 点击增加 counter 到3

  • 点击一下 “Show alert” 按钮

  • 点击增加 counter到 5 并且在定时器回调触发前完成

你猜alert会弹出什么呢?是alert的时候counter的实时状态 5 呢,还是我点击时候的状态 3 呢?

答案是 3,react 会捕获我点击 show alert 按钮时候的状态。但它是怎么工作的呢?

前面说过,count在每一次函数调用中都是一个常量值,但实际上我们的 Counter 组件函数在每次渲染时候都会被调用,同样的在其内部也都会生成一个新版本的 handleAlertClick。每一个版本的handleAlertClick“记住” 了它自己的 count:

// 首次渲染
function Counter() {
  const count = 0; // Returned by useState()  
  // ...
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + 0);
    }, 3000);
  }
  // ...
}

// 第一次点击
function Counter() {
  const count = 1; // Returned by useState()  
  // ...
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + 1);
    }, 3000);
  }
  // ...
}

// 第二次点击
function Counter() {
  const count = 2; // Returned by useState()  
  // ...
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + 2);
    }, 3000);
  }
  // ...
}

所以这也就解释了上面的 demo 中,事件处理函数属于某一次特定的渲染,当我们点击 show alert 按钮时候,它调用的是那次渲染中的 handleAlertClick 函数,它内部使用的 count 值也是独立的,它们都“属于”一次特定的渲染。

每一次渲染都有自己的 Effect

从前面的分析中我们其实可以很容易得到, effcts 并没有什么两样。虽然我们看到的是一个 effect,但其实同样的,每次渲染都是一个不同的函数,并且每个 effect 函数用到的 props 和 state 都来自于属于它那次特定的渲染。

React 的 Effects 函数会在每次DOM 修改后并在浏览器重新绘制屏幕后去调用。这也是 useEffect 名字--"副作用" 的含义,在 React 组件中获取数据、订阅事件和手动更改 DOM 等这些副作用都是在这里面调用的。

我们可以用下面的方式了解下整个的渲染过程:

  • 第一次渲染:

    • React:给我状态为 0 时的 UI

    • Counter 组件:

      • 给你需要渲染的内容: <p>You clicked 0 times</p>。

      • 记得在渲染完了之后调用这个effect: () => { document.title = 'You clicked 0 times' }。

    • React**:** 没问题。开始更新UI, hello 浏览器,我要给DOM添加一些东西。

    • 浏览器**:** OK,我已经把它绘制到屏幕上了。

    • React**:** 好的, 我现在开始运行给我的effect

      • 运行 () => { document.title = 'You clicked 0 times' }。
  • 点击之后渲染

    • Counter 组件**:** 喂 React, 把我的状态设置为1。

    • React: 给我状态为 1 时候的UI。

    • Counter 组件:

      • 给你需要渲染的内容: <p>You clicked 1 times</p>。

      • 记得在渲染完了之后调用这个effect: () => { document.title = 'You clicked 1 times' }。

    • React: 没问题。开始更新UI,喂浏览器,我修改了DOM。

    • 浏览器: 酷,我已经将更改绘制到屏幕上了。

    • React: 好的, 我现在开始运行属于这次渲染的effect

      • 运行 () => { document.title = 'You clicked 1 times' }。

我们再来看下面的代码:

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

  useEffect(() => {
    setTimeout(() => {
      console.log(`You clicked ${count} times`);
    }, 3000);
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

如果我点击了很多次并且在effect里设置了延时,打印出来的结果会是什么呢?如果使用 class 组件又是什么样的结果呢?

class Example extends Component {
  state = {
    count: 0
  };
  componentDidMount() {
    setTimeout(() => {
      console.log(`You clicked ${this.state.count} times`);
    }, 3000);
  }
  componentDidUpdate() {
    setTimeout(() => {
      console.log(`You clicked ${this.state.count} times`);
    }, 3000);
  }
  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={() => this.setState({
          count: this.state.count + 1
        })}>
          Click me
        </button>
      </div>
    )
  }
}

答案是 hooks 组件会顺序的打印输出,每一个都属于某次特定的渲染。

image

而 class 组件每次打印输出都是5,因为 this.state.count总是指向最新的count值,而不是属于某次特定渲染的值。

image

但是,有时候我们可能就是想要在effect的回调函数里读取最新的值而不是捕获的值。最简单的实现方法是使用refs:

function Example() {
  const [count, setCount] = useState(0);
  const latestCount = useRef(count);

  useEffect(() => {
    // 为可变变量设置最新的值
    latestCount.current = count;
    setTimeout(() => {
      // 读取可变变量最新的值,而不再是读取一个常量
      console.log(`You clicked ${latestCount.current} times`);
    }, 3000);
  });
  // ...

Hooks 最佳实践

正确使用Effects 的依赖

前面讲了每次渲染都会有自己的 Effects 来执行,但很多时候我们并不需要这么做,我们都知道通过提供给useEffect一个依赖数组参数(deps) 来告诉 useEffect只有在 deps 里的值变化的时候才会重新渲染。如果当前这次渲染的这些依赖项和上一次运行这个effect的时候值一样,React就会自动跳过这次effect,反之则会再次执行这次effect 的回调。

举个例子,我们来写一个每秒递增的计数器:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return <h1>{count}</h1>;
}

上面的例子中,我们把依赖数组设置成了 [],按照我们的直觉就是只在初始化的时候创建一次定时器,然后定时器每秒运行就可以递增了。然而这个例子只会递增一次,这让人感到很困惑,为什么会有问题呢?

在第一次渲染中,count是0。因此,setCount(count + 1)在第一次渲染中等价于setCount(0 + 1)。由于我们设置了[]依赖,effect不会再重新运行,它后面每一秒都会调用setCount(0 + 1) 。

所以正确的做法应该是在依赖中包含所有 effect 中用到的组件内的值:

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);  
   }, 1000);
  return () => clearInterval(id);
}, [count]);

这样每次 count 修改都会重新运行 effect,并且定时器中的setCount(count + 1)会正确引用到那次渲染中的 count值。但是这样还是有一些问题, effect 里面的定时器会在每次 count 改变后清除和重新设定,这应该不是我们想要的。

为了不让定时器重复设置,像这种想要根据前一个状态更新状态的时候,可以使用 setState 的函数形式:

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);    
     }, 1000);
    return () => clearInterval(id);
  }, []);

这样尽管 effect 只运行了一次,第一次渲染中的定时器回调函数可以完美地在每次触发的时候给React发送c => c + 1更新指令,它不再需要知道当前的count值,因为React已经知道了。

然而即使是 setCount(c => c + 1) 也并不完美,它的使用场景非常受限,如果我们有两个互相依赖的状态,或者我们想基于一个prop来计算下一次的state,它就做不到了。但是这时候有一个更强大的模式,就是 useReducer。

useReducer--解耦 Effect 和 State 的更新

我们来修改上面的例子让它包含两个状态:count 和 step,定时器会每次在count上增加一个step值:

function Counter() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step);    
    }, 1000);
    return () => clearInterval(id);
  }, [step]);
  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
  );
}

上面的 demo 中 Effect 里面使用了 step,所以我们得把 step 加到依赖里,但是正如我们前面讲过的,这样一来每次 step 改变的时候就会重新执行 Effect 函数,里面的定时器就会重新设置和清除。虽然这没有错,大多数情况下我们确实应该这么做,但这种实现总是感觉不太那么优雅,那我们该如何从effect中移除对step的依赖呢?

这时候就需要用到 useReducer 了。

注意:当你想更新一个状态,并且这个状态更新依赖于另一个状态的值时,你可能需要用useReducer去替换它们。

const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;

useEffect(() => {
  const id = setInterval(() => {
    dispatch({ type: 'tick' });  
   }, 1000);
  return () => clearInterval(id);
}, [dispatch]);

相比于直接在 effect里面读取状态,它dispatch了一个action来描述发生了什么。这使得我们的effect和step状态解耦。我们的effect不再关心怎么更新状态,它只负责告诉我们发生了什么。更新的逻辑全都交由reducer去统一处理:

const initialState = {
  count: 0,
  step: 1,
};

function reducer(state, action) {
  const { count, step } = state;
  if (action.type === 'tick') {    
      return { count: count + step, step };  
  } else if (action.type === 'step') {
    return { count, step: action.step };
  } else {
    throw new Error();
  }
}

OK,上面我们讲了有两个互相依赖的状态,还有一种情况是假如我们需要依赖props去计算下一个状态呢?举个例子,我们的子组件是 <Counter step={1} />,在这种情况下,是不是我们就没法避免依赖props.step 了?

但是实际上, 可以避免!我们可以把reducer函数放到组件内去读取props:

function Counter({ step }) {  
  const [count, dispatch] = useReducer(reducer, 0);

  function reducer(state, action) {
    if (action.type === 'tick') {
      return state + step;    
     } else {
      throw new Error();
    }
  }

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);

  return <h1>{count}</h1>;
}

这就是为什么有的人认为useReducer是Hooks的“作弊模式”。它可以把更新逻辑和描述发生了什么分开。结果是,这可以帮助我移除不必需的依赖,避免不必要的effect调用。

把函数移到Effects里

很多人都有个误解是认为函数不应该成为依赖。举个例子,

function SearchResults() {
  const [data, setData] = useState({ list: [] });

  async function fetchData() {
    const result = await axios(
      'https://xxxxxx?query=react',
    );
    setData(result.data);
  }

  useEffect(() => {
    fetchData();
  }, []); 
  // ...

上面的代码确实可以正常运行,但这样做在组件日渐复杂的迭代过程中我们很难确保它在各种情况下还能正常运行。

上面的 demo 太简单,实际业务场景中我们的代码肯定会更加复杂,函数体积也会更大, 我们对代码做下面这样的改变,并且假设每一个函数的体量是现在的好几倍,然后我们在某些函数内使用了某些state或者prop:

function SearchResults() {
  const [query, setQuery] = useState('react');

  // 假设函数体积很大,逻辑也很多
  function getFetchUrl() {
    return 'https://xxxxxx?query=' + query;  
  }

  async function fetchData() {
    const result = await axios(getFetchUrl());
    setData(result.data);
  }

  useEffect(() => {
    fetchData();
  }, []);

  // ...
}

上面这种情况,如果我们忘记去更新使用这些函数的effects的依赖,effects 就不会同步props和state带来的变更。

这种问题有一个很简单的解决方案,就是如果某些函数仅在 effect中调用,你可以把它们的定义移到effect中:

function SearchResults() {
  // ...
  useEffect(() => {
    function getFetchUrl() {
     return 'https://xxxxxx?query=' + query;  
    } 

    async function fetchData() {
      const result = await axios(getFetchUrl());
      setData(result.data);
    }
    
    fetchData();
  }, [query]);
  // ...
}

这样的话我们就不需要再去考虑其它的 间接依赖,只用去关心 effect 内部引用了什么即可, 除了 query 之外,在我们的effect中确实没有再使用组件范围内的其它东西。

推荐开启 eslint-plugin-react-hooks 插件的exhaustive-depslint规则,它会在编码的时候就分析effects并且提供可能遗漏依赖的建议。

但是有时候我们可能不太想把函数移入effect里。比如组件内有几个effect使用了相同的函数,你不想在每个effect里复制粘贴一遍这个逻辑,也或许这个函数是一个prop。

function SearchResults() {
  function getFetchUrl(query) {
    return 'https://xxxx?query=' + query;
  }

  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 
  }, []); // lint: Missing dep: getFetchUrl

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 
  }, []); // lint: Missing dep: getFetchUrl

  // ...
}

上面的例子中,因为我们想复用逻辑,可能不想把getFetchUrl 移到effects中,另一方面,即使我们按照 lint 规则在每个 effect 里添加了依赖 getFetchUrl,我们可能会掉到依赖陷阱里,因为我们前面讲过,每次渲染 getFetchUrl 其实都是一个新的函数,这会导致每次渲染都会触发 effect 函数。

这种情况下,

我们有两个更简单的解决办法。

第一个, 如果一个函数没有使用组件内的任何值,你应该把它提到组件外面去定义,然后就可以自由地在effects中使用:

// 不受数据流的影响
function getFetchUrl(query) {
  return 'https://xxxx?query=' + query;
}

function SearchResults() {
  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 
  }, []); 

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 
  }, []); 
  // ...
}

我们不再需要把它设为依赖,因为它们不在渲染范围内,因此不会被数据流影响。

另一个方法就是,我们可以把它包装成 useCallback hook:

function SearchResults() {
  // useCallback 的依赖不变时,内部的函数不会重新定义
  const getFetchUrl = useCallback((query) => {
    return 'https://xxxx?query=' + query;
  }, []); 

  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 
  }, [getFetchUrl]); 

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 
  }, [getFetchUrl]); 

  // ...
}

useCallback本质上是添加了一层依赖检查,**使函数本身只在需要的时候才改变,而不是去掉对函数的依赖。**这时候即使 query 是通过 state 或者 props 获取的,我们也可以很方便的添加到依赖中。

使用了 useCallback可能更糟糕?

下面来看一个真实项目中的例子:

image

上面的代码中对 fetchData 函数是用了 useCallback 包裹,看起来是做了一些优化,使 fetchData 函数只在需要的时候才发生改变,但问题是这样写真的对性能更好吗?

正确答案是:不使用 useCallback 的代码性能会更好!

为什么呢?我们稍微重构一下 useCallback 的代码(没有实际的改变,只是移动下代码):

  // 原来的写法
  const fetchData = useCallback(async () => {
    await getWithdrawList({
      params,
    });
  }, [params]);
  
  
  // 调整后的写法
  const getWithdrawList = async () => {
    await getWithdrawList({
      params,
    });
  };
  const fetchData = useCallback(getWithdrawList, [])

仔细看上面的代码,调整后本质上没有什么不同,除了useCallback版本做了更多的工作之外,它们完全相同。只是把匿名函数放在了外面使用 getWithdrawList 具名函数进行了定义。这样其实可以清晰的看出使用 useCallback 与不使用只改变了下面这一行代码:

const fetchData = useCallback(getWithdrawList, [])

我们需要明白 执行的每行代码都有成本。在不使用 useCallback 的版本中,组件再次渲染时,原来的 fetchData 函数被垃圾收集(释放内存空间),然后创建一个新的 fetchData 函数。但是使用 useCallback 时,原来的 fetchData 函数不会被垃圾收集(因为需要保持引用不变),并且会创建一个新的 fetchData 函数,我们不仅需要定义函数,还要定义一个数组([])并调用 useCallback,useCallback 函数本身会设置属性和运行逻辑表达式等。所以从内存的角度来看,这其实会变得更糟。

一两个非必要的 useCallback 或 useMemo 的调用无所谓。但是当非常多的在第一次渲染时毫无作用的 useMemo/useCallback 出现的时候,它们会拖慢渲染速度,增加内存消耗,增加你的代码大小及复杂度。

一个常见的错误认知是 “useCallback 避免了在 render 时候创建新函数”。非也。

useCallback 做的是基于给定的依赖,返回缓存过的函数。如果给定了同样的依赖(基于引用的比较),你会获得上一次返回的函数,两者引用相同。

如果一个组件或者 hook 不关心你传进去的函数是否有变,那么与不使用 useCallback 的情况相比,现在不仅创建了一个新的函数,还执行了一下 useCallback、比较了一下它的依赖是否相等,并且在内存中还额外存了函数及依赖数据。相比每次创建一个新函数,useCallback 明显昂贵得多。

下面是 useCallback 和 useMemo 的源码,仔细看下就会明白了。

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]; // 缓存的是 callback 函数和依赖
    return callback;
  }
  
  function updateMemo(nextCreate, 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];
        }
      }
    }
    const nextValue = nextCreate();
    hook.memoizedState = [nextValue, nextDeps];// 缓存的是 callback 函数的执行结果和依赖
    return nextValue;
  }

什么时候使用 useCallback 和 useMemo

useCallback 和 useMemo 都可缓存函数的引用或值,但是从更细的使用角度来说 useCallback 缓存函数的引用,useMemo 缓存计算数据的值。

子组件使用了 React.memo

React.memo() 是 React v16.6 中引入的新功能,与 React.PureComponent 类似,有助于控制 函数组件 的重新渲染。React.memo() 对应的是函数组件,React.PureComponent 对应的是类组件。

我们来看一个 demo

function Counter() {
  const [count, setCount] = useState(0);
  const handleCount = () => setCount(count + 1);

  return (
    <div>
      <p>You clicked {count} times</p> 
      <Button onClick={handleCount}> Click me</Button>  
      <AotherComponent onClick={handleCount} />   
    </div>
  );
}

function Button(props) {
  console.log('Button 组件渲染了');
  return <button onClick={props.onClick}>{props.children}</button>;
}

// 假如 AotherComponent 内部有比较复杂的渲染逻辑
function AotherComponent({ onClick }) {
  console.log("AotherComponent 组件渲染了");
  return (
    <div>
      <button onClick={onClick}>其他组件</button>
    </div>
  );
}

每次单击其中一个按钮,因为 count 的状态发生变化,因此会重新渲染,Button 组件和 AotherComponent 组件也都会渲染,但是,实际上只需要重新渲染被点击的那个按钮吧?假如 AotherComponent 内部有比较复杂的渲染逻辑,这种 “不必要的重新渲染” 则会造成性能上的问题。

image

我们来通过 React.memo 优化上面代码:demo

const Button = React.memo(function Button(props) {
  console.log("Button 组件渲染了");
  return <button onClick={props.onClick}>{props.children}</button>;
});

const AotherComponent = React.memo(function AotherComponent({ onClick }) {
  console.log("AotherComponent 组件渲染了");
  return (
    <div>
      <button onClick={onClick}>其他组件</button>
    </div>
  );
});

现在 React 只会当 props 改变时会重新渲染这两个组件,但是还有一个问题,前面我们讲过每次渲染 handleCount 都会是一个新函数,因此会导致上面的 React.memo 优化失效了,这时候我们来引入useCallback:

const handleCount = useCallback(() => setCount(count =>count + 1), []);

因为我们的 handleCount 是一个缓存函数,所以当我们传递给经过React.memo 优化的组件 Button 时不会触发渲染。但是如果这时候我们没有对 AotherComponent 使用 React.memo 包裹,你会发现即使 handleCount 使用了 useCallback 包裹,结果 AotherComponent 还是重新渲染了。

所以我们总结一下:当子组件是用了React.memo 或 PureComponent,这时候的 prop 会作为重绘的依据,需要使用 useMemo/useCallback,否则只是单纯的使用 useMemo 或 useCallback 并不会提升性能,反而会影响性能。

引用相等

引用相等的例子我们前面提到过,再看一下下面的这个例子:

function Child({bar, baz}) {
  const options = {bar, baz}
  React.useEffect(() => {
    buzz(options)
  }, [options]) // 我们想要bar or baz 变化时重新执行
  return <div>foobar</div>
}

function Parent() {
  return <Foo bar="bar value" baz={3} />
}

我们知道由于 useEffect 将对每次渲染中对依赖数组 deps 进行引用相等性检查,并且每次渲染 options 都是新的,所以当 React 测试 options 是否在两次渲染之间发生变化时,它将始终计算为 true,意味着每次渲染后都会调用 useEffect 回调,而不是仅在 bar 和 baz 更改时调用。

这个问题我们可以通过分成多个依赖来解决:

function Child({bar, baz}) {
  const options = {bar, baz}
  React.useEffect(() => {
    buzz(options)
  }, [bar, baz]) // 我们想要bar or baz 变化时重新执行
  return <div>foobar</div>
}

但是还有一种情况,如果 bar 或者 baz 是(非原始值)对象、数组、函数等,这还是有问题的,因为在父组件 Parent 里面 bar 和 baz 每次渲染都是一个新的值,就会导致子组件里的 useEffect 重复执行:

function Parent() {
  const bar = () => {}
  const baz = [1, 2, 3]
  return <Foo bar={bar} baz={baz} />
}

这时候 useCallback 和 useMemo 又派上用场了:

function Child({bar, baz}) {
  React.useEffect(() => {
    const options = {bar, baz}
    buzz(options)
  }, [bar, baz])
  return <div>foobar</div>
}

function Parent() {
  const bar = React.useCallback(() => {}, [])
  const baz = React.useMemo(() => [1, 2, 3], [])
  return <Foo bar={bar} baz={baz} />
}

我们再总结一下: 当引用对象作为 useEffect 的依赖时,为了保持引用相等避免不停执行 useEffect中的函数,需要使用 useMemo/useCallback。

复杂计算

当我们用到一个耗时的计算,而此计算的输入是一个在重绘时引用不会变更的变量时,使用 useMemo,比如:数据量较大的 map/filter 操作。

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算。

建议规则

OK,最后我们总结一下,在实际的项目中,什么时候不该使用和什么时候该使用 useCallback/useMemo。

  • 什么时候不应该用

    • 当函数、对象直接传给 DOM 组件(div,span,imgs),不要使用 useMemo/useCallback。React 并不关心 DOM 组件的 prop 的函数、对象的引用是否变更。

    • 当函数、对象直接传给叶子组件或者组件的 children 时,不要使用 useMemo/useCallback。通常情况下,叶子组件都不会使用 React.memo,而 children 每次引用都会变,所以这些组件其实并不关心传入的函数、对象是否引用有变更。

    • 当函数、对象传入的组件不关心是否是个新的引用时,不要使用 useMemo/useCallback(你需要查看组件的源码,确认没有使用 React.memo/PureComponent 并且同步访问最新的 prop 传参,或者直接将 prop 透传给 DOM 或者叶子节点组件)。还要考虑你是否每次都传了一个没有缓存的新的 children。

  • 什么时候应该用

    • 在使用 Context Provider 时,使用 useMemo。Provider 通常会有很多组件订阅它的变更,像<Provider value={{id, name}}> 这样的代码,会导致每次组件重绘时,Provider value 引用更新,导致所有订阅此 Provider 的 Consumer 组件及 useContext 所在的组件重绘。

    • 当你用到一个耗时的计算,而此计算的输入是一个在重绘时引用不会变更的变量时,使用 useMemo。

    • 当使用 ref 函数时,使用 useMemo/useCallback。比如:当使用 useIntersectionObserver hook 时,ref 函数会根据形参是否为 null 执行 disconnect 和 re-connect 操作,需要避免 ref 引用变更导致无用的 disconnect re-connect 调用。

    • 当引用对象作为 useEffect 的依赖时,为了避免不停执行 useEffect 中的函数,需要使用 useMemo/useCallback。

    • 当子组件使用了 React.memo/PureComponent,prop 作为是否重绘的唯一依据,需要使用 useMemo/useCallback。

总结

React hooks 使用起来简单易上手,除了带来简洁的代码外,也存在许多对其使用不当的情况。因此需要我们重新审视函数式组件的意义,完全理解 React hooks 的真正含义,才能得到更好的实践。关于 React hooks 的实现原理敬请期待。

参考文档