看到赚到!React Hooks 奇技淫巧 —— 副作用, 闭包 与 Timer
本文假设你具有以下知识或者使用经验:
- React >= 16.9
- React Class Component
- React Functional Component
- React Hooks, 主要是 useState/useEffect/useRef
我是 东墨 , 如需新的工作机会, 请联系我 richardo2016#gmail.com
.useRef
vs
.useState
在
这篇文章
中, 我们提到了
.useRef
是 React Hooks 的作弊器(就是《魂斗罗》里按“↑↑↓↓←→←→BA”加 30 条命这类作弊码): 它像 Hooks API 一样在 React Functional Component 的多轮渲染中可以保存一个值, 并严格按照你 set/get 的顺序来存取值. 这和
.useState
返回的
[state, updater]
不太一样, 两者对比
当可以在你
在函数式组件
中想
按你永远直觉预期获取到状态最新值
的时, 就用
.useRef
.
依赖列表(Deps)
对于 Hooks 而言, 其依赖是开发者必须考虑的一个特点, 如果忽略它或者错误地理解它, 可能会给组件、应用带来毁灭性的副作用 —— 比如, 无限循环的 effect.
下面这个组件一旦被引用到组件中, 就会不停地发送 getJSON 请求, 让应用直接崩溃.
function InfiniteRequestHookComponent () {
React.useEffect(() => {
getJSON(...)
}
我们需要给 useEffect 一个依赖列表 —— 起码是一个空数组.
function SafeHookComponent () {
React.useEffect(() => {
getJSON(...)
}, []) // add one deps list
}
对这个 依赖列表(deps) 的理解是如此重要, 其重要程度不亚于你必须理解 React Class Component 里的这些规则:
- state 只能在 constructor 中初始化
- props 是不可变对象
- componentDidMount 对在一个组件的 Lifecycle 中只会被调用一次
要想使用 React Hook 写出稳定可靠的组件, 必须好好理解 Hooks 依赖列表(下文统称为 deps), 然后处理这些场景
deps 什么时候为
[]
?
也许你已经在别处看到了这样的介绍: 当把一个 React Class Component 改造为 React Function Component 时, 可以将
componentDidMount
中的数据请求逻辑放在
React.useEffect(callback, [])
的 callback 中, 像这样:
// class component
class FooClassComponent extends React.Component {
componentDidMount() {
asyncRequest()
.then((result) => {
// deal with your result
// function component
function FooFunctionalComponent () {
React.useEffect(() => {
asyncRequest()
.then((result) => {
// deal with your result
}, [])
}
若
React.useEffect
的 deps 列表为空数组, 则意味着其中的业务逻辑(Effect)在
FooFunctionalComponent
只会执行一次(在组件第一次 render 的时候), 其后, 不管
FooFunctionalComponent
re-render 多少次, 其中的业务逻辑(Effect)都不会再被执行 —— 因为 deps 为空, 则 Effect 不因任何外部因素而重执行.
这机制就很类似于
componentDidMount
在整个
FooClassComponent
生命周期中的表现: 只在组件完成渲染的第一次执行, 其后无论
FooClassComponent
进行多少次 re-render,
componentDidMount
都不再执行.
这里我们特意强调,
componentDidMount
不
等价于
React.useEffect(callback, [])
, 因为二者所处的调度机制并不相同, 只是二者能起到类似的作用. 这一点一定要记清: Functional Component Hooks 的执行机制, 和 Class Component Lifecycle 的执行机制, 是
两回事
.
如果 deps 不为空会如何?
有这样的场景: 当用户在
<input />
中输入的时候, 我们希望能随着用户的输入实时做一些异步的动作, 比如:
- 实时校验
- 远程搜索
- ...
以远程搜索为例, 这类动作用 Hooks 可以描述如下:
function SearchComponent () {
const [ keyword, setKeyword ] = React.useState('');
// hook1
React.useEffect(() => {
// callback: do some search action against keyword
searchByKeyword(keyword)
.then(result => {
// process search result
}, [ keyword ])
return (
<input
value={keyword}
onChange={(evt) => {
setKeyword(evt.target.value || '')
}
这里有一个 hook1(
.useEffect
), 其 deps 为
[ keyword ]
—— 意味着 keyword 发生变化的时候,
.useEffect(callback, deps)
的 callback 会再执行一次; 当用户输入时, 触发
input[onChange]
, 其中
setKeyword
不仅
会引起
keyword
更新,
还
会引起组件的重新渲染.
使用作弊器
.useRef
如上文所说,
.useRef
是提供了一个保存值的容器, 并
允许你能严格按顺序读取它
.
比如
const sthRef = useRef(null)
sthRef.current = 1;
setTimout(() => {
sthRef.current = 3;
}, 3000);
setTimeout(() => {
sthRef.current = 9;
}, 9000);
sthRef.current
的初始值为
null
, 而后立刻被更新为
1
, 3s 后变成
3
, 9s 后变成
9
.
和
.useState
不同, 更新
sthRef.current
不会引起 Functional Comopnent 的 re-render.
一步步实现一个
useTimeout
在
这篇文章
结尾, 我们留了一个问题, 如何提供一个合适的
useTimeout
, 克服闭包问题, 使得 3s 后, 在
useTimeout
中 count 为最新的值
5
(因为它在
useEffect
中被更新了).
const TimeoutExample = () => {
const [count, setCount] = React.useState(0)
const [countInTimeout, setCountInTimeout] = React.useState(0)
React.useEffect(() => {
setTimeout(() => {
// count at next line equals to `0` :( due to closure issue.
// can we provide one useful `useTimeout` update whole callback of `setTimeout`?
setCountInTimeout(count)
}, 3000)
setCount(5)
}, [])
return (
Count: {count}
setTimeout Count: {countInTimeout}
}
在
这篇文章
文章中, 未提及
useTimeout
的时候, 我们使用
countRef
来保存了
count
的值解决了
Hooks 的闭包陷阱问题
, 但这样太不通用了, 下次遇到类似的值又要新建一个
xxxRef
来保存么? 既然在 Hooks 和
setTimeout(callback, 3000)
结合使用的时候,
callback
的闭包导致我们无法取
count
最新值的问题, 那我们尝试更新闭包行不行? 基于这种想法, 我们提出了
useTimeout
, 希望可以直接更新整个
setTimeout(callback, 3000)
的
callback
, 如果真的可以实现, 那么最终的写法类似下面:
const TimeoutExample = () => {
const [count, setCount] = React.useState(0)
const [countInTimeout, setCountInTimeout] = React.useState(0)
useTimeout(() => {
setCountInTimeout(count)
}, 3000, [ count ])
useEffect(() => {
setCount(5)
}, [])
return (
Count: {count}
setTimeout Count: {countInTimeout}
}
先不考虑 deps, 我们只考虑把先要把
setTimeout
的两个参数
cb
和
timeout
存下来, 并且我们希望在合适的时候调用
setTimeout
来启动 timer, 启动 timer 是一个副作用, 我们放在
.useEffect()
里:
function useTimeout (cb, timeout) {
const [callback, setCallback] = React.useState(cb)
React.useEffect(() => {
setTimeout(callback, timeout)
}, [])
}
不过, 如果使用
.useState
来存 cb 的话, 每次
setCallback
时, 引用
.useTimeout()
组件也会被更新 —— 根据我们的目的"在 count 变化的时候更新整个闭包", 显然我们是要更新 callback 的, 但由此引起的视图更新似乎就不是很有必要了, 我们用一下作弊器, 改用
.useRef
来保存 cb:
function useTimeout (cb, timeout) {
const callbackRef = React.useRef(cb)
React.useEffect(() => {
setTimeout(callback, timeout)
}, [])
}
现在我们还没有体现"更新
callback
"这件事, 回顾下我们的目的: 当 count 变化的时候, 我们保存下来的 callback 也要能变. 所以我们把
count
进来, 放在
.useEffect
的 deps 中, 并且在
.useEffect
中更新
callbackRef.current
:
function useTimeout (cb, timeout, count) {
const callbackRef = React.useRef(cb)
React.useEffect(() => {
// update it if count updated
callbackRef.current = cb;
setTimeout(callback, timeout)
// count as item of deps
}, [count])
}
不过, 直接传
count
只适应于这个场景, 换了个场景别人可能希望传别的, 不妨把第 3 个参数直接设计成 deps, 用户爱传什么传什么:
// user should put `count` in deps
function useTimeout (cb, timeout, deps = []) {
const callbackRef = React.useRef(cb)
React.useEffect(() => {
// update it if count updated
callbackRef.current = cb;
setTimeout(callback, timeout)
}, deps)
// user should put `count` in deps
useTimeout(cb, 3000, [ count ])
这里还有个问题, 每次 deps 中有更新时,
.useEffect(effect, deps)
的
effect
会再执行一次, 但这个 effect 中有一个
setTimeout
. 我们都知道,
const timerId = setTimeout(...)
启动的 timer 直到被
clearTimeout(timerId)
主动取消或者执行完了才会从事件队列里面移出, 当
count
发生变化导致
.useEffect(effect, deps)
的
effect
再执行的时候, 我们尚未取消上一个
setTimeout
产生的 timer, 就又产生了一个新的
timer = setTimeout
.
这显然是不可取的: 在这里场景中, 如果我们都要更新
callbackRef.current
了, 那之前未执行的 timer 应该要被取消(当然已经执行完的就算了), 我们来手动做一下这件事:
function useTimeout (cb, timeout, deps = []) {
const callbackRef = React.useRef(cb)
const timerRef = React.useRef(null)
React.useEffect(() => {
callbackRef.current = cb;
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = setTimeout(callback, timeout)
}, deps)
}
如上, 我们又用了一次作弊器, 这里为什么我们不使用一个
let timer = null
来保存之前执行的 timer 呢? 相信聪明的你想一下就能明白.
不过, 我们 duck 不必自己保存之前的 timer.
React.useEffect(effect, deps)
允许 effect 中返回一个 dispose 函数, 如果开发者确实返回了这个 dispose 函数, 则当 Functional Componnet 下一次运行(re-render)到这个
React.useEffect(effect, deps)
时, 会调用上一次返回的 dispose 函数, 像这样:
React.useEffect(() => {
// some side effect here
return () => { // dispose function
// clear some side effect here
}, deps)
所以对于
useTimeout
而言, 如果我们想在
每次更新 cb 时
消除
上一次 effect
中
setTimeout
产生的 timer, 我们也可以这样写:
function useTimeout (cb, timeout, deps = []) {
const callbackRef = React.useRef(cb)
React.useEffect(() => {
callbackRef.current = cb;
const timerId = setTimeout(cb, timeout)
return () => {
clearTimeout(timerId)
}, deps)
}
这里我们反而利用了 dispose 的闭包特性, 简洁而准确地消除了上一次 effect 的副作用.
这样就完了么? 逻辑上是已经理顺了了, 不过我们还可以增加一点点细节, 提高
useTimeout
的健壮性:
function useTimeout (cb, timeout, deps = []) {
const callbackRef = React.useRef(cb)
React.useEffect(() => {
if (timeout < 0 || typeof callbackRef.current !== 'function')
return;
callbackRef.current = cb;
const timerId = setTimeout(cb, timeout)