React Hooks 带来的困扰与思考

当前使用 React 版本:v16.10.2

自从 React Hooks 推出以来,给日常工作带来新的开发体验,提高开发效率的同时也增加了代码的可阅读性。本人最近刚好接到一个从零开始的项目需求,趁这个机会就采用 Hooks 函数式组件进行开发,目前代码量已有1w+。相较于之前的 class 组件,Hooks 下的函数式组件确实带来了不少便利性,让我这个从 Vue 转过来的重新喜欢上了 React。好处之类的就不详细说了,各大文章也有介绍,本文主要罗列一下 Hooks 在日常开发中遇到一些纠结的地方。

依赖数组的正确性问题

在官方文档中,一直强调要确保 useEffect 依赖数组的正确性,副作用的函数中,使用了 props 或 state 变量一定要存在于依赖数组中。使用了官方提供的 eslint 配置后,副作用函数中有 props 或 state 变量未存在于依赖数组中,会有 exhaustive-deps 规则的提醒。然而在某些场景下,这又与这个规则不符。

比如:希望 useEffect 只关心部分 props 或 state 变量的变动,从而重新执行副作用函数,其它 props 或 state 变量只取决于当时的状态。

场景1:只在组件 mount 之后执行的方法。

例子中希望实现 componentDidMount 类似的功能,但是因为引用了 count 变量且未在 useEffect 的依赖数组中声明,就触发了 exhaustive-deps 规则的提醒。出于对 eslint 规则的遵守(强迫症),在不禁用该规则的前提下,只好通过 useRef 来避开这个规则。对副作用函数中每个 props 或 state 变量都创建对应的 useRef 值显然比较麻烦且不合理,那就拿副作用函数来操作好了。为了便于复用,封装成 useMount 函数,也可使用 react-use 库。

function useMount(mountedFn) {
  const mountedFnRef = useRef(null);

  mountedFnRef.current = mountedFn;

  useEffect(() => {
    mountedFnRef.current();
  }, [mountedFnRef]);
}

使用后,warning 解除。

场景2:只关心部分变量的变动。

例子中的 Modal 组件需要根据 visible 变量的变动来执行相应的方法,又需要引用到其它的 props 或 state 变量,但是又不希望将它们放入 useEffect 依赖数组里,因为不关心它们的变动。如果将它们放入 useEffect 数组中,在 visible 变量不变的情况下,其它变量的变动会带来副作用函数的重复执行,这可能是非预期的。这时就需要一个辅助变量来记录 visible 变量的前一状态值,用来在副作用函数中判断是否因为 visible 变量变动触发的函数执行。为了便于复用,封装成 usePrevious 函数,也可使用 react-use 库。

const usePrevious = (value) => {
  const ref = useRef();

  useEffect(() => {
    ref.current = value;
  });

  return ref.current;
};

使用后,warning 解除。

从上面的例子可以看出,我们希望 useEffect 的依赖数组中是与副作用函数更有效的变量,而不是副作用函数中全部引用的变量。而从官方提供的文档和 eslint 规则来看,这似乎与官方传达的意图不符。如果要遵循官方更为严格的规则,就需要写更多的条件判断。

依赖数组中变量的比较问题

React 对于各 hook 函数依赖数组中变量的比较都是严格比较(===),所以我们需要注意对象类型变量的引用。类似下面的情况应该尽量避免,因为每次 App 组件渲染,传给 Child 组件的 list 变量都是一个全新引用地址的数组。如果 Child 组件将 list 变量放入了某个 hook 函数的依赖数组里,就会引起该 hook 函数的依赖变动。

function App() {
  const list = [1, 2, 3];

  return (
    <>
      <Child list={list}></Child>
      <Child list={[4, 5, 6]}></Child>
    </>
  );
}

上面这种情况多加注意还是可以避免的,但在某些情况下我们希望依赖数组中对象类型的比较是浅比较或深比较。在 componnetDidUpdate 声明周期函数中这确实不难实现,但在函数式组件中还是需要借助 useRef 函数。

例子:

import { isEqual } from 'lodash';

function useCampare(value, compare) {
  const ref = useRef(null);

  if (!compare(value, ref.current)) {
    ref.current = value;
  }

  return ref.current;
}

function Child({ list }) {
  const listArr = useCampare(list, isEqual);

  useEffect(() => {
    console.log(listArr);
  }, [listArr]);
}

在该例子中,使用了一个 ref 变量,每次组件渲染时都会取之前的值与当前值进行自定义函数的比较,如果不相同,则覆盖当前值,最后返回 ref.current 值。从而实现了自定义依赖数组比较方法的功能。

函数引用变动问题

