Главная » Хабрахабр » [Перевод] Реактивность в JavaScript: простой и понятный пример

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

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

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

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

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

<div id="app"> <div>Price: $}</div> <div>Total: ${{ price*quantity }}</div> <div>Taxes: ${{ totalPriceWithTax }}</div>
</div>

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

<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script> var vm = new Vue({ el: '#app', data: { price: 5.00, quantity: 2 }, computed: { totalPriceWithTax() { return this.price * this.quantity * 1.03 } } })
</script>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

▍Решение

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

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

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

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

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

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

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

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

Свойства price и quantity

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

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

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

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

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

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

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

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

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

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

▍Решение: 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 // при установке свойства вызывается сеттер

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

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

Однако он не производит чтения или установки значений, так как мы переопределили стандартный функционал геттеров и сеттеров. Как видите, наш пример просто выводит пару строчек текста в консоль. А именно, ожидается, что геттеры возвращают значения соответствующих методов, а сеттеры их устанавливают. Восстановим функционал этих методов. Поэтому добавим в код новую переменную, 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 // при установке свойства вызывается сеттер

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

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

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

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

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

Когда выполняется фрагмент кода наподобие 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
})

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

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

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

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

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

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

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

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

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

Итоги

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

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

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

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


Оставить комментарий

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

*

x

Ещё Hi-Tech Интересное!

[Из песочницы] Валидация сложных форм React. Часть 1

Для начала надо установить компонент react-validation-boo, предполагаю что с react вы знакомы и как настроить знаете. npm install react-validation-boo Чтобы много не болтать, сразу приведу небольшой пример кода. import React, from 'react'; import {connect, Form, Input, logger} from 'react-validation-boo'; class ...

[Перевод] Микросервисы на Go с помощью Go kit: Введение

Эта статья — введение в Go kit. В этой статье я опишу использование Go kit, набора инструментов и библиотек для создания микросервисов на Go. Первая часть в моем блоге, исходный код примеров доступен здесь. Когда вы разрабатываете облачно-ориентированную распределенную систему, ...