Транзакции
Транзакция — это граница выполнения синхронной работы модели. Она позволяет писать императивный код и не показывать наблюдателям недописанное состояние.
Эта страница описывает поведение для пользователя. Внутренние механизмы рантайма и причины выбранных решений разобраны во Внутреннем устройстве.
Если коротко:
- синхронные вызовы юнитов работают в одной транзакции;
- записи в сторы попадают в draft транзакции;
- чтения сторов внутри транзакции видят draft;
- сторы коммитятся один раз после внешнего синхронного вызова;
- реакции на изменившиеся сторы запускаются после коммита;
awaitзавершает текущую транзакцию;- lifecycle-сторы эффектов публикуются сразу.
Зачем нужны транзакции
Без транзакций каждая запись в стор сразу уведомляла бы наблюдателей. Это просто реализовать, но неудобно для реального кода модели:
reaction({
on: incremented,
run() {
count.value++;
count.value++;
},
});Полезный результат здесь — count + 2, а не два отдельных рендера UI и две отдельные инвалидации computed. В Virentia обе записи обновляют draft транзакции, а count коммитится один раз с финальным значением.
Что начинает транзакцию
Транзакция начинается при запуске юнита:
await allSettled(submitted, { scope: appScope });То же правило работает для прямых вызовов юнитов внутри активного scope:
scoped(appScope, () => {
submitted();
});Прямая запись в стор вне существующей транзакции создает маленькую неявную транзакцию:
scoped(appScope, () => {
count.value = 1;
});В большинстве случаев пользователю не нужно вручную открывать или закрывать транзакции. Это правило рантайма, а не отдельный публичный примитив.
Чтение draft
Внутри транзакции последующий код читает текущее draft-значение.
reaction({
on: incremented,
run() {
count.value++;
console.log(count.value); // уже включает первый increment
count.value++;
console.log(count.value); // включает оба increment
},
});Снаружи транзакции наблюдатели видят только закоммиченные значения. Подписчики, derived stores, реакции и UI-bindings не видят промежуточное значение после первой записи.
Коммит и уведомления
После завершения внешней синхронной транзакции Virentia коммитит изменившиеся сторы. Каждый изменившийся стор применяет финальное значение и затем уведомляет подписчиков.
unit starts
read stores
write drafts
call nested units
commit changed stores
notify subscribers and derived graph
run reactions caused by committed storesЕсли стор получает то же значение по Object.is, update пропускается.
Реакции, вызванные закоммиченными сторами, запускаются после коммита. Если эти реакции пишут в другие сторы, такие записи батчатся в своей следующей транзакции. Так записи и уведомления разделены: код модели свободно пишет состояние, а наблюдатели реагируют уже на закоммиченный результат.
Явные вложенные вызовы
Явные синхронные вызовы сохраняют обычный порядок JavaScript.
reaction({
on: featureTogglePressed,
run() {
featureEnabled();
legacyModeDisabled();
},
});featureEnabled выполнится раньше legacyModeDisabled. Если обе ветки читают и пишут один стор, вторая ветка увидит draft-изменения первой.
Это сделано специально. Когда пользователь пишет вызовы в конкретном порядке, Virentia уважает этот порядок и не перекладывает работу в скрытые priority layers.
Соседние реакции
Независимые реакции, подписанные на один юнит, отличаются от явных вложенных вызовов.
reaction({
on: submitted,
run() {
count.value = 1;
},
});
reaction({
on: submitted,
run() {
console.log(count.value);
},
});Порядок рантайма детерминированный, но бизнес-логика не должна зависеть от порядка соседних реакций. Если одна соседняя реакция пишет стор, а другая читает этот же стор в той же транзакции, результат зависит от порядка подписки.
WARNING
Считайте такой код order-dependent. Virentia не запрещает его, потому что иногда это допустимо для низкоуровневых сценариев, но бизнес-правила лучше не строить на порядке регистрации соседних реакций.
Лучше писать так:
reaction({
on: submitted,
run() {
count.value = 1;
nextStep();
},
});Или реагировать на закоммиченное значение стора:
reaction({
on: count,
run(value) {
console.log(value);
},
});Несколько записей и конфликты
Несколько записей в явном коде валидны. Побеждает последняя явная запись.
reaction({
on: changed,
run() {
count.value = 1;
count.value = 2;
},
});Закоммитится 2.
Несколько независимых соседних реакций, которые пишут в один стор, рантайм тоже допускает, но обычно это признак неудачной модели:
reaction({ on: changed, run: () => { count.value = 1; } });
reaction({ on: changed, run: () => { count.value = 2; } });Результат будет детерминированным, но важное правило такое: не завязывайте бизнес-решения на порядок соседних реакций. Вынесите решение в одну реакцию, вызывайте юниты явно или используйте отдельный примитив для append/merge-heavy данных, когда он появится.
WARNING
Если devtools или runtime diagnostics подсвечивают несколько sibling writes в один стор, воспринимайте это как повод пересмотреть модель. Это не обязательно ошибка выполнения, но почти всегда слабое место в причинности.
Эффекты и lifecycle-сторы
У эффектов есть lifecycle-сторы:
searchFx.$pending;
searchFx.$inFlight;Это состояние исполнения рантайма. Оно публикуется сразу при старте и завершении async-работы, даже если эффект запущен внутри транзакции.
reaction({
on: submitted,
run() {
formTouched.value = true;
searchFx(query.value);
},
});searchFx.$pending станет true сразу. UI сможет показать loading state, не дожидаясь коммита несвязанных бизнес-записей.
Lifecycle-события вроде started, doneData, failData и settled при этом остаются обычными юнитами. Если реакции на эти события пишут бизнес-сторы, такие записи транзакционные.
Async-границы
await завершает текущую транзакцию. Ожидающие записи в сторы коммитятся до продолжения async-кода.
scoped(appScope, async () => {
saving.value = true;
const user = await saveUserFx(form.value);
profile.value = user;
saving.value = false;
});Это две транзакции:
transaction 1:
saving = true
commit
await saveUserFx
transaction 2:
profile = user
saving = false
commitDraft не живет через await. Если держать mutable draft через async-работу, lifetime и правила конфликтов станут слишком неочевидными.
const runInScope = scoped(appScope);
button.addEventListener("click", runInScope.wrap(async () => {
saving.value = true;
await saveFx();
saving.value = false;
}));scoped в таком коде нужен не для продления транзакции, а для сохранения scope через внешний callback или async-продолжение. Каждый синхронный участок все равно получает свою транзакцию.
Практические правила
- Пишите прямой императивный код, когда порядок явный.
- Давайте сторам коммититься один раз вместо ручного batching.
- Реагируйте на закоммиченные значения сторов, если логика зависит от результата записи.
- Не завязывайте бизнес-решения на порядок соседних реакций.
- Используйте lifecycle-сторы эффектов для UI-состояния исполнения.
- Оборачивайте async callbacks в
scoped, если им нужен scope. - Считайте
awaitграницей транзакции.
Модель специально близка к JavaScript: синхронный код выполняется в написанном порядке, async-работа разделяет выполнение, а наблюдатели видят закоммиченное состояние вместо каждого промежуточного write.