发布于 2026-01-06 6 阅读
0

响应式中的异步派生

响应式中的异步派生

恭喜你顺利看完本系列教程。但接下来事情就开始变得复杂了。响应式编程可能涉及调度,但我们目前所了解的大部分内容都是同步的,状态可以在任何时间点进行检查。

异步编程改变了一切。在 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;
}
Enter fullscreen mode Exit fullscreen mode

但让它看起来像是顺序执行并不能解决我们所有的问题。调用者还需要知道某些操作是异步的:

// 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");
Enter fullscreen mode Exit fullscreen mode

据说 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} />
  </>
}
Enter fullscreen mode Exit fullscreen mode

我们有并行化的方法,但问题仍然存在:

async function ShowSomeUI() {
  const [user1, user2] = await Promise.all([fetchUser(1), fetchUser(2)]);

  return <SharedLayout>
    <ShowUnrelatedUI />
    <User user={user1} />
    <User user={user2} />
  </SharedLayout>
}
Enter fullscreen mode Exit fullscreen mode

如果它<ShowUnrelatedUI />还有其他异步依赖项呢?你仍然会遇到瀑布图。如果可以<ShowUnrelatedUI />在异步内容加载之前显示某些信息呢?如果还有其他状态可以在异步请求进行期间尝试独立更新呢?

以上种种原因都表明,异步函数并不适合用于交互式组件。它与独立交互部件的预期不符。

你想要做的不是await把承诺传递给它被使用的地方:

function ShowSomeUI() {
  const user1 = fetchUser(1);
  const user2 = fetchUser(2); 

  return <SharedLayout>
    <ShowUnrelatedUI />
    <User user={user1} />
    <User user={user2} />
  </SharedLayout>
}
Enter fullscreen mode Exit fullscreen mode

但这很尴尬,原因有二。

首先,你的组件期望接收 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)} />
  <>
}
Enter fullscreen mode Exit fullscreen mode

我们可以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} />
}
Enter fullscreen mode Exit fullscreen mode

如果你使用信号(例如 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)
Enter fullscreen mode Exit fullscreen mode

异步函数有可能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));
Enter fullscreen mode Exit fullscreen mode

这段代码的工作原理是:当这段代码首次运行时:

  1. 对用户执行获取props.id操作
  2. firstName 备忘录已创建,但未运行
  3. 该效果已安排好
  4. 效果的前半部分运行,并读取firstName
    • firstName尚未经过评估,因此可以运行。它读取user
    • 它看到它user在飞行中,然后投掷出去。
    • firstName捕获该节点并将其添加为依赖项,然后抛出自身。
    • 效果的前半部分捕获该节点并将其添加为依赖项,然后停止运行副作用。
  5. user解决,并向下通知。
  6. 效果的前半部分运行,并读取firstName
    • firstName已被标记为可能存在问题,因此运行。内容如下user
    • user返回解析后的值
    • firstName返回其解析值
    • 该效果的前半部分存储更新后的值。
  7. 副作用是会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>
}
Enter fullscreen mode Exit fullscreen mode

我们可以看到,这大大简化了代码。我们的用户是一个自动更新的信号。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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

是的,这仅用于说明目的。一份仅包含初始值的备忘录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()
);
Enter fullscreen mode Exit fullscreen mode

这就是问题的关键所在。异步不仅使所有操作都响应式,而且还绕过了某些限制untrack。假设你读取了一个异步值,untrack之后又读取了其他响应式值,该怎么办?如果该值是异步的,并且在读取时抛出异常,那么props.count该值解析后,你需要重新运行函数。虽然在后续运行中不会被添加为依赖项,但实际上,它在第一次运行时就构成了一个依赖项。doubleCountprops.countprops.count

你不能因为某个操作是untrack异步的就假定它永远不会执行。这样做会破坏下游的所有操作,仅仅因为某个原本不是异步的操作变成了异步操作。

那么如何才能避免这种行为呢?并不容易。如果只读取最近解析的值,或者undefined不抛出异常,虽然可行,但这会改变代码的语义。

const [multiplier, setMultiplier] = createSignal(2);
const doubleCount = createMemo(
  () => latest(() => props.count) * multiplier()
);
Enter fullscreen mode Exit fullscreen mode

你不能乘以undefined一个数字。即使你在此处添加了必要的空值检查(你知道这里有一个latest包装器),这对于任意的响应式表达式也无济于事。你需要确保对latest边界内的每个潜在异步值进行空值检查,但由于缺乏类型信息,每个值都会认为自己是 `int` 类型T而不是 ` int` 类型T | undefined

最好的办法是在异步操作的源头设置退出选项:

const count = createAsync(() => fetchCount());

<Multiplier count={count.latest || 0} />
Enter fullscreen mode Exit fullscreen mode

.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);
Enter fullscreen mode Exit fullscreen mode

这只是强化了我们一直以来暗示的观点。而这正是它的妙处所在。为什么可更新状态之前不能派生呢?useEffect如果我们永远不需要同步 props,可以避免多少灾难?如果可以这样派生,那么 effects 引入到初学者中的时间又会推迟多少?难以置信,我深入研究响应式编程十多年了,竟然还在不断领悟。

下次我们将探讨响应式编程中另一个相对较少被深入研究的领域:可变状态和派生。我们将研究差异化的本质,以及不可变响应式和可变响应式如何共存。

文章来源:https://dev.to/this-is-learning/async-derivations-in-reactivity-ec5