Страшная реактивность

Череда страхов

Не знаю, как у вас, но моя история в веб-разработке состоит из череды сражений с предрассудками и страхами. Естественно, всегда оказывается, что бояться было нечего, что нужно было лишь почитать документацию и попробовать, что всё, что я придумывал себе про ту или иную технологию – не более, чем рассуждения невежды.

Так было с Node.js, с Typescript, с React и многим-многим другим. Та же участь постигла и реактивность. До относительно недавнего времени нутро фронтентд-библиотек казалось мне какой-то магией высшего порядка.

К сожалению, интернет помогает далеко не всегда. Очень часто я сталкивался с тем, что статьи “для начинающих” предполагают, что у тебя уже есть довольно серьёзный бэкграунд по теме. К примеру, я читал много статей про Node.js для начинающих, но до меня далеко не сразу дошло, что это про сервер. Конечно, это рождает много вопросов к моему интеллекту, но, всё же, я был довольно “зелёный”.

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

Уверен, все заметили, что это слово очень часто мелькает в материалах по фронтенду. В принципе, до определённой точки знать что это такое и как именно это работает необязательно, но рано или поздно настанет момент, когда станет либо слишком интересно, либо натурально необходимо.

Для начала, я бы хотел обозначить несколько утверждений, важных для понимания вот этого всего. Кому-то из вас они покажутся очевидными, но всё же, закрепить их нужно.

  1. Реактивность - не от слова “реактивный”, как самолёт
    Да, звучит глупо, но я правда так думал, и предполагаю, что я такой не один. Многое сразу становится понятно, когда осознаёшь, что это слово образовано от слова “реакция”. Впрочем, “реактивный” в контексте самолёта тоже произошло от слова “реакция”, но в обиходе оно вызывает ассоциации с чем-то быстрым, технологичным и т.д.
  2. Вы используете реактивность, если работаете с JavaScript даже без фреймворков и библиотек
    В общем смысле реактивность реализует идею “событие - реакция”. Каждый из нас работал с этим, вне всяких сомнений. К примеру, если вы писали обработчик нажатия на кнопку - в каком-то смысле это реактивность :)
  3. Реактивность - это не rocket science, это обычный код
    И чаще всего довольно простой. Позволю себе небольшой спойлер: совсем скоро в этой публикации мы рассмотрим пример реактивного кода, и он займет менее 30 строк кода.
  4. Спектр задач, который можно решить с помощью реактивного кода, шире, чем фронтенд-библиотеки Всё ограничено вашей фантазией. Если вам кажется, что на бэкенде можно решить что-то с помощью реактивности - не бойтесь это делать.

От слов к делу

Давайте рассмотрим простейший пример работы с DOM. Допустим, вам нужно получить внутренний html элемента после того, как браузер построил дерево элементов. Предположим, что мы пишем код внутри head.

В вашем коде вы, скорее всего, напишете так:

const elem = document.getElementById('myelem');
const html = elem.innerHTML

Всё логично, но код не работает, потому что элемент ещё не создан. Всё, что нужно - дождаться, пока браузер построит дерево элементов. Есть вариант использовать таймаут или интервал, чтобы проверять, создан элемент или нет. Также можно использовать блокирующий while-цикл, который запретит вообще всё, пока элемент не будет создан. Но ведь такие грязные решения не придут никому в голову, правильно?

Да и зачем, если есть событие DOMContentLoaded, которое как раз сигнализирует, что DOM построен? Мы лучше сделаем так:

document.addEventListener('DOMContentLoaded', function() {
  const elem = document.getElementById('myelem');
  const html = elem.innerHTML
})

Поздравляю! Мы только что потрогали реактивность. Таким образом, основополагающий принцип реактивности можно сформулировать так:

Сделай определённое действие, когда узнаёшь, что произошло определённое событие

Используем встроенные возможности

Все браузеры реализовали спецификацию событий в том или ином виде. Есть два основных способа отправить событие: через Event и CustomEvent (если вам это не знакомо, почитайте здесь).

Использование метода dispatchEvent, а также прикрепление обработчика через addEventListener как раз реализует принцип “событие - реакция”.

Допустим, вы хотите написать онлайн-калькулятор (не важно зачем), и хотите уведомлять пользователя каждый раз, когда результат вычислений больше 200 (да, у нас очень странный калькулятор).

function calculate(...args) {
/* 
    ...
    Тут проводим вычисления, и в конечном итоге
    получаем переменную result
  */
  if (result > 200) {
    const event = new Event('moreThan200')
    window.dispatchEvent(event)
  }
}
// А тут как раз добавляем обработчик
window.addEventListener('moreThan200', function() {
  alert('Результат больше 200!')
})

Код выше “реагирует” на определённые условия, запуская функцию-обработчик. Реакция может быть любой, а можно и данные передать (используя detail) в CustomEvent. Чётко! Удобно!

Важный момент: используя EventListener‘ы важно обязательно удалять прослушивания событий, если они не нужны, так как добавление нескольких обработчиков на одно и то же событие (особенно автоматизированное) ведет к неизбежным утечкам памяти. А на это совсем не надо.

Паттерн Observable

Есть шаблон проектирования, который называется “Observable” (наблюдаемый). С виду он позволяет делать практически то же самое, что и браузерные события. Разница лишь в том, что раз мы реализуем его самостоятельно, он может быть оптимизирован под конкретные задачи.

Главная задача Observable – уведомлять всех, кто наблюдает за его состоянием (они же называются Observer) о том, что оно поменялось. На языке умных и взрослых дяденек это называется связью “один ко многим”. Единственная задача наблюдаемого объекта – сообщить об изменениях и, возможно, передать изменённые данные. Что с этими данными делают наблюдатели ему, по задумке, не интересно.

