В реактивных системах существуют специальные функции, такие как 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
Буду признателен, если поделитесь мыслями в комментариях или предложите новые тесты.
ссылка на оригинал статьи https://habr.com/ru/articles/925304/
Добавить комментарий