信号
信号是用于管理应用程序状态的响应式基元。
信号的独特之处在于,状态更改会自动以尽可能高效的方式更新组件和 UI。自动状态绑定和依赖项跟踪使信号能够提供出色的符合人体工程学和生产力,同时消除最常见的状态管理陷阱。
信号在任何规模的应用程序中都非常有效,其符合人体工程学的设计可以加速小型应用程序的开发,其性能特性可确保任何规模的应用程序默认情况下都很快。
简介
JavaScript 中状态管理的许多痛苦之处在于对给定值的更改做出反应,因为值不可直接观察。解决方案通常通过将值存储在变量中并持续检查它们是否已更改来解决此问题,这既麻烦又不适合性能。理想情况下,我们希望有一种方法来表达一个值,告诉我们它何时更改。这就是信号的作用。
从本质上讲,信号是一个具有 .value
属性的对象,该属性保存一个值。这具有一个重要特征:信号的值可以改变,但信号本身始终保持不变
import { signal } from "@preact/signals";
const count = signal(0);
// Read a signal’s value by accessing .value:
console.log(count.value); // 0
// Update a signal’s value:
count.value += 1;
// The signal's value has changed:
console.log(count.value); // 1
在 REPL 中运行在 Preact 中,当信号作为 props 或上下文向下传递到树时,我们仅传递对信号的引用。信号可以在不重新渲染任何组件的情况下更新,因为组件看到的是信号而不是其值。这让我们能够跳过所有昂贵的渲染工作,并立即跳转到实际访问信号的 .value
属性的树中的任何组件。
信号具有第二个重要特征,即它们会跟踪其值何时被访问以及何时被更新。在 Preact 中,从组件内访问信号的 .value
属性会自动在该信号的值发生更改时重新渲染组件。
import { signal } from "@preact/signals";
// Create a signal that can be subscribed to:
const count = signal(0);
function Counter() {
// Accessing .value in a component automatically re-renders when it changes:
const value = count.value;
const increment = () => {
// A signal is updated by assigning to the `.value` property:
count.value++;
}
return (
<div>
<p>Count: {value}</p>
<button onClick={increment}>click me</button>
</div>
);
}
在 REPL 中运行最后,信号已深度集成到 Preact 中,以提供最佳性能和人体工程学。在上面的示例中,我们访问了 count.value
以检索 count
信号的当前值,但这没有必要。相反,我们可以让 Preact 通过在 JSX 中直接使用 count
信号为我们完成所有工作
import { signal } from "@preact/signals";
const count = signal(0);
function Counter() {
return (
<div>
<p>Count: {count}</p>
<button onClick={() => count.value++}>click me</button>
</div>
);
}
在 REPL 中运行安装
可以通过将 @preact/signals
包添加到项目来安装信号
npm install @preact/signals
通过你选择的包管理器安装后,你就可以在应用中导入它了。
使用示例
让我们在真实场景中使用信号。我们将构建一个待办事项列表应用,你可以在其中添加和删除待办事项列表中的项目。我们将从建模状态开始。首先,我们需要一个包含待办事项列表的信号,我们可以用一个 Array
来表示
import { signal } from "@preact/signals";
const todos = signal([
{ text: "Buy groceries" },
{ text: "Walk the dog" },
]);
为了让用户输入新待办事项的文本,我们需要一个更多信号,稍后我们将把它连接到一个 <input>
元素。现在,我们可以使用此信号来创建一个将待办事项添加到列表中的函数。记住,我们可以通过分配给它的 .value
属性来更新信号的值
// We'll use this for our input later
const text = signal("");
function addTodo() {
todos.value = [...todos.value, { text: text.value }];
text.value = ""; // Clear input value on add
}
:bulb: 提示:只有当你给信号分配一个新值时,它才会更新。如果你分配给信号的值等于其当前值,则它不会更新。
const count = signal(0); count.value = 0; // does nothing - value is already 0 count.value = 1; // updates - value is different
让我们检查到目前为止我们的逻辑是否正确。当我们更新 text
信号并调用 addTodo()
时,我们应该看到一个新项目被添加到 todos
信号中。我们可以通过直接调用这些函数来模拟此场景 - 暂时不需要用户界面!
import { signal } from "@preact/signals";
const todos = signal([
{ text: "Buy groceries" },
{ text: "Walk the dog" },
]);
const text = signal("");
function addTodo() {
todos.value = [...todos.value, { text: text.value }];
text.value = ""; // Reset input value on add
}
// Check if our logic works
console.log(todos.value);
// Logs: [{text: "Buy groceries"}, {text: "Walk the dog"}]
// Simulate adding a new todo
text.value = "Tidy up";
addTodo();
// Check that it added the new item and cleared the `text` signal:
console.log(todos.value);
// Logs: [{text: "Buy groceries"}, {text: "Walk the dog"}, {text: "Tidy up"}]
console.log(text.value); // Logs: ""
在 REPL 中运行我们想要添加的最后一个功能是从列表中删除待办事项的能力。为此,我们将添加一个函数,从 todos 数组中删除给定的待办事项
function removeTodo(todo) {
todos.value = todos.value.filter(t => t !== todo);
}
构建 UI
现在我们已经对应用程序的状态进行了建模,是时候将其连接到用户可以与之交互的漂亮 UI 了。
function TodoList() {
const onInput = event => (text.value = event.currentTarget.value);
return (
<>
<input value={text.value} onInput={onInput} />
<button onClick={addTodo}>Add</button>
<ul>
{todos.value.map(todo => (
<li>
{todo.text}{' '}
<button onClick={() => removeTodo(todo)}>❌</button>
</li>
))}
</ul>
</>
);
}
有了它,我们就有了一个完全可用的待办事项应用!你可以尝试一下完整的应用 在这里 :tada
通过计算信号派生状态
让我们为待办事项应用程序添加一个功能:可以将每个待办事项标记为已完成,并且我们将向用户显示他们已完成的项目数量。为此,我们将导入 computed(fn)
函数,该函数允许我们基于其他信号的值创建一个新的信号。返回的计算信号是只读的,并且当从回调函数中访问的任何信号发生更改时,其值会自动更新。
import { signal, computed } from "@preact/signals";
const todos = signal([
{ text: "Buy groceries", completed: true },
{ text: "Walk the dog", completed: false },
]);
// create a signal computed from other signals
const completed = computed(() => {
// When `todos` changes, this re-runs automatically:
return todos.value.filter(todo => todo.completed).length;
});
// Logs: 1, because one todo is marked as being completed
console.log(completed.value);
在 REPL 中运行我们简单的待办事项列表应用程序不需要很多计算信号,但更复杂的应用程序倾向于依赖 computed() 来避免在多个地方复制状态。
:bulb: 提示:尽可能多地派生状态可确保你的状态始终具有单一的事实来源。这是信号的一个关键原则。如果应用程序逻辑稍后出现缺陷,这将使调试变得更加容易,因为需要担心的位置更少。
管理全局应用程序状态
到目前为止,我们只在组件树外部创建了信号。对于像待办事项列表这样的小应用程序来说,这是可以的,但对于更大更复杂的应用程序,这会使测试变得困难。测试通常涉及更改应用程序状态中的值以重现特定场景,然后将该状态传递给组件并在呈现的 HTML 上进行断言。为此,我们可以将待办事项列表状态提取到一个函数中
function createAppState() {
const todos = signal([]);
const completed = computed(() => {
return todos.value.filter(todo => todo.completed).length
});
return { todos, completed }
}
:bulb: 提示:请注意,我们有意不在这里包含
addTodo()
和removeTodo(todo)
函数。将数据与其修改函数分开通常有助于简化应用程序架构。有关更多详细信息,请查看 面向数据的架构。
我们现在可以在渲染时将待办事项应用程序状态作为道具传递
const state = createAppState();
// ...later:
<TodoList state={state} />
这在我们的待办事项列表应用程序中有效,因为状态是全局的,但是较大的应用程序通常最终会出现多个组件,这些组件需要访问相同的状态片段。这通常涉及将状态“提升”到一个通用的共享祖先组件。为了避免通过道具手动将状态传递给每个组件,可以将状态放入 Context 中,以便树中的任何组件都可以访问它。以下是如何实现的快速示例
import { createContext } from "preact";
import { useContext } from "preact/hooks";
import { createAppState } from "./my-app-state";
const AppState = createContext();
render(
<AppState.Provider value={createAppState()}>
<App />
</AppState.Provider>
);
// ...later when you need access to your app state
function App() {
const state = useContext(AppState);
return <p>{state.completed}</p>;
}
如果你想了解有关上下文如何工作的更多信息,请前往 Context 文档。
带有信号的本地状态
大多数应用程序状态最终都会使用道具和上下文传递。但是,在许多情况下,组件都有自己的内部状态,该状态特定于该组件。由于没有理由让此状态作为应用程序全局业务逻辑的一部分存在,因此应将其限制在需要它的组件中。在这些情况下,我们可以在组件中直接使用 useSignal()
和 useComputed()
钩子创建信号以及计算信号
import { useSignal, useComputed } from "@preact/signals";
function Counter() {
const count = useSignal(0);
const double = useComputed(() => count.value * 2);
return (
<div>
<p>{count} x 2 = {double}</p>
<button onClick={() => count.value++}>click me</button>
</div>
);
}
这两个钩子是 signal()
和 computed()
的薄包装器,它们在组件首次运行时构造一个信号,并在后续渲染中简单地使用相同的信号。
:bulb: 幕后,这是实现
function useSignal(value) { return useMemo(() => signal(value), []); }
高级信号用法
我们到目前为止讨论的主题是你开始所需要的一切。以下部分针对想要通过完全使用信号对应用程序状态进行建模来获得更多好处的读者。
在组件外部对信号做出反应
在组件树外部使用信号时,你可能已经注意到,除非你主动读取计算信号的值,否则它们不会重新计算。这是因为信号默认是惰性的:它们仅在访问其值时才计算新值。
const count = signal(0);
const double = computed(() => count.value * 2);
// Despite updating the `count` signal on which the `double` signal depends,
// `double` does not yet update because nothing has used its value.
count.value = 1;
// Reading the value of `double` triggers it to be re-computed:
console.log(double.value); // Logs: 2
这提出了一个问题:我们如何在组件树外部订阅信号?也许我们希望在信号值发生变化时将某些内容记录到控制台,或者将状态持久保存到 LocalStorage。
要响应信号变化运行任意代码,我们可以使用 effect(fn)
。与计算信号类似,效果会跟踪访问哪些信号,并在这些信号发生变化时重新运行其回调。与计算信号不同,effect()
不会返回信号 - 它是变更序列的结尾。
import { signal, computed, effect } from "@preact/signals-core";
const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => `${name.value} ${surname.value}`);
// Logs name every time it changes:
effect(() => console.log(fullName.value));
// Logs: "Jane Doe"
// Updating `name` updates `fullName`, which triggers the effect again:
name.value = "John";
// Logs: "John Doe"
或者,你可以从提供给 effect()
的回调中返回一个清理函数,该函数将在进行下一次更新之前运行。这允许你“清理”副作用并可能重置回调的后续触发器的任何状态。
effect(() => {
Chat.connect(username.value)
return () => Chat.disconnect(username.value)
})
你可以通过调用返回的函数来销毁效果并取消订阅它访问的所有信号。
import { signal, effect } from "@preact/signals-core";
const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => name.value + " " + surname.value);
const dispose = effect(() => console.log(fullName.value));
// Logs: "Jane Doe"
// Destroy effect and subscriptions:
dispose();
// Updating `name` does not run the effect because it has been disposed.
// It also doesn't re-compute `fullName` now that nothing is observing it.
name.value = "John";
:bulb: 提示:如果你广泛使用效果,请不要忘记清理效果。否则,你的应用程序将消耗比需要更多的内存。
在不订阅信号的情况下读取信号
在极少数情况下,你需要在 effect(fn)
中写入信号,但不想在该信号发生变化时重新运行效果,你可以使用 .peek()
在不订阅的情况下获取信号的当前值。
const delta = signal(0);
const count = signal(0);
effect(() => {
// Update `count` without subscribing to `count`:
count.value = count.peek() + delta.value;
});
// Setting `delta` reruns the effect:
delta.value = 1;
// This won't rerun the effect because it didn't access `.value`:
count.value = 10;
:bulb: 提示:不想订阅信号的情况很少见。在大多数情况下,你希望你的效果订阅所有信号。仅在你真正需要时才使用
.peek()
。
将多个更新合并为一个
还记得我们在待办事项应用程序中之前使用的 addTodo()
函数吗?以下是它看起来的样子
const todos = signal([]);
const text = signal("");
function addTodo() {
todos.value = [...todos.value, { text: text.value }];
text.value = "";
}
请注意,该函数触发两个独立的更新:一个是在设置 todos.value
时,另一个是在设置 text
的值时。这有时可能是不可取的,并有理由将这两个更新合并为一个,以提高性能或出于其他原因。batch(fn)
函数可用于将多个值更新合并到回调结束时的“提交”中
function addTodo() {
batch(() => {
todos.value = [...todos.value, { text: text.value }];
text.value = "";
});
}
访问在批处理中已修改的信号将反映其更新的值。在批处理中访问被另一个信号使无效的计算信号将仅重新计算必要的依赖项,以返回该计算信号的最新值。任何其他无效信号保持不受影响,并且仅在批处理回调结束时更新。
import { signal, computed, effect, batch } from "@preact/signals-core";
const count = signal(0);
const double = computed(() => count.value * 2);
const triple = computed(() => count.value * 3);
effect(() => console.log(double.value, triple.value));
batch(() => {
// set `count`, invalidating `double` and `triple`:
count.value = 1;
// Despite being batched, `double` reflects the new computed value.
// However, `triple` will only update once the callback completes.
console.log(double.value); // Logs: 2
});
在 REPL 中运行:bulb: 提示:批处理也可以嵌套,在这种情况下,批处理更新仅在外层批处理回调完成后才刷新。
渲染优化
借助信号,我们可以绕过虚拟 DOM 渲染,并将信号更改直接绑定到 DOM 突变。如果你在文本位置将信号传递到 JSX 中,它将作为文本呈现,并在不进行虚拟 DOM 差异的情况下自动就地更新
const count = signal(0);
function Unoptimized() {
// Re-renders the component when `count` changes:
return <p>{count.value}</p>;
}
function Optimized() {
// Text automatically updates without re-rendering the component:
return <p>{count}</p>;
}
要启用此优化,请将信号传递到 JSX 中,而不是访问其 .value
属性。
在将信号作为 DOM 元素上的属性传递时,也支持类似的渲染优化。
API
本节概述了信号 API。它旨在为已经知道如何使用信号并需要提醒可用功能的人员提供快速参考。
signal(initialValue)
使用给定参数作为其初始值创建新的信号
const count = signal(0);
在组件中创建信号时,请使用钩子变体:useSignal(initialValue)
。
返回的信号具有 .value
属性,可以获取或设置该属性以读取和写入其值。要从信号中读取而不订阅它,请使用 signal.peek()
。
computed(fn)
创建新的信号,该信号基于其他信号的值计算。返回的计算信号是只读的,并且当从回调函数中访问的任何信号更改时,其值将自动更新。
const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => `${name.value} ${surname.value}`);
在组件中创建计算信号时,请使用钩子变体:useComputed(fn)
。
effect(fn)
要响应信号更改运行任意代码,我们可以使用 effect(fn)
。与计算信号类似,效果会跟踪访问哪些信号,并在这些信号更改时重新运行其回调。如果回调返回函数,则该函数将在下一次值更新之前运行。与计算信号不同,effect()
不会返回信号 - 它是更改序列的结束。
const name = signal("Jane");
// Log to console when `name` changes:
effect(() => console.log('Hello', name.value));
// Logs: "Hello Jane"
name.value = "John";
// Logs: "Hello John"
在组件中响应信号更改时,请使用钩子变体:useSignalEffect(fn)
。
batch(fn)
batch(fn)
函数可用于将多个值更新合并到提供的回调结束时的“提交”中。批处理可以嵌套,并且仅在外层批处理回调完成后才刷新更改。访问在批处理中已修改的信号将反映其更新的值。
const name = signal("Jane");
const surname = signal("Doe");
// Combine both writes into one update
batch(() => {
name.value = "John";
surname.value = "Smith";
});