六种会表现为 React 渲染错误的异步定时错误
学会识别六种异步 JavaScript 模式,从过期的捕获值到缺失的 Promise 返回值,这些都会导致 React 组件显示错误或不可能的状态。
许多被标记为“React 错误”的问题其实并非源自 React 本身。旧查询的返回结果列表、永不消失的加载指示器、在数据尚未准备好时就出现的成功提示:这些问题的出现地点仅仅是 React,其真正根源通常在于更低一层,即异步 JavaScript 如何随时间传递数据。
异步代码为程序引入了时间概念,每一个 await 或 .then() 都可能是程序状态发生变化的节点。下文将介绍六种常见的时间处理错误、每种错误为何会导致令人困惑的用户界面问题,以及相应的简单修复方法,帮助你在代码审查时及时发现这些问题。
错误 1:过时的请求仍在修改当前状态
搜索框就是典型的例子。下面的实现已经通过 300 毫秒的防抖机制处理输入,并在组件销毁时清除计时器。
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const timeoutId = setTimeout(async () => {
const results = await search(query);
setResults(results);
}, 300);
return () => {
clearTimeout(timeoutId);
};
}, [query]);
现在想象有人输入“Async JavaScript Mistakes”。他先输入“Async”,稍作停顿让防抖机制失效,随后一个请求被发送出去。接着他继续输入,又有一个针对完整短语的请求被发出。防抖机制虽然减少了请求数量,但无法防止有两个请求同时处于处理状态。
请求离开浏览器的顺序由你控制,但响应返回的顺序则不可控。如果较长的查询先得到结果,而“Async”查询后得到结果,那么第二个setResults调用会生效,列表中显示的将是“Async”的结果,而输入框里显示的仍是“Async JavaScript Mistakes”。
这里并没有什么问题。网络可以重新排序返回的数据,而且代码从未告诉 React 哪个响应仍然是有效的。通常的解决办法是在效应函数中创建一个 AbortController,并在清理阶段中止它,从而取消已被替代的任务,这样过时的响应要么根本不会到达,要么会被忽略。如果数据流已经是基于 RxJS 构建的,switchMap 能实现同样的“只有最新数据有效”的逻辑。关于这一场景的更深入探讨,可参阅 解决防抖无法处理的竞态条件。
错误 2:在 await 之前就根据已捕获的值做决定
将每个 await 视为一种边界。在此之前你所知道的只是某个快照;而在其执行之后发生的一切则可能发生在稍后的时刻,甚至在其他状态发生变化之后。
const handlePublish = async () => {
const { canPublish } = permissions;
await saveDraft();
if (canPublish) {
publish();
}
};
该处理程序从 permissions 中读取 canPublish 的值,等待草稿保存完成,然后再决定是否发布。问题在于这个决策是基于初始时为真的值做出的。如果在 saveDraft() 执行期间用户的权限被撤销,本地常量仍然会显示允许发布的状态。
类似的情况也出现在已选项目、激活的过滤器、路由参数、编辑器内容以及许多其他状态中。当 await 之后的决策依赖于当前的应用状态时,应从引用、存储或新的请求中重新读取该状态,而非依赖之前的副本。
错误3:假设函数的其他部分总会执行
这是一个包裹在 fetch 调用中的加载标志示例。
setLoading(true);
const dashboard = await getDashboard();
setDashboard(dashboard);
setLoading(false);
如果 getDashboard() 返回错误,执行会跳到 await 处并离开该函数,此时 setLoading(false) 永远不会被执行。旋转图标就会一直显示在屏幕上。
当有状态模型用于表示异步操作的生命周期时,无论操作成功还是失败,都必须重置该状态。使用 finally 块可以直接表达这一意图:
setLoading(true);
try {
const dashboard = await getDashboard();
setDashboard(dashboard);
} finally {
setLoading(false);
}
注意 try 块仍然没有 catch 块。错误会继续传递给调用此代码的程序,而这通常正是我们希望看到的结果,同时加载标志也一定会被清除。只有当此处适合将错误转换为 UI 显示(如错误信息)时,才需要添加 catch 块。
错误4:独立请求导致屏幕状态混乱
并行发送无关请求通常是完全合理的。
getProfile().then(setProfile);
getPermissions().then(setPermissions);
getPreferences().then(setPreferences);
每次响应到达时都会进入独立的状态空间。这意味着React可以在任何顺序下渲染各种组合:没有权限或偏好的用户资料、仅有权限和偏好而没有用户资料,以及网络可能产生的所有其他排序方式。
其中一些组合可能毫无意义,甚至会对屏幕造成损害。当多个响应共同描述一个完整的屏幕状态时,这些请求可以保持并行处理,由单一负责人将结果整合起来,例如通过等待 Promise.all 并在一次性状态更新中应用所有更改,或者将屏幕建模为具有明确加载、就绪和错误状态的 reducer。并行获取数据与独立的 UI 状态是两个独立的决策。
错误 5:基于过时的快照构建下一个状态
这种错误很容易隐藏在事件处理程序中。
const handleAdd = async () => {
await saveItem(newItem);
setItems([...items, newItem]);
};
items 是组件在 handleAdd 开始时所持有的数据。在 saveItem() 处理过程中,可能会有其他操作添加或删除条目。当处理程序恢复执行时,它会使用旧的数组数据,从而覆盖那些新产生的变化。
每当新值依赖于前一个值时,让 React 通过更新器函数提供前一个值:
setItems(current => [...current, newItem]);
更新器会在处理更新时基于最新提交的状态运行,因此可以保留并发发生的变更。过时的闭包问题不仅存在于效应处理中:任何异步回调都可能比预期更长时间地保留某些值。
错误 6:因未返回而破坏 Promise 链
这条链看起来完全是顺序执行的:先保存,再刷新组件,最后标记为已保存。
saveDashboard()
.then(() => refreshWidgets())
.then(() => setSaved(true));
现在来看看 refreshWidgets 可能的实现方式:
const refreshWidgets = () => {
getWidgets().then(setWidgets);
};
该函数虽然启动了请求,但返回的是undefined而非Promise。从外部链式调用的角度来看,refreshWidgets()似乎立即执行完毕,因此下一个.then会立刻被触发,导致在组件仍在加载时就执行了setSaved(true)。
解决方法只需添加一个关键字,即返回Promise,这样链式调用就可以等待其完成。
const refreshWidgets = () => {
return getWidgets().then(setWidgets);
};
async函数也能以类似方式实现相同效果,因为它总会返回一个Promise,在其代码块执行完毕后该Promise就会得到解决。
const refreshWidgets = async () => {
const widgets = await getWidgets();
setWidgets(widgets);
};
在用户界面中,这个错误可能表现为成功消息过早出现、屏幕上残留过时的数据,或是刷新完成之前就发生了重定向。这些现象并不一定都表明缺少 return 语句。TypeScript 的代码检查规则,如 @typescript-eslint/no-floating-promises,能够自动检测出许多这类问题。
关键要点
React 与 JavaScript 之间的界限并不总是那么明显。React 负责渲染状态,而异步 JavaScript 决定该状态何时到达,以及到达时它仍是最新的、已过时的还是不一致的。在审查组件中的异步代码时,以下三个问题最容易暴露出这类错误:
- 这个值是在何时被捕获的?在
await执行期间它有可能发生变化吗?