[Перевод] Реактивность в JavaScript: простой и понятный пример

Во многих фронтенд-фреймворках, написанных на JavaScript (например, в Angular, React и Vue) имеются собственные системы реактивности. Понимание особенностей работы этих систем пригодится любому разработчику, поможет ему более эффективно использовать современные JS-фреймворки.

c_k4dpwjifcbq6pvrg-gn7wydsu.jpeg

В материале, перевод которого мы сегодня публикуем, продемонстрирован пошаговый пример разработки системы реактивности на чистом JavaScript. Эта система реализует те же механизмы, которые применяются в Vue.

Система реактивности


Тому, кто впервые сталкивается с работой системы реактивности Vue, она может показаться таинственным чёрным ящиком. Рассмотрим простое Vue-приложение. Вот разметка:

Price: ${{ price }}
Total: ${{ price*quantity }}
Taxes: ${{ totalPriceWithTax }}


Вот команда подключения фреймворка и код приложения.




Каким-то образом Vue узнаёт о том, что, при изменении price, движку нужно выполнить три действия:

  1. Обновить значение price на веб-странице.
  2. Пересчитать выражение, в котором price умножается на quantity, и вывести полученное значение на страницу.
  3. Вызвать функцию totalPriceWithTax и, опять же, поместить то, что она вернёт, на страницу.


То, что здесь происходит, показано на следующей иллюстрации.

373af515eac946ce5c656df9fe2da573.jpg


Откуда Vue знает, что нужно делать при изменении свойства price?

Теперь у нас возникают вопросы о том, откуда Vue знает, что именно надо обновлять при изменении price, и о том, как движок отслеживает то, что происходит на странице. То, что тут можно наблюдать, не похоже на работу обычного JS-приложения.

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

let price = 5
let quantity = 2
let total = price * quantity //тут будет 10
price = 20;
console.log(`total is ${total}`)


Как вы думаете, что будет выведено в консоль? Так как тут ничего, кроме обычного JS, не используется, в консоль попадёт 10.

030992c52de473284847fa7f8f2f6785.png


Результат работы программы

А при использовании возможностей Vue, мы, в похожей ситуации, можем реализовать сценарий, в котором значение total пересчитывается при изменении переменных price или quantity. То есть, если бы при выполнении вышеописанного кода применялась бы система реактивности, в консоль было бы выведено уже не 10, а 40:

22cc4e6c79914b1c9f2c4df6bf83c9e1.png


Вывод в консоль, сформированный гипотетическим кодом, использующим систему реактивности

JavaScript — это язык, который может функционировать и как процедурный, и как объектно-ориентированный, но встроенной системы реактивности в нём нет, поэтому тот код, который мы рассматривали, при изменении price, число 40 в консоль не выведет. Для того чтобы показатель total пересчитывался бы при изменении price или quantity, нам понадобится создать систему реактивности самостоятельно и тем самым добиться нужного нам поведения. Путь к этой цели мы разобьём на несколько небольших шагов.

Задача: хранение правил расчёта показателей


Нам нужно где-то сохранить сведения о том, как рассчитывается показатель total, что позволит нам выполнять его перерасчёт при изменении значений переменных price или quantity.

▍Решение


Для начала нам требуется сообщить приложению следующее: «Вот код, который я собираюсь запустить, сохрани его, мне может понадобиться выполнить его в другой раз». Затем нам надо будет запустить код. Позже, если показатели price или quantity изменились, нужно будет вызвать сохранённый код для повторного расчёта total. Выглядит это так:

7e8de06ce6262d1989c13153884cd225.png


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

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

let price = 5
let quantity = 2
let total = 0
let target = null

target = function () {
    total = price * quantity
}

record() // Поместим функцию в хранилище на тот случай, если нужно будет вызвать её позже
target() // Вызовем функцию


Обратите внимание на то, что мы сохраняем анонимную функцию в переменной target, а затем вызываем функцию record. О ней мы поговорим ниже. Тут же хочется отметить, что функцию target, с использованием синтаксиса стрелочных функций ES6, можно переписать так:

target = () => { total = price * quantity }


Вот объявление функции record и структуры данных, используемой для хранения функций:

let storage = [] // Здесь будем хранить функции target

function record () { // target = () => { total = price * quantity }
    storage.push(target)
}


С помощью функции record мы сохраняем функцию target (в нашем случае это { total = price * quantity }) в массиве storage, что позволяет нам вызвать эту функцию позже, возможно, с помощью функции replay, код которой показан ниже. Это позволит нам вызвать все функции, сохранённые в storage.

