likes
comments
collection
share

React Hooks 一点心得

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

Hooks 的心智负担

React Hooks给前端应用开发带来了极大的便利,但同时也带来了极大的心智负担。每次书写 Hooks 时担心是否会重复渲染,会不会拿不到最新的 states 或 props,依赖写错,导致无限渲染等。

笔者也是如此,每次写 Hooks,总是有种绞尽脑汁的感觉,因此决定花点时间梳理一下如何写出的 Hooks 的简单、轻松的用法,如果你也有 Hooks 的使用心得,也欢迎留言探讨。

组件触发渲染

对 React 组件来说,触发渲染无非就以下两种情况:

  • 父组件渲染导致子组件渲染
  • 组件自身 states 和 props 变化导致的渲染

对于父组件的重新渲染,导致子组件的渲染,一般而言,可以使用React.memo包裹子组件,此时需要注意传递给子组件的 props 是否发生变化,例如下面这种情况:

function _Child(props) {
    return <div style={props.style} onClick={props.onClick}>我是子组件</div>
}

const Child = React.memo(_Child);

function Father() {
    const onClick = () => {
        console.log('Click Me');
    };
    return (
        <>
            // 字面量类型的对象,每次都会导致子组件重新渲染
            <Child style={{ color: 'red' }} onClick={onClick} />
            <div>我是父组件</div>
        </>
    )
}

这种情况下,因为 props 引用发生了变化,所以即使使用了React.memo,子组件还是会渲染。

对于函数组件,每一次的渲染其 props 和 states 都是确定的,并且作为函数的属性,其内部的声明的函数或者引用类型变量,再每次重新渲染的时候,其引用都会发生改变,所以对于 Father 组件,onClick 每次渲染都会产生一个新的引用,也会导致 Child 组件跟着重新渲染。

一般来说,建议传递给子组件的 props 如果是引用类型,最好保证引用一致。 虽然通常情况下,组件重新渲染如果不会造成性能问题,也不会有功能问题的话,可以忽略这条,但是随着组件功能越来越复杂,状态和属性也越来越多,很多重复渲染,能避免还是尽量避免,如果真到了无法避免的地步,那可能需要考虑重构组件了。

保持引用一致,通常可以这么写

function _Child(props) {
    return <div style={props.style} onClick={props.onClick}>我是子组件</div>
}

const Child = React.memo(_Child);

const style = {color: 'red'};

function Father() {
    const onClick = useCallback(() => {
        console.log('Click Me');
    }, []);
    return (
        <>
            // 保持渲染前后引用一致
            <Child style={style} onClick={onClick} />
            <div>我是父组件</div>
        </>
    )
}

非常简单,但是也非常容易忽略,记住那些传递的 props 的引用类型,它能帮助我们后期更好的优化。这时,可能有的人会说useCallback也会有性能负担,就是空间换时间而已。这话说的没错,但是笔者想说的是,API 设计出来就是给大家合理使用的,考虑太多,难以取舍,那就变成了沉重的心智负担了,从收益对比角度来说,使用useCallback能够阻止重新渲染是值得的。

同理,useMemo也是一样的。注意到上面的 style 变量,我们其实也可以这么写:

