前言
本文假设你具有以下知识或者使用经验:
- 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]
不太一样, 两者对比
const ref = useRef(initVal) |
const [ state, updater ] = useState(initVal) |
|
---|---|---|
get | ref.current |
state |
set | ref.current = newVal |
updater(newVal) |
set 是否会引起 React Fiber 调度 | ❌ | ✅ |
set/get 是否符合”set 即改变, get 总能拿到最新值” 的直觉 | ✅ | ❌ |
当你希望在函数式组件中按你直觉获取到状态最新值时, 就用 .useRef
.
依赖列表(Deps)
对于 Hooks 而言, 其依赖是开发者必须考虑的一个特点, 如果忽略它或者错误地理解它, 可能会给组件、应用带来毁灭性的副作用 —— 比如, 无限循环的 effect.
下面这个组件一旦被引用到应用中, 就会不停地发送 getJSON 请求, 并更新组件引起视图更新, 让应用直接崩溃.
1 | function InfiniteRequestHookComponent () { |
因此, 开发者对这个**依赖列表(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 中, 像这样:
1 | // class component |
若 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 可以描述如下:
1 | function SearchComponent() { |
这里有一个 hook1(.useEffect
), 其 deps 为 [ keyword ]
—— 意味着 keyword 发生变化的时候, .useEffect(callback, deps)
的 callback 会再执行一次; 当用户输入时, 触发 input[onChange]
, 其中 setKeyword
不仅会引起 keyword
更新, 还会引起组件的重新渲染(即, 重新执行一次 Function of Functional Component).
使用作弊器 .useRef
如上文所说, .useRef 是提供了一个保存值的容器, 并允许你能严格按顺序读取它.
比如
1 | const sthRef = useRef(null); |
sthRef.current
的初始值为 null
, 而后立刻被更新为 1, 3s 后变成 3, 9s 后变成 9.
和 .useState
不同, 更新 sthRef.current
不会引起 Functional Comopnent 的 re-render.
一步步实现一个 useTimeout
在这篇文章结尾, 笔者留了一个问题, 如何提供一个合适的 useTimeout
, 克服闭包问题, 使得 3s 后, 在 useTimeout
中 count
为最新的值 5(因为它在 useEffect
中被更新了).
1 | const TimeoutExample = () => { |
在 这篇文章 文章中, 未提及 useTimeout
的时候, 我们使用 countRef
来保存了 count
的值解决了 Hooks 的闭包陷阱问题, 但这样太不通用了, 下次遇到类似的值又要新建一个 xxxRef 来保存么? 既然在 Hooks 和 setTimeout(callback, 3000)
结合使用的时候, callback
的闭包导致我们无法取 count 最新值的问题, 那我们尝试更新闭包行不行? 基于这种想法, 我们提出了 useTimeout
, 希望可以直接更新整个 setTimeout(callback, 3000)
的 callback
, 如果真的可以实现, 那么最终的写法类似下面:
1 | const TimeoutExample = () => { |
先不考虑 deps, 我们只考虑把先要把 setTimeout
的两个参数 cb
和 timeout
存下来, 并且我们希望在合适的时候调用 setTimeout
来启动 timer, 启动 timer 是一个副作用, 我们放在 .useEffect()
里:
1 | function useTimeout(cb, timeout) { |
不过, 如果使用 .useState
来存 cb
的话, 每次 setCallback
时, 引用 .useTimeout()
组件也会被更新 —— 根据我们的目的”在 count 变化的时候更新整个闭包”, 显然我们是要更新 callback
的, 但由此引起的视图更新似乎就不是很有必要了, 我们用一下作弊器, 改用 .useRef
来保存 cb
:
1 | function useTimeout(cb, timeout) { |
现在我们还没有体现”更新 callback”这件事, 回顾下我们的目的: 当 count
变化的时候, 我们保存下来的 callback
也要能变. 所以我们把 count
进来, 放在 .useEffect
的 deps 中, 并且在 .useEffect
中更新 callbackRef.current
:
1 | function useTimeout(cb, timeout, count) { |
不过, 直接传 count
只适应于这个场景, 换了个场景别人可能希望传别的, 不妨把第 3 个参数直接设计成 deps, 用户爱传什么传什么:
1 | // user should put `count` in deps |
这里还有个问题, 每次 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 就应当被取消(当然已经执行完的就算了), 我们来手动做一下这件事:
1 | function useTimeout(cb, timeout, deps = []) { |
如上, 我们又用了一次作弊器, 这里为什么我们不使用一个 let timer = null
来保存之前执行的 timer 呢? 相信读者想一下就能明白.
不过, 我们 duck 不必自己保存之前的 timer. React.useEffect(effect, deps)
允许 effect 中返回一个 dispose 函数, 如果开发者确实返回了这个 dispose 函数, 则当 Functional Componnet 下一次运行(re-render)到这个 React.useEffect(effect, deps)
时, 会调用上一次返回的 dispose 函数, 像这样:
1 | React.useEffect(() => { |
所以对于 useTimeout
而言, 如果我们想在每次更新 cb 时消除上一次 effect中 setTimeout
产生的 timer, 我们也可以这样写:
1 | function useTimeout(cb, timeout, deps = []) { |
这里我们反而利用了 dispose 的闭包特性, 简洁而准确地消除了上一次 effect 的副作用.
这样就完了么? 逻辑上是已经理顺了了, 不过我们还可以增加一点点细节, 提高 useTimeout
的健壮性:
1 | function useTimeout(cb, timeout, deps = []) { |
我们来试试这个 useTimeout
能否满足我们在 这篇文章 末尾提出的要求, 查看 Codesandbox Demo, 点击刷新, 启动后等 3s, 看看视图是否按预期更新了 :)