异步任务
有时组件需要渲染只能通过_异步_方式获取的数据。这些数据可能需要从服务器、数据库获取,或者通常是从异步 API 中获取或计算得到。
虽然 Lit 的响应式更新生命周期是批量异步的,但 Lit 模板总是_同步_渲染的。在渲染时,模板中使用的数据必须是可读的。要在 Lit 组件中渲染异步数据,你必须等待数据准备就绪,将其存储为可读状态,然后触发新的渲染以同步使用数据。通常还需要考虑在数据获取过程中显示什么内容,以及数据获取失败时如何处理。
@lit/task
包提供了一个 Task
响应式控制器来帮助管理这个异步数据工作流。
Task
是一个控制器,它接收一个异步任务函数,并在其参数发生变化时手动或自动运行该函数。Task 存储任务函数的结果,并在任务函数完成时更新宿主元素,以便在渲染时使用结果。
这是一个使用 Task
通过 fetch()
调用 HTTP API 的示例。每当 productId
参数发生变化时就会调用 API,并且在获取数据时组件会渲染一个加载消息。
import {Task} from '@lit/task';
class MyElement extends LitElement {
@property() productId?: string;
private _productTask = new Task(this, {
task: async ([productId], {signal}) => {
const response = await fetch(`http://example.com/product/${productId}`, {signal});
if (!response.ok) { throw new Error(response.status); }
return response.json() as Product;
},
args: () => [this.productId]
});
render() {
return this._productTask.render({
pending: () => html`<p>正在加载产品...</p>`,
complete: (product) => html`
<h1>${product.name}</h1>
<p>${product.price}</p>
`,
error: (e) => html`<p>错误:${e}</p>`
});
}
}
import {Task} from '@lit/task';
class MyElement extends LitElement {
static properties = {
productId: {},
};
_productTask = new Task(this, {
task: async ([productId], {signal}) => {
const response = await fetch(`http://example.com/product/${productId}`, {signal});
if (!response.ok) { throw new Error(response.status); }
return response.json();
},
args: () => [this.productId]
});
render() {
return this._productTask.render({
pending: () => html`<p>正在加载产品...</p>`,
complete: (product) => html`
<h1>${product.name}</h1>
<p>${product.price}</p>
`,
error: (e) => html`<p>错误:${e}</p>`
});
}
}
Task 处理了许多正确管理异步工作所需的事项:
- 在宿主更新时收集任务参数
- 在参数变化时运行任务函数
- 跟踪任务状态(初始、等待中、完成或错误)
- 保存任务函数的最后完成值或错误
- 在任务状态变化时触发宿主更新
- 处理竞态条件,确保只有最新的任务调用才能完成任务
- 根据当前任务状态渲染正确的模板
- 允许使用
AbortController
中止任务
这消除了代码中正确使用异步数据所需的大部分样板代码,并确保了对竞态条件和其他边缘情况的健壮处理。
什么是异步数据?
Permalink to "什么是异步数据?"异步数据是指不能立即获得,但可能在将来某个时间点可用的数据。例如,不同于可以同步使用的字符串或对象值,promise 在未来提供一个值。
异步数据通常从异步 API 返回,可以有几种形式:
- Promise 或异步函数,如
fetch()
- 接受回调的函数
- 发出事件的对象,如 DOM 事件
- 可观察对象和信号等库
Task 控制器处理 promise,因此无论你的异步 API 形式如何,你都可以将其适配为 promise 以便与 Task 一起使用。
什么是任务?
Permalink to "什么是任务?"Task 控制器的核心是"任务"本身的概念。
任务是一个异步操作,它执行一些工作并在 Promise 中返回数据。任务可以处于几种不同的状态(初始、等待中、完成和错误)并且可以接受参数。
任务是一个通用概念,可以表示任何异步操作。它们最适用于具有请求/响应结构的场景,如网络获取、数据库查询或等待对某个动作的单个事件响应。它们不太适用于自发的或流式操作,如开放式事件流、流式数据库响应等。
npm install @lit/task
Task
是一个响应式控制器,因此它可以响应并触发 Lit 的响应式更新生命周期。
通常,你的组件需要执行的每个逻辑任务都会有一个 Task 对象。将任务作为类的字段安装:
class MyElement extends LitElement {
private _myTask = new Task(this, {/*...*/});
}
class MyElement extends LitElement {
_myTask = new Task(this, {/*...*/});
}
作为类字段,任务状态和值很容易获取:
this._task.status;
this._task.value;
任务声明中最关键的部分是_任务函数_。这是执行实际工作的函数。
任务函数在 task
选项中给出。Task 控制器会自动用参数调用任务函数,这些参数通过单独的 args
回调提供。参数会被检查是否有变化,只有在参数发生变化时才会调用任务函数。
任务函数将任务参数作为_数组_作为第一个参数,将选项参数作为第二个参数:
new Task(this, {
task: async ([arg1, arg2], {signal}) => {
// 在这里执行异步工作
},
args: () => [this.field1, this.field2]
})
任务函数的参数数组和 args 回调应该具有相同的长度。
将 task
和 args
函数写成箭头函数,这样 this
引用就会指向宿主元素。
任务可以处于以下四种状态之一:
INITIAL
:任务尚未运行PENDING
:任务正在运行并等待新值COMPLETE
:任务已成功完成ERROR
:任务出错
任务状态可以在 Task 控制器的 status
字段中获取,它由 TaskStatus
枚举类对象表示,该对象具有 INITIAL
、PENDING
、COMPLETE
和 ERROR
属性。
import {TaskStatus} from '@lit/task';
// ...
if (this.task.status === TaskStatus.ERROR) {
// ...
}
通常,Task 会从 INITIAL
进入 PENDING
,然后进入 COMPLETE
或 ERROR
之一,如果任务重新运行则返回 PENDING
。当任务状态发生变化时,它会触发宿主更新,以便宿主元素可以处理新的任务状态并在需要时进行渲染。
理解任务可能处于的状态很重要,但通常不需要直接访问它。
Task 控制器上有几个与任务状态相关的成员:
status
:任务的状态。value
:如果任务已完成,则为任务的当前值。error
:如果任务出错,则为任务的当前错误。render()
:根据当前状态选择要运行的回调的方法。
渲染任务最简单和最常用的 API 是 task.render()
,因为它会选择正确的代码来运行并为其提供相关数据。
render()
接受一个配置对象,其中包含每个任务状态的可选回调:
initial()
pending()
complete(value)
error(err)
你可以在 Lit 的 render()
方法中使用 task.render()
来根据任务状态渲染模板:
render() {
return html`
${this._myTask.render({
initial: () => html`<p>等待开始任务</p>`,
pending: () => html`<p>任务运行中...</p>`,
complete: (value) => html`<p>任务完成,结果为:${value}</p>`,
error: (error) => html`<p>糟糕,出错了:${error}</p>`,
})}
`;
}
默认情况下,任务会在参数发生变化时运行。这由 autoRun
选项控制,默认值为 true
。
在_自动运行_模式下,任务会在宿主更新时调用 args
函数,将参数与之前的参数进行比较,如果它们发生了变化就调用任务函数。具有空 args
数组的任务运行一次。未定义 args
的任务处于手动模式。
如果 autoRun
设置为 false,任务将处于_手动_模式。在手动模式下,你可以通过调用 .run()
方法来运行任务,可能是在事件处理程序中:
class MyElement extends LitElement {
private _getDataTask = new Task(
this,
{
task: async () => {
const response = await fetch(`example.com/data/`);
return response.json();
},
args: () => []
}
);
render() {
return html`
<button @click=${this._onClick}>获取数据</button>
`;
}
private _onClick() {
this._getDataTask.run();
}
}
class MyElement extends LitElement {
_getDataTask = new Task(
this,
{
task: async () => {
const response = await fetch(`example.com/data/`);
return response.json();
},
args: () => []
}
);
render() {
return html`
<button @click=${this._onClick}>获取数据</button>
`;
}
_onClick() {
this._getDataTask.run();
}
}
在手动模式下,你可以直接向 run()
提供新参数:
this._task.run(['arg1', 'arg2']);
如果没有向 run()
提供参数,它们将从 args
回调中获取。
任务函数可能在之前的任务运行仍在等待时被调用。在这些情况下,等待中的任务运行的结果将被忽略,你应该尝试取消任何未完成的工作或网络 I/O 以节省资源。
你可以使用传递给任务函数的第二个参数的 signal
属性中的 AbortSignal
来实现这一点。当等待中的任务运行被新的运行取代时,传递给等待中运行的 AbortSignal
将被中止,以通知任务运行取消任何等待中的工作。
AbortSignal
不会自动取消任何工作 - 它只是一个信号。要取消某些工作,你必须自己检查信号,或将信号转发给接受 AbortSignal
的其他 API,如 fetch()
或 addEventListener()
。
使用 AbortSignal
最简单的方法是将其转发给接受它的 API,如 fetch()
。
private _task = new Task(this, {
task: async (args, {signal}) => {
const response = await fetch(someUrl, {signal});
// ...
},
});
_task = new Task(this, {
task: async (args, {signal}) => {
const response = await fetch(someUrl, {signal});
// ...
},
});
将信号转发给 fetch()
将导致浏览器在信号被中止时取消网络请求。
你也可以在任务函数中检查信号是否已被中止。你应该在从异步调用返回到任务函数后检查信号。throwIfAborted()
是一种方便的方法:
private _task = new Task(this, {
task: async ([arg1], {signal}) => {
const firstResult = await doSomeWork(arg1);
signal.throwIfAborted();
const secondResult = await doMoreWork(firstResult);
signal.throwIfAborted();
return secondResult;
},
});
_task = new Task(this, {
task: async ([arg1], {signal}) => {
const firstResult = await doSomeWork(arg1);
signal.throwIfAborted();
const secondResult = await doMoreWork(firstResult);
signal.throwIfAborted();
return secondResult;
},
});
有时你想在一个任务完成时运行另一个任务。 如果任务具有不同的参数,这会很有用,这样链式任务可以在第一个任务不再运行时运行。 在这种情况下,它会像缓存一样使用第一个任务。要实现这一点,你可以使用一个任务的值作为另一个任务的参数:
class MyElement extends LitElement {
private _getDataTask = new Task(this, {
task: ([dataId]) => getData(dataId),
args: () => [this.dataId],
});
private _processDataTask = new Task(this, {
task: ([data, param]) => processData(data, param),
args: () => [this._getDataTask.value, this.param],
});
}
class MyElement extends LitElement {
_getDataTask = new Task(this, {
task: ([dataId]) => getData(dataId),
args: () => [this.dataId],
});
_processDataTask = new Task(this, {
task: ([data, param]) => processData(data, param),
args: () => [this._getDataTask.value, this.param],
});
}
你也可以经常使用一个任务函数并等待中间结果:
class MyElement extends LitElement {
private _getDataTask = new Task(this, {
task: ([dataId, param]) => {
const data = await getData(dataId);
return processData(data, param);
},
args: () => [this.dataId, this.param],
});
}
class MyElement extends LitElement {
_getDataTask = new Task(this, {
task: ([dataId, param]) => {
const data = await getData(dataId);
return processData(data, param);
},
args: () => [this.dataId, this.param],
});
}
TypeScript 中更准确的参数类型
Permalink to "TypeScript 中更准确的参数类型"Task 参数类型有时会被 TypeScript 推断得过于宽松。这可以通过使用 as const
转换参数数组来修复。 考虑以下具有两个参数的任务。
class MyElement extends LitElement {
@property() myNumber = 10;
@property() myText = "Hello world";
_myTask = new Task(this, {
args: () => [this.myNumber, this.myText],
task: ([number, text]) => {
// 实现省略
}
});
}
按照上面的写法,任务函数的参数列表类型被推断为 Array<number | string>
。
但理想情况下,这应该被类型化为元组 [number, string]
,因为参数的大小和位置是固定的。
args
的返回值可以写成 args: () => [this.myNumber, this.myText] as const
,这将为 task
函数的参数列表生成一个元组类型。
class MyElement extends LitElement {
@property() myNumber = 10;
@property() myText = "Hello world";
_myTask = new Task(this, {
args: () => [this.myNumber, this.myText] as const,
task: ([number, text]) => {
// 实现省略
}
});
}