LeCodesdocs

signal, computed & effect

Точечная реактивность для состояния приложения. signal хранит значение; чтение .value внутри effect, computed или UI-привязки подписывает её, а запись .value заново выполняет ровно этих подписчиков. Никакого virtual DOM и никакого ре-рендера: запись сигнала ложится точечным обновлением свойства на удержанном дереве UI.

Обзор

TypeScript
const count = signal(0)
const label = computed(() => `Count: ${count.value}`)

const screen = UIScreen([
  UIColumn([
    UIText(() => label.value)                        // привязка текста — обновляется сама
      .style({ color: "white", fontSize: 20 }),
    UIButton([ UIText("+1").style({ color: "white" }) ])
      .style({
        height: 40, borderRadius: 8, justifyContent: "center",
        bgColor: () => (count.value > 9 ? "#22c55e" : "#FF4032"),   // привязка стиля
      })
      .onClick(() => count.value++),
  ]).style({ gap: 8, p: 16 }),
])
Router.push(screen)

Нигде нет ручного label.text = … — привязки перевыполняются, когда меняется count.

API

TypeScript
const s = signal(0)          // Signal<number>
s.value                      // чтение (подписывает внутри effect/computed/привязки)
s.value = 1                  // запись — уведомляет подписчиков, пакетно за микрозадачу
s.peek()                     // чтение БЕЗ подписки

const c = computed(() => s.value * 2)   // Computed<number> — ленивый, кэшируется до смены зависимости
c.value

const dispose = effect(() => {          // выполняется сейчас и снова после смены любого прочитанного сигнала
  console.log("count is", s.value)
})
dispose()                               // остановить; неостановленные effect живут весь запуск приложения
  • Записи пакетируются: несколько записей .value = за один тик дают одно перевыполнение на каждый затронутый effect (на следующей микрозадаче). Запись равного значения (Object.is) — no-op.
  • Зависимости отслеживаются на каждый прогон: ветка, переставшая читать сигнал, отписывается от него.
  • effect возвращает свою функцию остановки. UI-привязкам остановка не нужна — они живут и умирают вместе со своим элементом.

UI-привязки

Везде, где UI-элемент принимает текстовую строку или примитивное значение стиля, функция () => value вместо этого создаёт привязку:

TypeScript
UIText(() => `${items.value.length} items`)          // реактивный текст
el.style({ opacity: () => (open.value ? 1 : 0) })    // реактивное свойство стиля (форма слияния)
el.style.transform = () => `translateY(${y.value}px)` // реактивная запись одного свойства

Правила:

  • Привязки выполняются один раз синхронно при создании, поэтому элемент сразу держит конкретное значение.
  • Последующий .style({ prop: staticValue }) заменяет привязку по этому ключу; последующий .style({ prop: () => … }) перепривязывает её. (Прямая запись el.style.prop = staticValue не снимает привязку — следующая смена сигнала перезапишет её.)
  • Только верхний уровень: функции-значения внутри вложенных блоков состояний (onPressed: { … }, onLandscape: { … }) — не привязки.
  • Ручная установка .text на привязанном UIText работает, но перезаписывается при следующей смене сигнала.

Реактивные дети

Контейнер также принимает функцию в позиции детей — список сам себя перерисовывает, когда меняется прочитанный им сигнал:

TypeScript
const todos = signal([ { title: "one", done: false } ])

UIColumn(() => todos.value.map(todo =>
  UIRow([ UIText(todo.title) ])
))

// условный рендер — ложные записи просто исчезают:
UIColumn(() => [ header, expanded.value && details ])

todos.value = [ ...todos.value, { title: "two", done: false } ]   // вставлен один узел, остальные не тронуты

Обновления минимальны: рантайм сравнивает по идентичности элементов — ушедшие узлы удаляются, новые вставляются на свою позицию, неизменные остаются смонтированными. Обычный .map(…) внутри функции-детей мемоизируется покомпонентно компилятором (через внутренний хелпер __uiMap — сам его никогда не зови), поэтому объект-элемент, оставшийся в списке, сохраняет свой элемент между обновлениями. Отсюда правила:

  • Идентичность элемента — это ссылка на объект. Обновляй списки, записывая новый массив, переиспользующий неизменные объекты-элементы ([ ...items.value, added ], .filter(…)). Пересоздание каждого объекта-элемента при каждой записи (items.value.map(i => ({ ...i }))) вызывает полную перерисовку.
  • Колбэк рендера выполняется один раз на новый элемент (как компонент), а не на каждое изменение — не клади туда побочные эффекты в расчёте на прогон при каждом обновлении.
  • Контейнер с функцией-детьми принадлежит этой привязке — не вызывай на нём ещё и .append() / .remove() вручную.
  • Переставленный элемент удаляется и вставляется заново (его состояние — фокус ввода, скролл — сбрасывается).
  • Для сотен строк используй UIVirtualizedList; функции-дети — для маленьких и средних списков.

el.setContent(() => …) привязывается так же.

Реактивные классы: bindClass

Привяжи $-класс-стиля к сигналу — переходы, объявленные на классе, применяются автоматически:

TypeScript
row.style({ $done: { opacity: 0.4, duration: 150 } })
   .bindClass("done", () => todo.done.value)

Автоматическая обёртка (что компилятор делает за тебя)

В коде проекта текстовый аргумент UIText или значение стиля верхнего уровня, читающее .value сигнала, оборачивается в привязку на этапе компиляции — поэтому явная форма () => в этих позициях необязательна:

TypeScript
UIText(`Count: ${count.value}`)                    // компилируется в UIText(() => `…`)
btn.style({ bgColor: active.value ? "#f43" : "#333" })   // компилируется в привязку стиля

Формы с () => остаются валидными и ведут себя идентично. Чтения внутри твоих собственных функций-хелперов (UIText(fmt())) не распознаются — там используй явную стрелку.

Подводные камни

TypeScript
// ✗ сначала прочитать .value в обычную переменную — чтение происходит один раз, дальше ничего не обновляется
const n = count.value
UIText(`Count: ${n}`)
// ✓ читай .value прямо в текстовом выражении (авто-обёртка) или пиши привязку явно
UIText(`Count: ${count.value}`)
UIText(() => `Count: ${count.value}`)

// ✗ сигнал с массивом, изменённым на месте — та же ссылка, уведомления нет
items.value.push(x)
// ✓ запиши новую ссылку
items.value = [ ...items.value, x ]

// ✗ ждать, что DOM изменится синхронно после записи (обновления сбрасываются на микрозадаче)
count.value = 5; /* label ещё не перерисован на этой строке */

Смотрите также