Реактивные системы: возможно ли отслеживать зависимости в асинхронном коде?

от автора

В реактивных системах существуют специальные функции, такие как watchEffect во Vue или autorun в MobX, которые умеют автоматически отслеживать зависимости и перезапускать «эффект» при их изменении. Принцип их работы следующий:

  1. Регистрация эффекта
    Функция принимает другую функцию (так называемый «эффект») и сразу её выполняет.

  2. Трекинг зависимостей
    Во время выполнения эффекта система фиксирует все обращения к реактивным свойствам и подписывается на их изменения.

  3. Перезапуск
    При изменении любого наблюдаемого значения эффект выполняется заново, и процесс повторяется бесконечно, пока эффект не будет явно остановлен.

Это похоже на useEffect в React-е, только вместо того, чтобы требовать явного указания зависимостей, система определяет их автоматически. Такой подход создаёт мощный и удобный механизм, но имеет свои ограничения.

  1. Нельзя мутировать наблюдаемые значения внутри эффекта
    Изменение наблюдаемого значения внутри эффекта приводит к бесконечному циклу и переполнению стека (эффект изменяет значение → вызывает перезапуск → снова изменяет значение и т.д.).

  2. Зависимости отслеживаются синхронно
    Переданный в качестве аргумента «эффект» может быть как синхронной, так и асинхронной функцией, но 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *