异步任务

有时组件需要渲染只能通过_异步_方式获取的数据。这些数据可能需要从服务器、数据库获取,或者通常是从异步 API 中获取或计算得到。

虽然 Lit 的响应式更新生命周期是批量异步的,但 Lit 模板总是_同步_渲染的。在渲染时,模板中使用的数据必须是可读的。要在 Lit 组件中渲染异步数据,你必须等待数据准备就绪,将其存储为可读状态,然后触发新的渲染以同步使用数据。通常还需要考虑在数据获取过程中显示什么内容,以及数据获取失败时如何处理。

@lit/task 包提供了一个 Task 响应式控制器来帮助管理这个异步数据工作流。

Task 是一个控制器,它接收一个异步任务函数,并在其参数发生变化时手动或自动运行该函数。Task 存储任务函数的结果,并在任务函数完成时更新宿主元素,以便在渲染时使用结果。

这是一个使用 Task 通过 fetch() 调用 HTTP API 的示例。每当 productId 参数发生变化时就会调用 API,并且在获取数据时组件会渲染一个加载消息。

Task 处理了许多正确管理异步工作所需的事项:

  • 在宿主更新时收集任务参数
  • 在参数变化时运行任务函数
  • 跟踪任务状态(初始、等待中、完成或错误)
  • 保存任务函数的最后完成值或错误
  • 在任务状态变化时触发宿主更新
  • 处理竞态条件,确保只有最新的任务调用才能完成任务
  • 根据当前任务状态渲染正确的模板
  • 允许使用 AbortController 中止任务

这消除了代码中正确使用异步数据所需的大部分样板代码,并确保了对竞态条件和其他边缘情况的健壮处理。

异步数据是指不能立即获得,但可能在将来某个时间点可用的数据。例如,不同于可以同步使用的字符串或对象值,promise 在未来提供一个值。

异步数据通常从异步 API 返回,可以有几种形式:

  • Promise 或异步函数,如 fetch()
  • 接受回调的函数
  • 发出事件的对象,如 DOM 事件
  • 可观察对象和信号等库

Task 控制器处理 promise,因此无论你的异步 API 形式如何,你都可以将其适配为 promise 以便与 Task 一起使用。

Task 控制器的核心是"任务"本身的概念。

任务是一个异步操作,它执行一些工作并在 Promise 中返回数据。任务可以处于几种不同的状态(初始、等待中、完成和错误)并且可以接受参数。

任务是一个通用概念,可以表示任何异步操作。它们最适用于具有请求/响应结构的场景,如网络获取、数据库查询或等待对某个动作的单个事件响应。它们不太适用于自发的或流式操作,如开放式事件流、流式数据库响应等。

Task 是一个响应式控制器,因此它可以响应并触发 Lit 的响应式更新生命周期。

通常,你的组件需要执行的每个逻辑任务都会有一个 Task 对象。将任务作为类的字段安装:

作为类字段,任务状态和值很容易获取:

任务声明中最关键的部分是_任务函数_。这是执行实际工作的函数。

任务函数在 task 选项中给出。Task 控制器会自动用参数调用任务函数,这些参数通过单独的 args 回调提供。参数会被检查是否有变化,只有在参数发生变化时才会调用任务函数。

任务函数将任务参数作为_数组_作为第一个参数,将选项参数作为第二个参数:

任务函数的参数数组和 args 回调应该具有相同的长度。

taskargs 函数写成箭头函数,这样 this 引用就会指向宿主元素。

任务可以处于以下四种状态之一:

  • INITIAL:任务尚未运行
  • PENDING:任务正在运行并等待新值
  • COMPLETE:任务已成功完成
  • ERROR:任务出错

任务状态可以在 Task 控制器的 status 字段中获取,它由 TaskStatus 枚举类对象表示,该对象具有 INITIALPENDINGCOMPLETEERROR 属性。

通常,Task 会从 INITIAL 进入 PENDING,然后进入 COMPLETEERROR 之一,如果任务重新运行则返回 PENDING。当任务状态发生变化时,它会触发宿主更新,以便宿主元素可以处理新的任务状态并在需要时进行渲染。

理解任务可能处于的状态很重要,但通常不需要直接访问它。

Task 控制器上有几个与任务状态相关的成员:

  • status:任务的状态。
  • value:如果任务已完成,则为任务的当前值。
  • error:如果任务出错,则为任务的当前错误。
  • render():根据当前状态选择要运行的回调的方法。

渲染任务最简单和最常用的 API 是 task.render(),因为它会选择正确的代码来运行并为其提供相关数据。

render() 接受一个配置对象,其中包含每个任务状态的可选回调:

  • initial()
  • pending()
  • complete(value)
  • error(err)

你可以在 Lit 的 render() 方法中使用 task.render() 来根据任务状态渲染模板:

默认情况下,任务会在参数发生变化时运行。这由 autoRun 选项控制,默认值为 true

在_自动运行_模式下,任务会在宿主更新时调用 args 函数,将参数与之前的参数进行比较,如果它们发生了变化就调用任务函数。具有空 args 数组的任务运行一次。未定义 args 的任务处于手动模式。

如果 autoRun 设置为 false,任务将处于_手动_模式。在手动模式下,你可以通过调用 .run() 方法来运行任务,可能是在事件处理程序中:

在手动模式下,你可以直接向 run() 提供新参数:

如果没有向 run() 提供参数,它们将从 args 回调中获取。

任务函数可能在之前的任务运行仍在等待时被调用。在这些情况下,等待中的任务运行的结果将被忽略,你应该尝试取消任何未完成的工作或网络 I/O 以节省资源。

你可以使用传递给任务函数的第二个参数的 signal 属性中的 AbortSignal 来实现这一点。当等待中的任务运行被新的运行取代时,传递给等待中运行的 AbortSignal 将被中止,以通知任务运行取消任何等待中的工作。

AbortSignal 不会自动取消任何工作 - 它只是一个信号。要取消某些工作,你必须自己检查信号,或将信号转发给接受 AbortSignal 的其他 API,如 fetch()addEventListener()

使用 AbortSignal 最简单的方法是将其转发给接受它的 API,如 fetch()

将信号转发给 fetch() 将导致浏览器在信号被中止时取消网络请求。

你也可以在任务函数中检查信号是否已被中止。你应该在从异步调用返回到任务函数后检查信号。throwIfAborted() 是一种方便的方法:

有时你想在一个任务完成时运行另一个任务。 如果任务具有不同的参数,这会很有用,这样链式任务可以在第一个任务不再运行时运行。 在这种情况下,它会像缓存一样使用第一个任务。要实现这一点,你可以使用一个任务的值作为另一个任务的参数:

你也可以经常使用一个任务函数并等待中间结果:

TypeScript 中更准确的参数类型

Permalink to "TypeScript 中更准确的参数类型"

Task 参数类型有时会被 TypeScript 推断得过于宽松。这可以通过使用 as const 转换参数数组来修复。 考虑以下具有两个参数的任务。

按照上面的写法,任务函数的参数列表类型被推断为 Array<number | string>

但理想情况下,这应该被类型化为元组 [number, string],因为参数的大小和位置是固定的。

args 的返回值可以写成 args: () => [this.myNumber, this.myText] as const,这将为 task 函数的参数列表生成一个元组类型。