浅谈 React useCallback 使用场景

2,870 阅读6分钟

背景:

我认识的一些朋友在新学习 React 的时候,不太清楚 useCallback 有哪些使用场景,对于useCallbackuseEffect 的依赖处理也不是很熟练,使得 Hook API 给他们带来了不少的心智负担。我根据我的经验,浅浅的讲讲常用的 Hook 的用法以及使用场景。如果文章有所不对的地方,请各位指点指点。

API 概述:

React 核心心智模型简单来讲就是UI=render(state),通过数据驱动视图。

React 有两种方式来创建 UI,分别为 class 组件函数组件

在 React 16.8 之前,函数组件只能作为无状态组件(Stateless component)来使用,函数组件顾名思义就是一个JavaScript/TypeScirpt函数,通过这个函数实时计算返回 JSX,遵循「参数 => 返回值」这种「纯函数」的概念。

只要在 JavaScript/TypeScirpt 中执行或调用函数,会创建一个新的执行上下文并将其放在执行堆栈中。这样就创建了执行上下文,就像全局上下文一样。它将拥有自己的变量和函数空间。它将经历创建阶段,然后它将逐行执行函数中的代码。函数执行完毕之后,会弹出执行栈,如果发现没有变量再指向函数空间,也会在堆中销毁该函数空间。

由于函数组件调用一次之后,其内部的声明的变量会被销毁,而 React 组件反复执行函数创建 JSX,故不方便做状态管理。

于是 React 在 16.8 这个版本中,带来了 Hook API,可以在函数组件里面管理状态,让 React 更加的函数式。

useState API 创建的 state 会被放在函数组件对应的 fiber 上面,通过 useState 返回的元组第二个参数setCount触发更新,更新会造成组件 render,组件 render 之后,useState 返回的count为更新后的结果。

const [count,setCount] = useState()

useEffect API有点像是将 class 中的 componentDidMountcomponentDidUpdatecomponentWillUnmount结合到了一起,但是也不能生搬硬套。

传给 useEffect 的函数会在浏览器完成布局与绘制之后,在一个延迟事件中被调用。useEffect第二个参数为一个依赖数组,当依赖数组里面的值变化时,会重新执行useEffect里面的函数,并且此时useEffect里面才能拿到最新的依赖值。

有时你会在组件中添加一些事件订阅或者其他监听器,useEffect返回了一个函数,将清理方法添加到此返回函数即可。

useEffect(()=>{
    // do some effect
    () => {
    // clear some effect
    }
},[deps])

Hook 提供了 useCallback API,用来保证在函数组件里面声明的函数,在函数组件反复创建的过程中能指向同一个引用。该回调函数仅在某个依赖项(函数外部的 state、props 或者其他变量)改变时才会更新。

const handleClick = useCallback(()=>{
    // do something
},[deps])

与之相对应的,useMemo API 与 useCallback 很类似,useCallback 用来缓存函数useMemo 用来缓存,仅当依赖变化时才重新计算新的值,这种优化有助于避免在每次渲染时都进行高开销的计算。

useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

const memoizedValue = useMemo(()=>{
    // do something
},[deps])

API 详解

场景一:

我们写页面的时候,一定会遇到接口请求获取数据然后渲染的情况,比如我想查询全部用户信息,像这种副作用操作我们可以在useEffect里面执行。

useEffect(()=>{
    getAllUser().then((res)=>{
        // do something
    })
},[])

如果你想使用await,可以用执行函数(IIFE)写法:

useEffect(()=>{
    // Using an IIFE
    (async function getUser() {
         const users = await fetchAllUser();
         // do something
      })();
},[])

如果我们在详情页面需要根据 id 查询某一条用户的详细信息呢?我们需要将 id 放在依赖数组中,这样才能在useEffect里面拿到最新的id值,而且为了避免页面挂载的时候,id 没有值的情况,需要先判断有无 id,再请求接口。

useEffect(()=>{
    if(id){
        fetchUser(id).then((res)=>{
            // do something
        })
    }
},[id])

