type
status
date
slug
summary
tags
category
icon
password
本文来源于React团队Dan的一篇名为A Complete Guide to useEffect的文章
做个笔记:
首先Dan提出在用Hooks时可能遇到的问题:
  • 🤔 如何用useEffect模拟componentDidMount生命周期?
  • 🤔 如何正确地在useEffect里请求数据?[]又是什么?
  • 🤔 我应该把函数当做effect的依赖吗?
  • 🤔 为什么有时候会出现无限重复请求的问题?
  • 🤔 为什么有时候在effect里拿到的是旧的state或prop?
在他开始寻找答案前,Dan提出了一个概念(unlearning)
意思就是事实上我们并不需要学习某些东西,而是花时间试图忘记某些概念:
当我不再透过熟悉的class生命周期方法去窥视useEffect 这个Hook的时候,我才得以融会贯通。

基本概念

首先Dan提出了一个概念:

每一次渲染都有它自己的 Props and State

首先我们来看一个计时器组件counter 注意高亮的count属性:
count 会"监听"状态变化然后自动更新吗,这可能是刚学React时候的第一感觉,但它并不是精确地心智模型
上面例子中,count仅是一个数字而已。它不是神奇的“data binding”, “watcher”, “proxy”,(总感觉这里Dan在说Vue)或者其他任何东西。它就是一个普通的数字像下面这个一样:
每当更新状态是,React就会重新渲染组件。每一次渲染都会拿到独立的React State状态,这个状态值是函数中的一个常量。
它仅仅只是在渲染输出中插入了count这个数字,这个数字由React提供,当setCount时, React会
带着一个不同的count重新调用组件。然后React会更新DOM以保持和渲染输出一致。
这里关键的点在于任意一次渲染中的count常量都不会随着时间改变。渲染输出会变是因为我们的组件被一次次调用,而每一次调用引起的渲染中,它包含的count值独立于其他渲染。
那么事件处理函数呢?
如果我按照下面的步骤去操作:
  • 点击增加counter到3
  • 点击一下 “Show alert”
  • 点击增加 counter到5并且在定时器回调触发前完成
notion image
alert会弹出什么呢? 是点击show alert时候的3吗 ? 还是弹出来时候的5呢?
结果是点击时候的3
alert会“捕获”我点击按钮时候的状态。

但是它是如何工作的呢?
值得注意的是,count 在每次函数调用的时候都是一个常量值,我们的组件函数每次在渲染时候都会被调用,但是每一次调用中的count的值都是常量,而且都被赋予了当前渲染中的状态值
这里其实不仅是FC特有的现象,普通函数也有类似行为
这个例子中, 外层的someone会被赋值很多次(就像在React中,当前的组件状态会改变一样)。然后,在sayHi函数中,局部常量name会和某次调用中的person关联。因为这个常量是局部的,所以每一次调用都是相互独立的。结果就是,当定时器回调触发的时候,每一个alert都会弹出它拥有的name
这就解释了我们的事件处理函数如何捕获了点击时候的count值。如果我们应用相同的替换原理,每一次渲染“看到”的是它自己的count
所以实际上,每一次渲染都有一个“新版本”的handleAlertClick。每一个版本的handleAlertClick“记住” 了它自己的 count
这就是为什么在这个demo中中,事件处理函数“属于”某一次特定的渲染,当你点击的时候,它会使用那次渲染中counter的状态值。
在任意一次渲染中,props和state是始终保持不变的。如果props和state在不同的渲染中是相互独立的,那么使用到它们的任何值也是独立的(包括事件处理函数)。它们都“属于”一次特定的渲染。即便是事件处理中的异步函数调用“看到”的也是这次渲染中的count值。
然后Dan 又提出了第二个观点:

每次渲染都有它自己的effects

回到例子:
抛一个问题给你:effect是如何读取到最新的count 状态值的呢?
也许,是某种“data binding”或“watching”机制使得count能够在effect函数内更新?也或许count是一个可变的值,React会在我们组件内部修改它以使我们的effect函数总能拿到最新的值?
都不是。
我们已经知道count是某个特定渲染中的常量。事件处理函数“看到”的是属于它那次特定渲染中的count状态值。对于effects也同样如此:
并不是count的值在“不变”的effect中发生了改变,而是effect 函数本身在每一次渲染中都不相同。
每一个effect版本“看到”的count值都来自于它属于的那次渲染:
这是啥,不就是闭包吗
React会记住你提供的effect函数,并且会在每次更改作用于DOM并让浏览器绘制屏幕后去调用它。
所以虽然我们说的是一个 effect(这里指更新document的title),但其实每次渲染都是一个不同的函数 — 并且每个effect函数“看到”的props和state都来自于它属于的那次特定渲染。
概念上,你可以想象effects是渲染结果的一部分。
严格地说,它们并不是(为了允许Hook的组合并且不引入笨拙的语法或者运行时)。但是在我们构建的心智模型上,effect函数属于某个特定的渲染,就像事件处理函数一样。

现在为了加深理解,Dan用了更通俗的对话形式去回顾了第一次的渲染:
  • React: 给我状态为 0时候的UI。
  • 你的组件:
    • 给你需要渲染的内容: <p>You clicked 0 times</p>
    • 记得在渲染完了之后调用这个effect: () => { document.title = 'You clicked 0 times' }
  • React: 没问题。开始更新UI,喂浏览器,我要给DOM添加一些东西。
  • 浏览器: 酷,我已经把它绘制到屏幕上了。
  • React: 好的, 我现在开始运行给我的effect
    • 运行 () => { document.title = 'You clicked 0 times' }

现在我们回顾一下我们点击之后发生了什么:
  • 你的组件: 喂 React, 把我的状态设置为1
  • React: 给我状态为 1时候的UI。
  • 你的组件:
    • 给你需要渲染的内容: <p>You clicked 1 times</p>
    • 记得在渲染完了之后调用这个effect: () => { document.title = 'You clicked 1 times' }
  • React: 没问题。开始更新UI,喂浏览器,我修改了DOM。
  • Browser: 酷,我已经将更改绘制到屏幕上了。
  • React: 好的, 我现在开始运行属于这次渲染的effect
    • 运行 () => { document.title = 'You clicked 1 times' }

每一次渲染都有它自己的…所有

我们现在知道effects会在每次渲染后运行,并且概念上它是组件输出的一部分,可以“看到”属于某次特定渲染的props和state。
我们来做一个思想实验,思考下面的代码:
如果我点击了很多次并且在effect里设置了延时,打印出来的结果会是什么呢?

剧透预警

你可能会认为这是一个很绕的题并且结果是反直觉的。完全错了!我们看到的就是顺序的打印输出 — 每一个都属于某次特定的渲染,因此有它该有的count值。你可以自己试一试
notion image
你可能会想:“它当然应该是这样的。否则还会怎么样呢?”
不过,class中的this.state并不是这样运作的。你可能会想当然以为下面的class 实现和上面是相等的:
然而,this.state.count总是指向最新的count值,而不是属于某次特定渲染的值。所以你会看到每次打印输出都是5
notion image
我觉得Hooks这么依赖Javascript闭包是挺讽刺的一件事。有时候组件的class实现方式会受闭包相关的苦(the canonical wrong-value-in-a-timeout confusion),但其实这个例子中真正的混乱来源是可变数据(React 修改了class中的this.state使其指向最新状态),并不是闭包本身的错。
当封闭的值始终不会变的情况下闭包是非常棒的。这使它们非常容易思考因为你本质上在引用常量。正如我们所讨论的,props和state在某个特定渲染中是不会改变的。顺便说一下,我们可以使用闭包修复上面的class版本…

