| title | description |
|---|---|
Сигналы |
Составное реактивное состояние с автоматическим рендерингом |
Сигналы — это реактивные примитивы для управления состоянием приложения.
Уникальность сигналов заключается в том, что изменения состояния автоматически обновляют компоненты и пользовательский интерфейс наиболее эффективным образом. Автоматическое связывание состояний и отслеживание зависимостей позволяет сигналам обеспечивать превосходную эргономику и производительность, устраняя при этом наиболее распространённые проблемы управления состояниями.
Сигналы эффективны в приложениях любого размера, их эргономика ускоряет разработку небольших приложений, а характеристики производительности гарантируют, что приложения любого размера будут работать быстро по умолчанию.
Важно
В этом руководстве мы рассмотрим использование сигналов в Preact, и хотя это в значительной степени применимо как к библиотекам Core, так и к React, есть некоторые различия в использовании. Лучшие рекомендации по их использованию — в соответствующих документациях: @preact/signals-core, @preact/signals-react
Большая часть проблем управления состоянием в JavaScript связана с реакцией на изменения данного значения, поскольку значения не наблюдаются напрямую. Решения обычно обходят эту проблему, сохраняя значения в переменной и постоянно проверяя, не изменились ли они, что обременительно и не идеально для производительности. В идеале нам нужен способ выразить значение, которое сообщит нам, когда оно изменится. Это то, что делают сигналы.
По своей сути сигнал — это объект со свойством .value, содержащим значение. У этого есть важная особенность: значение сигнала может меняться, но сам сигнал всегда остается неизменным:
// --repl
import { signal } from '@preact/signals';
const count = signal(0);
// Получаем значение сигнала, обращаясь к .value:
console.log(count.value); // 0
// Обновляем значение сигнала:
count.value += 1;
// Значение сигнала изменилось:
console.log(count.value); // 1В Preact, когда сигнал передается через дерево в качестве параметра или контекста, мы передаем только ссылки на сигнал. Сигнал можно обновить без повторной визуализации каких-либо компонентов, поскольку компоненты видят сигнал, а не его значение. Это позволяет нам пропустить всю дорогостоящую работу по рендерингу и сразу перейти к любым компонентам в дереве, которые фактически обращаются к свойству .value сигнала.
У сигналов есть вторая важная характеристика: они отслеживают, когда к их значению обращаются и когда оно обновляется. В Preact доступ к свойству .value сигнала изнутри компонента автоматически перерисовывает компонент при изменении значения этого сигнала.
// --repl
import { render } from 'preact';
// --repl-before
import { signal } from '@preact/signals';
// Создаём сигнал, на который можно подписаться:
const count = signal(0);
function Counter() {
// Компонент автоматически перерисовывается при доступе к .value`:
const value = count.value;
const increment = () => {
// Сигнал обновляется путём присвоения значения свойству `.value`:
count.value++;
};
return (
<div>
<p>Счётчик: {value}</p>
<button onClick={increment}>нажми меня</button>
</div>
);
}
// --repl-after
render(<Counter />, document.getElementById('app'));Наконец, сигналы глубоко интегрированы в Preact, чтобы обеспечить максимально возможную производительность и эргономику. В приведенном выше примере мы обратились к count.value, чтобы получить текущее значение сигнала count, однако в этом нет необходимости. Вместо этого мы можем позволить Preact сделать всю работу за нас, используя сигнал count непосредственно в JSX:
// --repl
import { render } from 'preact';
// --repl-before
import { signal } from '@preact/signals';
const count = signal(0);
function Counter() {
return (
<div>
<p>Count: {count}</p>
<button onClick={() => count.value++}>нажми меня</button>
</div>
);
}
// --repl-after
render(<Counter />, document.getElementById('app'));Сигналы можно установить, добавив в проект пакет @preact/signals:
npm install @preact/signalsПосле установки через выбранный вами менеджер пакетов вы готовы импортировать сигналы в свое приложение.
Давайте использовать сигналы в реальном сценарии. Мы собираемся создать приложение списка дел, в котором вы сможете добавлять и удалять элементы. Начнём с моделирования состояния. Сначала нам понадобится сигнал, содержащий список задач, который мы можем представить с помощью массива (Array):
import { signal } from '@preact/signals';
const todos = signal([{ text: 'Купить продукты' }, { text: 'Выгулять собаку' }]);Чтобы позволить пользователю вводить текст для нового элемента задачи, нам понадобится ещё один сигнал, который мы вскоре подключим к элементу <input>. На данный момент мы уже можем использовать этот сигнал для создания функции, которая добавляет элемент задачи в наш список. Помните, мы можем обновить значение сигнала, присвоив его свойству .value:
// Мы будем использовать это для ввода позже.
const text = signal('');
function addTodo() {
todos.value = [...todos.value, { text: text.value }];
text.value = ''; // Очистить входное значение при добавлении
}💡 Совет: Сигнал обновится только в том случае, если вы присвоите ему новое значение. Если значение, которое вы присваиваете сигналу, равно его текущему значению, оно не будет обновляться.
const count = signal(0); count.value = 0; // ничего не делает — значение уже равно 0 count.value = 1; // обновляется — значение другое
Давайте проверим, верна ли наша логика на данный момент. Когда мы обновляем сигнал text и вызываем addTodo(), мы должны увидеть новый элемент, добавляемый к сигналу todos. Мы можем смоделировать этот сценарий, вызывая эти функции напрямую — пользовательский интерфейс пока не нужен!
// --repl
import { signal } from '@preact/signals';
const todos = signal([{ text: 'Купить продукты' }, { text: 'Выгулять собаку' }]);
const text = signal('');
function addTodo() {
todos.value = [...todos.value, { text: text.value }];
text.value = ''; // Сбросить входное значение при добавлении
}
// Проверим, работает ли наша логика
console.log(todos.value);
// Лог: [{text: "Купить продукты"}, {text: "Выгулять собаку"}]
// Имитируем добавление новой задачи
text.value = 'Прибраться';
addTodo();
// Убеждаемся, что задача добавлена в массив, а сигнал `text` очищен:
console.log(todos.value);
// Лог: [{text: "Купить продукты"}, {text: "Выгулять собаку"}, {text: "Прибраться"}]
console.log(text.value); // Лог: ""Последняя функция, которую мы хотели бы добавить, — это возможность удалять элемент задачи из списка. Для этого мы добавим функцию, которая удаляет заданный элемент задачи из массива задач:
function removeTodo(todo) {
todos.value = todos.value.filter(t => t !== todo);
}Теперь, когда мы смоделировали состояние нашего приложения, пришло время подключить к нему красивый пользовательский интерфейс, с которым смогут взаимодействовать пользователи.
function TodoList() {
const onInput = event => (text.value = event.currentTarget.value);
return (
<>
<input value={text.value} onInput={onInput} />
<button onClick={addTodo}>Добавить</button>
<ul>
{todos.value.map(todo => (
<li>
{todo.text} <button onClick={() => removeTodo(todo)}>❌</button>
</li>
))}
</ul>
</>
);
}И теперь у нас есть полностью работающее приложение todo! Вы можете опробовать полную версию приложения здесь 🎉
Давайте добавим ещё одну функцию в наше приложение задач: каждый элемент задачи можно пометить как выполненный, и мы покажем пользователю количество выполненных элементов. Для этого мы импортируем функцию computed(fn), которая позволяет нам создать новый сигнал, вычисляемый на основе значений других сигналов. Возвращённый вычисленный сигнал доступен только для чтения, и его значение автоматически обновляется при изменении любых сигналов, к которым осуществляется доступ из функции обратного вызова.
// --repl
import { signal, computed } from '@preact/signals';
const todos = signal([
{ text: 'Buy groceries', completed: true },
{ text: 'Walk the dog', completed: false }
]);
// Создаём сигнал, вычисляемый из других сигналов
const completed = computed(() => {
// Когда `todos` изменяется, это автоматически повторяется:
return todos.value.filter(todo => todo.completed).length;
});
// Лог: 1, потому что одна задача помечена как выполненная
console.log(completed.value);Нашему простому приложению со списком задач не требуется много вычисляемых сигналов, но более сложные приложения, как правило, полагаются на метод computed(), чтобы избежать дублирования состояния в нескольких местах.
💡 Совет: Получение как можно большего количества состояний гарантирует, что ваше состояние всегда будет иметь единственный источник истины. Это ключевой принцип сигналов. Это значительно упрощает отладку на случай, если в дальнейшем возникнет ошибка в логике приложения, поскольку будет меньше поводов для беспокойства.
До сих пор мы создавали сигналы только вне дерева компонентов. Это подходит для небольшого приложения, такого как список дел, но для более крупных и сложных приложений это может затруднить тестирование. Тесты обычно включают в себя изменение значений состояния вашего приложения для воспроизведения определённого сценария, а затем передачу этого состояния компонентам и утверждение в отображаемом HTML. Для этого мы можем извлечь состояние нашего списка дел в функцию:
function createAppState() {
const todos = signal([]);
const completed = computed(() => {
return todos.value.filter(todo => todo.completed).length;
});
return { todos, completed };
}💡 Совет: Обратите внимание, что мы сознательно не включили сюда функции
addTodo()иremoveTodo(todo). Отделение данных от функций, которые их изменяют, часто помогает упростить архитектуру приложения. Для получения более подробной информации ознакомьтесь со статьёй дизайн, ориентированный на данные.Теперь мы можем передать состояние нашего приложения 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>
);
// ...позже, когда вам понадобится доступ к состоянию вашего приложения
function App() {
const state = useContext(AppState);
return <p>{state.completed}</p>;
}Если вы хотите узнать больше о том, как работает контекст, перейдите к документации о контексте.
Большая часть состояния приложения передается с использованием параметров и контекста. Однако существует множество сценариев, в которых компоненты имеют собственное внутреннее состояние, специфичное для этого компонента. Поскольку нет никаких оснований для того, чтобы это состояние существовало как часть глобальной бизнес-логики приложения, его следует ограничить компонентом, которому оно необходимо. В этих сценариях мы можем создавать сигналы, а также вычисляемые сигналы непосредственно внутри компонентов, используя хуки 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++}>нажми меня</button>
</div>
);
}Эти два хука представляют собой тонкие оболочки вокруг signal() и computed(), которые создают сигнал при первом запуске компонента и просто используют этот же сигнал при последующих рендерингах.
💡 За кулисами реализация выглядит так:
function useSignal(value) { return useMemo(() => signal(value), []); }
Темы, которые мы рассмотрели до сих пор, — это всё, что вам нужно для начала. Следующий раздел предназначен для читателей, которые хотят получить ещё большую выгоду от моделирования состояния своего приложения, полностью используя сигналы.
Работая с сигналами за пределами дерева компонентов, вы, возможно, заметили, что вычисленные сигналы не пересчитываются, если вы активно не читаете их значение. Это связано с тем, что сигналы по умолчанию являются ленивыми: они вычисляют новые значения только тогда, когда к их значению осуществляется доступ.
const count = signal(0);
const double = computed(() => count.value * 2);
// Несмотря на обновление сигнала `count`, от которого зависит сигнал `double`,
// `double` ещё не обновляется, поскольку его значение ещё не использовалось.
count.value = 1;
// Чтение значения `double` приводит к его перерасчёту:
console.log(double.value); // Лог: 2Возникает вопрос: как мы можем подписаться на сигналы вне дерева компонентов? Возможно, мы хотим регистрировать что-то в консоли при каждом изменении значения сигнала или сохранять состояние в LocalStorage.
Чтобы запустить произвольный код в ответ на изменение сигнала, мы можем использовать effect(fn). Подобно вычисляемым сигналам, эффекты отслеживают, к каким сигналам осуществляется доступ, и повторно запускают обратный вызов при изменении этих сигналов. В отличие от вычисляемых сигналов, effect() не возвращает сигнал — это конец последовательности изменений.
import { signal, computed, effect } from '@preact/signals';
const name = signal('Джейн');
const surname = signal('Доу');
const fullName = computed(() => `${name.value} ${surname.value}`);
// Отслеживание `name` при каждом изменении:
effect(() => console.log(fullName.value));
// Лог: "Джейн Доу"
// Обновление `name` обновляет `fullName`, что снова запускает эффект:
name.value = 'Джон';
// Лог: "Джон Доу"Опционально, вы можете вернуть функцию очистки из колбэка, переданного в effect(), которая будет выполнена перед следующим обновлением. Это позволяет «очистить» побочный эффект и, при необходимости, сбросить состояние для следующего вызова колбэка.
effect(() => {
Chat.connect(username.value);
return () => Chat.disconnect(username.value);
});Вы можете уничтожить эффект и отказаться от подписки на все сигналы, к которым он получил доступ, вызвав возвращаемую функцию.
import { signal, effect } from '@preact/signals';
const name = signal('Джейн');
const surname = signal('Доу');
const fullName = computed(() => name.value + ' ' + surname.value);
const dispose = effect(() => console.log(fullName.value));
// Лог: "Джейн Доу"
// Уничтожить эффект и подписки:
dispose();
// Обновление `name` не приводит к эффекту, поскольку оно было удалено.
// Он также не пересчитывает `fullName` теперь, когда за ним никто не наблюдает.
name.value = 'Джон';💡 Совет: не забудьте очистить эффекты, если вы их часто используете. В противном случае ваше приложение будет потреблять больше памяти, чем необходимо.
В тех редких случаях, когда вам нужно записать сигнал внутри effect(fn), но вы не хотите, чтобы эффект повторно запускался при изменении этого сигнала, вы можете использовать .peek(), чтобы получить текущее значение сигнала без подписки.
const delta = signal(0);
const count = signal(0);
effect(() => {
// Обновляем `count` без подписки на `count`:
count.value = count.peek() + delta.value;
});
// Установка значения `delta` повторно запускает эффект:
delta.value = 1;
// Это не приведет к повторному запуску эффекта, поскольку он не получил доступ к `.value`:
count.value = 10;💡 Совет: Сценарии, в которых вы не хотите подписываться на сигнал, встречаются редко. В большинстве случаев вы хотите, чтобы ваш эффект подписывался на все сигналы. Используйте
.peek()только тогда, когда вам это действительно нужно.
В качестве альтернативы .peek() у нас есть функция untracked, которая принимает функцию в качестве аргумента и возвращает результат выполнения этой функции. В untracked вы можете ссылаться на любой сигнал с помощью .value без создания подписки. Это может быть полезно, когда у вас есть многоразовая функция, которая обращается к .value, или вам нужно получить доступ к более чем одному сигналу.
const delta = signal(0);
const count = signal(0);
effect(() => {
// Обновляем `count` без подписки на `count` или `delta`:
count.value = untracked(() => {
return count.value + delta.value;
});
});Помните функцию addTodo(), которую мы использовали ранее в нашем приложении todo? Вот как это выглядело:
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 = '';
});
}Доступ к сигналу, который был изменен в пакете, отразит его обновлённое значение. Доступ к вычисленному сигналу, который был признан недействительным другим сигналом в пакете, приведёт к повторному вычислению только необходимых зависимостей для возврата актуального значения для этого вычисленного сигнала. Любые другие недействительные сигналы остаются неизменными и обновляются только в конце пакетного обратного вызова.
// --repl
import { signal, computed, effect, batch } from '@preact/signals';
const count = signal(0);
const double = computed(() => count.value * 2);
const triple = computed(() => count.value * 3);
effect(() => console.log(double.value, triple.value));
batch(() => {
// Устанавливаем `count`, делая недействительными `double` и `triple`:
count.value = 1;
// Несмотря на пакетную обработку, `double` отражает новое вычисленное значение.
// Однако `triple` будет обновляться только после завершения обратного вызова.
console.log(double.value); // Лог: 2
});💡 Совет: Пакеты также могут быть вложенными, и в этом случае пакетные обновления сбрасываются только после завершения самого внешнего пакетного обратного вызова.
С помощью сигналов мы можем обойти рендеринг Virtual DOM и связать изменения сигналов непосредственно с мутациями DOM. Если вы передаете сигнал в JSX в текстовой позиции, он будет отображаться как текст и автоматически обновляться на месте без различия Virtual DOM:
const count = signal(0);
function Unoptimized() {
// Перерисовывает компонент при изменении `count`:
return <p>{count.value}</p>;
}
function Optimized() {
// Текст автоматически обновляется без повторной отрисовки компонента:
return <p>{count}</p>;
}Чтобы включить эту оптимизацию, передайте весь сигнал в JSX вместо доступа к его свойству .value.
Аналогичная оптимизация рендеринга также поддерживается при передаче сигналов в качестве атрибута в элементах DOM.
Модели предоставляют структурированный способ создания реактивных контейнеров состояния, которые инкапсулируют сигналы, вычисляемые значения, эффекты и действия. Они предлагают чистый паттерн для организации сложной логики состояния, обеспечивая при этом автоматическую очистку и пакетные обновления.
По мере роста сложности приложений управление состоянием с помощью отдельных сигналов может стать неудобным. Модели решают эту проблему, объединяя связанные сигналы, вычисляемые значения и действия в цельные единицы. Это делает ваш код более поддерживаемым, тестируемым и понятным.
Модели дают несколько ключевых преимуществ:
- Инкапсуляция: Группируют связанное состояние и логику вместе, делая очевидным, что к чему относится
- Автоматическая очистка: Эффекты, созданные в моделях, автоматически удаляются при уничтожении модели, предотвращая утечки памяти
- Автоматическая пакетная обработка: Все методы автоматически оборачиваются как действия, обеспечивая оптимальную производительность
- Компонуемость: Модели можно вкладывать друг в друга и комбинировать, при этом родительские модели автоматически управляют жизненным циклом дочерних
- Повторное использование: Модели могут принимать параметры инициализации, что делает их пригодными для повторного использования в разных контекстах
- Тестируемость: Модели можно создавать и тестировать изолированно, без необходимости рендеринга компонентов
Вот простой пример, показывающий, как модели организуют состояние:
import { signal, computed, createModel } from '@preact/signals';
const CounterModel = createModel((initialCount = 0) => {
const count = signal(initialCount);
const doubled = computed(() => count.value * 2);
return {
count,
doubled,
increment() {
count.value++;
},
decrement() {
count.value--;
}
};
});
const counter = new CounterModel(5);
counter.increment();
console.log(counter.count.value); // 6Подробнее о том, как использовать модели в ваших компонентах, и полная справочная информация по API — см. API моделей в разделе API ниже.
В этом разделе представлен обзор API сигналов. Он призван стать кратким справочником для людей, которые уже знают, как использовать сигналы, и нуждаются в напоминании о том, что доступно.
Создает новый сигнал с аргументом initialValue в качестве начального значения:
const count = signal(0);Возвращённый сигнал имеет свойство .value, которое можно получить или установить для чтения и записи его значения. Чтобы прочитать сигнал без подписки на него, используйте signal.peek().
При создании сигналов внутри компонента используйте вариант с хуком: useSignal(initialValue). Он работает аналогично signal(), но использует мемоизацию, чтобы гарантировать использование одного и того же экземпляра сигнала при повторных рендерах компонента.
function MyComponent() {
const count = useSignal(0);
}Создает новый сигнал, который вычисляется на основе значений других сигналов. Возвращённый вычисленный сигнал доступен только для чтения, и его значение автоматически обновляется при изменении любых сигналов, к которым осуществляется доступ из функции обратного вызова.
const name = signal('Джейн');
const surname = signal('Доу');
const fullName = computed(() => `${name.value} ${surname.value}`);При создании вычисляемых сигналов внутри компонента используйте вариант с хуком: useComputed(fn).
function MyComponent() {
const name = useSignal('Jane');
const surname = useSignal('Doe');
const fullName = useComputed(() => `${name.value} ${surname.value}`);
}Чтобы запустить произвольный код в ответ на изменение сигнала, мы можем использовать effect(fn). Подобно вычисляемым сигналам, эффекты отслеживают, к каким сигналам осуществляется доступ, и повторно запускают обратный вызов при изменении этих сигналов. В отличие от вычисляемых сигналов, effect() не возвращает сигнал — это конец последовательности изменений.
const name = signal('Джейн');
// Отображаем сообщение в консоли при изменении `name`:
effect(() => console.log('Привет, ', name.value));
// Лог: "Привет, Джейн"
name.value = 'Джон';
// Лог: "Привет, Джон"При реагировании на изменения сигнала внутри компонента используйте вариант с хуком: useSignalEffect(fn).
function MyComponent() {
const name = useSignal('Джейн');
// Отображаем сообщение в консоли при изменении `name`:
useSignalEffect(() => console.log('Привет, ', name.value));
}Функцию batch(fn) можно использовать для объединения нескольких обновлений значений в одну «фиксацию» в конце предоставленного обратного вызова. Пакеты могут быть вложенными, а изменения сбрасываются только после завершения обратного вызова самого внешнего пакета. Доступ к сигналу, который был изменен в пакете, отразит его обновлённое значение.
const name = signal('Джейн');
const surname = signal('Доу');
// Объединяем обе записи в одно обновление
batch(() => {
name.value = 'Джон';
surname.value = 'Смит';
});Функция untracked(fn) может быть использована для доступа к значению нескольких сигналов без подписки на них.
const name = signal("Джейн");
const surname = signal("Доу");
effect(() => {
untracked(() => {
console.log(`${name.value} ${surname.value}`)
})
})Функция createModel(factory) создаёт конструктор модели из фабричной функции. Фабричная функция может принимать аргументы для инициализации и должна возвращать объект, содержащий сигналы, вычисляемые значения и методы-действия.
import { signal, computed, effect, createModel } from '@preact/signals';
const CounterModel = createModel((initialCount = 0) => {
const count = signal(initialCount);
const doubled = computed(() => count.value * 2);
effect(() => {
console.log('Счётчик изменился:', count.value);
});
return {
count,
doubled,
increment() {
count.value++;
},
decrement() {
count.value--;
}
};
});
// Создаём новый экземпляр модели с помощью `new`
const counter = new CounterModel(5);
counter.increment(); // Обновления автоматически группируются в пакет
console.log(counter.count.value); // 6
console.log(counter.doubled.value); // 12
// Очищаем все эффекты, когда закончили
counter[Symbol.dispose]();- Аргументы фабрики: Фабричные функции могут принимать аргументы для инициализации, что делает модели повторно используемыми с разными конфигурациями.
- Автоматическая пакетная обработка: Все методы, возвращаемые из фабрики, автоматически оборачиваются как действия, поэтому обновления состояния внутри них группируются в пакет и не отслеживаются.
- Автоматическая очистка эффектов: Эффекты, созданные при построении модели, захватываются и автоматически удаляются при уничтожении модели через
Symbol.dispose. - Компонуемые модели: Модели естественно компонуются — эффекты из вложенных моделей захватываются родительской и удаляются вместе с ней при уничтожении родителя.
Модели можно вкладывать друг в друга. При уничтожении родительской модели все эффекты из вложенных моделей автоматически очищаются:
const TodoItemModel = createModel((text) => {
const completed = signal(false);
return {
text,
completed,
toggle() {
completed.value = !completed.value;
}
};
});
const TodoListModel = createModel(() => {
const items = signal([]);
return {
items,
addTodo(text) {
const todo = new TodoItemModel(text);
items.value = [...items.value, todo];
},
removeTodo(todo) {
items.value = items.value.filter(t => t !== todo);
todo[Symbol.dispose]();
}
};
});
const todoList = new TodoListModel();
todoList.addTodo('Купить продукты');
todoList.addTodo('Выгулять собаку');
// Уничтожение родительской модели также очищает все эффекты вложенных моделей
todoList[Symbol.dispose]();Функция action(fn) оборачивает функцию для выполнения в пакетном и неотслеживаемом контексте. Это полезно, когда нужно создать самостоятельные действия вне модели:
import { signal, action } from '@preact/signals';
const count = signal(0);
const incrementBy = action((amount) => {
count.value += amount;
});
incrementBy(5); // Пакетное обновлениеХук useModel доступен как в пакете @preact/signals, так и в @preact/signals-react. Он отвечает за создание экземпляра модели при первом рендере, сохранение того же экземпляра при последующих перерисовках и автоматическое уничтожение модели при размонтировании компонента.
import { signal, createModel } from '@preact/signals';
import { useModel } from '@preact/signals';
const CounterModel = createModel(() => ({
count: signal(0),
increment() {
this.count.value++;
}
}));
function Counter() {
const model = useModel(CounterModel);
return (
<button onClick={() => model.increment()}>
Счётчик: {model.count}
</button>
);
}Для моделей, которым требуются аргументы конструктора, оберните создание экземпляра в фабричную функцию:
const CounterModel = createModel((initialCount) => ({
count: signal(initialCount),
increment() {
this.count.value++;
}
}));
function Counter({ initialValue }) {
// Используйте фабричную функцию для передачи аргументов
const model = useModel(() => new CounterModel(initialValue));
return (
<button onClick={() => model.increment()}>
Счётчик: {model.count}
</button>
);
}Для лучшей инкапсуляции объявляйте интерфейс вашей модели явно и используйте ReadonlySignal для сигналов, которые должны изменяться только через экшены:
import { signal, computed, createModel, ReadonlySignal } from '@preact/signals';
interface Counter {
count: ReadonlySignal<number>;
doubled: ReadonlySignal<number>;
increment(): void;
decrement(): void;
}
const CounterModel = createModel<Counter>(() => {
const count = signal(0);
const doubled = computed(() => count.value * 2);
return {
count,
doubled,
increment() {
count.value++;
},
decrement() {
count.value--;
}
};
});
const counter = new CounterModel();
counter.increment(); // OK
counter.count.value = 10; // Ошибка TypeScript: Cannot assign to 'value'Если вашей модели требуется пользовательская логика очистки, не связанная с сигналами (например, закрытие WebSocket-соединений), используйте эффект без зависимостей, который возвращает функцию очистки:
const WebSocketModel = createModel((url) => {
const messages = signal([]);
const ws = new WebSocket(url);
ws.onmessage = (e) => {
messages.value = [...messages.value, e.data];
};
// Этот эффект выполняется один раз; его очистка запускается при уничтожении
effect(() => {
return () => {
ws.close();
};
});
return {
messages,
send(message) {
ws.send(message);
}
};
});
const chat = new WebSocketModel('wss://example.com/chat');
chat.send('Привет!');
// Закрывает WebSocket-соединение при уничтожении
chat[Symbol.dispose]();Этот паттерн аналогичен useEffect(() => { return cleanup }, []) в React и гарантирует, что очистка происходит автоматически при композиции моделей — родительским моделям не нужно знать о функциях уничтожения вложенных моделей.
Начиная с версии v2.1.0, пакет @preact/signals/utils предоставляет дополнительные вспомогательные компоненты и хуки, упрощающие работу с сигналами.
Компонент <Show> предоставляет декларативный способ условного отображения контента на основе значения сигнала.
import { signal } from '@preact/signals';
import { Show } from '@preact/signals/utils';
const isVisible = signal(false);
function App() {
return (
<Show when={isVisible} fallback={<p>Здесь ничего нет</p>}>
<p>Теперь вы меня видите!</p>
</Show>
);
}
// Вы также можете использовать функцию для доступа к значению
function App() {
return <Show when={isVisible}>{value => <p>Значение: {value}</p>}</Show>;
}Компонент <For> помогает отображать списки из массивов-сигналов с автоматическим кэшированием отрендеренных элементов.
import { signal } from '@preact/signals';
import { For } from '@preact/signals/utils';
const items = signal(['A', 'B', 'C']);
function App() {
return (
<For each={items} fallback={<p>Нет элементов</p>}>
{(item, index) => <div key={index}>Элемент: {item}</div>}
</For>
);
}Хук useLiveSignal(signal) позволяет создать локальный сигнал, который остаётся синхронизированным с внешним сигналом.
import { signal } from '@preact/signals';
import { useLiveSignal } from '@preact/signals/utils';
const external = signal(0);
function Component() {
const local = useLiveSignal(external);
// локальное значение будет автоматически обновляться при изменении внешнего
}Хук useSignalRef(initialValue) создаёт сигнал, который ведёт себя как реф React со свойством .current.
import { useSignalEffect } from '@preact/signals';
import { useSignalRef } from '@preact/signals/utils';
function Component() {
const ref = useSignalRef(null);
useSignalEffect(() => {
if (ref.current) {
console.log('Реф получил значение:', ref.current);
}
});
return (
<div ref={ref}>
Реф был прикреплён к элементу {ref.current?.tagName}.
</div>
);
}Если вы используете Preact Signals в своём приложении, доступны специализированные инструменты для отладки:
- Signals Debug — Инструмент разработки, который предоставляет подробный вывод в консоль об обновлениях сигналов, выполнении эффектов и пересчётах вычисляемых значений.
- Signals DevTools — Визуальный интерфейс DevTools для отладки и визуализации Preact Signals в реальном времени. Его можно встроить прямо на страницу для демонстраций или интегрировать в собственные инструменты.
Примечание: Это инструменты, независимые от фреймворка, из библиотеки Signals. Хотя они отлично работают с Preact, они не являются специфичными именно для Preact.