假如我们需要点击按钮之后,主动查询一次用户信息呢?为了复用性,我们需要将获取用户详情的逻辑抽离出来。

以下代码会符合预期的执行,但是存在一个问题:依赖不直观,若getUserInfo中添加了依赖,忘记及时在useEffect中更新依赖,则会出现不可预料的bug。

const getUserInfo = async () => {
    if(id){
        const user = await fetchUser(id)
        // do something
    }
}

useEffect(()=>{
    getUserInfo()
},[id])

假如这样解决呢?这样确实会避免上面的问题,但是假如getUserInfo里面需要再添加参数,需要修改useEffectgetUserInfo两个地方的代码,而且随着参数变多,两处代码的可维护性也相当的糟糕,useEffect应该只做他该做的事情,保证代码的纯净性。

const getUserInfo = async (userID) => {
    if(userID){
        const user = await fetchUser(userID)
        // do something
    }
}

useEffect(()=>{
    getUserInfo(id)
},[id])

可以用useCallback来解决这个问题,如果getUserInfo需要依赖更多的参数,只需要在此片段修改即可,依赖也很直观,不需要影响到useEffect里面的代码。

当id数据更新时,useCallback会重新返回一个新的函数,useCallback里面的函数中会取到最新的id。作为useEffect的依赖,既然getUserInfo函数的引用更新了,那么useEffect里面的函数也会重新执行。

const getUserInfo = useCallback(async () => {
    if(id){
        const user = await fetchUser(id)
        // do something
    }
},[id])

useEffect(()=>{
    getUserInfo()
},[getUserInfo])

场景二:

从性能优化方面来讲,首先要考虑是怎么减少组件的render

React提供了memo这个高阶组件(HOC),浅层检查 props 变更,跳过非必要的render,以提高组件的性能。

在函数组件中,由于memo是浅层比较props的,而函数执行会反复创建新的上下文,函数里面的变量、函数都会生成新的引用,往往需要配合useCallback或者useMemo使用,才能达到最优的效果。

// Increase.jsx
export const Increase = ({ onClick }) => {
  console.log("child render");
  return <button onClick={onClick}>Increase</button>;
};
// App.jsx
function App() {
  const [count, setCount] = useState(0);
  const handleIncrease = () => {
    setCount((c) => c + 1);
  };
  return (
    <div>
      <div>{count}</div>
      <Increase onClick={handleIncrease} />
    </div>
  );
}

image.png

以上是很常见的场景,父组件将事件通过props传递给子组件,以完成父子通信。每点击一次【Increase】按钮,count 数值会加1。

image.png

点击多次【Increase】按钮之后,控制台上会输出很多次的“child render”,这说明了子组件 Increase 在反复渲染,如何避免其反复渲染呢?

引入memo试一下:

// Increase.jsx
export const Increase = memo(({ onClick }) => {
  console.log("child render");
  return <button onClick={onClick}>Increase</button>;
});

看一看多次点击【Increase】按钮之后的结果

image.png

从控制台输出的内容来看,子组件仍反复创建,说明只添加memo并不会有效果。

改造一下父组件,在handleIncrease函数上面添加一个useCallback

// App.jsx
function App() {
  const [count, setCount] = useState(0);

  const handleIncrease = useCallback(() => {
    setCount((c) => c + 1);
  }, []);
  
  return (
    <div>
      <div>{count}</div>
      <Increase onClick={handleIncrease} />
    </div>
  );
}

image.png

现在控制台只会在页面初始化的时候打印“child render”,无论点击多少次按钮,都不会再打印。这说明子组件不会再反复渲染了。

此时如果保留父组件函数中的useCallback,而去掉子组件的 memo,子组件仍会反复创建。

除了函数之外,引用类型值也同理,不过此时应该使用useMemo,只要是涉及到了事件、引用类型值,要想达到阻止子组件没必要的重复渲染,仅仅使用memo是不够的,需要与useCallbackuseMemo搭配使用。

结语:

以上简单的介绍了一下几个hook的常用的使用场景,后面有空会讲一讲函数组件中事件订阅与实时状态的坑。