聊聊 React Hooks 中的那些心智负担

1,750 阅读8分钟

前段时间跟几个做 React 的前端朋友谈及 React Hooks 中的心智负担问题,得到的答案让我很是惊讶,因为他们说没有感受到 React Hooks 带来的心智负担。

我突然就有点自闭了?难道是我自己的问题?

不得不说,React Hooks 的出现的确给 React 开发者带来了很多方便。但是在实际的使用过程中,我也的确发现它在带来一系列方便的同时,也带来了很多令我困扰以及不爽的地方。

这种感觉,在使用 React 其他相关的轮子(Redux、Mobox、Dva等等)时也经常遇到,那就是要么是隔靴搔痒只解决问题的一部分,要么是在解决一个问题的同时,又会引入新的问题,总之就是不让你爽的干净利落。

下面我就介绍一下我在实际项目中使用 React Hooks 遇到的那些心智负担,供大家参考指正。

陷阱一:引用旧的变量

先来看一个简单的 Hooks 使用例子。

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

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

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

实现的功能呢很简单,组件安装的时候注册一个定时器,每秒加1,然后组件卸载的时候清除定时器。

乍一看没问题,但是实际上呢,页面显示的始终是1。这是因为组件每次渲染时,都会创建一个新的变量 count,但是 useEffect 里面的函数是一个闭包,里面引用的 count 变量始终都是第一次渲染时的变量(值始终为1)。

如果只是解决这个问题,倒是很简单,使用状态改变的函数式调用Api即可:把setCount(count + 1)改成setCount(count => count + 1)

但是如果 useEffect 里面的函数稍微复杂一点,引用的是多个状态互相依赖,就没法使用这种方式了:

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

  useEffect(() => {
    const id = setInterval(() => {
        if (varA === 'xxx') {
            setCount(count + 1);
        } else {
            setCount(count + 2);
        }
    }, 1000);
    return () => clearInterval(id);
  }, []);

  //...
}

那该怎么办呢?

将 useState 换成 useRef?倒是能解决变量引用的问题,但是 useRef 有个问题,就是改变 ref 的值组件并不会重新渲染,界面也不会更新,pass!

当然还是有解决办法的。

第一种,使用 useReducer,这里不作示例了, 这里的问题倒是能解决,但是如果 dispatch 带参数的话又回到了问题本身。

第二种,使用 useEffect 的依赖数组:

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

  useEffect(() => {
    const id = setInterval(() => {
        if (varA === 'xxx') {
            setCount(count + 1);
        } else {
            setCount(count + 2);
        }
    }, 1000);
    return () => clearInterval(id);
  }, [count, varA]);

  //...
}

但是这种方式有个新的问题,就是 count 和 varA 的每一次改变,定时函数都会重新创建一次,在这个例子里勉强能用,但是在有些场景就有问题了,后面讨论。

陷阱二:useEffect 到底加不加依赖?

import { showTip } from 'tip';

function ExampleTip() {
    const [varA, setVarA] = useState('');
    const [varB, setVarB] = useState('');
    
    useEffect(() => {
        showTip({ a: varA, b: varB })
    }, [varA]);
}

上面这类的需求,其实在开发中很常见,那就是某一个状态(varA)改变时,我们需要做某件事(这里的例子是弹出一个tip框),但是做这件事又需要引用其他的状态(varB等)。

但是 varB 改变时,我们不应该弹出 tip 框。

这时候,varB 是不应该加入依赖数组的。


另一种情况:

import { fetch } from 'api';

function Example() {
    const [varA, setVarA] = useState('');
    const [varB, setVarB] = useState('');
    
    useEffect(() => {
        window.addEventListener('click', () => {
            doSomeThing({ a: varA, b: varB }) 
        });
        return () => {
            // remove listener
        }
    }, [varA,varB]);
}

在这里,useEffect 里,对于varB我们想使用的不是useEffect调用时的值,而是varB最新的值。但是,useEffect里引用的变量始终是varA改变时的值,varB改变之后 useEffect 里并不知道。

这时候,我们就需要把 varB 也加入依赖数组,varB改变时重新监听。


对于很简单的代码,我们一般能够清楚哪个该加哪个不该加。但是每次编写的时候我们都要考虑哪些该加哪些不该加,无疑加重了心智负担。

而且,如果没有工具的保证,就会很容易出错,这也是为什么我们需要 eslint、typescript 等工具的原因。

官方也意识到了这个问题,为我们提供了相应的 eslint 插件:eslint-plugin-react-hooks

但现实问题是,对于任何一个状态变量,存在着该加入依赖数组和不该加入依赖数组两种情况,并且这两种情况,工具是没法从代码层面进行区分的!

怎么办呢?官方的建议是都开启exhaustive-deps规则,也就是说只要是在 useEffect 中引用到的状态变量(除开ref),都应该加入依赖数组!

官方文档里也说,未来版本,或许会在构建时自动加入第二个参数。

这意味着,将所有 useEffect 里使用到的变量都加入依赖数组,似乎是官方推荐的、最正确的选择。而且你想要使用 eslint 相关工具的话,也要使用这种方式。

这解决了引用旧变量的问题,让我们不用去思考该不该加入依赖数组的问题.

但又引入了新的问题,比如上面showTip那个,我们不得不加入一些额外的代码:

