Мутабельные сторы
@virentia/mutable — стор для @virentia/core, чей .value это глубоко мутабельный объект: мутируешь на месте, без иммутабельных церемоний и без зависимостей.
pnpm add @virentia/mutableimport { computed, event, reaction } from "@virentia/core";
import { mutableStore } from "@virentia/mutable";
const itemAdded = event<Item>();
const cart = mutableStore({ items: [] as Item[], total: 0 });
const itemCount = computed(() => cart.value.items.length);
reaction({
on: itemAdded,
run(item) {
cart.value.items.push(item); // мутируем на месте — вложенные объекты и массивы
cart.value.total += item.price;
},
});Чтение .value регистрирует зависимость по keypath, поэтому computed, авто-реакция или map перезапускаются только когда изменена именно та часть, которую они читали — см. Гранулярная реактивность. Каждый scope хранит своё значение. См. Правила scope.
Глубоко трекаются только plain object и array; Date, Map, Set и инстансы классов — листья, заменяются целиком.
Бенчмарки
ops/sec (больше — лучше) против immer produce и mutative create, на массиве из 50 000 элементов с растущим числом изменённых за обновление. @virentia/mutable мутирует принадлежащие scope узлы на месте — без копии дерева, — поэтому впереди обоих.
Кейсы описаны в tests/cow.bench.ts; воспроизводится через pnpm bench. Цифры зависят от нагрузки и машины.
API
mutableStore(initial)→MutableStore<T>:.value(get: мутабельный draft; set: замена целиком),.node,.subscribe(fn),.map(fn).seedMutableStore(scope, store, value)— задать значение scope (тесты, SSR).unwrap(value)— сырой объект за мутабельным прокси.
Гранулярная реактивность
Draft трекает чтения по keypath. computed, map или авто-реакция подписываются только на пути, которые реально прочитали, поэтому мутация одной ветки перезапускает только читателей этой ветки — та же мелкая гранулярность, что обычный store получает из структурного разделения, но с мутацией на месте.
const cart = mutableStore({ items: [] as Item[], coupon: null as string | null });
const count = computed(() => cart.value.items.length);
const coupon = computed(() => cart.value.coupon);
cart.value.items.push(item);
// Перезапустит только `count` — `coupon` не читал `items`, поэтому не тронут.Правило простое: «на что читал — на то и подписан». Чтение cart.value.items[3].text подписывает на items, items[3] и items[3].text: последующее cart.value.items[3].text = "…" перезапустит этого читателя, а правка items[4] — нет.
map и авто-реакции подчиняются тому же правилу — они читают .value через тот же draft:
const total = cart.map((c) => c.items.reduce((sum, item) => sum + item.price, 0));
reaction(() => {
// Читает только `coupon` → перезапустится только при изменении купона.
banner.value = cart.value.coupon ? "Скидка применена" : "";
});В компоненте подписывайтесь на срез через map/computed, чтобы он перерендеривался точечно — см. React и Vue.
Два чтения намеренно грубые, потому что зависят от значения целиком, а не от среза:
unwrap(cart.value)берёт сырой объект, поэтому зависит от любого изменения.store.subscribe(...)иuseUnit(store)на весь стор читают всё значение и срабатывают на каждый commit.
Когда использовать
Берите мутабельный стор для состояния, которое вы правите на месте, вглубь и часто — форма, rich-text или canvas-документ, большая таблица, которую вы патчите по ячейкам. Написать
doc.value.blocks[3].items[7].text = "hello";проще, чем пересобирать этот путь руками, и производные остаются гранулярными: перезапустятся только читатели blocks[3].items[7].text.
Два момента, оба — следствие мутации на месте:
- Identity нестабилен.
cart.valueмутируется на месте, поэтому ссылка на объект может не измениться до и после записи. Не сравнивайте снимки через===, чтобы поймать изменение — зависьте от того, что читаете, или берите обычныйstore, когда нужна identity значения. - Подписчики на весь объект — грубые.
store.subscribeиuseUnit(store)читают всё значение и срабатывают на каждый commit. Подписывайтесь на срез черезmap/computed, когда потребителю нужна лишь часть состояния.
Для плоского состояния, которое вы чаще заменяете целиком, чем правите вглубь, обычный store или reactive проще и даёт стабильную identity значения.
Как это устроено
Этот раздел объясняет, как мутабельный стор работает под API. Для использования пакета он не нужен — он здесь для отладки и понимания стоимости.
Значение — copy-on-write draft
Чтение .value возвращает Proxy над текущим закоммиченным объектом scope — draft. Пока вы только читаете, draft форвардит каждый доступ к нижележащему объекту; ничего не копируется, закоммиченное значение не трогается.
При первой записи в узел draft делает shallow-копию только этого узла и протягивает копию вверх по родителям, так что каждый предок начинает ссылаться на новый дочерний. Ветки-соседи, которые вы не трогали, сохраняют исходную ссылку. Никакого structuredClone и никакого полного снэпшота — это тот же copy-on-write, что и у immer с mutative.
Поскольку целью записи draft-узла служит реальный массив/объект, length, индексы и перечисление работают корректно, а push/splice/sort выполняются как нативные методы массива над копией.
Владение: мутация на месте после первого расхождения
Стор мутируют снова и снова, поэтому пересоздавать одни и те же узлы на каждое обновление расточительно. Каждый scope помнит узлы, которые уже скопировал (свой набор owned). Запись в owned-узел мутирует его на месте — без новой копии, — а запись в ещё общий узел (равный дефолту или прошлому закоммиченному значению) копирует его один раз, а затем владеет им.
Это и есть отличие от immer и mutative, которые на каждый вызов производят новое иммутабельное значение. Поэтому бенчмарки в пользу мутабельного стора: после первого изменения пути последующие изменения — обычные присваивания на месте.
Объект, который вы положили в дерево, считается общим, а не owned: при спуске и мутации он сначала копируется, так что переданный вами объект не мутируется.
Коммит на границе транзакции
Пока вы мутируете, draft не коммитится, и каждая запись фиксирует изменённый keypath. На границе транзакции — сразу для изменения в обычном scoped(...) или батчем в конце реакции/эффекта (см. Транзакции) — последнее дерево draft записывается в scope, и оповещаются изменённые пути. Поскольку мутация на месте может не изменить identity объекта, оповещение принудительное, а не по Object.is: стор дёргает узлы графа для изменённых keypath'ов (плюс один грубый узел для подписчиков на весь объект), так что читатель перезапускается ровно тогда, когда записан прочитанный им путь.
Стор — это настоящий узел графа на @virentia/core/internal, поэтому reaction, computed, subscribe и map наблюдают его как любой стор, а per-scope значения, seed и очистка работают как обычно.
Трекинг по keypath
Пока читатель собирает зависимости — вычисляется computed, выполняется авто-реакция — каждое чтение прокси репортит тронутый keypath, а стор мапит его на лениво созданный узел графа, на который читатель подписывается. Чтение репортит каждый префикс пути (get идёт родитель→ребёнок), поэтому замена предка инвалидирует глубоких читателей; запись репортит только точный путь, поэтому правка соседа — нет. Вне окна трекинга — при обычной мутации — хуки чтения не делают ничего, поэтому путь записи остаётся таким же быстрым, как мутация обычного объекта.
Листья
Проксируются только plain object и array. Date, RegExp, Map, Set и инстансы классов — листья: чтения возвращают их сырыми, а заменяете вы их целиком (state.value.when = new Date()). Мутация внутрь листа (state.value.when.setHours(0)) не отслеживается.
Почему это быстро
Каждый изменённый узел — это один компактный state-объект (класс, ради стабильной формы) плюс один Proxy — без замыканий на узел и без сторонних таблиц поиска. Нетронутые узлы бесплатны (прокси создаются лениво, на доступ). Коммит мутирует на месте, и нет прохода финализации/заморозки — immer по умолчанию замораживает результат, что и доминирует в его стоимости.