你正在查看 Lit 的旧版本文档。点击 这里查看最新版本。

自定义指令

指令是可以通过自定义模板表达式的渲染方式来扩展 Lit 的函数。指令非常实用且强大,因为它们可以是有状态的、能够访问 DOM、在模板断开和重新连接时收到通知,并且可以在渲染调用之外独立更新表达式。

在模板中使用指令就像在模板表达式中调用一个函数一样简单:

html`<div>
${fancyDirective('some text')}
</div>`

Lit 提供了许多内置指令,如 repeat()cache()。用户也可以编写自己的自定义指令。

指令有两种类型:

  • 简单函数
  • 基于类的指令

简单函数返回一个要渲染的值。它可以接受任意数量的参数,也可以不接受任何参数。

export noVowels = (str) => str.replaceAll(/[aeiou]/ig,'x');

基于类的指令让你能够完成简单函数无法做到的事情。使用基于类的指令可以:

  • 直接访问渲染后的 DOM(例如,添加、删除或重新排列渲染后的 DOM 节点)。
  • 在多次渲染之间保持状态。
  • 在渲染调用之外异步更新 DOM。
  • 当指令从 DOM 断开时清理资源。

本页的其余部分将描述基于类的指令。

要创建基于类的指令:

  • 将指令实现为一个继承 Directive 类的类。
  • 将你的类传给 directive() 工厂函数,以创建一个可以在 Lit 模板表达式中使用的指令函数。
import {Directive, directive} from 'lit/directive.js';

// 定义指令
class HelloDirective extends Directive {
render() {
return `Hello!`;
}
}
// 创建指令函数
const hello = directive(HelloDirective);

// 使用指令
const template = html`<div>${hello()}</div>`;

当模板被求值时,指令_函数_(hello())返回一个 DirectiveResult 对象,它指示 Lit 创建或更新指令_类_(HelloDirective)的一个实例。然后 Lit 调用指令实例上的方法来运行其更新逻辑。

有些指令需要在正常的更新周期之外异步更新 DOM。要创建_异步指令_,需要继承 AsyncDirective 基类而不是 Directive。详见异步指令

指令类有几个内置的生命周期方法:

  • 类构造函数,用于一次性初始化。
  • render(),用于声明式渲染。
  • update(),用于命令式 DOM 访问。

所有指令都必须实现 render() 回调。实现 update() 是可选的。update() 的默认实现会调用 render() 并返回其值。

异步指令可以在正常的更新周期之外更新 DOM,它们使用一些额外的生命周期回调。详见异步指令

一次性初始化:constructor()

Permalink to "一次性初始化:constructor()"

当 Lit 第一次在表达式中遇到 DirectiveResult 时,它会构造相应指令类的一个实例(从而触发指令的构造函数和所有类字段初始化器的运行):

class MyDirective extends Directive {
// 类字段只会初始化一次,可用于在多次渲染之间保持状态
value = 0;
// 构造函数仅在表达式中首次使用该指令时运行
constructor(partInfo: PartInfo) {
super(partInfo);
console.log('MyDirective created');
}
...
}
class MyDirective extends Directive {
// 类字段只会初始化一次,可用于在多次渲染之间保持状态
value = 0;
// 构造函数仅在表达式中首次使用该指令时运行
constructor(partInfo) {
super(partInfo);
console.log('MyDirective created');
}
...
}

只要每次渲染时在同一表达式中使用相同的指令函数,之前的实例就会被重用,因此实例的状态会在多次渲染之间保持。

构造函数接收一个 PartInfo 对象,它提供了关于指令所在表达式的元数据。这对于在指令被设计为仅在特定类型的表达式中使用时提供错误检查非常有用(参见限制指令为单一表达式类型)。

render() 方法应该返回要渲染到 DOM 中的值。它可以返回任何可渲染的值,包括另一个 DirectiveResult

除了引用指令实例上的状态外,render() 方法还可以接受传入指令函数的任意参数:

const template = html`<div>${myDirective(name, rank)}</div>`

render() 方法中定义的参数决定了指令函数的签名:

class MaxDirective extends Directive {
maxValue = Number.MIN_VALUE;
// 定义 render 方法,可以接受参数:
render(value: number, minValue = Number.MIN_VALUE) {
this.maxValue = Math.max(value, this.maxValue, minValue);
return this.maxValue;
}
}
const max = directive(MaxDirective);

// 使用 `render()` 中定义的 `value` 和 `minValue` 参数调用指令:
const template = html`<div>${max(someNumber, 0)}</div>`;
class MaxDirective extends Directive {
maxValue = Number.MIN_VALUE;
// 定义 render 方法,可以接受参数:
render(value, minValue = Number.MIN_VALUE) {
this.maxValue = Math.max(value, this.maxValue, minValue);
return this.maxValue;
}
}
const max = directive(MaxDirective);

// 使用 `render()` 中定义的 `value` 和 `minValue` 参数调用指令:
const template = html`<div>${max(someNumber, 0)}</div>`;

在更高级的用例中,你的指令可能需要访问底层 DOM 并命令式地读取或修改它。你可以通过覆盖 update() 回调来实现这一点。