import { showTip } from 'tip';

function ExampleTip() {
    const [varA, setVarA] = useState('');
    const [varB, setVarB] = useState('');
    
    const varBRef = useRef(varB);
    varBRef.current = varB;
    
    useEffect(() => {
        showTip({ a: varA, b: varBRef.current })
    }, [varA]);
}

useRef 变量不必要加入依赖数组,因为引用不变。

但是如果 useEffect 里引用的变量有很多个呢?每一个都加一个ref岂不是很麻烦?

我一般使用这种方法:

function ExampleTip() {
    const [varA, setVarA] = useState('');
    const [varB, setVarB] = useState('');
    
    const showTipRef = useRef(null);
    showTipRef.current = () => {
        showTip({ a: varA, b: varBRef.current });
    }
    
    useEffect(() => {
        showTipRef.current && showTipRef.current();
    }, [varA]);
}

问题是解决了,但是怎么也不爽。

陷阱三:引用到底变没变?

对于同一个组件里,我们定义的 useRef 变量可以不加入依赖数组,因为 eslint 插件能识别出这是一个不变的引用。

但是,看下面这个组件:

function Example(props) {
    const { id, fetch } = props;
    
    useEffect(() => {
        fetch(id);
    }, [id, fetch]);
}

我们需要在id改变的时候,执行fetch方法。但是 fetch 引用改变的时候,我们一般是不需要重新 fetch 的。

即使传入的 fetch 引用是不变的,但是 eslint 插件并不能识别出来,所以它还是要求将 fetch 加入依赖数组。

这样,乍一看这个组件,你能区分出 fetch 会不会变吗?你能保证只有在 id 改变的时候 fetch 吗?

看起来是能跑,但是怎么看怎么不放心……

陷阱四:依赖引入的死循环风险

function Child(props) {
    const { onAppear, onLeave } = props;
    useEffect(() => {
        onAppear();
        return () => {
            onLeave();
        }
    }, [onAppear, onLeave])
}

function Parent() {
    const [count, setCount] = useState(0);
    const appearItem = () => {
        setCount(count + 1);
    }
    const leaveItem = () => {
        setCount(count - 1);
    }
    
    return (
        <>
            <Child onAppear={appearItem} onLeave={leaveItem} />
            <Child onAppear={appearItem} onLeave={leaveItem} />
        </>
    );
}

上面这个例子,单独开每个组件似乎没有问题,但是跑起来就会死循环。因为 Child 组件里的 onAppear, onLeave 引用每次渲染都会变!

为了解决这个问题,我们不得不将 appearItem, leaveItem通过 useCallback 包裹起来:

function Child(props) {
    const { onAppear, onLeave } = props;
    useEffect(() => {
        onAppear();
        return () => {
            onLeave();
        }
    }, [onAppear, onLeave])
}

function Parent() {
    const [count, setCount] = useState(0);
    const appearItem = useCallback(() => {
        setCount(count + 1);
    }, [count]);
    const leaveItem = useCallback(() => {
        setCount(count - 1);
    }, [count]);
    
    return (
        <>
            <Child onAppear={appearItem} onLeave={leaveItem} />
            <Child onAppear={appearItem} onLeave={leaveItem} />
        </>
    );
}

看似解决了,但还是会死循环,因为 count 每次渲染都会变!这时候又不得不上 useRef 大法了……

这种隐形的死循环风险,其实在 hooks 代码中很常见。为了规避这些风险,我们不得不想很多。

陷阱五:误导性的参数

function Component(props) {
    const varA = useRef(props.a);
    const [varB] = useState(props.b);
}

上面这个简单的组件,又有什么问题呢?

乍一眼看去,varA,varB的值是根据props的值动态计算的,因为每次渲染 useRef,useState 都会调用一次,props.a 和 props.b 都会传入一次。

但是事实上,React 只会使用第一次传入的值!

这对 useRef, useState 这样经常用的 hooks 函数也许影响不大,因为你一直在注意,都形成了本能,知道它的运行方式了。

但是对于一个自定义hooks来说呢?

function Component() {
    const [value, setValue] = useState('');
    const test = useCustomHook(value);
}

这个vluae,到底是第一个值会被使用,还是所有的值都会被使用呢?

总结

写的好累!上面描述的这些例子,都是我在实际工作中经常会遇到的问题。除了这些,还有其他一些问题,只不过很多都不是三言两语能够描述清楚的。

这只是根据我的水平所得到的理解,其中有些也许不是特别正确,欢迎批评指正!

总之,给我个人的感觉就是,React Hooks 给我不爽的主要有以下几点:

  • 违背直觉:代码实际的运行方式和你第一眼看上去的感觉差别很大。
  • 需要想很多:很多时候,我们不得不去考虑一些本来不该我们考虑、而应该是框架层面解决的问题。
  • 不安全感:即使你花很多心思写出来的组件,很多时候回头看写下的代码时,总是觉得哪里不对,不是很有安全感。

仔细寻思一下,其实所有问题的根源,是 React 函数时组件机制所限:每次组件渲染,组件里的所有代码都会被重新调用一次。

而 Vue 的组合式Api 和 React hooks 如此类似,但是它之所以没有这么多烦恼,主要是因为它的 setup 只会在整个组件的生命周期内执行一次。