javascript

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

  • понедельник, 7 июля 2025 г. в 00:00:03
https://habr.com/ru/articles/925304/

В реактивных системах существуют специальные функции, такие как 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

Буду признателен, если поделитесь мыслями в комментариях или предложите новые тесты.