到目前为止,我们可以明确地喊出下面重要的事实:每一个组件内的函数(包括事件处理函数,effects,定时器或者API调用等等)会捕获某次渲染中定义的props和state。
所以下面的两个例子是相等的:
在组件内什么时候去读取props或者state是无关紧要的。因为它们不会改变。在单次渲染的范围内,props和state始终保持不变。(解构赋值的props使得这一点更明显。)
当然,有时候你可能在effect的回调函数里读取最新的值而不是捕获的值。最简单的实现方法是使用refs,这篇文章的最后一部分介绍了相关内容。
需要注意的是当你想要从过去渲染中的函数里读取未来的props和state,你是在逆潮而动。虽然它并没有(有时候可能也需要这样做),但它因为打破了默认范式会使代码显得不够“干净”。这是我们有意为之的,因为它能帮助突出哪些代码是脆弱的,是需要依赖时间次序的。在class中,如果发生这种情况就没那么显而易见了。
下面这个计数器版本 模拟了class中的行为:
notion image
在React中去直接修改值看上去有点怪异。然而,在class组件中React正是这样去修改this.state的。不像捕获的props和state,你没法保证在任意一个回调函数中读取的latestCount.current是不变的。根据定义,你可以随时修改它。这就是为什么它不是默认行为,而是需要你主动选择这样做。

那Effect中的清理又是怎样的呢?

文档中解释的, 有些 effects 可能需要有一个清理步骤。本质上,它的目的是消除副作用(effect),比如取消订阅。
思考下面的代码:
假设第一次渲染的时候props{id: 10},第二次渲染的时候是{id: 20}。你可能会认为发生了下面的这些事:
  • React 清除了 {id: 10}的effect。
  • React 渲染{id: 20}的UI。
  • React 运行{id: 20}的effect。
(事实并不是这样。)
如果依赖这种心智模型,你可能会认为清除过程“看到”的是旧的props因为它是在重新渲染之前运行的,新的effect“看到”的是新的props因为它是在重新渲染之后运行的。这种心智模型直接来源于class组件的生命周期。不过它并不精确。让我们来一探究竟。
React只会在浏览器绘制后运行effects。这使得你的应用更流畅因为大多数effects并不会阻塞屏幕的更新。Effect的清除同样被延迟了。上一次的effect会在重新渲染后被清除:
  • React 渲染{id: 20}的UI。
  • 浏览器绘制。我们在屏幕上看到{id: 20}的UI。
  • React 清除{id: 10}的effect。
  • React 运行{id: 20}的effect。
引用上半部分得到的结论:
组件内的每一个函数(包括事件处理函数,effects,定时器或者API调用等等)会捕获定义它们的那次渲染中的props和state。

告诉React去对比你的Effects

现在答案显而易见。effect的清除并不会读取“最新”的props。它只能读取到定义它的那次渲染中的props值:
其实我们已经从React处理DOM的方式中学习到了解决办法。React只会更新DOM真正发生改变的部分,而不是每次渲染都大动干戈。
当你把
更新到
React 能够看到两个对象:
它会检测每一个props,并且发现children发生改变需要更新DOM,但className并没有。所以它只需要这样做:
我们也可以用类似的方式处理effects吗?如果能够在不需要的时候避免调用effect就太好了。
举个例子,我们的组件可能因为状态变更而重新渲染:
但是我们的effect并没有使用counter这个状态。我们的effect只会同步name属性给document.title,但name并没有变。在每一次counter改变后重新给document.title赋值并不是理想的做法。
好了,那React可以…区分effects的不同吗?
并不能。React并不能猜测到函数做了什么如果不先调用的话。(源码中并没有包含特殊的值,它仅仅是引用了name属性。)
这是为什么你如果想要避免effects不必要的重复调用,你可以提供给useEffect一个依赖数组参数(deps):
这好比你告诉React:“Hey,我知道你看不到这个函数里的东西,但我可以保证只使用了渲染中的name,别无其他。”
如果当前渲染中的这些依赖项和上一次运行这个effect的时候值一样,因为没有什么需要同步React会自动跳过这次effect:
即使依赖数组中只有一个值在两次渲染中不一样,我们也不能跳过effect的运行。要同步所有!

对于依赖项不要对React说谎

