自从 react@16.8 正式发布react hooks已经有一段时间了,这段时间我也一直在项目里使用hook的方式来写组件,其间也遇到了不少问题,下面列一下踩坑记录。

该篇假设你已经了解react hooks的基本用法,如果对react hooks毫不了解,建议先阅读官方文档

想在第一次render前执行的代码,可以放在useState里

类似class component里的constructorcomponentWillMount。例如

const instance = useRef(null);
useState(() => {
  instance.current = 'initial value';
});

useState里数据务必为immutable

虽然class component的state也提倡使用immutable数据,但不是强制的,因为只要调用了setState就会触发更新。但是使用useState时,如果在更新函数里传入同一个对象将无法触发更新。

举个例子,有时可能会写出这种代码

const [list, setList] = useState([2,32,1,534,44]);
return (
  <>
    <ol>
      {list.map(v => <li key={v}>{v}</li>)}
    </ol>
    <button
      onClick={() => {
        // bad 这样无法触发更新
        setList(list.sort((a, b) => a - b));
        // good 必须传入一个新的对象
        setList(list.slice().sort((a, b) => a - b));
      }}
    >sort</button>
  </>
)

useMemo has no semantic guarantee

这句话出自useMemo的API Reference

You may rely on useMemo as a performance optimization, not as a semantic guarantee. In the future, React may choose to “forget” some previously memoized values and recalculate them on next render, e.g. to free memory for offscreen components. Write your code so that it still works without useMemo — and then add it to optimize performance.

也就是说在未来react的版本,useMemo在必要时会清除缓存而重新执行creater function,所以最好不要使用useMemo来执行具有side effect的函数。

举个例子,比如我想实现基于lodash throttle的hook,使用useMemo来实现会比较理想,类似下面的代码

const thRef = useRef(null);
useMemo(() => {
  if (thRef.current) {
    thRef.current.cancel();
  }
  thRef.current = _.throttle(fn, wait, options);
}, [wait, options && options.leading, options &&options.trailing]);
// ...other codes

目前这么写是没问题,但是考虑到未来react实现了上述“forget”特性的话,这样的代码就可能产生预料之外的结果,解决方法就是自己实现一个稳定的useMemo,实现示例

useEffect和useLayoutEffect有什么区别?

简单来说就是调用时机不同,useLayoutEffect和原来componentDidMount&componentDidUpdate一致,在react完成DOM更新后马上同步调用的代码,会阻塞页面渲染。而useEffect是会在整个页面渲染完才会调用的代码。

官方建议优先使用useEffect

However, we recommend starting with useEffect first and only trying useLayoutEffect if that causes a problem.

在实际使用时如果想避免页面抖动(在useEffect里修改DOM很有可能出现)的话,可以把需要操作DOM的代码放在useLayoutEffect里。

不过useLayoutEffect在服务端渲染时会出现一个warning,要消除的话得用useEffect代替或者推迟渲染时机。见说明讨论

在useEffect和useLayoutEffect里使用async函数

因为async函数肯定会返回一个Promise,会和useEffect返回的cleanup函数混淆所以不要直接将async function传给useEffect,最简单的解决方法是IIFE

useEffect(() => {
  (async () => {
    await fetchSomething();
  })();
}, []);

使用useCallback时,要注意闭包问题(或者说是Capture Value特性)

和避免使用inline function一样,有时需要使用useCallback来优化性能,但是useCallback会返回之前的闭包,使用到的局部变量是不会更新的。
举个例子

const [count, setCount] = useState(0);
const increaseCount = useCallback(() => {
  setCount(count + 1);
}, []);

// 因为useCallback总是返回第一次render时传入的闭包,increaseCount内访问到的count永远都是0
increaseCount(); // 相当于setCount(1);
increaseCount(); // 无论调用几次都是setCount(1);

解决方法就是避免引用外部的局部变量

const [count, setCount] = useState(0);
const vRef = useRef(0);
const increaseCount = useCallback(() => {
  // 传入function的话每次都能拿到最新值
  setCount(prevCount => prevCount + 1);
  // 用一个对象来保存,适用于不需要触发更新的情况
  vRef.current += 1;
}, []);

// 使用useReducer解决
const [count, increase] = useReducer((c, increment) => c + increment, 0);
const increaseCount = useCallback(() => {
  increase(1);
}, []);

但是注意不要这么写

关于Capture Value可以参考这篇文章

useEffect、useCallback、useMemo等API的第二个参数数组的长度不能变

有时可能会写出这样的代码

const [selectedStatuses, setSelected] = useState([]);
useEffect(() => {
  fetchListById(selectedStatuses);
}, selectedStatuses);

这里如果将selectedStatuses[]更新为['active']是不会触发effect的,react也会给你一个warning。相关源码

这里最好将整个state作为deps的一项传入,或者使用一个key来控制

useEffect(() => {
  fetchListById(selectedStatuses);
}, [selectedStatuses]);

泛型参数怎么写?

通常我们会这么写函数组件

const MyCom: React.FC<MyComProps> = (props) => {
  return <div>...</div>
};

但是在参数里使用了泛型,就不能这么写了,因为在变量声明里必须要指定确切的类型,所以这里要回到传统function的写法。放心,这么写也是有类型提示的。

// generic props
interface MyComProps<T> {
  value?: T;
  onChange?(value: T): void;
}

function MyCom<T extends { type: string; }>(props: MyComProps<T>) {
  return <div>{props.value && props.value.type}</div>;
}

不过如果要用React.forwardRef的话目前没什么什么优雅的方案,还是需要明确类型才行。

使用React.memoReact.forwardRef包装的组件为什么提示我children类型不对?

过去使用ComponentFC等类型定义组件时一般不需要我们定义props里children的类型,因为在上述类型里已经帮你默认加上了 { children?: ReactNode } 的定义。但是@types/react的维护者认为这样导致children几乎没有类型约束,组件开发者应该显式地声明children类型。所以对新的API应该都不会自动加上children的定义了,需要开发者手动添加。

详情见讨论

使用act给react hooks写单元测试

react@16.8给test-utils新加了一个 act API,关于这个API可以看看作者写的这篇通俗易懂的解释

这里简单总结一下act主要是为了解决useEffect的测试问题出现的,因为useEffect的执行时机会很晚,甚至在断言之后。如果在useEffect里执行了ui变更,就很难写测试了,虽然可以用useLayoutEffect解决,但是不能为了通过测试而修改原代码。这里用act就能很好地解决了,这个API能同步执行所有useEffect以及相应的更新,在断言时就能拿到正确的结果了。

before
before
use act
use act

总之所有可能触发更新的代码都应该放到act里,不然test-utils会给一个warning。

另外act会batchUpdate,可能会导致一个隐藏bug,见文章中这段内容,需要注意一下。

Function Component与ant design的Form

Form非常适合使用react hooks来实现,官方目前看来没什么进展,不过目前hoc也没什么问题。

用FC写的自定义表单控件会有ref相关的warning,因为antd form需要拿到组件的ref,而FC默认是没有实例的。这里我们可以通过React.forwardRef + useImperativeHandle解决,官方示例。不过这么写validateFieldsAndScroll这个API可能就没用了,建议把ref传给底层的表单元素组件。


我收集了一些常用的hooks,欢迎使用和一起开发。
https://frezc.github.io/react-hooks-common/

References