update() 回调接收两个参数:

  • 一个 Part 对象,提供用于直接管理与表达式关联的 DOM 的 API。
  • 一个包含 render() 参数的数组。

你的 update() 方法应该返回 Lit 可以渲染的内容,或者在不需要重新渲染时返回特殊值 noChangeupdate() 回调非常灵活,但典型的用途包括:

  • 从 DOM 读取数据,并用它来生成要渲染的值。
  • 使用 Part 对象上的 elementparentNode 引用命令式地更新 DOM。在这种情况下,update() 通常返回 noChange,表示 Lit 不需要采取任何进一步的操作来渲染该指令。

每个表达式位置都有自己特定的 Part 对象:

  • ChildPart 用于 HTML 子元素位置中的表达式。
  • AttributePart 用于 HTML 属性值位置中的表达式。
  • BooleanAttributePart 用于布尔属性值中的表达式(名称以 ? 为前缀)。
  • EventPart 用于事件监听器位置中的表达式(名称以 @ 为前缀)。
  • PropertyPart 用于属性值位置中的表达式(名称以 . 为前缀)。
  • ElementPart 用于元素标签上的表达式。

除了 PartInfo 中包含的特定于 part 的元数据外,所有 Part 类型都提供对与表达式关联的 DOM element 的访问(对于 ChildPart 则是 parentNode),可以在 update() 中直接访问。例如:

// 将父元素的属性名渲染为 textContent
class AttributeLogger extends Directive {
attributeNames = '';
update(part: ChildPart) {
this.attributeNames = (part.parentNode as Element).getAttributeNames?.().join(' ');
return this.render();
}
render() {
return this.attributeNames;
}
}
const attributeLogger = directive(AttributeLogger);

const template = html`<div a b>${attributeLogger()}</div>`;
// 渲染结果:`<div a b>a b</div>`
// 将父元素的属性名渲染为 textContent
class AttributeLogger extends Directive {
attributeNames = '';
update(part) {
this.attributeNames = part.parentNode.getAttributeNames?.().join(' ');
return this.render();
}
render() {
return this.attributeNames;
}
}
const attributeLogger = directive(AttributeLogger);

const template = html`<div a b>${attributeLogger()}</div>`;
// 渲染结果:`<div a b>a b</div>`

此外,directive-helpers.js 模块包含许多辅助函数,这些函数作用于 Part 对象,可用于在指令的 ChildPart 中动态创建、插入和移动 parts。

update() 的默认实现只是调用 render() 并返回其值。如果你覆盖了 update() 但仍然想调用 render() 来生成值,你需要显式调用 render()

render() 的参数以数组形式传入 update()。你可以像这样将参数传递给 render()

class MyDirective extends Directive {
update(part: Part, [fish, bananas]: DirectiveParameters<this>) {
// ...
return this.render(fish, bananas);
}
render(fish: number, bananas: number) { ... }
}
class MyDirective extends Directive {
update(part, [fish, bananas]) {
// ...
return this.render(fish, bananas);
}
render(fish, bananas) { ... }
}

虽然 update() 回调比 update() 回调更强大,但有一个重要的区别:当使用 @lit-labs/ssr 包进行服务端渲染(SSR)时,服务器上_只会_调用 render() 方法。为了兼容 SSR,指令应该从 render() 返回值,仅将 update() 用于需要访问 DOM 的逻辑。

有时指令可能没有新的内容需要 Lit 渲染。你可以通过从 update()render() 方法返回 noChange 来表示这一点。这与返回 undefined 不同,后者会导致 Lit 清除与指令关联的 Part。返回 noChange 会保留之前渲染的值。

返回 noChange 有几个常见的原因:

  • 根据输入值,没有新的内容需要渲染。
  • update() 方法已经命令式地更新了 DOM。
  • 在异步指令中,对 update()render() 的调用可能返回 noChange,因为_暂时_没有内容可渲染。

例如,指令可以跟踪传入的先前值,并执行自己的脏检查来确定指令的输出是否需要更新。update()render() 方法可以返回 noChange 来表示指令的输出不需要重新渲染。

import {Directive} from 'lit/directive.js';
import {noChange} from 'lit';
class CalculateDiff extends Directive {
a?: string;
b?: string;
render(a: string, b: string) {
if (this.a !== a || this.b !== b) {
this.a = a;
this.b = b;
// 开销较大且花哨的文本差异算法
return calculateDiff(a, b);
}
return noChange;
}
}
import {Directive} from 'lit/directive.js';
import {noChange} from 'lit';
class CalculateDiff extends Directive {
render(a, b) {
if (this.a !== a || this.b !== b) {
this.a = a;
this.b = b;
// 开销较大且花哨的文本差异算法
return calculateDiff(a, b);
}
return noChange;
}
}

限制指令为单一表达式类型

Permalink to "限制指令为单一表达式类型"

有些指令只在特定上下文中有用,例如属性表达式或子元素表达式。如果放置在错误的上下文中,指令应该抛出相应的错误。