Очень многие реактивные библиотеки имеют в основе этот паттерн. К примеру, writable в Svelte – реализация Observable, как и Rx.js. Интересно ещё и то, что этот паттерн можно реализовать практически в любом языке программирования (смотрите тут, лично я отдельно умилился с Pascal).

Давайте напишем свою простейшую реализацию Observable, которая впоследствии поможет нам сделать зачатки своего фронтенд-фреймворка. На всякий случай оговорюсь, что в чём-то реализация неполная, и вообще, это совсем-совсем прототип.

Итак, мы будем работать с синтаксисом JavaScript-классов, потому что он мне нравится, однако, этого же можно добиться и через Object.defineProperty.

Наш собственный Observable

class Observable {
    constructor(initialValue = null) {
        this._value = initialValue
    }
}

Начнётся всё так. В конструктор нашего класса мы должны передать изначальное значение. Если мы его не передаём, мы считаем, что оно равно null. Значок “земля” добавляем для того, чтобы впоследствии задать для объекта геттер и сеттер по ключу value.

Кстати: лично я больше люблю TypeScript, и в плане изначальных значений там может быть больше вариантов. К примеру, лучше использовать дженерики, чтобы понимать, с каким типом данных работает наблюдаемый объект.

У Observable должно быть ещё одно свойство – “очередь” наблюдателей. Если называть вещи простыми словами, то очередь - обычный массив или объект, в который мы будем “складывать” новых подписчиков. Я предпочитаю объекты, потому что люблю обращаться по ключам:

class Observable {
    constructor(initialValue = null) {
        this._value = initialValue
        // Добавляем очередь наблюдателей
        this.subscribersQueue = {}
    }
}

Самое время реализовать метод подписки на изменение значения. Алгоритм такой:

  1. Добавить переданный метод в очередь, присвоив ему идентификатор;
  2. вернуть функцию “отписки”, чтобы можно быть отвязаться от наблюдения за объектом.
class Observable {
    constructor(initialValue = null) {
        this._value = initialValue
        this.subscribersQueue = {}
    }

    // Метод subscribe - подписываемся на изменения
    subscribe(listener) {
        const listenerId = Math.random().toString(36).substr(2, 9)
        this.subscribersQueue[listenerId] = listener
        return () => delete this.subscribersQueue[listenerId]
    }
}

Мы уже очень близко к финалу. Осталось лишь добавить геттер и сеттер для значения. С геттером всё просто, а вот внутри сеттера нужно будет уведомлять всех подписчиков о том, что состояние изменилось. Предлагаю вынести это в отдельный метод:

class Observable {
    constructor(initialValue = null) {
        this._value = initialValue
        this.subscribersQueue = {}
    }

    subscribe(listener) {
        const listenerId = Math.random().toString(36).substr(2, 9)
        this.subscribersQueue[listenerId] = listener
        return () => delete this.subscribersQueue[listenerId]
    }
    
    // Геттер: получаем значение
    get value() {
        return this._value
    }
    
    // Сеттер: обновляем значение и вызываем метод-уведомитель
    set value(newValue) {
        this._value = newValue
        this.notifySubscribers()
    }
    
    // Вспомогательный метод-уведомитель
    notifySubscribers() {
        for (const listenerId in this.subscribersQueue) {
            const listener = this.subscribersQueue[listenerId]
            listener(this._value)
        }
    }
}

И это всё. В общем смысле, естественно. У нас есть класс, который хранит состояние, принимает всех желающих наблюдать за состоянием, уведомляет подписчиков об изменениях этого состояния. Пока что всё это очень абстрактно, и, возможно, у вас нет понимания, где это можно применить.

Вернёмся к первому примеру из этой публикации: div-элемент с id myelem:

<div id="myelem">
    Text inside
</div>

Сейчас мы можем связать наблюдаемую переменную с контентом этого блока, и в автоматическом режиме менять свойство innerHTML. Для этого напишем функцию:

function observeHtml(id) {
  const elem = document.getElementById(id)
  if (!elem) return
  const value = elem.innerHTML
  const observable = new Observable(value)
  observable.subscribe((value) => {
    elem.innerHTML = value
  })
  return observable
}

Осталось только вызвать эту функцию с нужным ID:

const myElemObservable = observeHtml('myelem')

Теперь если мы напишем, скажем, myElemObservable.value = 'New content', содержимое блока div сразу изменится. А что будет, если мы пройдёмся по всем-всем-всем элементам в документе и создадим их “тень”, состоящую из Observable, сохраняя иерархию через JSON? Получится тот самый пресловутый Virtual Dom.

Понятно, что далеко на этом не уедешь. Эта реализация не учитывает тысячи факторов и кейсов реактивного фреймворка. Однако начало, вернее, основа всему – положена.

Реактивность как помощник

Когда я понял, как всё это дело работает, я перестал воспринимать реактивность как господина, под диктовку которого мне следует плясать. Сразу же она стала помощником, который справляется со многими задачами за меня.

Понятное дело, что во многих случаях такой “велосипед” не оправдан. Если в вашем любимом фреймворке реализована реактивность, не следует писать в нём же свою реализацию. Не потому, что получится хуже, нет. Просто потому, что в этом нет особого смысла, ведь всё уже сделали за вас.

Я лишь в своё время хотел разобраться в этом исключительно из-за интереса. Плюс, понимание таких штук открывает множество интересных возможностей, таких как создание собственных шин событий, более многогранная работа с асинхронностью и т.д.

На данный момент я использую это в своих проектах, к примеру, в роутере Easyroute. Понимание принципов реактивности помогло решить много проблем в нём.

Как я и говорил, нет ничего сверхъестественного в реактивности. Все поначалу удивительные задачи решаются в несколько строк кода.