【学习记录】React: useEffect开启一个定时器的方案优化

783 阅读8分钟

前言

Dan Abramov(React作者)在他的一篇文章useEffect完整指南 详尽的讲解了useEffect,本文是对文章中定时器例子的摘抄,想看完整文章的小伙伴请点上面链接OVO

正文

举个例子,我们来写一个每秒递增的计数器。在Class组件中,我们的直觉是:“开启一次定时器,清除也是一次”。这里有一个例子说明怎么实现它。当我们理所当然地把它用useEffect的方式翻译,直觉上我们会设置依赖为[]。“我只想运行一次effect”,对吗?

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

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

然而,这个例子只会递增一次天了噜。

如果你的心智模型是“只有当我想重新触发effect的时候才需要去设置依赖”,这个例子可能会让你产生存在危机。你想要触发一次因为它是定时器 — 但为什么会有问题?

如果你知道依赖是我们给React的暗示,告诉它effect所有需要使用的渲染中的值,你就不会吃惊了。effect中使用了count但我们撒谎说它没有依赖。如果我们这样做迟早会出幺蛾子。

在第一次渲染中,count0。因此,setCount(count + 1)在第一次渲染中等价于setCount(0 + 1)既然我们设置了[]依赖,effect不会再重新运行,它后面每一秒都会调用setCount(0 + 1) :

