前言
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但我们撒谎说它没有依赖。如果我们这样做迟早会出幺蛾子。
在第一次渲染中,count是0。因此,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在组件的声明周期内保持不变。所以上面例子中不再需要重新订阅定时器。
我们解决了问题!
(你可以从依赖中去除dispatch, setState, 和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 如果你之前错过了。)