Полгода назад я написал статью «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 — работают на "автопилоте", то есть умеют самостоятельно отслеживать зависимости, подписываться и отписываться.
Размер

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. В этом и заключается нюанс. Сам по себе маленький размер бесполезен, если он компенсируется бойлерплейтом.
Производительность

По производительности 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; // увеличиваем 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 – регистрирует функцию, которая будет немедленно вызвана один раз, а затем – каждый раз, когда изменяется любое из отслеживаемых значений.
При выполнении этого кода:
Сначала сработают оба
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
Простое 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)
nin-jin
20.05.2025 08:49Проведем еще один небольшой, синтетический тест, сравнив Vue, MobX и Observable - библиотеки с наиболее схожим API. Суть проверки проста: создаем наблюдаемый объект, изменяем одно свойство и считываем оба значения. Результат печатаем в консоль, чтобы JIT не хулиганил
Это делается всё же так:
Я вижу тут утечку памяти по 373 байта на итерацию.
nihil-pro Автор
20.05.2025 08:49Разве JIT не будет игнорировать создание объектов если поймет что на деле они не используются? Вроде как печать в консоль с открытыми дев-тулзами дает больше гарантии, что он этого не сделает.
Кстати, переход по ссылке открывает пустую страницуnin-jin
20.05.2025 08:49Печать в консоль - дорогая операция. А JIT не на столько умный, чтобы за микросекунду проанализировать все сайд-эффекты. Странно, у меня всё открыло.
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-ами, достаточно обычных переменных для состояния вместо использования реактивных менеджеров состояния.
shai_hulud
Хорошая статья пр RxJS, согласен со всем кроме:
Именно реактивность как концепция сложная для классического программирования основанного на толкании данных императивно. А реализация реактивности в RxJS вообще требует постоянного заглядывания в документацию. Удобно, мощно, сложно.
Отсюда всякие сигналы появились.
nihil-pro Автор
В статье под Observable понимается не паттерн, а сама библиотека kr-observable. Найти подходящее название, да еще и чтобы его не было в npm оказалось так же сложно, как и саму библиотеку писать )).
Спасибо
nin-jin
Сигналы появились задолго до rxjs.