阅读 facebook大佬:Dan Abramov 的文章很有感悟javascript
大佬 github地址 https://github.com/gaearonhtml
useEffect
是同步的props
和 state
useRef
获取改变后的 props
和 state
[]
不能欺骗useReducer
useCallback
设置依赖useMemo
让复杂对象作动态改变但有时候当你使用 useEffect
你总以为哪儿有点不对劲。你会嘀咕你可能遗漏了什么。它看起来像class的生命周期...但真的是这样吗?你发觉本身在问相似下面的这些问题:前端
useEffect
模拟 componentDidMount
生命周期?useEffect
里请求数据? []
又是什么?当我再也不透过熟悉的class生命周期方法去窥视 useEffect
这个Hook的时候,我才得以融会贯通。java
"忘记你已经学到的。" — Yodareact
若是你打算阅读整篇文章,你彻底能够跳过这部分。我会在文章末尾带上摘要的连接。ios
🤔 Question: 如何用 useEffect
模拟 componentDidMount
生命周期?git
虽然可使用 useEffect(fn, [])
,但它们并不彻底相等。和 componentDidMount
不同, useEffect
会 捕获 props和state。因此即使在回调函数里,你拿到的仍是初始的props和state。若是你想获得"最新"的值,你可使用ref。不过,一般会有更简单的实现方式,因此你并不必定要用ref。记住,effects的心智模型和 componentDidMount
以及其余生命周期是不一样的,试图找到它们之间彻底一致的表达反而更容易使你混淆。想要更有效,你须要"think in effects",它的心智模型更接近于实现状态同步,而不是响应生命周期事件。github
🤔 Question: 如何正确地在 useEffect
里请求数据? []
又是什么?redux
这篇文章 是很好的入门,介绍了如何在 useEffect
里作数据请求。请务必读完它!它没有个人这篇这么长。 []
表示effect没有使用任何React数据流里的值,所以该effect仅被调用一次是安全的。 []
一样也是一类常见问题的来源,也即你觉得没使用数据流里的值但其实使用了。你须要学习一些策略(主要是 useReducer
和 useCallback
)来移除这些effect依赖,而不是错误地忽略它们。axios
🤔 Question: 我应该把函数当作effect的依赖吗?
通常建议把不依赖props和state的函数提到你的组件外面,而且把那些仅被effect使用的函数放到effect里面。若是这样作了之后,你的effect仍是须要用到组件内的函数(包括经过props传进来的函数),能够在定义它们的地方用 useCallback
包一层。为何要这样作呢?由于这些函数能够访问到props和state,所以它们会参与到数据流中。咱们官网的FAQ有更详细的答案。
🤔 Question: 为何有时候会出现无限重复请求的问题?
这个一般发生于你在effect里作数据请求而且没有设置effect依赖参数的状况。没有设置依赖,effect会在每次渲染后执行一次,而后在effect中更新了状态引发渲染并再次触发effect。无限循环的发生也多是由于你设置的依赖老是会改变。你能够经过一个一个移除的方式排查出哪一个依赖致使了问题。可是,移除你使用的依赖(或者盲目地使用 []
)一般是一种错误的解决方式。你应该作的是解决问题的根源。举个例子,函数可能会致使这个问题,你能够把它们放到effect里,或者提到组件外面,或者用 useCallback
包一层。 useMemo
能够作相似的事情以免重复生成对象。
🤔 为何有时候在effect里拿到的是旧的state或prop呢?
Effect拿到的老是定义它的那次渲染中的props和state。这可以避免一些bugs,但在一些场景中又会有些讨人嫌。对于这些场景,你能够明确地使用可变的ref保存一些值(上面文章的末尾解释了这一点)。若是你以为在渲染中拿到了一些旧的props和state,且不是你想要的,你极可能遗漏了一些依赖。能够尝试使用这个lint 规则来训练你发现这些依赖。可能没过几天,这种能力会变得像是你的次日性。一样能够看咱们官网FAQ中的这个回答。
我但愿这个摘要对你有所帮助!要不,咱们开始正文。
在咱们讨论effects以前,咱们须要先讨论一下渲染(rendering)。
咱们来看一个计数器组件Counter:
function Counter() { const [count, setCount] = useState(0); return ( <div> <p>You clicked {count} times </p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> ); }
高亮的代码到底是什么意思呢? count
会"监听"状态的变化并自动更新吗?这么想多是学习React的时候有用的第一直觉,但它并非精确的心智模型。
上面例子中, count
仅是一个数字而已。它不是神奇的"data binding", "watcher", "proxy",或者其余任何东西。它就是一个普通的数字像下面这个同样:
const count = 42; <p>You clicked {count} times </p>
咱们的组件第一次渲染的时候,从 useState()
拿到 count
的初始值 0
。当咱们调用 setCount(1)
,React会再次渲染组件,这一次 count
是 1
。如此等等:
function Counter() { const count = 0; <p>You clicked {count} times</p> } function Counter() { const count = 1; <p>You clicked {count} times</p> } function Counter() { const count = 2; <p>You clicked {count} times</p> }
当咱们更新状态的时候,React会从新渲染组件。每一次渲染都能拿到独立的 count
状态,这个状态值是函数中的一个常量。
因此下面的这行代码没有作任何特殊的数据绑定:
<p>You clicked {count} times</p>
它仅仅只是在渲染输出中插入了count这个数字。这个数字由React提供。当 setCount
的时候,React会带着一个不一样的 count
值再次调用组件。而后,React会更新DOM以保持和渲染输出一致。
这里关键的点在于任意一次渲染中的 count
常量都不会随着时间改变。渲染输出会变是由于咱们的组件被一次次调用,而每一次调用引发的渲染中,它包含的 count
值独立于其余渲染。
(关于这个过程更深刻的探讨能够查看个人另外一篇文章React as a UI Runtime 。)
到目前为止一切都还好。那么事件处理函数呢?
看下面的这个例子。它在三秒后会alert点击次数 count
:
function Counter() { const [count, setCount] = useState(0); function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + count); }, 3000); } return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> <button onClick={handleAlertClick}>Show alert</button> </div> ); }
若是我按照下面的步骤去操做:
来本身 试试吧!
这篇文章深刻探索了个中原因。正确的答案就是 3 。alert会"捕获"我点击按钮时候的状态。
(虽然有其余办法能够实现不一样的行为,但如今我会专一于这个默认的场景。当咱们在构建一种心智模型的时候,在可选的策略中分辨出"最小阻力路径"是很是重要的。)
但它到底是如何工做的呢?
咱们发现 count
在每一次函数调用中都是一个常量值。值得强调的是 — 咱们的组件函数每次渲染都会被调用,可是每一次调用中 count
值都是常量,而且它被赋予了当前渲染中的状态值。
这并非React特有的,普通的函数也有相似的行为:
function sayHi(person) { const name = person.name; setTimeout(() => { alert('Hello, ' + name); }, 3000); } let someone = {name: 'Dan'}; sayHi(someone); someone = {name: 'Yuzhi'}; sayHi(someone); someone = {name: 'Dominic'}; sayHi(someone);
在 这个例子中, 外层的 someone
会被赋值不少次(就像在React中, _当前_的组件状态会改变同样)。 而后,在 sayHi
函数中,局部常量 name
会和某次调用中的 person
关联。由于这个常量是局部的,因此每一次调用都是相互独立的。结果就是,当定时器回调触发的时候,每个alert都会弹出它拥有的 name
。
这就解释了咱们的事件处理函数如何捕获了点击时候的 count
值。若是咱们应用相同的替换原理,每一次渲染"看到"的是它本身的 count
:
function Counter() { const count = 0; function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + count); }, 3000); } } function Counter() { const count = 1; function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + count); }, 3000); } } function Counter() { const count = 2; function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + count); }, 3000); } }
因此实际上,每一次渲染都有一个"新版本"的 handleAlertClick
。每个版本的 handleAlertClick
"记住" 了它本身的 count
:
function Counter() { function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + 0); }, 3000); } <button onClick={handleAlertClick} /> } function Counter() { function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + 1); }, 3000); } <button onClick={handleAlertClick} /> } function Counter() { function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + 2); }, 3000); } <button onClick={handleAlertClick} /> }
这就是为何在这个demo中中,事件处理函数"属于"某一次特定的渲染,当你点击的时候,它会使用那次渲染中 counter
的状态值。
在任意一次渲染中,props和state是始终保持不变的。若是props和state在不一样的渲染中是相互独立的,那么使用到它们的任何值也是独立的(包括事件处理函数)。它们都"属于"一次特定的渲染。即使是事件处理中的异步函数调用"看到"的也是此次渲染中的 count
值。
备注:上面我将具体的 count
值直接内联到了 handleAlertClick
函数中。这种心智上的替换是安全的由于 count
值在某次特定渲染中不可能被改变。它被声明成了一个常量而且是一个数字。这样去思考其余类型的值好比对象也一样是安全的,固然须要在咱们都赞成应该避免直接修改state这个前提下。经过调用 setSomething(newObj)
的方式去生成一个新的对象而不是直接修改它是更好的选择,由于这样能保证以前渲染中的state不会被污染。
function Counter() { const [count, setCount] = useState(0); useEffect(() => { document.title = `You clicked ${count} times`; }); return ( <div> <p>You clicked {count} times </p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> ); }
抛一个问题给你:effect是如何读取到最新的 count
状态值的呢?
也许,是某种"data binding"或"watching"机制使得 count
可以在effect函数内更新?也或许 count
是一个可变的值,React会在咱们组件内部修改它以使咱们的effect函数总能拿到最新的值?
都不是。
咱们已经知道 count
是某个特定渲染中的常量。事件处理函数"看到"的是属于它那次特定渲染中的 count
状态值。对于effects也一样如此:
并非 count
的值在"不变"的effect中发生了改变,而是 effect 函数自己 在每一次渲染中都不相同。
每个effect版本"看到"的 count
值都来自于它属于的那次渲染:
function Counter() { useEffect( () => { document.title = `You clicked ${0} times`; } ); } function Counter() { useEffect( () => { document.title = `You clicked ${1} times`; } ); } function Counter() { useEffect( () => { document.title = `You clicked ${2} times`; } ); }
React会记住你提供的effect函数,而且会在每次更改做用于DOM并让浏览器绘制屏幕后去调用它。
因此虽然咱们说的是一个 effect(这里指更新document的title),但其实每次渲染都是一个 不一样的函数 — 而且每一个effect函数"看到"的props和state都来自于它属于的那次特定渲染。
概念上,你能够想象effects是渲染结果的一部分。
严格地说,它们并非(为了容许Hook的组合而且不引入笨拙的语法或者运行时)。可是在咱们构建的心智模型上,effect函数 _属于_某个特定的渲染,就像事件处理函数同样。
为了确保咱们已经有了扎实的理解,咱们再回顾一下第一次的渲染过程:
0
时候的UI。<span> You clicked 0 times</span>
。() => { document.title = 'You clicked 0 times' }
。() => { document.title = 'You clicked 0 times' }
。如今咱们回顾一下咱们点击以后发生了什么:
1
。1
时候的UI。<span> You clicked 1 times</span>
。() => { document.title = 'You clicked 1 times' }
。() => { document.title = 'You clicked 1 times' }
。咱们如今知道effects会在每次渲染后运行,而且概念上它是组件输出的一部分,能够"看到"属于某次特定渲染的props和state。
咱们来作一个思想实验,思考下面的代码:
function Counter() { const [count, setCount] = useState(0); useEffect(() => { setTimeout(() => { console.log(`You clicked ${count} times`); }, 3000); }); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> ); }
若是我点击了不少次而且在effect里设置了延时,打印出来的结果会是什么呢?
你可能会认为这是一个很绕的题而且结果是反直觉的。彻底错了!咱们看到的就是顺序的打印输出 — 每个都属于某次特定的渲染,所以有它该有的 count
值。你能够本身试一试:
不过,class中的 this.state
并非这样运做的。你可能会想固然觉得下面的class 实现和上面是相等的:
this.state.count
老是指向 _最新_的count值,而不是属于某次特定渲染的值。因此你会看到每次打印输出都是 5
:
我以为Hooks这么依赖Javascript闭包是挺讽刺的一件事。有时候组件的class实现方式会受闭包相关的苦(the canonical wrong-value-in-a-timeout confusion),但其实这个例子中真正的混乱来源是可变数据(React 修改了class中的 this.state
使其指向最新状态),并非闭包自己的错。
当封闭的值始终不会变的状况下闭包是很是棒的。这使它们很是容易思考由于你本质上在引用常量。正如咱们所讨论的,props和state在某个特定渲染中是不会改变的。顺便说一下,咱们能够使用闭包修复上面的class版本...
到目前为止,咱们能够明确地喊出下面重要的事实: 每个组件内的函数(包括事件处理函数,effects,定时器或者API调用等等)会捕获某次渲染中定义的props和state。
因此下面的两个例子是相等的:
function Example(props) { useEffect(() => { setTimeout(() => { console.log(props.counter); }, 1000); }); }
function Example(props) { const counter = props.counter; useEffect(() => { setTimeout(() => { console.log(counter); }, 1000); }); }
在组件内何时去读取props或者state是可有可无的。由于它们不会改变。在单次渲染的范围内,props和state始终保持不变。(解构赋值的props使得这一点更明显。)
固然,有时候你可能想在effect的回调函数里读取最新的值而不是捕获的值。最简单的实现方法是使用refs,这篇文章的最后一部分介绍了相关内容。
须要注意的是当你想要从 过去 渲染中的函数里读取 将来 的props和state,你是在逆潮而动。虽然它并无 错(有时候可能也须要这样作),但它由于打破了默认范式会使代码显得不够"干净"。这是咱们有意为之的,由于它能帮助突出哪些代码是脆弱的,是须要依赖时间次序的。在class中,若是发生这种状况就没那么显而易见了。
下面这个计数器版本 模拟了class中的行为:
function Example() { const [count, setCount] = useState(0); const latestCount = useRef(count); useEffect(() => { latestCount.current = count; setTimeout(() => { console.log(`You clicked ${latestCount.current} times`); }, 3000); }); }
在React中去直接修改值看上去有点怪异。然而,在class组件中React正是这样去修改 this.state
的。不像捕获的props和state,你无法保证在任意一个回调函数中读取的 latestCount.current
是不变的。根据定义,你能够随时修改它。这就是为何它不是默认行为,而是须要你主动选择这样作。
像 文档中解释的, 有些 effects 可能须要有一个清理步骤。本质上,它的目的是消除反作用(effect),好比取消订阅。
思考下面的代码:
useEffect(() => { ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange); return () => { ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange); }; });
假设第一次渲染的时候 props
是 {id: 10}
,第二次渲染的时候是 {id: 20}
。
React只会在浏览器绘制后运行effects。这使得你的应用更流畅由于大多数effects并不会阻塞屏幕的更新。Effect的清除一样被延迟了。 上一次的effect会在从新渲染后被清除:
{id: 20}
的UI。{id: 20}
的UI。{id: 10}
的effect。{id: 20}
的effect。你可能会好奇:若是清除上一次的effect发生在props变成 {id: 20}
以后,那它为何还能"看到"旧的 {id: 10}
?
引用上半部分获得的结论:
组件内的每个函数(包括事件处理函数,effects,定时器或者API调用等等)会捕获定义它们的那次渲染中的props和state。
如今答案显而易见。effect的清除并不会读取"最新"的props。它只能读取到定义它的那次渲染中的props值:
我最喜欢React的一点是它统一描述了初始渲染和以后的更新。这下降了你程序的熵。
好比我有个组件像下面这样:
function Greeting({ name }) { return ( <h1 className="Greeting"> Hello, {name} </h1> ); }
我先渲染 <greeting name="Dan"></greeting>
而后渲染 <greeting name="Yuzhi"></greeting>
,和我直接渲染 <greeting name="Yuzhi"></greeting>
并无什么区别。在这两种状况中,我最后看到的都是"Hello, Yuzhi"。
人们老是说:"重要的是旅行过程,而不是目的地"。在React世界中,刚好相反。 重要的是目的,而不是过程。这就是JQuery代码中 $.addClass
或 $.removeClass
这样的调用(过程)和React代码中声明CSS类名 应该是什么(目的)之间的区别。
React会根据咱们当前的props和state同步到DOM。"mount"和"update"之于渲染并无什么区别。
你应该以相同的方式去思考effects。 ** useEffect
使你可以根据props和state 同步 React tree以外的东西。**
function Greeting({ name }) { useEffect(() => { document.title = 'Hello, ' + name; }); return ( <h1 className="Greeting"> Hello, {name} </h1> ); }
这就是和你们熟知的 _mount/update/unmount_心智模型之间细微的区别。理解和内化这种区别是很是重要的。 若是你试图写一个effect会根据是否第一次渲染而表现不一致,你正在逆潮而动。若是咱们的结果依赖于过程而不是目的,咱们会在同步中犯错。
先渲染属性A,B再渲染C,和当即渲染C并无什么区别。虽然他们可能短暂地会有点不一样(好比请求数据时),但最终的结果是同样的。
不过话说回来,在 _每一次_渲染后都去运行全部的effects可能并不高效。(而且在某些场景下,它可能会致使无限循环。)
因此咱们该怎么解决这个问题?
其实咱们已经从React处理DOM的方式中学习到了解决办法。React只会更新DOM真正发生改变的部分,而不是每次渲染都大动干戈。
当你把
<h1 className="Greeting"> Hello, Dan </h1>
更新到
<h1 className="Greeting"> Hello, Yuzhi </h1>
React 可以看到两个对象:
const oldProps = {className: 'Greeting', children: 'Hello, Dan'}; const newProps = {className: 'Greeting', children: 'Hello, Yuzhi'};
它会检测每个props,而且发现 children
发生改变须要更新DOM,但 className
并无。因此它只须要这样作:
domNode.innerText = 'Hello, Yuzhi';
咱们也能够用相似的方式处理effects吗?若是可以在不须要的时候避免调用effect就太好了。
举个例子,咱们的组件可能由于状态变动而从新渲染:
function Greeting({ name }) { const [counter, setCounter] = useState(0); useEffect(() => { document.title = 'Hello, ' + name; }); return ( <h1 className="Greeting"> Hello, {name} <button onClick={() => setCounter(counter + 1)}>Increment</button> </h1> ); }
可是咱们的effect并无使用 counter
这个状态。 咱们的effect只会同步 name
属性给 document.title
,但 name
并无变。在每一次counter改变后从新给 document.title
赋值并非理想的作法。
好了,那React能够...区分effects的不一样吗?
let oldEffect = () => { document.title = 'Hello, Dan'; }; let newEffect = () => { document.title = 'Hello, Dan'; };
并不能。React并不能猜想到函数作了什么若是不先调用的话。(源码中并无包含特殊的值,它仅仅是引用了 name
属性。)
这是为何你若是想要避免effects没必要要的重复调用,你能够提供给 useEffect
一个依赖数组参数(deps):
useEffect(() => { document.title = 'Hello, ' + name; }, [name]);
这比如你告诉React:"Hey,我知道你看不到这个函数里的东西,但我能够保证只使用了渲染中的 name
,别无其余。"
若是当前渲染中的这些依赖项和上一次运行这个effect的时候值同样,由于没有什么须要同步React会自动跳过此次effect:
const oldEffect = () => { document.title = 'Hello, Dan'; }; const oldDeps = ['Dan']; const newEffect = () => { document.title = 'Hello, Dan'; }; const newDeps = ['Dan'];
即便依赖数组中只有一个值在两次渲染中不同,咱们也不能跳过effect的运行。要同步全部!
关于依赖项对React撒谎会有很差的结果。直觉上,这很好理解,但我曾看到几乎全部依赖class心智模型使用 useEffect
的人都试图违反这个规则。(我刚开始也这么干了!)
function SearchResults() { async function fetchData() { } useEffect(() => { fetchData(); }, []); }
(官网的Hooks FAQ 解释了应该怎么作。 咱们在下面 会从新回顾这个例子。)
"但我只是想在挂载的时候运行它!",你可能会说。如今只须要记住:若是你设置了依赖项, effect中用到的全部组件内的值都要包含在依赖中。这包括props,state,函数 — 组件内的任何东西。
有时候你是这样作了,但可能会引发一个问题。好比,你可能会遇到无限请求的问题,或者socket被频繁建立的问题。 解决问题的方法不是移除依赖项。咱们会很快了解具体的解决方案。
不过在咱们深刻解决方案以前,咱们先尝试更好地理解问题。
若是依赖项包含了全部effect中使用到的值,React就能知道什么时候须要运行它:
useEffect(() => { document.title = 'Hello, ' + name; }, [name]);
(依赖发生了变动,因此会从新运行effect。)
可是若是咱们将 []
设为effect的依赖,新的effect函数不会运行:
useEffect(() => { document.title = 'Hello, ' + name; }, []);
(依赖没有变,因此不会再次运行effect。)
在这个例子中,问题看起来显而易见。但在某些状况下若是你脑子里"跳出"class组件的解决办法,你的直觉极可能会欺骗你。
举个例子,咱们来写一个每秒递增的计数器。在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)
:
function Counter() { useEffect( () => { const id = setInterval(() => { setCount(0 + 1); }, 1000); return () => clearInterval(id); }, [] ); } function Counter() { useEffect( () => { 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
值:
function Counter() { useEffect( () => { const id = setInterval(() => { setCount(0 + 1); }, 1000); return () => clearInterval(id); }, [0] ); } function Counter() { useEffect( () => { const id = setInterval(() => { setCount(1 + 1); }, 1000); return () => clearInterval(id); }, [1] ); }
这能解决问题可是咱们的定时器会在每一次 count
改变后清除和从新设定。这应该不是咱们想要的结果:
(依赖发生了变动,因此会从新运行effect。)
第二种策略是修改effect内部的代码以确保它包含的值只会在须要的时候发生变动。咱们不想告知错误的依赖 - 咱们只是修改effect使得依赖更少。
让咱们来看一些移除依赖的经常使用技巧。
咱们想去掉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已经知道了。
还记得咱们说过同步才是理解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
。
咱们来修改上面的例子让它包含两个状态: 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 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(); } }
const [state, dispatch] = useReducer(reducer, initialState); const { count, step } = state; useEffect(() => { const id = setInterval(() => { dispatch({ type: 'tick' }); }, 1000); return () => clearInterval(id); }, [dispatch]);
(查看 demo。)
你可能会问:"这怎么就更好了?"答案是 React会保证 dispatch
在组件的声明周期内保持不变。因此上面例子中再也不须要从新订阅定时器。
咱们解决了问题!
(你能够从依赖中去除 dispatch
, setState
, 和 useRef
包裹的值由于React会确保它们是静态的。不过你设置了它们做为依赖也没什么问题。)
相比于直接在effect里面读取状态,它dispatch了一个 _action_来描述发生了什么。这使得咱们的effect和 step
状态解耦。咱们的effect再也不关心怎么更新状态,它只负责告诉咱们发生了什么。更新的逻辑全都交由reducer去统一处理:
(这里是demo 若是你以前错过了。)
咱们已经学习到如何移除effect的依赖,无论状态更新是依赖上一个状态仍是依赖另外一个状态。 但假如咱们须要依赖 props 去计算下一个状态呢?举个例子,也许咱们的API是 <counter step="{1}"></counter>
。肯定的是,在这种状况下,咱们无法避免依赖 props.step
。是吗?
实际上, 咱们能够避免!咱们能够把 _reducer_函数放到组件内去读取props:
function Counter({ step }) { const [count, dispatch] = useReducer(reducer, 0); function reducer(state, action) { if (action.type === 'tick') { return state + step; } else { throw new Error(); } } useEffect(() => { const id = setInterval(() => { dispatch({ type: 'tick' }); }, 1000); return () => clearInterval(id); }, [dispatch]); return <h1>{count}</h1>; }
这种模式会使一些优化失效,因此你应该避免滥用它,不过若是你须要你彻底能够在reducer里面访问props。(这里是demo。)
即便是在这个例子中,React也保证 dispatch
在每次渲染中都是同样的。 因此你能够在依赖中去掉它。它不会引发effect没必要要的重复执行。
你可能会疑惑:这怎么可能?在以前渲染中调用的reducer怎么"知道"新的props?答案是当你 dispatch
的时候,React只是记住了action - 它会在下一次渲染中再次调用reducer。在那个时候,新的props就能够被访问到,并且reducer调用也不是在effect里。
这就是为何我倾向认为 useReducer
是Hooks的"做弊模式"。它能够把更新逻辑和描述发生了什么分开。结果是,这能够帮助我移除没必要需的依赖,避免没必要要的effect调用。
一个典型的误解是认为函数不该该成为依赖。举个例子,下面的代码看上去能够运行正常:
function SearchResults() { const [data, setData] = useState({ hits: [] }); async function fetchData() { const result = await axios( 'https://hn.algolia.com/api/v1/search?query=react', ); setData(result.data); } useEffect(() => { fetchData(); }, []);
(这个例子 改编自Robin Wieruch这篇很棒的文章 —点击查看 !)
须要明确的是,上面的代码能够正常工做。 但这样作在组件日渐复杂的迭代过程当中咱们很难确保它在各类状况下还能正常运行。
想象一下咱们的代码作下面这样的分离,而且每个函数的体量是如今的五倍,而后咱们在某些函数内使用了某些state或者prop:
function SearchResults() { const [query, setQuery] = useState('react'); function getFetchUrl() { return 'https://hn.algolia.com/api/v1/search?query=' + query; } async function fetchData() { const result = await axios(getFetchUrl()); setData(result.data); } useEffect(() => { fetchData(); }, []); }
若是咱们忘记去更新使用这些函数(极可能经过其余函数调用)的effects的依赖,咱们的effects就不会同步props和state带来的变动。这固然不是咱们想要的。
幸运的是,对于这个问题有一个简单的解决方案。 若是某些函数仅在effect中调用,你能够把它们的定义移到effect中:
function SearchResults() { useEffect(() => { function getFetchUrl() { return 'https://hn.algolia.com/api/v1/search?query=react'; } async function fetchData() const result = await axios(getFetchUrl()); setData(result.data); } fetchData(); }, []); }
(这里是demo.)
这么作有什么好处呢?咱们再也不须要去考虑这些"间接依赖"。咱们的依赖数组也再也不撒谎: 在咱们的effect中确实没有再使用组件范围内的任何东西。
若是咱们后面修改 getFetchUrl
去使用 query
状态,咱们更可能会意识到咱们正在effect里面编辑它 - 所以,咱们须要把 query
添加到effect的依赖里:
function SearchResults() { const [query, setQuery] = useState('react'); useEffect(() => { function getFetchUrl() { return 'https://hn.algolia.com/api/v1/search?query=' + query; } async function fetchData() { const result = await axios(getFetchUrl()); setData(result.data); } fetchData(); }, [query]); }
(这里是demo.)
添加这个依赖,咱们不只仅是在"取悦React"。在query改变后去从新请求数据是合理的。 useEffect
的设计意图就是要强迫你关注数据流的改变,而后决定咱们的effects该如何和它同步 - 而不是忽视它直到咱们的用户遇到了bug。
感谢 eslint-plugin-react-hooks
插件的 exhaustive-deps
lint规则,它会在你编码的时候就分析effects而且提供可能遗漏依赖的建议。换句话说,机器会告诉你组件中哪些数据流变动没有被正确地处理。
很是棒。
有时候你可能不想把函数移入effect里。好比,组件内有几个effect使用了相同的函数,你不想在每一个effect里复制黏贴一遍这个逻辑。也或许这个函数是一个prop。
在这种状况下你应该忽略对函数的依赖吗?我不这么认为。再次强调, effects不该该对它的依赖撒谎。一般咱们还有更好的解决办法。一个常见的误解是,"函数历来不会改变"。可是这篇文章你读到如今,你知道这显然不是事实。实际上,在组件内定义的函数每一次渲染都在变。
函数每次渲染都会改变这个事实自己就是个问题。 好比有两个effects会调用 getFetchUrl
:
function SearchResults() { function getFetchUrl(query) { return 'https://hn.algolia.com/api/v1/search?query=' + query; } useEffect(() => { const url = getFetchUrl('react'); }, []); useEffect(() => { const url = getFetchUrl('redux'); }, []); }
在这个例子中,你可能不想把 getFetchUrl
移到effects中,由于你想复用逻辑。
另外一方面,若是你对依赖很"诚实",你可能会掉到陷阱里。咱们的两个effects都依赖 getFetchUrl
, 而它每次渲染都不一样,因此咱们的依赖数组会变得无用:
function SearchResults() { function getFetchUrl(query) { return 'https://hn.algolia.com/api/v1/search?query=' + query; } useEffect(() => { const url = getFetchUrl('react'); }, [getFetchUrl]); useEffect(() => { const url = getFetchUrl('redux'); }, [getFetchUrl]); }
一个可能的解决办法是把 getFetchUrl
从依赖中去掉。可是,我不认为这是好的解决方式。这会使咱们后面对数据流的改变很难被发现从而忘记去处理。这会致使相似于上面"定时器不更新值"的问题。
相反的,咱们有两个更简单的解决办法。
第一个, 若是一个函数没有使用组件内的任何值,你应该把它提到组件外面去定义,而后就能够自由地在effects中使用:
function getFetchUrl(query) { return 'https://hn.algolia.com/api/v1/search?query=' + query; } function SearchResults() { useEffect(() => { const url = getFetchUrl('react'); }, []); useEffect(() => { const url = getFetchUrl('redux'); }, []); }
你再也不须要把它设为依赖,由于它们不在渲染范围内,所以不会被数据流影响。它不可能忽然意外地依赖于props或state。
或者, 你也能够把它包装成 useCallback
Hook:
function SearchResults() { const getFetchUrl = useCallback((query) => { return 'https://hn.algolia.com/api/v1/search?query=' + query; }, []); useEffect(() => { const url = getFetchUrl('react'); }, [getFetchUrl]); useEffect(() => { const url = getFetchUrl('redux'); }, [getFetchUrl]); }
useCallback
本质上是添加了一层依赖检查。它以另外一种方式解决了问题 - 咱们使函数自己只在须要的时候才改变,而不是去掉对函数的依赖。
咱们来看看为何这种方式是有用的。以前,咱们的例子中展现了两种搜索结果(查询条件分别为 'react'
和 'redux'
)。但若是咱们想添加一个输入框容许你输入任意的查询条件(query
)。不一样于传递 query
参数的方式,如今 getFetchUrl
会从状态中读取。
咱们很快发现它遗漏了 query
依赖:
function SearchResults() { const [query, setQuery] = useState('react'); const getFetchUrl = useCallback(() => { return 'https://hn.algolia.com/api/v1/search?query=' + query; }, []); }
若是我把 query
添加到 useCallback
的依赖中,任何调用了 getFetchUrl
的effect在 query
改变后都会从新运行:
function SearchResults() { const [query, setQuery] = useState('react'); const getFetchUrl = useCallback(() => { return 'https://hn.algolia.com/api/v1/search?query=' + query; }, [query]); useEffect(() => { const url = getFetchUrl(); }, [getFetchUrl]); }
咱们要感谢 useCallback
,由于若是 query
保持不变, getFetchUrl
也会保持不变,咱们的effect也不会从新运行。可是若是 query
修改了, getFetchUrl
也会随之改变,所以会从新请求数据。这就像你在Excel里修改了一个单元格的值,另外一个使用它的单元格会自动从新计算同样。
这正是拥抱数据流和同步思惟的结果。 对于经过属性从父组件传入的函数这个方法也适用:
function Parent() { const [query, setQuery] = useState('react'); const fetchData = useCallback(() => { const url = 'https://hn.algolia.com/api/v1/search?query=' + query; }, [query]); return <Child fetchData={fetchData} /> } function Child({ fetchData }) { let [data, setData] = useState(null); useEffect(() => { fetchData().then(setData); }, [fetchData]); }
由于 fetchData
只有在 Parent
的 query
状态变动时才会改变,因此咱们的 Child
只会在须要的时候才去从新请求数据。
有趣的是,这种模式在class组件中行不通,而且这种行不通恰到好处地揭示了effect和生命周期范式之间的区别。考虑下面的转换:
class Parent extends Component { state = { query: 'react' }; fetchData = () => { const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query; }; render() { return <Child fetchData={this.fetchData} />; } } class Child extends Component { state = { data: null }; componentDidMount() { this.props.fetchData(); } render() { } }
你可能会想:"少来了Dan,咱们都知道 useEffect
就像 componentDidMount
和 componentDidUpdate
的结合,你不能总是破坏这一条!" 好吧,就算加了 componentDidUpdate
照样无用:
class Child extends Component { state = { data: null }; componentDidMount() { this.props.fetchData(); } componentDidUpdate(prevProps) { if (this.props.fetchData !== prevProps.fetchData) { this.props.fetchData(); } } render() { } }
固然如此, fetchData
是一个class方法!(或者你也能够说是class属性 - 但这不能改变什么。)它不会由于状态的改变而不一样,因此 this.props.fetchData
和 prevProps.fetchData
始终相等,所以不会从新请求。那咱们删掉条件判断怎么样?
componentDidUpdate(prevProps) { this.props.fetchData(); }
等等,这样会在每次渲染后都去请求。(添加一个加载动画多是一种有趣的发现这种状况的方式。)也许咱们能够绑定一个特定的query?
render() { return <Child fetchData={this.fetchData.bind(this, this.state.query)} />; }
但这样一来, this.props.fetchData !== prevProps.fetchData
表达式永远是 true
,即便 query
并未改变。这会致使咱们老是去请求。
想要解决这个class组件中的难题,惟一现实可行的办法是硬着头皮把 query
自己传入 Child
组件。 Child
虽然实际并无直接 _使用_这个 query
的值,但能在它改变的时候触发一次从新请求:
class Parent extends Component { state = { query: 'react' }; fetchData = () => { const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query; }; render() { return <Child fetchData={this.fetchData} query={this.state.query} />; } } class Child extends Component { state = { data: null }; componentDidMount() { this.props.fetchData(); } componentDidUpdate(prevProps) { if (this.props.query !== prevProps.query) { this.props.fetchData(); } } render() { } }
在使用React的class组件这么多年后,我已经如此习惯于把没必要要的props传递下去而且破坏父组件的封装以致于我在一周以前才意识到我为何必定要这样作。
在class组件中,函数属性自己并非数据流的一部分。组件的方法中包含了可变的 this
变量致使咱们不能肯定无疑地认为它是不变的。所以,即便咱们只须要一个函数,咱们也必须把一堆数据传递下去仅仅是为了作"diff"。咱们没法知道传入的 this.props.fetchData
是否依赖状态,而且不知道它依赖的状态是否改变了。
使用 useCallback
,函数彻底能够参与到数据流中。咱们能够说若是一个函数的输入改变了,这个函数就改变了。若是没有,函数也不会改变。感谢周到的 useCallback
,属性好比 props.fetchData
的改变也会自动传递下去。
相似的,useMemo
可让咱们对复杂对象作相似的事情。
function ColorPicker() { const [color, setColor] = useState('pink'); const style = useMemo(() => ({ color }), [color]); return <Child style={style} />; }
我想强调的是,处处使用 useCallback
是件挺笨拙的事。当咱们须要将函数传递下去而且函数会在子组件的effect中被调用的时候, useCallback
是很好的技巧且很是有用。或者你想试图减小对子组件的记忆负担,也不妨一试。但总的来讲Hooks自己能更好地避免传递回调函数。
在上面的例子中,我更倾向于把 fetchData
放在个人effect里(它能够抽离成一个自定义Hook)或者是从顶层引入。我想让effects保持简单,而在里面调用回调会让事情变得复杂。("若是某个 props.onComplete
回调改变了而请求还在进行中会怎么样?")你能够模拟class的行为但那样并不能解决竞态的问题。
下面是一个典型的在class组件里发请求的例子:
class Article extends Component { state = { article: null }; componentDidMount() { this.fetchData(this.props.id); } async fetchData(id) { const article = await API.fetchArticle(id); this.setState({ article }); } }
你极可能已经知道,上面的代码埋伏了一些问题。它并无处理更新的状况。因此第二个你可以在网上找到的经典例子是下面这样的:
class Article extends Component { state = { article: null }; componentDidMount() { this.fetchData(this.props.id); } componentDidUpdate(prevProps) { if (prevProps.id !== this.props.id) { this.fetchData(this.props.id); } } async fetchData(id) { const article = await API.fetchArticle(id); this.setState({ article }); } }
这显然好多了!但依旧有问题。有问题的缘由是请求结果返回的顺序不能保证一致。好比我先请求 {id: 10}
,而后更新到 {id: 20}
,但 {id: 20}
的请求更先返回。请求更早但返回更晚的状况会错误地覆盖状态值。
这被叫作竞态,这在混合了 async
/ await
(假设在等待结果返回)和自顶向下数据流的代码中很是典型(props和state可能会在async函数调用过程当中发生改变)。
Effects并无神奇地解决这个问题,尽管它会警告你若是你直接传了一个 async
函数给effect。(咱们会改善这个警告来更好地解释你可能会遇到的这些问题。)
若是你使用的异步方式支持取消,那太棒了。你能够直接在清除函数中取消异步请求。
或者,最简单的权宜之计是用一个布尔值来跟踪它:
function Article({ id }) { const [article, setArticle] = useState(null); useEffect(() => { let didCancel = false; async function fetchData() { const article = await API.fetchArticle(id); if (!didCancel) { setArticle(article); } } fetchData(); return () => { didCancel = true; }; }, [id]); }
这篇文章讨论了更多关于如何处理错误和加载状态,以及抽离逻辑到自定义的Hook。我推荐你认真阅读一下若是你想学习更多关于如何在Hooks里请求数据的内容。
在class组件生命周期的思惟模型中,反作用的行为和渲染输出是不一样的。UI渲染是被props和state驱动的,而且能确保步调一致,但反作用并非这样。这是一类常见问题的来源。
而在 useEffect
的思惟模型中,默认都是同步的。反作用变成了React数据流的一部分。对于每个 useEffect
调用,一旦你处理正确,你的组件可以更好地处理边缘状况。
然而,用好 useEffect
的前期学习成本更高。这可能让人气恼。用同步的代码去处理边缘状况自然就比触发一次不用和渲染结果步调一致的反作用更难。
这不免让人担心若是 useEffect
是你如今使用最多的工具。不过,目前大抵还处理低水平使用阶段。由于Hooks太新了因此你们都还在低水平地使用它,尤为是在一些教程示例中。但在实践中,社区极可能即将开始高水平地使用Hooks,由于好的API会有更好的动量和冲劲。
我看到不一样的应用在创造他们本身的Hooks,好比封装了应用鉴权逻辑的 useFetch
或者使用theme context的 useTheme
。你一旦有了包含这些的工具箱,你就不会那么频繁地直接使用 useEffect
。但每个基于它的Hook都能从它的适应能力中获得益处。
目前为止, useEffect
主要用于数据请求。可是数据请求准确说并非一个同步问题。由于咱们的依赖常常是 []
因此这一点尤为明显。那咱们究竟在同步什么?
长远来看, Suspense用于数据请求 会容许第三方库经过第一等的途径告诉React暂停渲染直到某些异步事物(任何东西:代码,数据,图片)已经准备就绪。
当Suspense逐渐地覆盖到更多的数据请求使用场景,我预料 useEffect
会退居幕后做为一个强大的工具,用于同步props和state到某些反作用。不像数据请求,它能够很好地处理这些场景由于它就是为此而设计的。不过在那以前,自定义的Hooks好比这儿提到的是复用数据请求逻辑很好的方式。
如今你差很少知道了我关于如何使用effects的全部知识,能够检查一下开头的TLDR。你如今以为它说得有道理吗?我有遗漏什么吗?(个人纸尚未写完!)
译者写了一个 React + Hooks 的 UI 库,方便你们学习和使用,
欢迎关注公众号「前端进阶课」认真学前端,一块儿进阶。