function replay () {
    storage.forEach(run => run())
}


Тут мы проходимся по всем сохранённым в массиве storage анонимным функциям и выполняем каждую из них.

Затем в нашем коде мы можем сделать следующее:

price = 20
console.log(total) // 10
replay()
console.log(total) // 40


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

let price = 5
let quantity = 2
let total = 0
let target = null
let storage = []

function record () {
    storage.push(target)
}

function replay () {
    storage.forEach(run => run())
}

target = () => { total = price * quantity }

record()
target()

price = 20
console.log(total) // 10
replay()
console.log(total) // 40


Вот что будет выведено в консоли браузера после его запуска.

67c18e94b8afde9e116e95bb4cf8f41b.png


Результат работы кода

Задача: надёжное решение для хранения функций


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

▍Решение: класс Dependency


Один из подходов к решению вышеописанной задачи заключается в инкапсуляции нужного нам поведения в классе, который можно назвать Dependency (Зависимость). Этот класс будет реализовывать стандартный паттерн программирования «наблюдатель» (observer).

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

class Dep { // Dep - это сокращение от Dependency
    constructor () {
        this.subscribers = [] // зависимые функции, которые надо
                              // запускать при вызове notify()
    }
    depend () { // замена функции record
        if (target && !this.subscribers.includes(target)){
            // только если есть target и этой функции ещё нет
            // в числе подписчиков на изменения
            this.subscribers.push(target)
        }
    }
    notify () { // замена функции replay
        this.subscribers.forEach(sub => sub())
        // запуск функций-подписчиков или наблюдателей
    }
}


Обратите внимание на то, что вместо массива storage мы теперь сохраняем наши анонимные функции в массиве subscribers. Вместо функции record теперь вызывается метод depend. Также тут, вместо функции replay, используется функция notify. Вот как запустить наш код с использованием класса Dep:

const dep = new Dep()

let price = 5
let quantity = 2
let total = 0
let target = () => { total = price * quantity }
dep.depend() // добавим функцию target в число подписчиков
target() // запустим функцию чтобы посчитать total

console.log(total) // 10 - верное число
price = 20
console.log(total) // 10 - это уже не то, что нам надо
dep.notify() // запустим функции - подписчики
console.log(total) // 40 - теперь всё правильно


Наш новый код работает так же, как и раньше, но теперь он лучше оформлен, и возникает ощущение, что он лучше подходит для повторного использования.

Единственное, что пока в нём кажется странным — это работа с функцией, хранящейся в переменной target.

Задача: механизм создания анонимных функций


В будущем нам понадобится создавать объект класса Dep для каждой переменной. Кроме того, хорошо было бы где-то инкапсулировать поведение по созданию анонимных функций, которые следует вызывать при обновлениях соответствующих данных. Возможно, в этом нам поможет дополнительная функция, которую мы назовём watcher. Это приведёт к тому, что мы сможем заменить новой функцией эту конструкцию из предыдущего примера:

let target = () => { total = price * quantity }
dep.depend()
target()


Собственно говоря, вызов функции watcher, заменяющий этот код, будет выглядеть так:

watcher(() => {
    total = price * quantity
})


▍Решение: функция watcher


Внутри функции watcher, код которой представлен ниже, мы можем выполнить несколько простых действий:

function watcher(myFunc) {
    target = myFunc // активной функцией target становится функция myFunc
    dep.depend() // добавляем target в список подписчиков
    target() // вызываем функцию
    target = null // сбрасываем переменную target
}


Как видите, функция watcher принимает, в качестве аргумента, функцию myFunc, записывает её в глобальную переменную target, вызывает dep.depend() для того, чтобы добавить эту функцию в список подписчиков, вызывает эту функцию и сбрасывает переменную target.
Теперь мы получим всё те же значения 10 и 40, если выполним следующий код:

price = 20
console.log(total)
dep.notify()
console.log(total)


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

Задача: собственный объект Dep для каждой переменной


У нас имеется единственный объект класса Dep. Как быть, если нам надо, чтобы у каждой нашей переменной был бы собственный объект класса Dep? Прежде чем мы продолжим, давайте переместим данные, с которыми мы работаем, в свойства объекта:

let data = { price: 5, quantity: 2 }


Представим на минуту, что у каждого из наших свойств (price и quantity) есть собственный внутренний объект класса Dep.

0d67a0686a5c87e6080994309197e6b4.png