关于依赖项deps不能对React撒谎,举个最经典的例, 首次渲染加载数据
“但我只是想在挂载的时候运行它!”,你可能会说。现在只需要记住:如果你设置了依赖项,effect中用到的所有组件内的值都要包含在依赖中。这包括props,state,函数 — 组件内的任何东西。
这样做的结果就是可能会遇到bug,比如socket频繁被创建,无限请求
那么这里的问题出在哪里
首先先举个例子:
假设我们要写一个每秒递增的计数器,在class组件的写法里面,我们的直觉是"开启一次定时器,清除也是一次
然后,在函数组件里面,我们用useEffect的方式翻译,直觉上我们会设置依赖为[],因为这个作用我只想运行一次
然而,这个例子只会递增一次
也许你是这么想的,只有当我需要重新触发effect的时候才会设置依赖,这里因为它是定时器所以我只想触发一次,然而结果却并不是这样
事实上,依赖是我们给React的暗示,告诉React,effect所有需要在渲染中的值,effect使用了count但是我们撒谎说没有依赖。
在第一次渲染中,count0。因此,setCount(count + 1)在第一次渲染中等价于setCount(0 + 1)既然我们设置了[]依赖,effect不会再重新运行,它后面每一秒都会调用setCount(0 + 1)
我们对React撒谎说我们的effect不依赖组件内的任何值,可实际上我们的effect有依赖!
我们的effect依赖count - 它是组件内的值(不过在effect外面定义):
因此,设置[]为依赖会引入一个bug。React会对比依赖,并且跳过后面的effect:
notion image

两种告诉依赖的方法

