В реактивных системах существуют специальные функции, такие как 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.
Отслеживание зависимостей с несколькими await-ами в эффекте:
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
Буду признателен, если поделитесь мыслями в комментариях или предложите новые тесты.
cpud47
Если я правильно понял, Вы сделали в системе не более одного активного ассинхронного эффекта. Если это так, то в этом нет смысла — ассинхронные эффекты хочется всё же исполнять параллельно.
Также, совершенно непонятный кейс с await-ом эффектов. Мы же эффекты один раз настраиваем и они потом много раз перезапускаются — между эффектами нет явного порядка.
Короче, непонятно зачем это всё нужно (не в смысле ассинхронные эффекты в принципе, а конкретно Ваше виденье).
nihil-pro Автор
Вы очень невнимательно читали. Эффектов может быть сколько угодно. Я в статье привел пример такого теста, и еще дал ссылку на 30+ других тестов.
await эффектов это не какой-то отдельный кейс, и это никак не противоречит тому, что эффекты мы создаем один раз, а выполняются они сколько угодно раз.
cpud47
Тогда не очень понятно. Если у Вас могут параллельно могут исполняться два ассинхронных эффекта, то как Вы отслеживание их зависимости?
Тест из примера в статье не показывает параллельно ли они исполняются, или последовательно (может у Вас внутри там семафор).
Тогда совершенно непонятно, зачем он нужен. Какой юзкейс, безопасно ли пользователю забыть сделать await эффекту?
nihil-pro Автор
Внутри LiFo стек, точно такой же какой используется в любом раниаме джаваскрипта. Об это сказано в статье.
Безопасно ли забыть сделать await любому промису в программе?
cpud47
Не очень понятно. Давайте такой пример:
Сколько будет исполняться данный код:
При старте
После изменения shared
После изменения x2
После изменения y2
?
P.S. Я умышленно не сделал await autorun. Если это существенно влияет на ответы, либо корректность - скажите.
nihil-pro Автор
Вы можете самостоятельно проводить любые эксперименты, все необходимые ссылки в статье есть.
Еще немаловажно обозначить цель всего этого. В чем идея такого теста? Что именно он проверяет? Какое поведение вы ожидаете от программы если предположить, что реактивности вообще нет? То есть, опишите тоже самое обычным кодом на промисах без await, и скажите, что вы ожидаете и почему?
cpud47
Ну, хорошая реализация исполняет этот код за 2.5 секунды. Если же вводится запрет на параллельное исполнение ассинхронных эффектов, то потребуется целых 5 секунд. Из Вашего описания в статье, я так понимаю, Вы именно что вводите этот запрет, чтобы понимать их какого эффекта происходит чтение.
Ваши упоминания LIFO стека я увидел и при прочтении статьи. Но что конкретно это значит — разительно непонятно. Я могу себе представить 2-3+ сильно разных способов использовать LIFO стек. Вот поэтому и спрашиваю.
nihil-pro Автор
Какой-то неинтересный у нас диалог выходит. Если хотите вести конструктивный диалог, приводите реальные примеры, с реальным кодом и расчетами, а не предположения.
А хорошая, эта какая? Приведите пример, ну или продемонстрируйте это на чистом js. Я утверждаю, что выполнятся этот код будет дольше. На чистом JS, без какой либо реактивности:
Код на промисах без реактивности
Запустив этот код, мы получим такие логи:
Вы утверждаете, что 2,5 сек, так приведите доказательство.
В JavaScript нет многопоточности (читай параллелизма), а только асинхронность.
cpud47
Ну, так Ваши замеры и показывают, что 2.5 секунды. Не, понятно, что там ещё какие-то копейки будут от собственно синхронного исполнения — но это вообще не суть вопроса. Суть вопроса в том, чтобы понять как поведет Ваша методика, если там будут хоть какие-то нетривиальные промисы.
То, что Вы меряете только один промис, а не оба — просто придираетесь к формулировкам. Разумеется мерять нужно время от старта до момента появления обоих write-ов.
Ну и вопрос "сколько времени займет" здесь носит качественный, а не количественный характер. Вы делаете то, что в других библиотеках совершенно отсутствует — в этом случае вести разговор об абсолютном перфе нет особого смысла. Интересно понять качественное поведение Вашего решения.
Я вроде задаю конкретные вопросы и привожу конкретные примеры. Основной вопрос сводится к тому, что не до конца понятен принцип работы Вашего подхода, а также его особенности (и в течении нашего разговора у меня вопросов становиться только больше).
Про расчеты написал выше, что в них нет смысла при разговоре о качественном поведении подхода.
P. S. Вы почему-то то и дело пытаетесь меня в чем-то обвинить. Не надо так. Вы кажется достаточно хорошо знакомы с деталями, чтобы можно было опустить какие-то формальности и терминологические споры.