![](https://habrastorage.org/webt/c_/k4/dp/c_k4dpwjifcbq6pvrg-gn7wydsu.jpeg)
В материале, перевод которого мы сегодня публикуем, продемонстрирован пошаговый пример разработки системы реактивности на чистом JavaScript. Эта система реализует те же механизмы, которые применяются в Vue.
Система реактивности
Тому, кто впервые сталкивается с работой системы реактивности Vue, она может показаться таинственным чёрным ящиком. Рассмотрим простое Vue-приложение. Вот разметка:
<div id="app">
<div>Price: ${{ 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
, движку нужно выполнить три действия:- Обновить значение
price
на веб-странице. - Пересчитать выражение, в котором
price
умножается наquantity
, и вывести полученное значение на страницу. - Вызвать функцию
totalPriceWithTax
и, опять же, поместить то, что она вернёт, на страницу.
То, что здесь происходит, показано на следующей иллюстрации.
![](https://habrastorage.org/getpro/habr/post_images/373/af5/15e/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
.![](https://habrastorage.org/getpro/habr/post_images/030/992/c52/030992c52de473284847fa7f8f2f6785.png)
Результат работы программы
А при использовании возможностей Vue, мы, в похожей ситуации, можем реализовать сценарий, в котором значение
total
пересчитывается при изменении переменных price
или quantity
. То есть, если бы при выполнении вышеописанного кода применялась бы система реактивности, в консоль было бы выведено уже не 10, а 40:![](https://habrastorage.org/getpro/habr/post_images/22c/c4e/6c7/22cc4e6c79914b1c9f2c4df6bf83c9e1.png)
Вывод в консоль, сформированный гипотетическим кодом, использующим систему реактивности
JavaScript — это язык, который может функционировать и как процедурный, и как объектно-ориентированный, но встроенной системы реактивности в нём нет, поэтому тот код, который мы рассматривали, при изменении
price
, число 40 в консоль не выведет. Для того чтобы показатель total
пересчитывался бы при изменении price
или quantity
, нам понадобится создать систему реактивности самостоятельно и тем самым добиться нужного нам поведения. Путь к этой цели мы разобьём на несколько небольших шагов.Задача: хранение правил расчёта показателей
Нам нужно где-то сохранить сведения о том, как рассчитывается показатель
total
, что позволит нам выполнять его перерасчёт при изменении значений переменных price
или quantity
.?Решение
Для начала нам требуется сообщить приложению следующее: «Вот код, который я собираюсь запустить, сохрани его, мне может понадобиться выполнить его в другой раз». Затем нам надо будет запустить код. Позже, если показатели
price
или quantity
изменились, нужно будет вызвать сохранённый код для повторного расчёта total
. Выглядит это так:![](https://habrastorage.org/getpro/habr/post_images/7e8/de0/6ce/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
Вот что будет выведено в консоли браузера после его запуска.
![](https://habrastorage.org/getpro/habr/post_images/67c/18e/94b/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
.![](https://habrastorage.org/getpro/habr/post_images/0d6/7a0/686/0d67a0686a5c87e6080994309197e6b4.png)
Свойства price и quantity
Теперь мы можем вызывать функцию
watcher
так:watcher(() => {
total = data.price * data.quantity
})
Так как здесь производится работа со значением свойства
data.price
, нам надо, чтобы объект класса Dep
свойства price
помещал бы анонимную функцию (сохранённую в target
) в свой массив подписчиков (вызывая dep.depend()
). Кроме того, так как тут мы работаем и с data.quantity
, нам надо, чтобы объект класса Dep
свойства quantity
помещал бы анонимную функцию (опять же, сохранённую в target
) в свой массив подписчиков.Если изобразить это в виде схемы, то получится следующее.
![](https://habrastorage.org/getpro/habr/post_images/90a/2c9/16d/90a2c916de1e45a044fb1074d64ba196.png)
Функции попадают в массивы подписчиков объектов класса Dep, соответствующих разным свойствам
Если у нас будет ещё одна анонимная функция, в которой осуществляется работа лишь со свойством
data.price
, то соответствующая анонимная функция должна попасть лишь в хранилище объекта класса Dep
этого свойства.![](https://habrastorage.org/getpro/habr/post_images/8e9/ed1/040/8e9ed104054baab4bb24eeed338cc6a0.png)
Дополнительные наблюдатели могут добавляться и только к одному из имеющихся свойств
Когда может понадобиться вызов
dep.notify()
для функций, подписанных на изменения свойства price
? Это понадобится при изменении price
. Это означает, что, когда наш пример будет полностью готов, у нас должен работать следующий код.![](https://habrastorage.org/getpro/habr/post_images/2df/2d9/7a0/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 // при установке свойства вызывается сеттер
Если запустить этот код в консоли браузера, он выведет следующий текст.
![](https://habrastorage.org/getpro/habr/post_images/ed0/7a5/ec1/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 // при установке свойства вызывается сеттер
Теперь, когда геттер и сеттер работают так, как они должны работать, как вы думаете, что попадёт в консоль при выполнении этого кода? Взгляните на следующий рисунок.
![](https://habrastorage.org/getpro/habr/post_images/050/0c1/6b1/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
есть геттеры и сеттеры. Вот что появится в консоли после запуска этого кода.![](https://habrastorage.org/getpro/habr/post_images/bc6/af0/6b1/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
})
Поэкспериментируем теперь с этим кодом в консоли браузера.
![](https://habrastorage.org/getpro/habr/post_images/2bc/47f/c10/2bc47fc1035298c26eb1dfd84d72e738.png)
Эксперименты с готовым кодом
Как видите, работает он в точности так, как нам нужно! Свойства
price
и quantity
стали реактивными! Весь код, который ответственен за формирование total
, при изменении price
или quantity
, выполняется повторно.Теперь, после того, как мы написали собственную систему реактивности, эта иллюстрация из документации Vue, покажется вам знакомой и понятной.
![](https://habrastorage.org/getpro/habr/post_images/f1e/a0d/193/f1ea0d19322cee657e9b130cbd491e65.png)
Система реактивности в Vue
Видите этот прекрасный фиолетовый круг, в котором написано
Данные
, содержащий геттеры и сеттеры? Теперь он должен быть вам хорошо знаком. У каждого экземпляра компонента имеется экземпляр метода-наблюдателя (синий круг), который собирает зависимости от геттеров (красная линия). Когда, позже, вызывается сеттер, он уведомляет метод-наблюдатель, что приводит к повторному рендерингу компонента. Вот та же самая схема, снабжённая пояснениями, связывающими её с нашей разработкой.![](https://habrastorage.org/getpro/habr/post_images/276/fb4/6db/276fb46db5618c8a08428c5c1914e988.png)
Схема реактивности в Vue с пояснениями
Полагаем, теперь, после того, как мы написали собственную систему реактивности, эта схема в дополнительных пояснениях не нуждается.
Конечно, в Vue всё это устроено сложнее, но теперь вам должен быть понятен механизм, лежащий в основе систем реактивности.
Итоги
Прочтя этот материал, вы узнали следующее:
- Как создать класс
Dep
, который собирает функции с помощью методаdepend
, и, при необходимости, повторно их вызывает с помощью методаnotify
. - Как создать функцию
watcher
, которая позволяет управлять запускаемым нами кодом (это — функцияtarget
), который может понадобиться сохранить в объекте классаDep
. - Как использовать метод
Object.defineProperty()
для создания геттеров и сеттеров.
Всё это, собранное в едином примере, привело к созданию системы реактивности на чистом JavaScript, поняв которую вы сможете понять особенности функционирования подобных систем, используемых в современных веб-фреймворках.
Уважаемые читатели! Если, до прочтения этого материала, вы плохо представляли себе особенности механизмов систем реактивности, скажите, удалось ли вам теперь с ними разобраться?
![](https://habrastorage.org/files/1ba/550/d25/1ba550d25e8846ce8805de564da6aa63.png)
Комментарии (12)
Senyaak
31.07.2018 14:33> но встроенной системы реактивности в нём нет,
разве геттеры, сеттеры и Proxy не являются частью этой самой системы?mayorovp
01.08.2018 08:52Нет. Это слишком мелкий кирпичик, к тому же необязательный (см. Knockout.js)
Senyaak
01.08.2018 22:31-1Вот что что а это старьё нужно похоронить и забыть о нём -_- недавно наткнулся на него при работе в виде зависимости — так эта сволочь напрочь отказалась собираться вебпэком -_-
Я имел ввиду то что Геттеры Сеттеры и Прокси в совокупностью с ивентами дают нам реактивность)mayorovp
01.08.2018 22:41Неважно нужно ли хоронить Knockout. Важно то, что там реактивности достигли совершенно без геттеров, сеттеров и проксей…
Senyaak
01.08.2018 23:22Так в я и имелл ввиду что фрэймворк не ок — если бы он был так хорош его бы пихали везде и всюду — а пихают rx
То что там используеться незря меняют на новые фичи в новых стандартах…
PaulZi
31.07.2018 15:16+1Спасибо, давно хотел узнать, как Vue определяет зависимые данные у computed функций!
webdevium
Только мне одному кажется, что компания RUVDS просто пиарится, используя переводы не самых информативных статей?
Listrigon
С одной стороны да, тут даже не кажется. Но с другой стороны в большинстве статей есть минимум информации, которая может дать старт для дальнейшего более глубокого изучения, что намного лучше множества другого неприкрытого маркетинга других компаний.
Loki3000
А мне статья понравилась: общие принципы описаны, примеры кода даны. Для старта — хорошая отправная точка.
Fengol
Согласен, хорошая. Вот только описывается не реактивный механизм, а механизм связывания (binding). В статье ни строчки о реактивности нет.
bingo347
притом биндинг реализуется гораздо проще, без тонн лишних абстракций
достаточно посмотреть, как webpack оборачивает es6 export — 3 строки кода в шапке бандла и по одной доп строке на каждый export
theWaR_13
Ну лично я все равно нахожу много полезного в этих статьях. Да и в целом, я хоть и стараюсь следить за новостями, далеко не все оригиналы статей, которые переводит RUVDS, получается найти.