likes
comments
collection
share

其实你可以删除 useEffect 的某些 dependencies

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

引入场景: 分页请求

useEffect 有一个非常常见的用途, 那就是用来发请求. 考虑一个最简单的场景:

const [list, setList] = useState([]);

const fetchItem = useCallback(async () => {
    const data = await callAPI();
    setList(prev => [...prev, data]);
}, [])

useEffect(() => {
    fetchItem();
}, []);

上述场景非常简单, 在组件挂载时请求一条数据, 插入到 list 中.

现在考虑新增一个分页功能:

  1. 页面里会新增两个按钮
  2. 第一个按钮点击回到上一页, 第二个按钮点击进入下一页.
  3. 在进入下一页时, 如果当前页面没有数据, 那么需要先请求数据, 再进入下一页.
  4. 每一页只展示一条数据

代码大致会长成下面这样:

const [list, setList] = useState([]);

const fetchItem = useCallback(async () => {
    const data = await callAPI();
    setList(prev => [...prev, data]);
}, [])

const [curPage, setCurPage] = useState(0);

useEffect(() => {
    if(list.length <= curPage) {
        fetchItem();
    }
}, [curPage]); // React Hook useEffect has a missing dependency: 'list.length'. Either include it or remove the dependency array.

return <>
  <button onClick={() => setCurPage(prev => Math.max(0, prev - 1))}>prev</button>
  <button onClick={() => setCurPage(prev => prev + 1}>next</button>
</>

上面代码中, ESLint 会在useEffect 末尾抛出一个 warning:

React Hook useEffect has a missing dependency: 'list.length'. Either include it or remove the dependency array.

众所周知, 这是因为在 effect 中使用了 list.length, 但是并没有在 deps, 也就是依赖数组参数中声明 list.length.

于是, 如同 ESLint 告诉你的那样, 你试着在 deps 中加入 list.length 作为依赖:

useEffect(() => {
    if(list.length <= curPage) {
        fetchItem();
    }
}, [curPage, list.length]);

此时问题来了:

  1. curPage 更改 (+1)

  2. 触发 effect, 调用 fetchItem 请求下一条数据

  3. 请求返回后, 更新 list, list.length 自然也跟着更新

  4. 再次触发执行 effect

对于这个场景, 二次触发 effect 好像没有什么问题, 因为此时 list.length <= curPagefalse, 什么也不会做. 但是如果稍微复杂的场景呢?

useEffect(() => {
    const expensiveOperation = () => {
        // some expensive operations
    }

    if(list.length <= curPage) {
        fetchItem().then(expensiveOperation);
    } else {
        expensiveOperation();
    }
}, [curPage, list.length]);

此时就会导致 expensiveOperation 的额外执行, 可能带来 bug.

方案 1: 使用 ref

propsstate 属于 react 的数据流, 但 ref 不属于.

换言之, propsstate 的更新会被 react 感知到, 并且做出响应的动作 (重新渲染, 执行 effect 等), 但 ref 的更新不会.

重构以上代码为:

const [list, setList] = useState([]);
const listRef = useRef(list); // 用一个 ref 来保存最新的 list

const fetchItem = useCallback(async () => {
    const data = await callAPI();
    setList(prev => {
        const newList = [...prev, data];
        listRef.current = newList; // 每次 setList 时, 同步更新 listRef

        return newList;
    });
}, [])

const [curPage, setCurPage] = useState(0);

useEffect(() => {
    if(listRef.current.length <= curPage) {
        fetchItem();
    }
}, [curPage]);

return <>
  <button onClick={() => setCurPage(prev => Math.max(0, prev - 1))}>prev</button>
  <button onClick={() => setCurPage(prev => prev + 1}>next</button>
</>

这样就成功了, 既遵守了 react 官方的最佳实践, 避免了 ESLint 抛 warning, 又修复了二次执行 effect 的 bug.

方案 2: 其实可以无视 warning, 直接删掉

相信你已经看过无数次下面这段代码:

const [count, setCount] = useState(0);
const [step, setStep] = useState(1);

useEffect(() => {
    const interval = setInterval(() => {
         setCount((prev) => prev + step);
    }, 1000);

    return () => {
         clearInterval(interval);
    };
}, []); // eslint 会抛出同样的 warning, 告诉你把 step 加进去

上面这段经典代码经常被用来跟你阐述, 为什么需要把 effect 中用到的所有在 react 数据流中的变量都放进去, 以及不放进去可能产生的 bug.

众所周知, 上面一段代码的预期是:

  • 每次执行定时器时, 都会将 count 更新为 count + step 的值

  • 初始时 count 每次 +1

  • 假如将 step 更新为 2, 之后 count 每次 +2

但是实际上, 不管怎么更新 step, count 每次更新永远只会 +1. 这是因为闭包的原因, setInterval 接收的函数为第一次创建时的函数, 此时 step 为 1, 这个值会被闭包记录下来, 永远不会更新. 所以需要将 step 放到 deps 中, 这样每次 step 更新后, 都会重新执行 effect:

  • 关掉上一个 effect 开启的定时器

  • 创建一个新的函数, 该函数闭包内的 step 为最新的值

  • 开启一个新的定时器

所以 react 希望你不要对 deps 撒谎, 因为每次渲染都会产生一个新的 effect 闭包, 保存本次渲染的所有变量. 如果对 deps 撒谎, 可能导致一个较旧的闭包在一次较新的渲染中被执行的问题.

但是所有的情况都是这样吗? 并不是. 考虑以下代码:

export default function App() {
  const [data, setData] = useState('data');
  const [other, setOther] = useState('other');

  const [result, setResult] = useState(data + other);

  useEffect(() => {
    setResult(data + other);
  }, [data]);

  return (
    <div className="App">
      <input value={data} onChange={(e) => setData(e.target.value)} />
      <input value={other} onChange={(e) => setOther(e.target.value)} />
      <div>result: {result}</div>
    </div>
  );
}

上述代码描述了一个场景: 在 data 更新时, 需要基于 dataother 来更新 result.

上述代码也存在, other 不在 deps 中的问题.

  1. 初始时, data 的值为 "data", other 的值为 "other", result 的值为 "dataother".
  2. 此时我们通过第一个输入框修改 data 为 "data1", 那么 result 也被更新成了 "data1other", 符合预期.
  3. 此时我们通过第二个输入框修改 other 为 "other1", result 没有被更新, 这也符合预期.

那么问题来了, 此时我们再通过第一个输入框, 修改 data 为 "data13", 那么 result 的值会是什么呢?

  1. data13other
  2. data13other1

其实答案是 2, 符合预期, 并没有出现 "在最新的一次渲染中拿到了较旧的值" 的情况.

事实上, 永远不可能在 a 渲染中拿到 b 渲染的变量.

接下来, 我们仔细分析上面两个场景 (定时器, 输入框) 为什么一个不符合预期, 一个符合预期.

定时器

  1. 首次渲染 (组件挂载): 在组件内部创建了一个闭包 e1, 该闭包为 effect, 被传入给了 useEffect 作为第一个参数. 在 useEffect 中又创建一个闭包 i1, 该闭包中引用了 step = 1, 被传入给 setInterval 作为第一个参数.
  2. 在未修改 step 的情况下, 定时器每次执行 i1, 将 count 更新为 count + 1 ( i1 里的 step 永远为 1 ), 触发重新渲染.
  3. 重新渲染过程中, 会创建一个新的闭包 e2, 传入给 useEffect 作为第一个参数. 但是由于 deps 数组为空, e2 永远不会被执行.
  4. 修改 step, 触发一次新的渲染, 创建一个新的闭包 e3, 传入给 useEffect 作为第一个参数. 同样的, e3 也永远不会被执行.
  5. 定时器继续执行 i1, 将 count 更新为 count + 1.

输入框

  1. 首次渲染 (组件挂载): 在组件内部创建了一个闭包 e1, 被传入给了 useEffect 作为第一个参数.

  2. 修改 data, 触发重新渲染. 在这次重新渲染的过程中, 会创建一个新的闭包 e2, 传入给 useEffect 作为第一个参数. 该闭包中, data 为 "data1", other 为 "other" , 正确 setResult 为 "data1other".

  3. 修改 other, 触发重新渲染. 在这次重新渲染的过程中, 会创建一个新的闭包 e3, 传入给 useEffect 作为第一个参数. 该闭包中, data 为 "data1", other 为 "other1" . 但是由于 other 不在 deps 中, 该闭包没有执行.

  4. 修改 data, 触发重新渲染. 在这次重新渲染的过程中, 会创建一个新的闭包 e4, 传入给 useEffect 作为第一个参数. 该闭包中, data 为 "data13", other 为 "other1" , 正确 setResult 为 "data13other".

根本原因就在于, react 每次渲染都会创建一个新的 effect 闭包, 传给 useEffect. 在输入框的场景里, data 变化时, 执行的 effect 是最新的闭包, 里面当然能拿到最新的 dataother.

而定时器的场景里, 虽然每次也会创建新的 effectuseEffect, 但是新的 effect 永远不会被执行, (旧的也是). 从头到尾, 只在组件挂载时开了一个定时器, 反复执行挂载时传进去的第一个闭包 i1.

const [count, setCount] = useState(0);
const [step, setStep] = useState(1);

const effectClosure = () => { // 很容易被人忽略, 这里其实创建了一个新的函数
    const callback = () => { // 这里同样创建了一个新的函数
         setCount((prev) => prev + step);
    };

    const interval = setInterval(callback, 1000);

    return () => {
         clearInterval(interval);
    };
};

useEffect(effectClosure, []);

所以, 回到最开始分页请求数据的场景, 就算 deps 里只保留 curPage, 当 curPage 更新时, 重新创建的 effect 也会拿到最新的 list, 不会出现 bug.

建议 hooks 初学者刚开始写的时候, 可以将每个函数都单独定义, 而不是内联写法, 这样可能更有助于理解 react 每次渲染都会创建新的闭包 这句话.

const effect = () => { // 每次渲染都是新的
    console.log('data changed', data);
};
useEffect(effect, [data]); // BTW, [data] 每次渲染也是一个新的数组

const callback = () => {}; // 每次渲染都是新的
const cb = useCallback(callback, []);

另外, 从上面的写法也能看出来, 不要无脑使用 useCallback. 初学者可能会有误解, 认为 useCallback 可以避免创建新的函数, 提升性能. 但实际上, useCallback 无法避免创建新的函数, 从上面的代码应该也能看到了, 每次渲染都会创建一个新的 callback 作为第一个参数. useCallback 只是保证返回的 cbdeps 不变的情况下保持不变, 并且 useCallback 本身也是一次函数调用, 也是有调用成本的 ( 比如对比 deps 每个元素是否变了 ). 所以, 如果不是为了在传递给子组件等场景, 需要保证 cb 不变, 使用 useCallback 反而会降低性能.

方案 3: useEffectEvent

本文写作时间: 2023 年 5 月 27 日. 目前 react 的 experimental 版本中, 新增了一个新的 hook: useEffectEvent, 终于对这类场景提供了优雅的解决方案.

目前因为是实验版本, 所以需要 import { experimental_useEffectEvent as useEffectEvent } from 'react'. 未来不确定是否会加入正式版

useEffectEvent 只接收一个 callback 参数, 返回一个新的函数, 该函数会直接调用传入的 callback.

// useEffectEvent 源码

function mountEvent<Args, Return, F: (...Array<Args>) => Return>(
  callback: F,
): F {
  const hook = mountWorkInProgressHook();
  const ref = {impl: callback};
  hook.memoizedState = ref;
  // $FlowIgnore[incompatible-return]
  return function eventFn() {
    if (isInvalidExecutionContextForEventFunction()) {
      throw new Error(
        "A function wrapped in useEffectEvent can't be called during rendering.",
      );
    }
    return ref.impl.apply(undefined, arguments);
  };
}

function updateEvent<Args, Return, F: (...Array<Args>) => Return>(
  callback: F,
): F {
  const hook = updateWorkInProgressHook();
  const ref = hook.memoizedState;
  useEffectEventImpl({ref, nextImpl: callback});
  // $FlowIgnore[incompatible-return]
  return function eventFn() {
    if (isInvalidExecutionContextForEventFunction()) {
      throw new Error(
        "A function wrapped in useEffectEvent can't be called during rendering.",
      );
    }
    return ref.impl.apply(undefined, arguments);
  };
}

注意看, 其实该 hook 每次都创建了一个新的 eventFn , 然后返回, 所以每次返回值也是新的, 这一点和 useCallback 有本质区别.

该 hook 返回的函数就可以放心在 useEffect 中使用了, ESLint ( eslint-plugin-react-hooks 同样的 experimental 版本 ) 也不会报错. 因为每次都是返回的最新的函数, 该函数也能获取到所有最新的变量.

使用 useEffectEvent 重构分页请求场景:

const onCurPageChange = useEffectEvent(() => {
    if(list.length <= curPage) {
        fetchItem();
    }
});

useEffect(() => {
    onCurPageChange();
}, [curPage]);

重构输入框场景:

const tick = useEffectEvent(() => {
    setCount(count + step);
});

useEffect(() => {
    const timer = setInterval(() => { // 定时器只会开一个
        tick();
    }, 1000);

    return () => {
        clearInterval(timer);
    };
}, []);

References

overreacted.io/a-complete-…

blog.bitsrc.io/understandi…