Свойства price и quantity

Теперь мы можем вызывать функцию watcher так:

watcher(() => {
    total = data.price * data.quantity
})


Так как здесь производится работа со значением свойства data.price, нам надо, чтобы объект класса Dep свойства price помещал бы анонимную функцию (сохранённую в target) в свой массив подписчиков (вызывая dep.depend()). Кроме того, так как тут мы работаем и с data.quantity, нам надо, чтобы объект класса Dep свойства quantity помещал бы анонимную функцию (опять же, сохранённую в target) в свой массив подписчиков.

Если изобразить это в виде схемы, то получится следующее.

90a2c916de1e45a044fb1074d64ba196.png


Функции попадают в массивы подписчиков объектов класса Dep, соответствующих разным свойствам

Если у нас будет ещё одна анонимная функция, в которой осуществляется работа лишь со свойством data.price, то соответствующая анонимная функция должна попасть лишь в хранилище объекта класса Dep этого свойства.

8e9ed104054baab4bb24eeed338cc6a0.png


Дополнительные наблюдатели могут добавляться и только к одному из имеющихся свойств

Когда может понадобиться вызов dep.notify() для функций, подписанных на изменения свойства price? Это понадобится при изменении price. Это означает, что, когда наш пример будет полностью готов, у нас должен работать следующий код.

2df2d97a0a995fbbdb63703ea76f8349.png


Здесь, при изменении price, нужно вызвать dep.notify () для всех функций, подписанных на изменение price

Для того чтобы всё работало именно так, нам нужен какой-то способ перехватывать события доступа к свойствам (в нашем случае это price или quantity). Это позволит, когда подобное происходит, сохранять функцию target в массив подписчиков, и, когда соответствующая переменная меняется, выполнять функцию, сохранённую в этом массиве.

▍Решение: Object.defineProperty ()


Теперь нам надо познакомиться со стандартным методом ES5 Object.defineProperty (). Он позволяет назначать свойствам объектов геттеры и сеттеры. Позвольте, прежде чем мы перейдём к их практическому использованию, продемонстрировать работу этих механизмов на простом примере.

let data = { price: 5, quantity: 2 }

Object.defineProperty(data, 'price', { // назначим геттер и сеттер только свойству price

    get() { // геттер
        console.log(`I was accessed`)
    },

    set(newVal) { // сеттер
        console.log(`I was changed`)
    }
})
data.price // при обращении к свойству вызывается геттер
data.price = 20 // при установке свойства вызывается сеттер


Если запустить этот код в консоли браузера, он выведет следующий текст.

ed07a5ec1a3a8183516f908695d42c47.png


Результаты работы геттера и сеттера

Как видите, наш пример просто выводит пару строчек текста в консоль. Однако он не производит чтения или установки значений, так как мы переопределили стандартный функционал геттеров и сеттеров. Восстановим функционал этих методов. А именно, ожидается, что геттеры возвращают значения соответствующих методов, а сеттеры их устанавливают. Поэтому добавим в код новую переменную, internalValue, которую будем использовать для хранения текущего значения price.

let data = { price: 5, quantity: 2 }

let internalValue = data.price // начальное значение

Object.defineProperty(data, 'price', { // назначим геттер и сеттер только свойству price

    get() { // геттер
        console.log(`Getting price: ${internalValue}`)
        return internalValue
    },

    set(newVal) {
        console.log(`Setting price to: ${newVal}`)
        internalValue = newVal
    }
})

total = data.price * data.quantity // при обращении к свойству вызывается геттер
data.price = 20 // при установке свойства вызывается сеттер


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

0500c16b12301a46ee7a0db01ea3b823.png


Данные, выведенные в консоль

Итак, теперь у нас есть механизм, который позволяет получать уведомления при чтении значений свойств и при записи в них новых значений. Теперь, немного переработав код, мы можем оснастить геттерами и сеттерами все свойства объекта data. Тут мы воспользуемся методом Object.keys(), который возвращает массив ключей переданного ему объекта.

let data = { price: 5, quantity: 2 }

Object.keys(data).forEach(key => { // выполняем этот код для каждого свойства объекта data
    let internalValue = data[key]
    Object.defineProperty(data, key, {
        get() {
            console.log(`Getting ${key}: ${internalValue}`)
            return internalValue
        },
        set(newVal) {
            console.log(`Setting ${key} to: ${newVal}`)
            internalValue = newVal
        }
    })
})
let total = data.price * data.quantity
data.price = 20


