Полгода назад я написал статью «Observable — удобный state-manager». Это была скорее заметка, из-за чего мне немного досталось в комментариях. Данная статья — более подробное знакомство с Observable — библиотекой для реактивного программирования на JavaScript.

Преимущества Observable

  • Маленький размер (3.2 kB)
    Действительно маленький, а не «малое ядро», которое бесполезно без дополнительных модулей, увеличивающих итоговый размер.

  • Работает и с объектами, и с классами
    Для классов не накладывает никаких ограничений: можно использовать приватные свойства, наследование и другие возможности.

  • Нет зависимостей
    Observable — framework agnostic. Он лишь добавляет реактивность в ваш JavaScript.

  • Минимум бойлерплейта
    Убедиться в удобстве Observable с точки зрения Developer Experience можно в примерах с кодом из прошлой заметки или на observable.ru.

  • А также...
    Автобатчинг, автотрекинг зависимостей, автобинд, вычисляемые свойства (computed properties), наблюдаемые коллекции (observable Set, Map, Array), глубоконаблюдаемые объекты (deep observables).

Классический счетчик на React с Observable занимает всего 15 строк кода:

import { makeObservable, observer } from 'kr-observable';

// Состояние счетчика можно менять откуда угодно, и компонент перерендерится
const counter = makeObservable({ count: 0 });

function App() {
  return (
    <div>
      <button onClick={() => ++counter.count}>-</button>
      <div>{counter.count}</div>
      <button onClick={() => --counter.count}>+</button>
    </div>
  )
}

export default observer(App);

Чтобы показать остальные преимущества, нужно сравнение с другими решениями. Сравнивать будем по нескольким параметрам: размер, потребление памяти, производительность и сама «реактивность».

Для начала обратимся к js-framework-benchmark. Подробнее ознакомиться с методикой измерений этого бенчмарка можно в его описании. Для нас он удобен тем, что позволяет сравнить несколько решений, работающих в одинаковых условиях. Мы возьмем те, что работают с React.js.

Важно отметить, что среди доступных решений:

  • Не все являются агностиками — многие завязаны на хуках React;

  • Только два решения — MobX и Observable — работают на "автопилоте", то есть умеют самостоятельно отслеживать зависимости, подписываться и отписываться.

Размер

https://krausest.github.io/js-framework-benchmark/current.html

Observable в общем зачете на четвертом месте, что достаточно неплохо. Но есть нюанс. Давайте вооружимся парой инструментов и разберемся.

Возьмем bundlephobia.com и bundlejs.com. Наша цель — вычесть из общего размера решения размер библиотеки и размер React, чтобы получить чистый объем кода задачи.

Размер React (58.3 kB)
import { createRoot } from 'react-dom/client';

const container = document.getElementById('main');
const root = createRoot(container);
root.render({});

Вставляем этот код в bundlejs.com с следующими параметрами

  • "compression": "gzip"

  • "format": "esm"

  • "bundle": true

  • "minify": true

  • "treeShaking": true

И получаем: Bundle size -> 58.3 kB

На bundlephobia.com смотрим размер двух библиотек для сравнения: Zustand и Observable:

  • kr-observable 2.0.0 — 3.2 kB

  • zustand 5.0.2 — 0.6 kB

Копируем реализации на Zustand и Observable, вставляем в bundlejs.com и получаем общий размер:

  • zustand — 60.1 kB

  • kr-observable — 62.3 kB

Применяем нашу формулу: (общий размер) - (размер React) - (размер библиотеки)

  • zustand = 60.1 - 58.3 - 0.6 = 1.2 kB

  • kr-observable = 62.3 - 58.3 - 3.2 = 0.8 kB

То есть, чтобы решить задачу с использованием Zustand, пришлось написать на 50% больше кода, чем с Observable. В этом и заключается нюанс. Сам по себе маленький размер бесполезен, если он компенсируется бойлерплейтом.

Производительность

https://krausest.github.io/js-framework-benchmark/current.html

По производительности Observable также на четвертом месте.

Проведем еще один небольшой, синтетический тест, сравнив Vue, MobX и Observable - библиотеки с наиболее схожим API. Суть проверки проста: создаем наблюдаемый объект, изменяем одно свойство и считываем оба значения. Результат печатаем в консоль, чтобы JIT не хулиганил:

for (let i = 0; i < 1000; i++) {
  const obj = makeObservable({ a: i, b: i });
  obj.a += 1;
  console.log(obj.a + obj.b);
}
https://perf.js.hyoo.ru/#!bench=exrg1y_b7uskj/Hint=Measure memory

Оба бенчмарка показывают, что удобство, и хороший Developer Experience в Observable достигается не в ущерб производительности.

Потребление памяти

https://krausest.github.io/js-framework-benchmark/current.html

