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] 不太一样, 两者对比

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
2
3
4
5
6
7
8
9
10
function InfiniteRequestHookComponent () {
const [state, updater] = React.useState(null);

React.useEffect(() => {
getJSON(...)
.finally(() => {
updater(...);
})
})
}

因此, 开发者对这个**依赖列表(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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 可以描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 更新, 还会引起组件的重新渲染(即, 重新执行一次 Function of Functional Component).

使用作弊器 .useRef

如上文所说, .useRef 是提供了一个保存值的容器, 并允许你能严格按顺序读取它.

比如

1
2
3
4
5
6
7
8
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 后, 在 useTimeoutcount 为最新的值 5(因为它在 useEffect 中被更新了).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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, 如果真的可以实现, 那么最终的写法类似下面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 的两个参数 cbtimeout 存下来, 并且我们希望在合适的时候调用 setTimeout 来启动 timer, 启动 timer 是一个副作用, 我们放在 .useEffect() 里:

1
2
3
4
5
6
7
function useTimeout(cb, timeout) {
const [callback, setCallback] = React.useState(cb);

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

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

1
2
3
4
5
6
7
function useTimeout(cb, timeout) {
const callbackRef = React.useRef(cb);

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

现在我们还没有体现”更新 callback”这件事, 回顾下我们的目的: 当 count 变化的时候, 我们保存下来的 callback 也要能变. 所以我们把 count 进来, 放在 .useEffect 的 deps 中, 并且在 .useEffect 中更新 callbackRef.current:

1
2
3
4
5
6
7
8
9
10
11
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, 用户爱传什么传什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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 就应当被取消(当然已经执行完的就算了), 我们来手动做一下这件事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 函数, 像这样:

1
2
3
4
5
6
7
8
React.useEffect(() => {
// some side effect here

return () => {
// dispose function
// clear some side effect here
};
}, deps);

所以对于 useTimeout 而言, 如果我们想在每次更新 cb 时消除上一次 effectsetTimeout 产生的 timer, 我们也可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 的健壮性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 能否满足我们在 这篇文章 末尾提出的要求, 查看 Codesandbox Demo, 点击刷新, 启动后等 3s, 看看视图是否按预期更新了 :)

首页归档简历