Observable – не только удобный state-manager

от автора

Полгода назад я написал статью «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


ссылка на оригинал статьи https://habr.com/ru/articles/904728/


Комментарии

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

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