С потреблением памяти тоже все хорошо. Zustand, например, потребляет на 6% больше, а Redux – на 38%.

Даже без учёта уменьшения бандла (за счёт сокращения бойлерплейта), переход с Redux на Observable повышает производительность на 20% и снижает потребление памяти на 38%. Масштабируя это на несколько миллионов приложений использующих Redux, выигрыш для их пользователей был бы значительным.

Реактивность

Оценить "реактивность" сложнее, чем размер или потребление памяти, но, к счастью, есть и такой инструмент.

Какие аспекты реактивности учтены в тесте:

  • Batch Changes — возможность обновить несколько состояний разом

  • Order Independent — порядок изменений не влияет на пересчет

  • Ignore Unrelated — изменение независимых данных не вызывает пересчет

  • Collapse Double — отсутствие лишних вычислений

  • Skip Untouched — если зависимость не нужна, она не вычисляется

  • Skip Redundant — если новое значение эквивалентно предыдущему

  • Reuse Moved — изменение порядка обращений не вызывает пересчет

  • Single Source — множественные подписки не ведут к множественным вычислениям

  • Effect Once — побочный эффект выполняется один раз на изменение

Более подробно с этим можно ознакомиться в Big State Managers Benchmark.

Если система реактивности соответствует критериям, в консоли мы должны увидеть "H" и "EH". Чем больше букв в консоли – тем хуже. Из 29 библиотек только 7 справляются с этой задачей, а 22 других (например, RxJS, Redux, SolidJs, Effector) — нет.

Тот же RxJS, который показывает отличные результаты в js-framework-benchmark, катастрофически плохо справляется с тестом на реактивность – его результаты выглядят как HEEEHFHEEHFF и HEEEHFHJHEHEHHFHJF.

Observable входит в топ и проходит тест с H и EH.

Безопасность и предсказуемость

Еще одно преимущество Observable — он терпимо относится к ошибкам. Не токсик, короче ?

Например, рассмотрим код с типичной ошибкой: наблюдаемое значение изменяется внутри эффекта.

import { reactive, watch } from 'vue';

const state = reactive({ count: 0 });

watch(
  () => state.count,
  () => console.log('effect', state.count = state.count + 1)
);

state.count += 1; // увеличиваем count

При выполнении этого кода Vue вызовет эффект 102 раза и сгенерирует ошибку.

Observable, напротив, отработает корректно, не выполняя лишних реакций:

import { makeObservable, autorun } from 'kr-observable';

const state = makeObservable({ count: 0 });

autorun(() => console.log('effect', state.count = state.count + 1));

state.count += 1; // увеличиваем count

Observable справляется и с более сложными случаями:

class Test extends Observable {
  a = 0;

  get b() {
    return `computed from ${this.a}`;
  }
  
  change() {
    this.a += 1
  }
}

const $res1 = document.getElementById('res1')
const $res2 = document.getElementById('res2')
const $btn1 = document.querySelector('button')

$btn1.onclick = foo.change;

autorun(() => {
  foo.a += 1
  $res1.innerText = `${foo.a} | ${foo.computed}`;
});

autorun(() => {
  $res2.innerText = `${foo.a} | ${foo.computed}`;
})

Что здесь происходит:

  • Есть свойство a со значением 0.

  • Есть computed свойство b, которое вычисляется при изменении a.

  • Первый autorun зависит от a и от b , и должен срабатывать при их изменении. Но при срабатывании он изменяет свойство a снова.

  • Второй autorun также зависит от a и от b .

autorun – регистрирует функцию, которая будет немедленно вызвана один раз, а затем – каждый раз, когда изменяется любое из отслеживаемых значений.

При выполнении этого кода:

  1. Сначала сработают оба autorun, и значение a станет 1.

  2. Далее при каждом нажатии на кнопку значение a будет увеличиваться на 2:

    • Один раз в методе change,

    • Второй раз в первом autorun.

  3. При этом оба autorun будут выполняться по одному разу на каждое нажатие кнопки. Демо на codepen.io

То есть Observable корректно и без лишних эффектов обработал ситуацию, в которой другие решения сломались бы из-за переполнения стека.

Или другой пример – случайно зарегистрируем одну реакцию несколько раз:

import { reactive, watch } from 'vue';

let called = 0;
const state = reactive({ count: 0 });
const getter = () => state.count;
const effect = () => console.log('effect', state.count, ++called);

for (let i = 0; i < 10; i++) {
  watch(getter, effect);
};

state.count += 1; // увеличиваем count

При выполнении этого кода Vue вызовет эффект 10 раз, и не почувствует подвоха.

Observable, напротив, отработает корректно, не выполняя лишних реакций:

import { makeObservable, autorun } from "kr-observable";

let called = 0;
const state = makeObservable({ count: 0 });
const effect = () => console.log('effect', state.count, ++called);

for (let i = 0; i < 10; i++) {
  autorun(effect);
};

