看到赚到!React Hooks 奇技淫巧 —— 副作用, 闭包 与 Timer

4,809 阅读8分钟

本文假设你具有以下知识或者使用经验:

  • 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

API
一样在 React Functional Component 的多轮渲染中可以保存一个值, 并严格按照你 set/get 的顺序来存取值. 这和 .useState 返回的 [state, updater] 不太一样, 两者对比

const ref = useRef(initVal)const [state, updater] = useState(initVal)
getref.currentstate
setref.current = newValupdater(newVal)
set 是否会引起 React Fiber 调度
set/get 是否符合直觉

当可以在你在函数式组件中想按你永远直觉预期获取到状态最新值的时, 就用 .useRef.

依赖列表(Deps)

对于 Hooks 而言, 其依赖是开发者必须考虑的一个特点, 如果忽略它或者错误地理解它, 可能会给组件、应用带来毁灭性的副作用 —— 比如, 无限循环的 effect.

下面这个组件一旦被引用到组件中, 就会不停地发送 getJSON 请求, 让应用直接崩溃.

function InfiniteRequestHookComponent () {
       React.useEffect(() => {
           getJSON(...)
       })
}
Copy

我们需要给 useEffect 一个依赖列表 —— 起码是一个空数组.

function SafeHookComponent () {
       React.useEffect(() => {
           getJSON(...)
       }, []) // add one deps list
}
Copy

对这个依赖列表(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
            })
    }, [])
}
Copy

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 || '')
            }} />
        )
}
Copy

这里有一个 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);
Copy

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>
    )
}
Copy

这篇文章 文章中, 未提及 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>
    )
}
Copy

先不考虑 deps, 我们只考虑把先要把 setTimeout 的两个参数 cbtimeout 存下来, 并且我们希望在合适的时候调用 setTimeout 来启动 timer, 启动 timer 是一个副作用, 我们放在 .useEffect() 里:

function useTimeout (cb, timeout) {
    const [callback, setCallback] = React.useState(cb)

    React.useEffect(() => {
        setTimeout(callback, timeout)
    }, [])
}
Copy

不过, 如果使用 .useState 来存 cb 的话, 每次 setCallback 时, 引用 .useTimeout() 组件也会被更新 —— 根据我们的目的"在 count 变化的时候更新整个闭包", 显然我们是要更新 callback 的, 但由此引起的视图更新似乎就不是很有必要了, 我们用一下作弊器, 改用 .useRef 来保存 cb:

function useTimeout (cb, timeout) {
    const callbackRef = React.useRef(cb)

    React.useEffect(() => {
        setTimeout(callback, timeout)
    }, [])
}
Copy

现在我们还没有体现"更新 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])
}
Copy

不过, 直接传 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 ])
Copy

这里还有个问题, 每次 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)
}
Copy

如上, 我们又用了一次作弊器, 这里为什么我们不使用一个 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)
Copy

所以对于 useTimeout 而言, 如果我们想在每次更新 cb 时消除上一次 effectsetTimeout 产生的 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)
}
Copy

这里我们反而利用了 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)
}
Copy

我们来试试这个 useTimeout 能否满足我们在 这篇文章 末尾提出的要求, 查看 Live Demo, 启动后等 3s, 看看视图是否按预期更新了 :)