Теперь у всех свойств объекта data есть геттеры и сеттеры. Вот что появится в консоли после запуска этого кода.

bc6af06b16c72f0772634160add682ab.png


Данные, выводимые в консоль геттерами и сеттерами

Сборка системы реактивности


Когда выполняется фрагмент кода наподобие total = data.price * data.quantity и в нём осуществляется получение значения свойства price, нам нужно, чтобы свойство price «запомнило» бы соответствующую анонимную функцию (target в нашем случае). В результате, если свойство price будет изменено, то есть — установлено в новое значение, это приведёт к вызову этой функции для повторения произведённых ей операций, так как ей известно, что от неё зависит определённая строка кода. В результате операции, выполняемые в геттерах и сеттерах, можно представить себе следующим образом:

  • Геттер — нужно запомнить анонимную функцию, которую мы вызовем снова при изменении значения.
  • Сеттер — надо выполнить сохранённую анонимную функцию, что приведёт к изменению соответствующего результирующего значения.


Если использовать в этом описании уже известный вам класс Dep, то получится следующее:

  • При чтении значения свойства вызывается dep.depend() для сохранения текущей функции target.
  • При записи значения в свойство вызывается dep.notify() для повторного запуска всех сохранённых функций.


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

let data = { price: 5, quantity: 2 }
let target = null

// Это - тот же самый класс, который мы уже рассматривали
class Dep {
    constructor () {
        this.subscribers = []
    }
    depend () {
        if (target && !this.subscribers.includes(target)){
            this.subscribers.push(target)
        }
    }
    notify () {
        this.subscribers.forEach(sub => sub())
    }
}

// Эту процедуру мы тоже уже рассматривали, но
// здесь она дополнена новыми командами
Object.keys(data).forEach(key => {
    let internalValue = data[key]

    // С каждым свойством будет связан собственный
    // экземпляр класса Dep
    const dep = new Dep()

    Object.defineProperty(data, key, {
        get() {
            dep.depend() // запоминаем выполняемую функцию target
            return internalValue
        },
        set(newVal) {
            internalValue = newVal
            dep.notify() // повторно выполняем сохранённые функции
        }
    })
})

// Теперь функция watcher не вызывает dep.depend(),
// так как этот вызов выполняется в геттере
function watcher(myFunc){
    target = myFunc
    target()
    target = null
}

watcher(() => {
    data.total = data.price * data.quantity
})


Поэкспериментируем теперь с этим кодом в консоли браузера.

2bc47fc1035298c26eb1dfd84d72e738.png


Эксперименты с готовым кодом

Как видите, работает он в точности так, как нам нужно! Свойства price и quantity стали реактивными! Весь код, который ответственен за формирование total, при изменении price или quantity, выполняется повторно.

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

f1ea0d19322cee657e9b130cbd491e65.png


Система реактивности в Vue

Видите этот прекрасный фиолетовый круг, в котором написано Данные, содержащий геттеры и сеттеры? Теперь он должен быть вам хорошо знаком. У каждого экземпляра компонента имеется экземпляр метода-наблюдателя (синий круг), который собирает зависимости от геттеров (красная линия). Когда, позже, вызывается сеттер, он уведомляет метод-наблюдатель, что приводит к повторному рендерингу компонента. Вот та же самая схема, снабжённая пояснениями, связывающими её с нашей разработкой.

276fb46db5618c8a08428c5c1914e988.png


Схема реактивности в Vue с пояснениями

Полагаем, теперь, после того, как мы написали собственную систему реактивности, эта схема в дополнительных пояснениях не нуждается.

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

Итоги


Прочтя этот материал, вы узнали следующее:

  • Как создать класс Dep, который собирает функции с помощью метода depend, и, при необходимости, повторно их вызывает с помощью метода notify.
  • Как создать функцию watcher, которая позволяет управлять запускаемым нами кодом (это — функция target), который может понадобиться сохранить в объекте класса Dep.
  • Как использовать метод Object.defineProperty() для создания геттеров и сеттеров.


Всё это, собранное в едином примере, привело к созданию системы реактивности на чистом JavaScript, поняв которую вы сможете понять особенности функционирования подобных систем, используемых в современных веб-фреймворках.

Уважаемые читатели! Если, до прочтения этого материала, вы плохо представляли себе особенности механизмов систем реактивности, скажите, удалось ли вам теперь с ними разобраться?

1ba550d25e8846ce8805de564da6aa63.png

© Habrahabr.ru