state.count += 1; // увеличиваем count

Еще один пример – потеря контекста:

const state = someReactiveObjectFactory({
  value: '',
  onChange(event) {
    this.value = event.target.value;
  },
  doSomething() {
    console.log(this)
  }
});

input.addEventListener('change', state.onChange);

setTimeout(state.doSomething);

На что ссылается this в методе onChange, на input или state? Что произойдет при вызове, если свойство value есть и у input и у state? На что ссылается this в setTimeout?

В Observable нет такой проблемы, this всегда ссылается на state, если только мы намеренно не переопределим его с помощью call, bind или apply.

const state = makeObservable({ 
  doSomething() { 
    console.log(this);
  }
});

const { doSomething } = state

queueMicrotask(state.doSomething) // state
queueMicrotask(doSomething) // state

setTimeout(state.doSomething) // state
setTimeout(doSomething) // state

input.addEventListener('change', state.doSomething); // state
input.addEventListener('change', doSomething); // state

Простое API

Есть два способа создать наблюдаемый объект

import { makeObservable, Observable } from 'kr-observable'

const foo = makeObservable({
  // ...
})

class Foo extends Observable {
  // ...
}

Наблюдатели – autorun, subscribe и listen

import { autorun, subscribe, listen } from 'kr-observable'

autorun(effect);

subscribe(foo, callback, keys);

listen(foo, callback)

HOC observer для React

import { observer } from 'kr-observable'

function Component() {}

export default observer(Component)

А также низкоуровневый API для интеграций. На нём, например, реализован упомянутый выше HOC для React. Это API пока не экспортируется наружу, но появится в одном из следующих релизов — как только допишу соответствующий раздел в документации.


Всё это делает Observable простым, удобным, но мощным решением для реактивности. А state-менеджмент — лишь одно из возможных применений.

Полезные ссылки

Спасибо всем, кто так или иначе внёс свой вклад в развитие Observable — будь то ценные комментарии или практическая помощь: @DmitryKazakov8, @Alexandroppolus, @clerik_r, @nin-jin, @supercat1337

Комментарии (9)


  1. shai_hulud
    20.05.2025 08:49

    Хорошая статья пр RxJS, согласен со всем кроме:

    Всё это делает Observable простым, удобным, но мощным решением для реактивности.

    Именно реактивность как концепция сложная для классического программирования основанного на толкании данных императивно. А реализация реактивности в RxJS вообще требует постоянного заглядывания в документацию. Удобно, мощно, сложно.

    Отсюда всякие сигналы появились.


    1. nihil-pro Автор
      20.05.2025 08:49

      В статье под Observable понимается не паттерн, а сама библиотека kr-observable. Найти подходящее название, да еще и чтобы его не было в npm оказалось так же сложно, как и саму библиотеку писать )).

      Хорошая статья

      Спасибо



  1. nin-jin
    20.05.2025 08:49

    Проведем еще один небольшой, синтетический тест, сравнив Vue, MobX и Observable - библиотеки с наиболее схожим API. Суть проверки проста: создаем наблюдаемый объект, изменяем одно свойство и считываем оба значения. Результат печатаем в консоль, чтобы JIT не хулиганил

    Это делается всё же так:

    Я вижу тут утечку памяти по 373 байта на итерацию.


    1. nihil-pro Автор
      20.05.2025 08:49

      Разве JIT не будет игнорировать создание объектов если поймет что на деле они не используются? Вроде как печать в консоль с открытыми дев-тулзами дает больше гарантии, что он этого не сделает.

      Кстати, переход по ссылке открывает пустую страницу


      1. nin-jin
        20.05.2025 08:49

        Печать в консоль - дорогая операция. А JIT не на столько умный, чтобы за микросекунду проанализировать все сайд-эффекты. Странно, у меня всё открыло.


  1. isumix
    20.05.2025 08:49

    Предложу вариант Observable с примером использования:

    export class Observable {
      #callbacks = new Set();
      notify() {
        for (const fn of this.#callbacks) fn();
      }
      subscribe(callback) {
        this.#callbacks.add(callback);
        return () => this.#callbacks.delete(callback); // unsubscribe
      }
    }

    А также вариант классического счетчика (не Реакт):

    const ClickCounter = ({count = 0}) => (
      <button click_e_update={() => count++}>
        Clicked {() => count} times
      </button>
    );

    В таких простых компонентах с event handler-ами, достаточно обычных переменных для состояния вместо использования реактивных менеджеров состояния.


    1. nihil-pro Автор
      20.05.2025 08:49

      Реактивная система вещь несколько сложнее чем реализация паттерна Observable на JS.


      1. isumix
        20.05.2025 08:49

        Зачем сложнее когда можно проще?

        Например чтобы не обновлять не нужные места, просто разделить данные на несколько Observable.