Багато фронтенд JS-фреймворків (наприклад, Angular, React, Vue) мають власні рушії реактивності. Зрозумівши, що таке реактивність, та як вона працює, ви зможете вдосконалити навички програмування та ефективніше використовувати JavaScript фреймворки.
Система Реактивності
Якщо ви вперше знайомитесь з реактивною системою у Vue.js, ви можете подумати, що це магія. Візьмемо простий застосунок на 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
знову та перезавантажити сторінку.
Гадаю, тепер ви хочете дізнатися, як Vue дізнається про необхідність оновлень, коли price
змінюється. Як він відстежує усе?
Зазвичай, поведінка JavaScript інакша
Наприклад, якщо виконати наведений нижче код:
let price = 5
let quantity = 2
let total = price * quantity // 10, вірно?
price = 20
console.log('total is ${total}')
Що, на вашу думку, буде виведено? З огляду на те, що ми не застосовуємо Vue, буде виведено 10.
>> total is 10
У Vue ми хочемо, щоб значення total
оновлювалося кожен раз, коли змінюються значення price
або quantity
. Таким чином:
>> total is 40
На жаль, JavaScript є процедурним, а не реактивним, тому бажаного результату не отримаємо. Для того, щоб зробити total
реактивним, необхідно застосувати JavaScript для зміни звичної поведінки нашого коду.
Проблема №1
Необхідно залишити спосіб розрахунку total
, щоб повторно запускати його при зміні значень price
або quantity
.
Рішення
По-перше, необхідно якимось чином повідомити нашому застосунку: «Збережи код, який я збираюсь запускати. Можливо, необхідно буде запустити його ще раз.» Потім ми запускаємо код, і якщо змінні price
або quantity
оновлюються, запускаємо його повторно.
Таким чином, використання record()
дає можливість запускати функцію знову.
let price = 5
let quantity = 2
let total = 0
let target = null
target = function () {
total = price * quantity
})
record()// Запам'ятайте це, якщо необхідно буде запустити пізніше
target()// Також продовжуй та запускай це
Помітьте, що змінній target
було присвоєно анонімну функцію, а потім викликано record()
. Застосовуючи синтаксис стрілкових функцій JavaScript ES6, можна записати:
target = () => { total = price * quantity }
Визначення record
просте:
let storage = [] // Зберігаємо нашу target функцію тут
function record () { // target = () => { total = price * quantity }
storage.push(target)
}
Зберігаємо target
(у нашому випадку total = price * quantity
) так, щоб можна було викликати функцію пізніше, можливо, разом з replay
, що дозволить відтворити все, що ми зберегли.
function replay (){
storage.forEach(run => run())
}
Цикл проходить по всім анонімним функціям, збереженим у масиві, та виконує кожну з них.
Тоді в нашому коді можемо просто зробити:
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())
}
record()
target()
price = 20
console.log(total) // => 10
replay()
console.log(total) // => 40
Проблема №2
Ми могли б продовжувати записувати target
за необхідністю, але було б непогано мати більш надійне рішення в рамках нашого застосунку. Можливо, необхідно мати клас, що буде піклуватися про сповіщення наших target
, коли потребуємо повторного запуску.
Рішення: клас залежностей
Одним зі способів вирішення проблеми є інкапсуляція в нашому класі залежностей, що реалізує шаблон проектування Спостерігач (Observer).
Таким чином, якщо ми створимо клас JavaScript для управління нашими залежностями (що схоже на поведінку Vue), він виглядатиме таким чином:
class Dep {
constructor(){
this.subscribers = [] // targets залежні
//їх необхідно викликати при запуску notify()
}
depend() { // Замінює нашу функцію record
if (target && !this.subscribers.includes(target)) {
// Тільки якщо це target і якщо не підписник
this.subscribers.push(target)
}
}
notify() // замінює функцію replay
this.subscribers.forEach(sub =>sub()) // запускає наші targets
}
}
Зауважте, що замість storage
ми зберігаємо анонімну функцію в subscribers
. Замість функції record
викликаємо depend
, і, наостанок, notify
на зміну replay
. Запустимо:
const dep = new Dep()
let price = 5
let quantity = 2
let total = 0
let target = () => { total = price * quantity }
dep.depend() // Додайте цей target до наших підписників
target() // Запустіть, щоб отримати загальний результат
console.log(total) // => 10... Правильний результат
price = 20
console.log(total) // => 10... Вже не правильний результат
dep.notify() // запускаємо підписників
console.log(total) // => 40 .. тепер правильний результат
Все працює, і код здається тепер більш мобільним. Натомість, досі дивним є встановлення та запуск target
.
Проблема №3
В майбутньому матимемо клас Dep для кожної змінної, і буде непогано інкапсулювати поведінку створення анонімних функцій, за оновленнями яких необхідно буде стежити. Можливо, функція watcher
могла б за це відповідати.
Тому замість виклику:
target = () => { total = price * quantity }
dep.depend()
target()
(це лише фрагмент вже наведеного коду)
Викличемо:
watcher (() => {
total = price * quantity
})
Рішення: функція watcher
У нашій функції watcher
можна зробити деякі нескладні речі:
function watcher(myFunc) {
target = myFunc // Встановлюємо як активний target
dep.depend() // Додаємо активний target як залежність
target() // Викликаємо target
target = null // Скидаємо target
}
Як видно, функція watcher
приймає аргумент myFunc
, встановлює його як нашу глобальну властивість target
, викликає dep.depend()
, щоб додати наш target
у ролі підписника. Далі викликає функцію, а потім скидає target
.
Тепер запустимо наступне:
price = 20
console.log(total)
dep.notify()
console.log(total)
Вам може бути цікаво, чому ми впровадили target
саме як глобальну змінну, замість передавання її у функцію, де необхідно. Для цього є вагома причина, яка стане очевидною трохи пізніше.
Проблема №4
Маємо єдиний Dep class
, але хочемо, щоб кожна з наших змінних мала свій власний Dep class
. Перш, ніж ми підемо далі, я звернусь до властивостей:
let data = { price: 5, quantity: 2 }
Припустимо, що кожна з наших властивостей (price
та quantity
) має свій внутрішній Dep class
.
Тепер запускаємо:
watcher(() => {
total = data.price * data. quantity
})
З моменту, коли значення data.price
отримано, я хочу, щоб Dep class
властивості price
розмістив нашу анонімну функцію (записану в target
) у своєму масиві підписника (шляхом виклику dep.depend()
). У той же час, я хочу, щоб Dep class
властивості quantity
розмістив цю анонімну функцію (записану в target
) у своєму масиві підписника.
Якщо у мене є ще одна анонімна функція, де лише отримано значення data.price
, я хочу розмістити її у Dep class
властивості price
.
Коли я викличу dep.notify()
у підписниках price
? У момент, коли властивість price
буде встановлено. До закінчення цієї статті я хочу мати можливість отримати в консолі:
>> total
10
>> price = 20 // Коли виконується, необхідно викликати notify() для price
>> total
40
Нам необхідно знайти спосіб роздобути властивість даних (на зразок price
або quantity
). Коли вони будуть отримані, ми матимемо змогу зберегти target
у нашому масиві підписнику, а коли будуть змінені - викликати функції, записані в нашому масиві підписнику.
Рішення: Object.defineProperty()
Необхідно розглянути ближче функцію Object.defineProperty()
, що є прикладом чистого ES5 JavaScript. Вона дозволяє визначати гетери та сетери для властивості. Я продемонструю її тривіальне застосування, перед тим як використати її у Dep class
.
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 // Викликається get()
data.price = 20 // Викликається set()
Як можна побачити, виводяться лише два рядки. Однак, жодні значення не встановлюються і не отримуються з того моменту, як ми проігнорували функціональність. Повернемо її. get()
очікує повернення значення, а set()
досі потребує оновлення значення, тому додамо змінну 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 // Викликається get()
data.price = 20 // Викликається set()
Тепер наші get()
та set()
працюють коректно. Як ви думаєте, що буде виведено в консоль?
Ми організували сповіщення про отримання та встановлення значень. Чи можемо ми, додавши трохи рекурсії, застосувати такий спосіб до всіх даних масиву?
До відома, функція Object.keys(data)
повертає масив ключів об'єкту.
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
}
})
})
total = data.price * data. quantity
data.price = 20
Тепер всі елементи масиву мають гетер та сетер, що видно у консолі:
Об'єднаємо ідеї
total = data.price * data.quantity
Коли схожий фрагмент коду запускається і приймає значення price
, ми хочемо, щоб price
запам'ятала цю анонімну функцію(target
). Таким чином, якщо властивість price
змінюється або встановлюється нове значення, вона активує функцію для повторного запуску. Ви можете розуміти так:
Get: Запам'ятайте цю анонімну функцію. Ми запустимо її знову, коли значення зміниться.
Set: Запустіть збережену анонімну функцію. Наше значення щойно змінено.
Або у разі застосування Dep class
.
Отримання price
(get): Викликайте dep.depend()
для збереження поточного значення target
.
Встановлення price
(set):Викликайте dep.notify()
для price
, повторно запускаючи усі targets
.
Об'єднаємо обидві ідеї у фінальний код:
let data = { price: 5, quantity: 2 }
let target = null
// Це такий самий Dep class
class Dep {
constructor(){
this.subscribers = []
}
depend() {
if (target && !this.subscribers.includes(target)) {
// Тільки якщо це target і якщо не підписник
this.subscribers.push(target)
}
}
notify()
this.subscribers.forEach(sub =>sub())
}
}
// Проходить через усі наші data-властивості
Object.keys(data).foreach(key => {
let internalValue = data[key]
// Кожна властивість отримує екземпляр залежності
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
dep.depend()
target()
target = null
}
watcher(() => {
total = data.price * data. quantity
})
А тепер подивимось що відбувається в нашій консолі.
Саме те, на що ми сподівалися! Як price
, так і quantity
дійсно реактивні. Увесь наш код запускається повторно кожен раз, коли значення будь-якої з наших властивостей оновлюється.
Vue робить усе складніше, але тепер ви знаєте основи.
Що ми вивчили?
-
Як організувати
Dep class
, що збирає залежності (depend
) та перезапускає їх (notify
). -
Як створити
watcher
аби керувати кодом, що виконується.watcher
може потребувати додаванняtarget
як залежності. -
Як використовувати
Object.defineProperty()
, щоб створити гетери та сетери.
Ще немає коментарів