Реактивные системы: возможно ли отслеживать зависимости в асинхронном коде?
- понедельник, 7 июля 2025 г. в 00:00:03
В реактивных системах существуют специальные функции, такие как watchEffect во Vue или autorun в MobX, которые умеют автоматически отслеживать зависимости и перезапускать «эффект» при их изменении. Принцип их работы следующий:
Регистрация эффекта
Функция принимает другую функцию (так называемый «эффект») и сразу её выполняет.
Трекинг зависимостей
Во время выполнения эффекта система фиксирует все обращения к реактивным свойствам и подписывается на их изменения.
Перезапуск
При изменении любого наблюдаемого значения эффект выполняется заново, и процесс повторяется бесконечно, пока эффект не будет явно остановлен.
Это похоже на useEffect
в React-е, только вместо того, чтобы требовать явного указания зависимостей, система определяет их автоматически. Такой подход создаёт мощный и удобный механизм, но имеет свои ограничения.
Нельзя мутировать наблюдаемые значения внутри эффекта
Изменение наблюдаемого значения внутри эффекта приводит к бесконечному циклу и переполнению стека (эффект изменяет значение → вызывает перезапуск → снова изменяет значение и т.д.).
Зависимости отслеживаются синхронно
Переданный в качестве аргумента «эффект» может быть как синхронной, так и асинхронной функцией, но autorun
/watchEffect
могут определить только те наблюдаемые значения, к которым произошло обращение во время синхронной части выполнения эффекта:
// MobX
autorun(() => {
fetch(url)
.then(() => {
// обращение к state.value невидимо для autorun-а
if (state.value) { /.../ }
})
})
// Vue
watchEffect(async () => {
await fetch(url);
// обращение к state.value невидимо для watchEffect-а
if (state.value) { /.../ }
})
На первый взгляд, эти ограничения кажутся непреодолимыми, они же заложены в самой природе языка, а не в конкретных реализациях реактивности. И всё же, мне удалось обойти первое ограничение.
Я обратил внимание на Proxy, где каждый метод в хэндлере по сути является эффектом. Когда мы изменяем значение внутри set
-тера — всё работает. Если такой подход работает там, почему бы не попробовать применить его в реактивной системе? Оказалось — можно! Я реализовал это в библиотеке Observable. Она позволяет изменять наблюдаемые значения внутри эффектов без переполнения стека и лишних перезапусков (подробности здесь).
После этого мне стало любопытно: а можно ли обойти второе ограничение? Не потому что это критически важно, а из чистого любопытства. Насколько глубоко можно переосмыслить механизм реактивности? Как далеко мне удастся зайти, прежде чем упрусь в непреодолимые ограничения рантайма? Спойлер – не все так безнадежно.
Итак, у нас есть такой интерфейс:
// autorun возвращает функцию dispose для остановки эффекта
type Disposer = () => void;
// эффект может быть синхронной или асинхронной функцией
type Effect = () => void | Promise<void>;
// сам autorun: принимает эффект и возвращает disposer
type autorun = (effect: Effect) => Disposer;
Задача – отслеживать зависимости в том числе во время выполнения асинхронной части эффекта.
Мы могли бы обернуть эффект в Promise
, но это сломает синхронный код. Если эффект синхронный, он должен выполняться синхронно. Значит, первое, что нам нужно сделать — определить тип эффекта и выполнять его по-разному в зависимости от того, синхронный он или асинхронный. Это можно сделать так:
const isAsync = effect.constructor.name === 'AsyncFunction';
Это работает для всех асинхронных функций, кроме асинхронных генераторов. У них тип AsyncGeneratorFunction
, но autorun
изначально не поддерживает генераторы поэтому нас все устраивает.
Теперь зная тип эффекта, мы можем выбирать соответствующую стратегию для его выполнения. Что-то вроде этого:
function autorun(effect) {
if (effect.constructor.name === 'AsyncFunction') {
await effect();
} else {
effect();
}
}
Но работать это не будет. Рассмотрим простой пример:
autorun(
// асинхронный эффект запустится при создании
async function asyncEffect() {
// дожидаемся промиса
await new Promise(resolve => setTimeout(resolve));
console.log(state.value);
}
)
autorun(
// синхронный эффект
function syncEffect() {
console.log(otherState.value);
}
)
В этом примере синхронный эффект второго autorun
выполнится в то время, пока мы ожидаем разрешения промиса внутри асинхронного эффекта первого autorun
. Из-за этого первый autorun
может ошибочно подписаться на зависимость из второго эффекта (otherState.value
), что приведёт к некорректному поведению реактивной системы.
Однако такая проблема возникает не только в асинхронном коде, но и в синхронном — например, при рендере вложенных компонентов в React:
function Componen1() {
return <span>{state.value}</span>
}
function Component2() {
return (
<div>
<span>{otherState.value}</span>
<Componen1 />
</div>
)
}
В этом примере Component2
может ошибочно подписаться на зависимость, которая на самом деле относится к Component1
. В библиотеке Observable эта проблема решена благодаря опоре на модель выполнения JavaScript: executor использует стек LIFO, что позволяет корректно отслеживать контекст выполнения. Но и этого не всегда достаточно: конфликт всё равно может произойти, если два асинхронных эффекта запустятся одновременно:
autorun(
async () => {
await new Promise(resolve => setTimeout(resolve));
console.log(state.value);
}
)
autorun(
async () => {
await new Promise(resolve => setTimeout(resolve));
console.log(otherState.value);
}
)
Если вы ознакомились с этой статьёй и библиотекой Observable в целом, то, возможно, заметили, что в ней практически отсутствует собственный DSL. Это не случайность — такова философия библиотеки. Мне хотелось, чтобы она была максимально нативной и предсказуемой. Для решения проблемы из приведённого выше примера я также стремился найти простой и предсказуемый подход — и он нашёлся!
Вспомните одно из своих собеседований — велика вероятность, что там был похожий вопрос. Обычно он сопровождается примерно таким кодом:
console.log('start');
fetch('google.com').then(() => console.log('data'));
console.log('end');
Промисы уже давно стали частью повседневного инструментария разработчика, и сегодня любой опытный JavaScript-программист без раздумий ответит: в консоли будет выведено start
, затем end
, и только потом — data
. А типичное исправление для синхронного поведения будет выглядеть так:
console.log('start');
await fetch('google.com').then(() => console.log('data'));
console.log('end');
Именно эту стратегию я и применил для решения описанной выше проблемы — использовать await
. Просто и предсказуемо:
await autorun(
async () => {
await new Promise(resolve => setTimeout(resolve));
console.log(state.value);
}
)
await autorun(
async () => {
await new Promise(resolve => setTimeout(resolve));
console.log(otherState.value);
}
)
Чтобы сделать использование более удобным, добавим немного магии через TypeScript. С помощью перегрузок функций мы добьёмся того, чтобы редактор сам подсказывал: если эффект асинхронный — не забудь про await
.
function autorun(effect: () => Promise<void>): Promise<() => void>;
function autorun(effect: () => void): () => void;
Я приведу здесь лишь три из 36 тестов, которые проверяют корректность работы этого механизма. Все тесты можно посмотреть в репозитории библиотеки на GitHub.
it('tracks across multiple awaits', async () => {
const m = makeObservable({
a: 1,
b: 2
});
const logs = [];
await autorun(async () => {
await Promise.resolve();
logs.push(`a=${m.a}`);
await Promise.resolve();
logs.push(`b=${m.b}`);
});
m.a = 10;
m.b = 20;
await new Promise(r => setTimeout(r, 10));
assert.deepStrictEqual(logs, ['a=1', 'b=2', 'a=10', 'b=20']);
});
Отрабатывает корректно. codepen.io
it('isolates dependencies between different observable classes', async () => {
class A extends Observable { value = 1 }
class B extends Observable { value = 2 }
const a = new A();
const b = new B();
const aLogs = [];
const bLogs = [];
await autorun(async function foo() {
await new Promise(r => setTimeout(r));
aLogs.push(a.value);
});
await autorun(async function bar() {
await new Promise(r => setTimeout(r));
bLogs.push(b.value);
});
a.value = 10;
b.value = 20;
await new Promise(r => setTimeout(r, 10));
assert.deepStrictEqual(aLogs, [1, 10]);
assert.deepStrictEqual(bLogs, [2, 20]);
});
Отрабатывает корректно. codepen.io
it('tracks independent sync/async autoruns without conflicts', async () => {
class User extends Observable { name = 'Alice' }
class Product extends Observable { price = 100 }
const user = new User();
const product = new Product();
const userLogs: string[] = [];
const productLogs: string[] = [];
// Sync autorun tracking user.name
const disposeSync = autorun(() => {
userLogs.push(`SYNC: ${user.name}`);
});
// Async autorun tracking product.price
const disposeAsync = await autorun(async () => {
await Promise.resolve();
productLogs.push(`ASYNC: ${product.price}`);
await delay(50);
productLogs.push(`ASYNC (late): ${product.price}`);
});
// Initial state verification
assert.deepStrictEqual(userLogs, ['SYNC: Alice']);
assert.deepStrictEqual(productLogs, ['ASYNC: 100', 'ASYNC (late): 100']); // Async hasn't resolved yet
await delay(10); // Let async autorun start
// Make changes
user.name = 'Bob';
await delay(10);
product.price = 150;
await delay(100); // Wait for all async operations
// Verify isolation
assert.deepStrictEqual(userLogs, [
'SYNC: Alice',
'SYNC: Bob' // Only reacted to user change
]);
assert.deepStrictEqual(productLogs, ['ASYNC: 100', 'ASYNC (late): 100', 'ASYNC: 150', 'ASYNC (late): 150']);
// Cleanup
disposeSync();
disposeAsync();
});
Отрабатывает корректно. codepen.io
Разумеется, это решение не идеально. На данный момент оно умеет отслеживать зависимости в асинхронных эффектах с несколькими await
или цепочками then
, если эффект возвращает промис:
await autorun(async function() {
return fetch('habr.com')
.then(() => state.value) // отследит
.then(() => otherState.value) // отследит
})
А вот с таймаутами или интервалами уже проблема — зависимости внутри них он отследить не может:
await autorun(async function() {
setTimeout(() => {
state.value // невидимо для autorun-а
})
})
Но умеет отслеживать зависимости внутри queueMicrotask:
await autorun(async () => {
await fetch('google.com')
queueMicrotask(() => {
console.log(m.value) // отследит
});
});
Эксперимент показывает, что даже в асинхронных сценариях можно добиться частичной автоматизации трекинга зависимостей.
Полный код библиотеки Observable и все тесты доступны на GitHub. Если у вас есть идеи, как улучшить этот механизм, — welcome to contributions! Этот функционал доступен только в бета версии 3.0.10-beta.2
Буду признателен, если поделитесь мыслями в комментариях или предложите новые тесты.