在 class 组件中,传递给子组件的 props 变量中函数大多数都是 this.xxx 形式,即引用都是稳定的。为了让函数能保持引用稳定,React 提供了 useCallback 函数,只要依赖数组保持稳定,会返回一个引用稳定的函数。如果要严格遵循该要求,useCallback 可能会成整个项目中最常用的 API。但当我们耗费心思去保持函数的引用稳定,但是组件树上层一个不小心可能就将之前的努力白费。

比如:

function Button({
  child,
  disabled,
  onClick
}) {
  /**
   * 备注:
   * 如何不将 disabled 和 onClick  变量加入依赖数组中,
   * handleBtnClick 函数触发时,只会取得第一次渲染的值。
   */
  const handleBtnClick = useCallback(() => {
    if (!disabled && onClick) {
      onClick();
    }
  }, [disabled, onClick]);

  return (
    <button onClick={handleBtnClick}>{child}</button>
  );
}

function App() {
  const onBtnClick = () => {};

  return (
    <Button onClick={onBtnClick} />
  );
}

上面例子中,我们希望 Button 组件中 handleBtnClick 函数只在 disabled 和 onClick 变量的引用变动时才返回一个新的引用的函数。但在 App 组件中却没有使用 useCallback 来保持 onBtnClick 函数的引用稳定,从而每次渲染传递给 Button 组件 都是全新引用的函数。子组件接收到这个全新引用的函数,就会触发 useCallback 的依赖变动重新生成一个全新引用的 handleBtnClick 函数。

而且在列表渲染中,我们希望在传递给子组件的函数变量中增加值,必然会导致每次父组件渲染,传递给子组件的都是全新引用的函数。

function App() {
  const [list] = useState(() => {
    return [1, 2, 3];
  });
  const handleItemClick = useCallback((item) => {
    console.log(item);
  }, []);

  return (
    <div>
      {
        list.map((value) => (
          <Item
            key={value}
            onClick={() => handleItemClick(value)}
          />
        ))
      }
    </div>
  );
}

这样,我们在子组件使用 useCallback 反而带来了不必要的比较。其实,对于组件 props 中函数的变量还可以使用以下这种形式。

function Button({
  child,
  disabled,
  onClick
}) {
  const handleBtnClickRef = useRef();

  handleBtnClickRef.current = () => {
    if (!disabled && onClick) {
      onClick();
    }
  };

  const handleBtnClick = useCallback(() => {
    handleBtnClickRef.current();
  }, [handleBtnClickRef]);

  return (
    <button onClick={handleBtnClick}>{child}</button>
  );
}

上面例子中,使用了一个 useRef 函数返回的变量 handleBtnClickRef 来保存最新的函数。因为该变量引用是固定的,所以 handleBtnClick 函数的引用也是固定的,触发 onClick 回调函数也能拿到最新的 disabled 和 onClick 值。

那么在日常开发中,究竟是使用 useCallback 方案,还是使用 useCallback + useRef 的 hack 方案呢?这里还想听听各位意见。

useRef 的使用

在整篇文章中,useRef 成了各种问题的解决方案,这种 mutable 的方式实现了类似于 class 的 this 功能。这种方式可以很好的解决闭包带来的不方便性,但是有时还是会纠结该不该都用这种方式。

比如 useEffect 中进行全局事件的绑定:

function App() {
  const handleResize = useCallback(() => {
    console.log(count);  
  }, [count]);
  useEffect(() => {
    window.addEventListener('resize', handleResize);

    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, [handleResize]);
}

在例子中,每次 count 变量的变动都会引起 handleResize 变量的变动,进而引起下方 useEffect 中副作用函数的执行,即执行事件解绑 + 事件绑定。而实际上,我们只希望在组件挂载时绑定 resize 事件,在组件销毁时解绑。如果要实现这样的功能,又需要借助 useRef。

function App() {
  const handleResizeRef = useRef();

  handleResizeRef.current = () => {
    console.log(count);  
  };

  useEffect(() => {
    const handleResize = () => {
        handleResizeRef.current();
    };
    window.addEventListener('resize', handleResize);

    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, [handleResize]);
}

目前,我不太清楚自己是否太在意事件的绑定与解绑的次数,又或者说仍用 class 组件生命周期函数来对待 useEffect。当然,从 useEffect 定义来看,是声明依赖于一系列数据的副作用,这些数据的变动必然需要导致副作用的变动。但是这种声明式副作用在一些场景下会带来多余的代码执行,比如上面例子中重复的解绑与绑定。

这些都是最近项目实践中遇到的一些问题点,在此仅做记录,留待以后想通。

编辑于 2019-10-20