响应式中的异步派生
恭喜你顺利看完本系列教程。但接下来事情就开始变得复杂了。响应式编程可能涉及调度,但我们目前所了解的大部分内容都是同步的,状态可以在任何时间点进行检查。
异步编程改变了一切。在 JavaScript 领域,我们下一步的发展方向几乎没有先例可循。与其照搬现有生态系统,不如让我们探索如何运用目前所学知识来实现这一目标。
为什么需要异步响应式?
异步操作很难。把事情想象成一个按顺序发生的序列要容易得多。这就是为什么我们有像async/这样的符号await:
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/user/${id}`);
const user = await res.json();
console.log("user", user);
return user;
}
但让它看起来像是顺序执行并不能解决我们所有的问题。调用者还需要知道某些操作是异步的:
// didn't await it
const user1 = fetchUser(1);
console.log("I will log before user 1 is fetched");
// did await
const user2 = await fetchUser(2);
console.log("I will log after user 2 is fetched");
据说 Async/Await 会给使用它的函数“着色”。一旦处理异步数据,调用者也需要是异步的,以此类推,直到你不再需要等待结果为止。
它还可能无意中造成瀑布,因为它使我们的模型变得臃肿。
async function ShowSomeUI() {
const user1 = await fetchUser(1);
// only start fetching 2 after 1 completes
const user2 = await fetchUser(2);
return <>
<User user={user1} />
<User user={user2} />
</>
}
我们有并行化的方法,但问题仍然存在:
async function ShowSomeUI() {
const [user1, user2] = await Promise.all([fetchUser(1), fetchUser(2)]);
return <SharedLayout>
<ShowUnrelatedUI />
<User user={user1} />
<User user={user2} />
</SharedLayout>
}
如果它<ShowUnrelatedUI />还有其他异步依赖项呢?你仍然会遇到瀑布图。如果可以<ShowUnrelatedUI />在异步内容加载之前显示某些信息呢?如果还有其他状态可以在异步请求进行期间尝试独立更新呢?
以上种种原因都表明,异步函数并不适合用于交互式组件。它与独立交互部件的预期不符。
你想要做的不是await把承诺传递给它被使用的地方:
function ShowSomeUI() {
const user1 = fetchUser(1);
const user2 = fetchUser(2);
return <SharedLayout>
<ShowUnrelatedUI />
<User user={user1} />
<User user={user2} />
</SharedLayout>
}
但这很尴尬,原因有二。
首先,你的组件期望接收 Promise 作为 props。props.user这是一个 Promise 对象,Promise<User>而不是一个数组User。因此,我们需要一种新的着色方式,因为每个下游 prop 都需要处理 Promise 对象的可能性。这包括派生值:
function User(props: {user: Promise<User>}) {
return <>
<h3>{props.user.then(u => u.firstName)}'s Profile</h3>
<Address address={props.user.then(u => u.address)} />
<>
}
我们可以await在这里解决这个问题。它确实需要在某个层面上得到解决,但我们这样做是因为这里是正确的位置,还是因为我们需要摆脱 Promise 地狱?是因为我们不想为每个组件编写两个版本,或者不想更新现有组件来处理以前不支持 Promise 的情况吗?
第二个需要注意的地方是,我们不仅要处理 Promise,还要处理 Promise 工厂。你不仅仅是获取一个用户,而是基于一个 prop 来获取用户。这个 prop 可能会改变,Promise 也必须随之改变,因为它只能解析一次。但你也不希望在无关的状态改变时进行获取操作。
function ShowSomeUI(props: {id: number}) {
const user = fetchUser(props.id); // id can update
return <User user={user} />
}
如果你使用信号(例如 Signals)来更新 UI,那么你已经具备了实现此目的所需的所有功能。信号具备解决此问题所需的所有特性。
它们会延迟求值,直到读取到目标位置,从而将解析过程下推到 UI 树的叶节点。你可以在 UI 树的更高层级编写 await,但只在它被使用的地方阻塞。对于像 prop transformation 这样的功能(Solid 和 Qwik 中都有),你需要传递的是类型本身,而不是该类型的 Promise 或 Signal。
它们可以轻松获取数据。当 props 发生变化时,它们可以生成新的 Promise。同步和异步操作共享一个通用接口。
结合细粒度渲染组件,它们不会重新运行,因此您无需担心引用稳定性或重新获取数据的问题。您可以将它们放在组件顶部,它们不会受到无关状态更改的影响。
无色异步
如今几乎可以说,使用 Signals 进行异步操作已经变得毫无差别。但保存同步值的 Signal 和保存异步值的 Signal 之间确实存在区别。
// sync
const [user1] = createSignal<User>(user1JSON);
// async
const [user2, setUser2] = createSignal<User | undefined>();
fetchUser(2).then(setUser2)
异步函数有可能undefined在解析完成之前就执行。添加空值检查的影响要小得多。尽早传入默认值可以消除这种紧张感。但作为亲身经历过 TypeScript 无法识别幂等函数的人,我认为第二种情况(空值检查)undefined经常出现!,而且会导致不必要的?.错误。
编写一个处理异步操作的组件意味着要编写一个能够接收undefined值的组件。至少在 Signals 的框架下是这样。但在最新的 React 版本中并非如此。如果 React 19 遇到use未解析的(带有 `is` 属性的)对象,它会直接抛出异常。你的用户代码不需要进行空值检查,因为它根本不会执行到那一步。
他们解决了问题的反面。在异步解析的下游,没有颜色标记。但在上游,他们需要传递 Promise。这导致上游阻塞,以避免过多的上游颜色标记。信号机制允许我们在上游解析异步任务,而不会阻塞 UI。
如何才能两全其美?创建一个信号库,当异步值未解析时抛出异常。
派生异步
第一步是区分“什么是异步”和“什么只是异步” undefined。你可能会想,如果一个信号或派生节点接收到 Promise 或 Async Iterable,那么它就是异步的。但如果你还记得我们上一篇文章的内容,如果派生节点是延迟执行的,这种方法就行不通了。抛出异常的异步操作需要被调度执行。所以很遗憾,现有的基本原语无法满足需求。
我们可以恢复立即派生,并添加特殊的 Promise/Async Iterable 处理,但由于不清楚这样做是否可取,我将引入一个新的原语:
const user = createAsync(() => fetchUser(props.id));
// we can derive from it too. Notice no null check
const firstName = createMemo(() => user().firstName)
// use it in an effect (split like in the last article)
createEffect(firstName, (name) => console.log(name));
这段代码的工作原理是:当这段代码首次运行时:
- 对用户执行获取
props.id操作- firstName 备忘录已创建,但未运行
- 该效果已安排好
- 效果的前半部分运行,并读取
firstName。
firstName尚未经过评估,因此可以运行。它读取user。- 它看到它
user在飞行中,然后投掷出去。firstName捕获该节点并将其添加为依赖项,然后抛出自身。- 效果的前半部分捕获该节点并将其添加为依赖项,然后停止运行副作用。
user解决,并向下通知。- 效果的前半部分运行,并读取
firstName。
firstName已被标记为可能存在问题,因此运行。内容如下user:user返回解析后的值firstName返回其解析值- 该效果的前半部分存储更新后的值。
- 副作用是会
console.log显示用户名。
更新时,运行过程基本相同,只是会从id更新开始,然后运行步骤 4 - 7。
让我们回到之前的例子:
function ShowSomeUI(props: { id: number }) {
const user = createAsync(() => fetchUser(props.id));
return <SharedLayout>
<ShowUnrelatedUI />
<User user={user()} />
</SharedLayout>
}
function User(props: {user: User}) {
return <Suspense fallback="Loading"}>
<h3>{props.user.firstName}'s Profile</h3>
<Address address={props.user.address} />
</Suspense>
}
我们可以看到,这大大简化了代码。我们的用户是一个自动更新的信号。User现在,我们的用户属性的类型已经明确,undefined并且我们将异步阻塞操作下推到了实际使用的位置。当然,出现部分内容缺失而其他内容显示不全的 UI 是不可接受的,因此我们仍然需要像 Suspense 这样的工具来管理占位符的显示。
但重点是:
- 地址组件不需要感知异步操作。
- 派生状态(例如
firstName`or`)address无需空值检查即可访问。 - 提升获取操作的开销是没有的……无论是否
user已传递给其他组件,ShowSomeUI我们都不需要阻塞任何操作。 - 我们可以立即渲染除显示姓名和地址的文本节点之外的所有内容(尽管我们可能还不会显示它们)。
- 悬念可以放在第一次阅读之前的任何位置,以便我们根据需要管理占位符。
在这种情况下,悬念是由renderEffect层级结构触发的,但异步计算会不受阻碍地流经 Pure 部分。
使用无色异步,一切皆有可能响应式
所以问题解决了吗?完美的异步系统就这么摆在那里等着我们去实现?好吧,凡事都有代价。这代价不应该很高,但我们往往会为了省事而偷工减料。我希望大家能明白这一点:
使用无色异步,一切皆有可能响应式
在模板方面,我们习惯于默认将所有元素都视为响应式。但对于组件来说,情况则有所不同。在 SolidJS 中,我们完成了一半的工作。我们为untrack所有组件都做了响应式处理,这样即使你在顶层访问响应式功能,应用也不会崩溃。但为了简洁起见,我们也允许你利用这一特性。
虽然我不认为这与思维的局部性有关,但当事情进展不顺利时,这确实会让人感到困惑。我们有 ESLint 规则来处理这种情况,但 Solid 在这方面并没有那么严格,不会报错。也许它应该更严格一些?
从信号中提取信号props
我这里有个例子,我相信每个开发者都遇到过这种情况。你有没有遇到过需要通过 prop 初始化状态的情况?
让我们来探讨一下以下两者的区别:
const [count, setCount] = createSignal(props.count);
const doubleCount = createMemo(() => props.count * 2);
Signal(state) 具有初始值,备忘录会随之更新props.count。这个例子在 Solid 和 React 中都能以类似的方式运行,但原因不同。React 需要保留状态,因此它只会在初始时获取该值。考虑到这可能是 React 唯一会忽略顶层访问的 prop 更改的情况,这种做法对于 React 来说显得有些不一致。在 Solid 中,这是隐式 `syncState` 的影响untrack。在这两种情况下,最终都会得到useEffect类似 `syncState` 的效果。
现在考虑以下两者的区别:
const [count, setCount] = createSignal(props.count);
const doubleCount = createMemo(() => untrack(() => props.count) * 2);
是的,这仅用于说明目的。一份仅包含初始值的备忘录untrack毫无用处。这两者都只依赖于初始值,更新后props.count不会改变它们。
props.count如果将来它变成了一个异步值会发生什么情况?
这样它就变成了一个你需要关注的响应式值。你肯定不希望计数像undefined你期望的那样number从属性类型中获取。
实际上createSignal,如果底层异步资源props.count从未解析,我们会在这里抛出异常,并向上抛出到最近的决策点。也许向上三个祖先节点就是一个三元表达式。异步解析完成后,它会重新渲染从该决策点开始的整个分支。但不是简单的虚拟 DOM 重新渲染,而是完整的 DOM 渲染。如果下游还有更多这样的节点,它会一直这样做,直到所有节点都解析完毕。
而如果未执行createMemo任何操作,则在读取之前不会发生任何事情。执行后,它会捕获抛出的异步节点本身,并且仅应用于正在渲染该节点的特定绑定。
这与之前语义相似的代码的行为截然不同。你绝对不希望出现像顶级访问那样抛出异常的情况createSignal。这和我们不把untrack组件放在顶级一样糟糕,但使用异步操作时,如果值不允许被访问,就没有隐式的保护机制undefined。
异步操作真的可行吗untrack?
const [multiplier, setMultiplier] = createSignal(2);
const doubleCount = createMemo(
() => untrack(() => props.count) * multiplier()
);
这就是问题的关键所在。异步不仅使所有操作都响应式,而且还绕过了某些限制untrack。假设你读取了一个异步值,untrack之后又读取了其他响应式值,该怎么办?如果该值是异步的,并且在读取时抛出异常,那么当props.count该值解析后,你需要重新运行函数。虽然在后续运行中不会被添加为依赖项,但实际上,它在第一次运行时就构成了一个依赖项。doubleCountprops.countprops.count
你不能因为某个操作是untrack异步的就假定它永远不会执行。这样做会破坏下游的所有操作,仅仅因为某个原本不是异步的操作变成了异步操作。
那么如何才能避免这种行为呢?并不容易。如果只读取最近解析的值,或者undefined不抛出异常,虽然可行,但这会改变代码的语义。
const [multiplier, setMultiplier] = createSignal(2);
const doubleCount = createMemo(
() => latest(() => props.count) * multiplier()
);
你不能乘以undefined一个数字。即使你在此处添加了必要的空值检查(你知道这里有一个latest包装器),这对于任意的响应式表达式也无济于事。你需要确保对latest边界内的每个潜在异步值进行空值检查,但由于缺乏类型信息,每个值都会认为自己是 `int` 类型T而不是 ` int` 类型T | undefined。
最好的办法是在异步操作的源头设置退出选项:
const count = createAsync(() => fetchCount());
<Multiplier count={count.latest || 0} />
该.latest字段位于number | undefined……。由于乘数需要一个数字,我们提供了一个默认值。但这并不是可组合的行为。
我们不能在运行时更改代码语义,还指望程序不会崩溃。因此,使用 Colorless Async 不仅意味着所有代码都可能具有响应式特性,而且是必然如此。
寻找一致的模型
那么,Colorless Async 真的是个谎言吗?
那么,当所有东西都用同一种颜色时,它还能算是有颜色吗?如果我们默认所有东西都可能是响应式的,而且这种响应式是不可避免的,那么我们就失去了选择的权利。无论好坏,我们都接受了这种单一模型,就像当初接受某个库的响应式特性一样。
或许它与我们习惯的模型有所不同?Solid 的 API 设计之初就旨在将所有数据都视为潜在的响应式数据。正因如此,它既没有isSignalprops 包装,也没有其他包装方式。Svelte 的 Runes 也遵循类似的理念,甚至禁止你持有底层 Signal 的引用。React 团队将其编译器定位为一种更自然地体验 React 组件整体响应式特性的方式。但它们的共同之处在于,尽管这些系统都提供了显式的语法来表达状态,但响应式特性本身却以一种开放的方式贯穿其中。
它要求完全遵守规则。就像 React 编译器只有在你遵循 React 规则时才能工作一样,这种方法要求你严格遵循响应式规则——即所有数据都可以是响应式的,并且“凡是能派生的,就应该派生”。
// don't do this
const [count, setCount] = createSignal(props.count);
createEffect(() => setCount(props.count));
// do this (assuming this expresses a derived Signal)
const [count, setCount] = createSignal(() => props.count);
这只是强化了我们一直以来暗示的观点。而这正是它的妙处所在。为什么可更新状态之前不能派生呢?useEffect如果我们永远不需要同步 props,可以避免多少灾难?如果可以这样派生,那么 effects 引入到初学者中的时间又会推迟多少?难以置信,我深入研究响应式编程十多年了,竟然还在不断领悟。
下次我们将探讨响应式编程中另一个相对较少被深入研究的领域:可变状态和派生。我们将研究差异化的本质,以及不可变响应式和可变响应式如何共存。
文章来源:https://dev.to/this-is-learning/async-derivations-in-reactivity-ec5