第一种是在依赖中包含所有effect中用到的组件内的值。让我们在依赖中包含count
先在每次count修改都会重新运行effect,而且定时器中的setCount(count + 1)会正确引用到count的值
这能解决问题但是我们的定时器会在每一次count改变后清除和重新设定。这应该不是我们想要的结果:
第二种策略是修改effect内部的代码以确保它包含的值只会在需要的时候发生变更。我们不想告知错误的依赖 - 我们只是修改effect使得依赖更少。
我们想去掉effect的count依赖。
为了实现这个目的,我们需要问自己一个问题:我们为什么要用count可以看到我们只在setCount调用中用到了count。在这个场景中,我们其实并不需要在effect中使用count。当我们想要根据前一个状态更新状态的时候,我们可以使用setState函数形式
我喜欢把类似这种情况称为“错误的依赖”。是的,因为我们在effect中写了setCount(count + 1)所以count是一个必需的依赖。但是,我们真正想要的是把count转换为count+1,然后返回给React。可是React其实已经知道当前的count我们需要告知React的仅仅是去递增状态 - 不管它现在具体是什么值。
这里就像是之前文章中说的我们用React就好像我们先去某个地方,不需要告诉React走哪条路,左转,再走那条路尔尔,更好的方法而是直接告诉它终点,它就会带我们到那个地方。不说具体的事情,而是抽象的改变状态。
这正是setCount(c => c + 1)做的事情。你可以认为它是在给React“发送指令”告知如何更新状态。这种“更新形式”在其他情况下也有帮助,比如你需要批量更新
注意我们做到了移除依赖,并且没有撒谎。我们的effect不再读取渲染中的count值。
notion image
(依赖没有变,所以不会再次运行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

解耦来自action的更新(useReducer)

如果修改上面例子让它包含两个状态:count和step:
(这里是demo.)
如果这里,假如我们不想在step改变后重启定时器,我们该如何从effect中移除对step依赖呢?
当你想更新一个状态,并且这个状态更新依赖于另一个状态的值时,你可能需要用useReducer去替换它们。
当你写类似setSomething(something => ...)这种代码的时候,也许就是考虑使用reducer的契机。reducer可以让你把组件内发生了什么(actions)和状态如何响应并更新分开表述。
我们用一个dispatch依赖去替换effect的step依赖
这里你可能想问,这里怎么就更好了?
答案是React会保证在组件的声明周期内保持不变,所以上面例子中不再需要重新订阅定时器
(你可以从依赖中去除dispatch, setState, 和useRef包裹的值因为React会确保它们是静态的。不过你设置了它们作为依赖也没什么问题。)
相比于直接在effect里面直接读取状态,它disapatch了一个action来描述发送了什么,这使得我们的effect和step状态解耦,我们的effect不再关心怎么关心状态,只负责告诉我们发生了什么,其他逻辑统一交给reducer处理:
为什么useReducer是Hooks的作弊模式
我们已经学习到如何移除effect的依赖,不管状态更新是依赖上一个状态还是依赖另一个状态。但假如我们需要依赖*props*去计算下一个状态呢?举个例子,也许我们的API是<Counter step={1} />。确定的是,在这种情况下,我们没法避免依赖props.step 。是吗?
实际上, 我们可以避免!我们可以把reducer函数放到组件内去读取props:
这种模式会使一些优化失效,所以你应该避免滥用它,不过如果你需要你完全可以在reducer里面访问props。(这里是demo。)
即使是在这个例子中,React也保证dispatch在每次渲染中都是一样的。 所以你可以在依赖中去掉它。它不会引起effect不必要的重复执行。
你可能会疑惑:这怎么可能?在之前渲染中调用的reducer怎么“知道”新的props?答案是当你dispatch的时候,React只是记住了action - 它会在下一次渲染中再次调用reducer。在那个时候,新的props就可以被访问到,而且reducer调用也不是在effect里。
这就是为什么我倾向认为useReducer是Hooks的“作弊模式”。它可以把更新逻辑和描述发生了什么分开。结果是,这可以帮助我移除不必需的依赖,避免不必要的effect调用。

把函数移到Effects里

一个典型的误解是认为函数不应该成为依赖。举个例子,下面的代码看上去可以运行正常:
(这个例子 改编自Robin Wieruch这篇很棒的文章 — 点击查看!)
需要明确的是,上面的代码可以正常工作。但这样做在组件日渐复杂的迭代过程中我们很难确保它在各种情况下还能正常运行。
想象一下我们的代码做下面这样的分离,并且每一个函数的体量是现在的五倍:
然后我们在某些函数内使用了某些state或者prop:
如果我们忘记去更新使用这些函数(很可能通过其他函数调用)的effects的依赖,我们的effects就不会同步props和state带来的变更。这当然不是我们想要的。
幸运的是,对于这个问题有一个简单的解决方案。如果某些函数仅在effect中调用,你可以把它们的定义移到effect中:
这么做有什么好处呢?我们不再需要去考虑这些“间接依赖”。我们的依赖数组也不再撒谎:在我们的effect中确实没有再使用组件范围内的任何东西。
如果我们后面修改 getFetchUrl去使用query状态,我们更可能会意识到我们正在effect里面编辑它 - 因此,我们需要把query添加到effect的依赖里:
(这里是demo.)
添加这个依赖,我们不仅仅是在“取悦React”。在query改变后去重新请求数据是合理的。useEffect的设计意图就是要强迫你关注数据流的改变,然后决定我们的effects该如何和它同步 - 而不是忽视它直到我们的用户遇到了bug。

有时候不能把函数放到effect里 useCallback

有时候你可能不想把函数移到effect里面,比如说组件内有几个effect用了相同的函数,你不想在每个effect里面赋值粘贴一遍这个逻辑,又或许这个函数是一个prop
在这种情况下你应该忽略对函数的依赖吗?我不这么认为。再次强调,effects不应该对它的依赖撒谎。通常我们还有更好的解决办法。一个常见的误解是,“函数从来不会改变”。但是这篇文章你读到现在,你知道这显然不是事实。实际上,在组件内定义的函数每一次渲染都在变。
函数每次渲染都会改变这个事实本身就是个问题。 比如有两个effects会调用 getFetchUrl:
在这个例子中,你可能不想把getFetchUrl 移到effects中,因为你想复用逻辑。
另一方面,如果你对依赖很“诚实”,你可能会掉到陷阱里。我们的两个effects都依赖getFetchUrl而它每次渲染都不同,所以我们的依赖数组会变得无用:
一个可能的解决办法是把getFetchUrl从依赖中去掉。但是,我不认为这是好的解决方式。这会使我们后面对数据流的改变很难被发现从而忘记去处理。这会导致类似于上面“定时器不更新值”的问题。
相反的,我们有两个更简单的解决办法。
第一个,既然一个函数没有使用组件内的任何值,应该将其提到组件外部定义,然后就可以自由在effcts中使用
你不再需要把它设为依赖,因为它们不再渲染范围内,因此不会受数据流影响。不可能突然意外依赖于props或者state
第二种方法就是,你也可以将它包装成useCallback hook
useCallback本质上是添加了一层依赖检查。它以另一种方式解决了问题 - 我们使函数本身只在需要的时候才改变,而不是去掉对函数的依赖。
我们来看看为什么这种方式是有用的。之前,我们的例子中展示了两种搜索结果(查询条件分别为'react''redux')。但如果我们想添加一个输入框允许你输入任意的查询条件(query)。不同于传递query参数的方式,现在getFetchUrl会从状态中读取。
我们很快发现它遗漏了query依赖:
如果我把query添加到useCallback 的依赖中,任何调用了getFetchUrl的effect在query改变后都会重新运行:
我们要感谢useCallback,因为如果query 保持不变,getFetchUrl也会保持不变,我们的effect也不会重新运行。但是如果query修改了,getFetchUrl也会随之改变,因此会重新请求数据。这就像你在Excel里修改了一个单元格的值,另一个使用它的单元格会自动重新计算一样。
这正是拥抱数据流和同步思维的结果。对于通过属性从父组件传入的函数这个方法也适用:
类似的 useMemo也可以实现对复杂对象做类似的事情:
我想强调的是,到处使用useCallback是件挺笨拙的事。当我们需要将函数传递下去并且函数会在子组件的effect中被调用的时候,useCallback 是很好的技巧且非常有用。或者你想试图减少对子组件的记忆负担,也不妨一试。但总的来说Hooks本身能更好地避免传递回调函数
在上面的例子中,我更倾向于把fetchData放在我的effect里(它可以抽离成一个自定义Hook)或者是从顶层引入。我想让effects保持简单,而在里面调用回调会让事情变得复杂。(“如果某个props.onComplete回调改变了而请求还在进行中会怎么样?”)你可以模拟class的行为但那样并不能解决竞态的问题。

竞态问题

下面是一个典型的在class组件里发请求的例子:
你很可能已经知道,上面的代码埋伏了一些问题。它并没有处理更新的情况。所以第二个你能够在网上找到的经典例子是下面这样的:
这显然好多了!但依旧有问题。有问题的原因是请求结果返回的顺序不能保证一致。比如我先请求 {id: 10},然后更新到{id: 20},但{id: 20}的请求更先返回。请求更早但返回更晚的情况会错误地覆盖状态值。
这被叫做竞态,这在混合了async / await(假设在等待结果返回)和自顶向下数据流的代码中非常典型(props和state可能会在async函数调用过程中发生改变)。
Effects并没有神奇地解决这个问题,尽管它会警告你如果你直接传了一个async 函数给effect。(我们会改善这个警告来更好地解释你可能会遇到的这些问题。)
如果你使用的异步方式支持取消,那太棒了。你可以直接在清除函数中取消异步请求。
或者,最简单的权宜之计是用一个布尔值来跟踪它:
这篇文章讨论了更多关于如何处理错误和加载状态,以及抽离逻辑到自定义的Hook。我推荐你认真阅读一下如果你想学习更多关于如何在Hooks里请求数据的内容。
 
ahooks输入URL后发生了什么
Loading...
NotionNext
NotionNext
一个普通的干饭人🍚
最新发布
ahooks
2024-2-27
深入useEffect
2023-8-8
CSS笔记
2023-3-29
ES6
2023-3-29
输入URL后发生了什么
2023-3-29
HTML笔记
2023-3-29