例如,classMap 指令验证它只能在 AttributePart 中使用,且只能用于 class 属性:

class ClassMap extends Directive {
constructor(partInfo: PartInfo) {
super(partInfo);
if (
partInfo.type !== PartType.ATTRIBUTE ||
partInfo.name !== 'class'
) {
throw new Error('The `classMap` directive must be used in the `class` attribute');
}
}
...
}
class ClassMap extends Directive {
constructor(partInfo) {
super(partInfo);
if (
partInfo.type !== PartType.ATTRIBUTE ||
partInfo.name !== 'class'
) {
throw new Error('The `classMap` directive must be used in the `class` attribute');
}
}
...
}

前面示例中的指令是同步的:它们从 render()/update() 生命周期回调中同步返回值,因此它们的结果在组件的 update() 回调期间被写入 DOM。

有时,你希望指令能够异步更新 DOM——例如,如果它依赖于异步事件(如网络请求)。

要异步更新指令的结果,指令需要继承 AsyncDirective 基类,该基类提供了 setValue() API。setValue() 允许指令在模板的正常 update/render 周期之外,将新值"推送"到其模板表达式中。

以下是一个简单的异步指令示例,它渲染一个 Promise 的值:

class ResolvePromise extends AsyncDirective {
render(promise: Promise<unknown>) {
Promise.resolve(promise).then((resolvedValue) => {
// 异步渲染:
this.setValue(resolvedValue);
});
// 同步渲染:
return `Waiting for promise to resolve`;
}
}
export const resolvePromise = directive(ResolvePromise);
class ResolvePromise extends AsyncDirective {
render(promise) {
Promise.resolve(promise).then((resolvedValue) => {
// 异步渲染:
this.setValue(resolvedValue);
});
// 同步渲染:
return `Waiting for promise to resolve`;
}
}
export const resolvePromise = directive(ResolvePromise);

在这里,渲染的模板先显示 "Waiting for promise to resolve",然后在 Promise 解析时显示其解析后的值。

异步指令通常需要订阅外部资源。为防止内存泄漏,异步指令应在指令实例不再使用时取消订阅或释放资源。为此,AsyncDirective 提供了以下额外的生命周期回调和 API:

  • disconnected():当指令不再使用时调用。指令实例在以下三种情况下会断开连接:

    • 当指令所在的 DOM 树从 DOM 中移除时
    • 当指令的宿主元素断开连接时
    • 当产生该指令的表达式不再解析为同一指令时

    指令收到 disconnected 回调后,应释放其在 updaterender 期间可能已订阅的所有资源,以防止内存泄漏。

  • reconnected():当先前断开的指令被返回使用时调用。因为 DOM 子树可能会暂时断开然后重新连接,断开的指令可能需要对重新连接做出响应。例如当 DOM 被移除并缓存供后续使用时,或当宿主元素被移动导致断开和重新连接时。reconnected() 回调应始终与 disconnected() 一起实现,以便将断开的指令恢复到工作状态。

  • isConnected:反映指令的当前连接状态。

请注意,AsyncDirective 在断开连接时仍可能继续接收更新(如果其所在子树被重新渲染)。因此,update 和/或 render 在订阅任何长期持有的资源之前,应始终检查 this.isConnected 标志,以防止内存泄漏。

下面是一个订阅 Observable 并正确处理断开和重新连接的指令示例:

class ObserveDirective extends AsyncDirective {
observable: Observable<unknown> | undefined;
unsubscribe: (() => void) | undefined;
// 当 observable 变化时,取消订阅旧的并订阅新的
render(observable: Observable<unknown>) {
if (this.observable !== observable) {
this.unsubscribe?.();
this.observable = observable
if (this.isConnected) {
this.subscribe(observable);
}
}
return noChange;
}
// 订阅 observable,每次值变化时调用指令的异步 setValue API
subscribe(observable: Observable<unknown>) {
this.unsubscribe = observable.subscribe((v: unknown) => {
this.setValue(v);
});
}
// 当指令从 DOM 断开时,取消订阅以确保指令实例可以被垃圾回收
disconnected() {
this.unsubscribe!();
}
// 如果指令所在的子树断开后重新连接,重新订阅以使指令恢复工作
reconnected() {
this.subscribe(this.observable!);
}
}
export const observe = directive(ObserveDirective);
class ObserveDirective extends AsyncDirective {
// 当 observable 变化时,取消订阅旧的并订阅新的
render(observable) {
if (this.observable !== observable) {
this.unsubscribe?.();
this.observable = observable
if (this.isConnected) {
this.subscribe(observable);
}
}
return noChange;
}
// 订阅 observable,每次值变化时调用指令的异步 setValue API
subscribe(observable) {
this.unsubscribe = observable.subscribe((v) => {
this.setValue(v);
});
}
// 当指令从 DOM 断开时,取消订阅以确保指令实例可以被垃圾回收
disconnected() {
this.unsubscribe();
}
// 如果指令所在的子树断开后重新连接,重新订阅以使指令恢复工作
reconnected() {
this.subscribe(this.observable);
}
}
export const observe = directive(ObserveDirective);