本文假设你具有以下知识或者使用经验:
- React >= 16.9
- React Class Component
- React Functional Component
- React Hooks, 主要是 useState/useEffect/useRef
我是东墨, 如需新的工作机会, 请联系我 👉 dongmo.cl#alibaba-inc.com
.useRef vs .useState
在这篇文章 中, 我们提到了 .useRef
是 React Hooks 的作弊器(就是《魂斗罗》里按“↑↑↓↓←→←→BA”加 30 条命这类作弊码): 它像 Hooks
.useState
返回的 [state, updater]
不太一样, 两者对比const ref = useRef(initVal) | const [state, updater] = useState(initVal) | |
---|---|---|
get | ref.current | state |
set | ref.current = newVal | updater(newVal) |
set 是否会引起 React Fiber 调度 | ❌ | ✅ |
set/get 是否符合直觉 | ✅ | ❌ |
当可以在你在函数式组件中想按你永远直觉预期获取到状态最新值的时, 就用 .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 (
<div>
Count: {count}
<br />
setTimeout Count: {countInTimeout}
</div>
)
}
在 这篇文章 文章中, 未提及 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 (
<div>
Count: {count}
<br />
setTimeout Count: {countInTimeout}
</div>
)
}
先不考虑 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)
return () => {
clearTimeout(timerId)
}
}, deps)
}
我们来试试这个 useTimeout
能否满足我们在 这篇文章 末尾提出的要求, 查看 Live Demo, 启动后等 3s, 看看视图是否按预期更新了 :)