// First render, state is 0
function Counter() {
  // ...
  useEffect(
    // Effect from first render
    () => {
      const id = setInterval(() => {
        setCount(0 + 1); // Always setCount(1)      }, 1000);
      return () => clearInterval(id);
    },
    [] // Never re-runs  );
  // ...
}

// Every next render, state is 1
function Counter() {
  // ...
  useEffect(
    // This effect is always ignored because    // we lied to React about empty deps.    () => {
      const id = setInterval(() => {
        setCount(1 + 1);
      }, 1000);
      return () => clearInterval(id);
    },
    []
  );
  // ...
}

我们对React撒谎说我们的effect不依赖组件内的任何值,可实际上我们的effect有依赖!

我们的effect依赖count - 它是组件内的值(不过在effect外面定义):

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

因此,设置[]为依赖会引入一个bug。React会对比依赖,并且跳过后面的effect:

定时器闭包示例图

(依赖没有变,所以不会再次运行effect。)

类似于这样的问题是很难被想到的。因此,我鼓励你将诚实地告知effect依赖作为一条硬性规则,并且要列出所以依赖。(我们提供了一个lint规则如果你想在你的团队内做硬性规定。)

两种诚实告知依赖的方法

有两种诚实告知依赖的策略。你应该从第一种开始,然后在需要的时候应用第二种。

第一种策略是在依赖中包含所有effect中用到的组件内的值。 让我们在依赖中包含count

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

现在依赖数组正确了。虽然它可能不是太理想但确实解决了上面的问题。现在,每次count修改都会重新运行effect,并且定时器中的setCount(count + 1)会正确引用某次渲染中的 count值:

// First render, state is 0
function Counter() {
  // ...
  useEffect(
    // Effect from first render
    () => {
      const id = setInterval(() => {
        setCount(0 + 1); // setCount(count + 1)      }, 1000);
      return () => clearInterval(id);
    },
    [0] // [count]  );
  // ...
}

// Second render, state is 1
function Counter() {
  // ...
  useEffect(
    // Effect from second render
    () => {
      const id = setInterval(() => {
        setCount(1 + 1); // setCount(count + 1)      }, 1000);
      return () => clearInterval(id);
    },
    [1] // [count]  );
  // ...
}

这能解决问题但是我们的定时器会在每一次count改变后清除和重新设定。这应该不是我们想要的结果:

定时器重复订阅示例图

(依赖发生了变更,所以会重新运行effect。)


第二种策略是修改effect内部的代码以确保它包含的值只会在需要的时候发生变更。 我们不想告知错误的依赖 - 我们只是修改effect使得依赖更少。

让我们来看一些移除依赖的常用技巧。


让Effects自给自足

我们想去掉effect的count依赖。

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

为了实现这个目的,我们需要问自己一个问题:我们为什么要用count 可以看到我们只在setCount调用中用到了count。在这个场景中,我们其实并不需要在effect中使用count。当我们想要根据前一个状态更新状态的时候,我们可以使用setState函数形式

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

我喜欢把类似这种情况称为“错误的依赖”。是的,因为我们在effect中写了setCount(count + 1)所以count是一个必需的依赖。但是,我们真正想要的是把count转换为count+1,然后返回给React。可是React其实已经知道当前的count我们需要告知React的仅仅是去递增状态 - 不管它现在具体是什么值。

这正是setCount(c => c + 1)做的事情。你可以认为它是在给React“发送指令”告知如何更新状态。这种“更新形式”在其他情况下也有帮助,比如你需要批量更新

注意我们做到了移除依赖,并且没有撒谎。我们的effect不再读取渲染中的count值。

运行良好的定时器示例图

(依赖没有变,所以不会再次运行effect。)

你可以自己 试试

尽管effect只运行了一次,第一次渲染中的定时器回调函数可以完美地在每次触发的时候给React发送c => c + 1更新指令。它不再需要知道当前的count值。因为React已经知道了。

函数式更新 和 Google Docs

还记得我们说过同步才是理解effects的心智模型吗?同步的一个有趣地方在于你通常想要把同步的“信息”和状态解耦。举个例子,当你在Google Docs编辑文档的时候,Google并不会把整篇文章发送给服务器。那样做会非常低效。相反的,它只是把你的修改以一种形式发送给服务端。

虽然我们effect的情况不尽相同,但可以应用类似的思想。只在effects中传递最小的信息会很有帮助。 类似于setCount(c => c + 1)这样的更新形式比setCount(count + 1)传递了更少的信息,因为它不再被当前的count值“污染”。它只是表达了一种行为(“递增”)。“Thinking in React”也讨论了如何找到最小状态。原则是类似的,只不过现在关注的是如何更新。

表达意图(而不是结果)和Google Docs如何处理共同编辑异曲同工。虽然这个类比略微延伸了一点,函数式更新在React中扮演了类似的角色。它们确保能以批量地和可预测的方式来处理各种源头(事件处理函数,effect中的订阅,等等)的状态更新。

然而,即使是setCount(c => c + 1)也并不完美。 它看起来有点怪,并且非常受限于它能做的事。举个例子,如果我们有两个互相依赖的状态,或者我们想基于一个prop来计算下一次的state,它并不能做到。幸运的是, setCount(c => c + 1)有一个更强大的姐妹模式,它的名字叫useReducer

解耦来自Actions的更新

我们来修改上面的例子让它包含两个状态:count 和 step。我们的定时器会每次在count上增加一个step值:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step);    }, 1000);
    return () => clearInterval(id);
  }, [step]);
  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
  );
}

(这里是demo.)

注意我们没有撒谎。既然我们在effect里使用了step,我们就把它加到依赖里。所以这也是为什么代码能运行正确。

这个例子目前的行为是修改step会重启定时器 - 因为它是依赖项之一。在大多数场景下,这正是你所需要的。清除上一次的effect然后重新运行新的effect并没有任何错。除非我们有很好的理由,我们不应该改变这个默认行为。

不过,假如我们不想在step改变后重启定时器,我们该如何从effect中移除对step的依赖呢?

当你想更新一个状态,并且这个状态更新依赖于另一个状态的值时,你可能需要用useReducer去替换它们。

当你写类似setSomething(something => ...)这种代码的时候,也许就是考虑使用reducer的契机。reducer可以让你把组件内发生了什么(actions)和状态如何响应并更新分开表述。

我们用一个dispatch依赖去替换effect的step依赖:

const [state, dispatch] = useReducer(reducer, initialState);const { count, step } = state;

useEffect(() => {
  const id = setInterval(() => {
    dispatch({ type: 'tick' }); // Instead of setCount(c => c + step);  }, 1000);
  return () => clearInterval(id);
}, [dispatch]);

(查看 demo。)

你可能会问:“这怎么就更好了?”答案是React会保证dispatch在组件的声明周期内保持不变。所以上面例子中不再需要重新订阅定时器。

我们解决了问题!

(你可以从依赖中去除dispatchsetState, 和useRef包裹的值因为React会确保它们是静态的。不过你设置了它们作为依赖也没什么问题。)

相比于直接在effect里面读取状态,它dispatch了一个action来描述发生了什么。这使得我们的effect和step状态解耦。我们的effect不再关心怎么更新状态,它只负责告诉我们发生了什么。更新的逻辑全都交由reducer去统一处理:

const initialState = {
  count: 0,
  step: 1,
};

function reducer(state, action) {
  const { count, step } = state;
  if (action.type === 'tick') {    return { count: count + step, step };  } else if (action.type === 'step') {
    return { count, step: action.step };
  } else {
    throw new Error();
  }
}

(这里是demo 如果你之前错过了。)