Observable – не только удобный state-manager
- среда, 21 мая 2025 г. в 00:00:05
 
Полгода назад я написал статью «Observable — удобный state-manager». Это была скорее заметка, из-за чего мне немного досталось в комментариях. Данная статья — более подробное знакомство с Observable — библиотекой для реактивного программирования на JavaScript.
Маленький размер (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 — работают на "автопилоте", то есть умеют самостоятельно отслеживать зависимости, подписываться и отписываться.

Observable в общем зачете на четвертом месте, что достаточно неплохо. Но есть нюанс. Давайте вооружимся парой инструментов и разберемся.
Возьмем bundlephobia.com и bundlejs.com. Наша цель — вычесть из общего размера решения размер библиотеки и размер React, чтобы получить чистый объем кода задачи.
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. В этом и заключается нюанс. Сам по себе маленький размер бесполезен, если он компенсируется бойлерплейтом.

По производительности 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);
}
Оба бенчмарка показывают, что удобство, и хороший Developer Experience в Observable достигается не в ущерб производительности.

С потреблением памяти тоже все хорошо. 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; // увеличиваем countObservable справляется и с более сложными случаями:
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 – регистрирует функцию, которая будет немедленно вызвана один раз, а затем – каждый раз, когда изменяется любое из отслеживаемых значений.
При выполнении этого кода:
Сначала сработают оба autorun, и значение a станет 1.
Далее при каждом нажатии на кнопку значение a будет увеличиваться на 2:
Один раз в методе change,
Второй раз в первом autorun.
При этом оба 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Есть два способа создать наблюдаемый объект
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