为何如此悬念?理解 JavaScript 框架中的异步一致性
最近有人联系我,问我“Suspense 的一般含义是什么?” 他们说他们能找到的只有React 的资源。我告诉他们,Suspense 这个词是他们自己创造的,这很有道理。但仔细研究之后,我发现它的含义远不止于此。
当我查看大多数文章中关于这个主题的解释时,它们只讨论了 Suspense 组件的表象,而没有真正解释它的含义。所以今天我将尝试解释为什么它不仅仅是一个渲染占位符的组件。
用户界面的一致性
信息显示的一致性是良好用户界面的基本要求。如果在同一页面中显示不一致的信息(且未明确说明),则会损害用户的信任。
摘自 Michel Westrate 的《成为完全响应式:深入讲解 MobX》
如果你的头像在一个地方更新了,而在另一个地方没有更新,你能相信你正在阅读的内容是最新的吗?你可能会忍不住重新加载浏览器页面以防万一。如果评论数量与你看到的评论数量不匹配,即使数量较少,你也可能认为自己错过了什么。但还有更严重的问题,比如产品价格不匹配怎么办?
这超出了你的视觉范围。如果我们的 Web 应用的内部状态与我们向最终用户显示的内容不匹配,会发生什么?这会导致他们做出原本不会做的决定吗?做出重要的决定吗?如果你向他们展示一套东西,而实际执行的是另一套东西,他们难道甚至意识不到吗?
当然,在网络上,我们已经习惯了看到的内容可能不是最新的。与后端的当前状态相比,通过网络发送数据总是存在延迟。虽然这可能会导致数据过期,导致某些请求被拒绝,但不一致的界面可能会导致我们陷入这样一种境地:应用程序认为自己在做正确的事情并通过了验证,而最终用户却浑然不知。
幸运的是,我们有专门为此构建的工具。通常,现代的 UI 库和框架在构建时都考虑到了一致性。
框架的一致性
最简单的一致性形式是确保派生状态与其源状态保持同步。例如,如果你有一个状态,那么count
该状态doubleCount
实际上总是该状态数量的两倍。在响应式库中,我们通常将此称为glitch-free
执行。它可能看起来像这样:
const [count, setCount] = useState(1);
const doubleCount = useMemo(() => count * 2, [count]);
不同的框架有不同的方法来确保这种关系的成立。在React中,状态更新不会立即应用,因此你会看到之前的状态,直到React同时应用所有状态。像 Vue 或Solid这样的响应式库倾向于更积极地更新,以便在更新后的下一行,不仅源数据会更新,所有派生数据也会更新。
// React
setCount(20);
console.log(count, doubleCount); // 1, 2
// Solid
setCount(20);
console.log(count, doubleCount); // 20, 40
在这种情况下,差异并不重要,因为两种情况下它们都是一致的。最终,结果相似。从外部来看,状态更新是原子性的,同时应用于所有地方。
异步一致性
关键在于,对于无故障库来说,无论更新是立即发生还是稍后发生,它们都会同步应用。所有更新都在同一时间发生,并且彼此可见。这对于一致性保证至关重要。但是,如果所有更新无法同步计算,会发生什么?
这是一个相当棘手的问题,许多学术论文都探讨过这个问题。甚至一些与 JavaScript 生态系统相关的论文,例如2013 年这篇关于 Elm 的论文。为了更好地说明这个问题,我们再次考虑count
和 ,doubleCount
但假设我们需要访问服务器来计算doubleCount
。
// Not real React code, just for illustrative purposes
const [count, setCount] = useState(1);
const doubleCount = useMemo(async () =>
await fetchDoubleCount(count)
, [count]
);
// somewhere else:
setCount(20);
现在,我们的值count
将从 1 开始,并且doubleCount
在获取数据时最初是未定义的,这使我们处于不一致的状态。稍后,当它解析时,值doubleCount
将变为 2,然后我们又会保持一致。稍后当我们将其设置count
为 20 时doubleCount
,值将变为 1,直到稳定在 40。如果您记录此信息,useEffect
您可能会看到:
1, undefined
1, 2
20, 1
20, 40
这并不出乎意料,但却不一致。问题就在这里。只有三种可能的结果可以避免用户看到这种不一致的状态:
1. 纾困
显示一些内容,而不是不一致的状态。某种加载指示器可以向最终用户隐藏不一致的情况,并让内容在后台稳定下来,直到准备好显示为止。
2. 停留在过去
不要应用任何更改并继续按原样显示内容,直到新内容准备好显示为止。
3.预测未来
立即应用更改并在异步状态更新时显示未来值,然后在完成后替换它(但它应该已经是同一件事了)。
嗯,作为通用解决方案,第一个方案相比其他方案相对简单。我们一直都在这么做。我们可能会立即应用源更改,然后显示一个加载指示器,直到我们准备好显示更新的内容。许多人和图书馆看到 Suspense 就止步于此了。
但是如果我们想做更多呢?删除内容并在一段时间后重新替换可能会带来相当不愉快的用户体验。我想我们所有人都希望生活在未来,但除非用户执行修改操作,否则这样做有一定的不切实际性。这些“乐观更新”是一个很好的工具,但它们并不完美,也并非总是适用。如果您只是想获取最新数据,那么您就无法获得尚未收到的数据。
那么,让我们回顾一下过去。棘手的部分是,如果我们不应用任何数据更改,该如何触发即将到来的异步请求?
好吧,我们可以复制一份我们希望将来更新的状态。就像我们可以将count
、 和futureCount
havedoubleCount
派生出来futureCount
,并且只在一切都解决后才将futureCount
的值应用回去count
。但这很棘手。如果有多个获取操作和多个不同的来源怎么办?我们需要克隆该更改下游的所有内容。
React或Solid中的Transitions或多或少就是这么做的。并发渲染的存在是为了确保应用程序在安全地渲染新的更新内容时能够保持在一个状态,并且只有在一切准备就绪后才提交这些更改。这是一种系统性的方法,可以让我们保持与过去状态的一致性,直到我们做好准备为止。
为什么要并发呢?因为你仍然需要向最终用户展示 UI,所以你肯定不希望它完全停止工作。比如动画和其他非破坏性交互。这意味着最终需要做更多工作来协调这些变化,但最终这是一个最终用户体验的功能。
整合起来
突然之间,React决定setState
保留过去状态似乎并不那么奇怪了。你不知道下游可能导致异步派生状态的原因,所以你需要谨慎行事,在弄清楚之前不要更新。话虽如此,这些框架仍然出于同样的原因明确选择启用并发渲染。
想象一下编写一个用于创建和更新某些状态的组件。如果某个接收 props 的下游子组件负责在并发转换中隔离你的状态变化(因为该状态是依赖项),那将非常不寻常。此行为需要选择启用。
同样,能够选择退出这种行为可能也很重要。有时,一定程度的不一致是必要的,甚至是可取的。例如,当你需要尽快查看数据时。
总而言之,Suspense 和 Transitions 提供了非常有用的工具,可以帮助我们解决用户界面一致性的问题。这对最终用户来说是一个很大的好处。这不仅仅关乎性能,也不仅仅关乎数据获取。它能够更轻松地创建用户可信赖的 UI,使其行为符合预期,并且无论用户如何浏览你的 Web 应用程序,都能提供流畅的体验。
文章来源:https://dev.to/this-is-learning/why-all-the-suspense-understanding-async-consistency-in-javascript-frameworks-3kdp