信号
什么是信号?
Permalink to "什么是信号?"信号是用于管理可观察状态的数据结构。
信号可以保存单个值或依赖于其他信号的计算值。信号是可观察的,因此当它们发生变化时可以通知消费者。由于它们形成了依赖关系图,当依赖项发生变化时,计算信号会重新计算并通知消费者。
信号对于建模和管理共享可观察状态非常有用——许多不同的组件可能访问和/或修改的状态。当信号更新时,每个使用并监视该信号或依赖于它的信号的组件都会更新。
信号是一个通用概念,在 JavaScript 库和框架中有许多不同的实现和变体。现在还有一个 TC39 提案,旨在将信号标准化为 JavaScript 的一部分。
信号 API 通常有三个主要概念:
- 状态信号,保存单个值
- 计算信号,包装可能依赖于其他信号的计算
- 监视器或效果,在信号值变化时运行带有副作用的代码
这是使用提议的标准 JavaScript 信号 API 的示例:
//
// 开发者可能编写的用于构建基于信号的状态的代码...
//
// 状态信号保存值:
const count = new Signal.State(0);
// 计算信号包装使用其他信号的计算:
const doubleCount = new Signal.Computed(() => count.get() * 2);
//
// 通常会在框架和信号消费库内部的较低级别代码...
//
// 当监视的信号发生变化时,监视器会收到通知:
const watcher = new Signal.subtle.Watcher(async () => {
// 通知回调不允许同步访问信号
await 0;
console.log('doubleCount is', doubleCount);
// 监视器运行后必须重新启用:
watcher.watch();
});
watcher.watch(doubleCount);
// 计算信号是惰性的,所以我们需要读取它来运行计算并
// 可能通知监视器:
doubleCount.get();
JavaScript 中有许多信号实现。许多都与框架紧密集成,只能在这些框架内使用,而有些是独立的库,可以从任何其他代码中使用。
虽然具体的信号 API 有一些差异,但它们非常相似。
Preact 的信号库 @preact/signals
是一个相对快速且小巧的独立库,因此我们围绕它构建了第一个 Lit Labs 信号集成包:@lit-labs/preact-signals
。
JavaScript 的信号提案
Permalink to "JavaScript 的信号提案"由于信号 API 之间的强烈相似性、在框架中使用信号实现响应式的增加,以及对使用信号的系统之间互操作性的需求,现在在 TC39 中正在进行标准化信号的提案,地址是 https://github.com/tc39/proposal-signals。
Lit 提供了 @lit-labs/signals
包来与此提案的官方 polyfill 集成。
这个提案对 Web 组件生态系统来说非常令人兴奋。因为所有采用标准的库和框架都将产生兼容的信号,不同的 Web 组件不必使用相同的库就能互操作地消费和产生信号。
更重要的是,信号有潜力成为广泛的状态管理系统和可观察性库(新的或现有的)的基础。这些库中的每一个,如 MobX 或 Redux,目前都需要一个特定的适配器来与 Lit 生命周期进行人体工程学集成。信号标准化可能意味着我们最终只需要一个 Lit 适配器(或者当信号支持内置到核心 Lit 库时根本不需要适配器)。
信号和 Lit
Permalink to "信号和 Lit"Lit 目前提供两个信号集成包:@lit-labs/signals
用于与 TC39 信号提案集成,以及 @lit-labs/preact-signals
用于与 Preact 信号集成。
因为 TC39 信号提案有望成为 JavaScript 系统趋同的唯一信号 API,我们建议使用它,并将在本文档中重点介绍其用法。
从 npm 安装 @lit-labs/signals
:
npm i @lit-labs/signals
@lit-labs/signals
提供三个主要导出:
SignalWatcher
mixin,应用于所有使用信号的类watch()
模板指令,用于通过精确更新监视单个信号html
模板标签,用于自动将监视指令应用于模板绑定
像这样导入它们:
import {SignalWatcher, watch, signal} from '@lit-labs/signals';
@lit-labs/signals
还为方便起见导出了一些 polyfill 的信号 API,以及一个 withWatch()
模板标签工厂,这样需要自定义模板标签的开发者可以轻松添加信号监视功能。
使用 SignalWatcher 自动监视
Permalink to "使用 SignalWatcher 自动监视"使用信号最简单的方法是在定义自定义元素类时应用 SignalWatcher
mixin。应用 mixin 后,你可以在 Lit 生命周期方法(如 render()
)中读取信号;这些信号值的任何变化都会自动启动更新。你可以在任何合适的地方写入信号——例如,在事件处理程序中。
在这个示例中,SharedCounterComponent
读取和写入共享信号。该组件的每个实例都将显示相同的值,并且当值发生变化时它们都会更新。
import {LitElement, html, css} from 'lit';
import {customElement} from 'lit/decorators.js';
import {SignalWatcher, signal} from '@lit-labs/signals';
const count = signal(0);
@customElement('shared-counter')
export class SharedCounterComponent extends SignalWatcher(LitElement) {
static styles = css`
:host {
display: block;
}
`;
render() {
return html`
<p>计数是 ${count.get()}</p>
<button @click=${this.#onClick}>增加</button>
`;
}
#onClick() {
count.set(count.get() + 1);
}
}
<!-- 这两个元素都将显示相同的计数值 -->
<shared-counter></shared-counter>
<shared-counter></shared-counter>
使用 watch()
进行精确更新
Permalink to "使用 watch() 进行精确更新" 信号还可以用于实现"精确"DOM 更新,针对单个绑定而不是整个组件。要做到这一点,我们需要使用 watch()
指令单独监视信号。
出于协调目的,由 watch()
指令触发的更新会被批处理,并仍然参与 Lit 响应式更新生命周期。但是,当给定的 Lit 更新纯粹由 watch()
指令触发时,只有具有变化信号的绑定会被更新;模板中的其余绑定会被跳过。
这个示例与前一个相同,但当 count
信号变化时,只有 ${watch(count)}
绑定会更新:
import {LitElement, html} from 'lit';
import {customElement} from 'lit/decorators.js';
import {SignalWatcher, watch, signal} from '@lit-labs/signals';
const count = signal(0);
@customElement('shared-counter')
export class SharedCounterComponent extends SignalWatcher(LitElement) {
static styles = css`
:host {
display: block;
}
`;
render() {
return html`
<p>计数是 ${watch(count)}</p>
<button @click=${this.#onClick}>增加</button>
`;
}
#onClick() {
count.set(count.get() + 1);
}
}
注意,这种精确更新避免的工作实际上很少:唯一跳过的是 render()
返回的模板的标识检查和 @click
绑定的值检查,这两者都很便宜。
事实上,在大多数情况下,watch()
不会比"普通"的 Lit 模板渲染带来显著的性能改进。这是因为 Lit 已经只为值发生变化的绑定更新 DOM。
watch()
的性能节省通常会随着可以在更新中跳过的模板逻辑和绑定数量的增加而增加,因此在具有大量逻辑和绑定的模板中,节省会更显著。
@lit-labs/signals
还没有信号感知的 repeat()
指令。在此之前,数组内容的更改将执行完整渲染。
使用信号的 html
模板标签进行自动精确更新
Permalink to "使用信号的 html 模板标签进行自动精确更新" @lit-labs/signals
还导出了 Lit 的 html
模板标签的特殊版本,它会自动将 watch()
指令应用于传递给绑定的任何信号值。
这可以方便地避免 watch()
指令的额外字符或没有 watch()
时需要的 signal.get()
调用。
如果你从 @lit-labs/signals
而不是从 lit
导入 html
,你将获得自动监视功能:
import {LitElement} from 'lit';
import {SignalWatcher, html, signal} from '@lit-labs/signals';
// SharedCounterComponent ...
render() {
return html`
<p>计数是 ${count}</p>
<button @click=${this.#onClick}>增加</button>
`;
}
信号的 html
标签还不能很好地与 lit-analyzer 配合使用。分析器会在使用信号的绑定上报告类型错误,因为它看到了 Signal<T>
到 T
的赋值。
确保正确安装 polyfill
Permalink to "确保正确安装 polyfill"@lit-labs/signals
包含 signal-polyfill
包作为依赖项,因此你不需要显式安装其他任何东西就可以开始使用信号。
但由于信号依赖于共享的全局数据结构(信号依赖图),正确安装 polyfill 至关重要:任何页面或应用程序中只能有一个 polyfill 包的副本。
如果安装了多个 polyfill 副本(可能是由于不兼容的版本或其他 npm 问题),则可能会_分区_信号图,导致某些监视器无法与某些信号一起工作,或某些信号不会被跟踪为其他信号的依赖项。
为了防止这种情况,请确保只有一个 signal-polyfill
安装,使用 npm ls
命令:
npm ls signal-polyfill
如果你看到多个 signal-polyfill
列表,并且旁边没有 deduped
,那么你有重复的 polyfill 副本。
你通常可以通过运行以下命令来修复这个问题:
npm dedupe
如果这不起作用,你可能需要更新依赖项,直到在你的包安装中获得单个兼容版本的 signal-polyfill
。
缺失的功能
Permalink to "缺失的功能"@lit-labs/signals
功能尚未完整。有一些设想的功能将使在 Lit 中使用信号更加可行和高效:
- [ ] 信号感知的
repeat()
指令。这将使数组的增量更新更加高效。 - [ ] 使用信号进行存储的
@property()
装饰器,以统一响应式属性和信号。这将使使用通用信号实用程序与 Lit 响应式属性更容易。 - [ ] 用于将方法标记为计算信号的
@computed()
装饰器。由于计算信号是记忆化的,这可以帮助处理昂贵的计算。 - [ ] 用于将方法标记为效果的
@effect()
装饰器。这可以是运行效果的更符合人体工程学的方式,而不是使用单独的实用程序。
有用的资源
Permalink to "有用的资源"signal-utils
Permalink to "signal-utils" signal-utils
npm 包包含许多用于处理 TC39 信号提案的实用程序,包括:
- 基于信号的可观察集合,如
Array
、Map
、Set
、WeakMap
、WeakSet
和Object
- 用于构建具有信号支持字段的类的装饰器
- 效果和反应
这些集合和装饰器对于从信号构建可观察数据模型很有用,在这里你经常需要管理比原始值更复杂的值。
例如,你可以创建一个可观察数组:
import {SignalArray} from 'signal-utils/array';
const numbers = new SignalArray([1, 2, 3]);
从数组读取,如迭代或读取 .length
,将被跟踪为信号访问,而数组的修改,如来自 .push()
或 .pop()
,将通知任何监视器。
装饰器让你可以用可观察字段建模一个类,很像 LitElement
:
import {signal} from 'signal-utils';
class GameState {
@signal
accessor playerOneTotal = 0;
@signal
accessor playerTwoTotal = 0;
@signal
accessor over = false;
readonly rounds = new SignalArray();
recordRound(playerOneScore, playerTwoScore) {
this.playerOneTotal += playerOneScore;
this.playerTwoTotal += playerTwoScore;
this.rounds.push([playerOneScore, playerTwoScore]);
}
}
这个 GameState
类的实例将被访问它的 SignalWatcher 类跟踪,并在游戏状态发生变化时更新。
状态和反馈
Permalink to "状态和反馈"这个包是 Lit Labs 实验性包系列的一部分,正在积极开发中。可能存在缺失的功能、实现中的严重错误,以及比核心 Lit 库更频繁的破坏性变更。
这个包还依赖于一个本身不稳定的提案和 polyfill。随着信号提案的推进,可能会对提议的 API 进行破坏性变更,然后这些变更会应用到 polyfill 中。
我们鼓励谨慎使用,以便我们获得 Lit 集成层的经验并获得反馈,但请仔细管理依赖项并谨慎测试,以便将意外的破坏性变更保持在最低限度。
请在 @lit-labs/signals 反馈讨论中留下反馈,并提交你遇到的任何问题。