const style = useMemo(() => ({
    color: 'red'
});

这和提取到函数组件外面是一样的,都能保证引用不变,同样从收益角度而言,返回一个不涉及任何组件自身 props 以及 states 的变量的变量,没有任何必要使用useMemo,直接提取到函数组件外面就可以了,所以上面的useCallback包裹的函数,如果也不涉及到组件的相关属性,也可以提取到外面:

function _Child(props) {
    return <div style={props.style} onClick={props.onClick}>我是子组件</div>
}

const Child = React.memo(_Child);

const style = {color: 'red'};

const onClick = () => {
    console.log('Click Me');
};

function Father() {
    return (
        <>
            // 保持渲染前后引用一致
            <Child style={style} onClick={onClick} />
            <div>我是父组件</div>
        </>
    )
}

这种写法对有些人来说,可能会有心理障碍:方法和属性不在函数组件内部,修改的话,还要上下文跳来跳去,组件好像被割裂开了。笔者当时也是这种心态,后来也说服了自己:React 组件本身就是一系列的状态集合的 UI 而已,所以对于无关组件状态的属性,不应该也不建议放入组件当中,不然反而会增加组件的复杂程度,不利于后期的维护。

顺便提一句,笔者看到也有观点认为 useMemo 只用来缓存复杂计算,但是如果计算的过程涉及到了状态数据,比如可能会是这种情况:

const style = useMemo(() => {
    return {
        width: count
    };
}, [count]);

这样无法把 style 变量提取到组件外面了,虽然也可以不使用 useMemo:

const style = {
    width: count
};

个人认为,还是上面那个建议,如果此时 style 不是作为子组件的 props 传递给子组件,这么写是没有问题,否则即使子组件包裹了 memo,也依然会发生更新。当然如果子组件并没有 memo 包裹,那自然也不需要 useMemo。

如何在 Hooks 中拿到最新的状态值

拿不到最新的值,大部分情况下可能是 Hooks 中缺少了对应的依赖,为什么缺少依赖会导致拿不到最新的值呢?无论是 useEffect、useCallback 还是 useMemo,如果其中使用了状态数据,但是没有在其依赖中声明,那么当该状态数据发生变化的时候,Hooks 并不会重新执行,对于 useEffect 来说,其回调只会执行一次,如果其中还包含了事件回调之类的函数:

// 省略其他内容
const [count, setCount] = useState(0);

useEffect(() => {
    const handler = () => {
      console.log(count, 'event');
    };
    window.addEventListener('click', handler);
}, []);

此时 count 无论如何点击打印的结果都是0,归根结底,就是事件回调是一个闭包函数引用了count变量初始化时候的值,解决问题也很简单,就是将 count 写入依赖数组中,注意要卸载时清除事件监听。

而对于 useCallback、useMemo 来说,它们第一个参数都是函数,如果没有写入相关依赖,则组件重新渲染的时候,useCallback、useMemo 包裹的函数同样作为闭包函数,引用了之前的状态值。

以上情况都是比较常见,大部分的小伙伴可能都知道,还有一种情况:

const { count } = props;
// 依赖props并不会产生新的状态
const [data, setData] = useState(() => count * 2);

代码很简单,就是 state 会依赖 传入的 props 计算得到,但是当 传入的 props 发生改变的时候,data 值并不会随之变化,会一直是初始传入的 props 值。这里可以理解 useState 会在该组件的 fiber 上挂载一个 memoizedState 保存状态值,组件重新更新的时候,其 fiber 上依然还是挂载了初始的值,只有 setData 才会改变该状态。

这里可以使用 useEffect 来更新 data 的最新值:

// 省略
useEffect(() => {
    setData(count);
}, [count]);

可以看到 Hooks 的依赖是一个非常消耗心智的内容,那就来聊聊依赖的问题。

Hooks 的依赖问题

有时候,你可能会发现,添加了 Hooks 的依赖,反而导致了不必要的更新,这并不是你想要的结果,比如下面的例子:

function Child(props) {
  const { params } = props;
  const [data, setData] = useState('');

  const fetchData = () => {
    return new Promise(resolve => {
      setTimeout(() => {
        console.log(params, 'params');
        resolve('result' + params);
      }, 3000);
    });
  }

  useEffect(() => {
    fetchData().then(res => {
      setData(res);
    });
  }, []);

  return (
    <p>获取远程接口数据:{data}</p>
  )
}

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

  useEffect(() => {
      setTimeout(() => {
          setCount(count => count + 1);
      }, 3000);
  }, []);

  return (
    <>
      <Child params={count} />
      <p>点击了{count}次</p>
    </>
  );
}

以上例子模拟了参数变化时发起数据请求,参数是另外一个接口获取,可以理解为一个接口依赖另外一个接口的返回。你可能一眼就看出来问题:无论点击多少次,打印的参数都是0,useEffect 缺少了 params 的依赖。

的确如此!但是如果想当然的就在 useEffect 中加上 params 依赖,你会发现每次 params 更新都会打印一次,这就意味着,如果是发起请求的话,params 更新会导致多次请求,而你只是希望初始化的时候获取数据而已,这就违背了你的意愿。

这个时候需要在 useEffect 中做一些处理:

useEffect(() => {
    if (!params) return;
    fetchData().then(res => {
      setData(res);
    });
}, [params]);

React Hooks 一点心得

不过这里会出现一个缺少 fetData 依赖的提示,如果你把 fetchData 作为依赖添加上,则又会提示需要使用 useCallback 包裹 fetData,但 fetchData 并不是作为 props 传给子组件的,笔者认为在这种情况下: 组件中的函数只是该组件独有的,没有必要使用 useCallback。

从使用角度来说,如果把 fetchData 放入 useEffect 依赖中,不使用 useCallback 包裹,这样只要组件重新渲染,都会导致 useEffect 执行,这肯定是不行的,如果使用了 useCallback 包裹,如果 fetchData 没有依赖其他状态,虽然 useEffect 不会更新,但也就节省了组件重新渲染时 fetchData 重新创建的步骤,而如果 fetchData 有其他的依赖,则又需要添加其他依赖,其他依赖更新导致 fetchData 引用变化,那么 useEffect 又会重新执行,这带来的负担是很沉重的。

所以对于 Hooks 的依赖,我们在使用时,还是保证非必要不添加,能不用 useCallback 就不要使用 useCallback。

对于上面这种 useEffect 依赖的问题,也可以考虑将 fetchData 定义放入 useEffect 中:

React Hooks 一点心得

这样算是一种消除依赖不错的方法,如果你不想看到讨厌的警告提示,又不想使用 useCallback 的话就可以这么去做,不过这种做法的使用场景有限,可以作为一种参考。

如何写出“简单”的 Hooks

对于 Hooks 如何更简单的使用,仁者见仁,以上仅是笔者在工作中,建议的做法,可能并不适合所有人,但是对于 Hooks 的工作原理来说都是相同的,如何使用只是一种方式,只要你明确你这么使用,不会造成“难以预料”的副作用,那就没问题。

以前写 Hooks 都是凭感觉去写,写多了,开始考虑如何写的更优雅一些,写完需求以后,再过一遍代码,可能会发现不一样的世界。