Пояснення реактивності JS на прикладі сирцевого коду Vue

12 хв. читання

Багато фронтенд 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 змінюється, необхідно зробити три речі.

  1. Оновити значення price на нашій веб-сторінці.
  2. Оновити значення виразів, в яких виконується множення price * quantity та перезавантажити сторінку.
  3. Викликати функцію 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

result

Проблема №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)

result

Вам може бути цікаво, чому ми впровадили target саме як глобальну змінну, замість передавання її у функцію, де необхідно. Для цього є вагома причина, яка стане очевидною трохи пізніше.

Проблема №4

Маємо єдиний Dep class, але хочемо, щоб кожна з наших змінних мала свій власний Dep class. Перш, ніж ми підемо далі, я звернусь до властивостей:

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

Припустимо, що кожна з наших властивостей (price та quantity) має свій внутрішній Dep class.

properties

Тепер запускаємо:

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

З моменту, коли значення data.price отримано, я хочу, щоб Dep class властивості price розмістив нашу анонімну функцію (записану в target) у своєму масиві підписника (шляхом виклику dep.depend()). У той же час, я хочу, щоб Dep class властивості quantity розмістив цю анонімну функцію (записану в target) у своєму масиві підписника.

dep class

Якщо у мене є ще одна анонімна функція, де лише отримано значення 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()

result

Як можна побачити, виводяться лише два рядки. Однак, жодні значення не встановлюються і не отримуються з того моменту, як ми проігнорували функціональність. Повернемо її. 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() працюють коректно. Як ви думаєте, що буде виведено в консоль?

console result

Ми організували сповіщення про отримання та встановлення значень. Чи можемо ми, додавши трохи рекурсії, застосувати такий спосіб до всіх даних масиву?

До відома, функція 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

Тепер всі елементи масиву мають гетер та сетер, що видно у консолі:

get-set

Об'єднаємо ідеї

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
})

А тепер подивимось що відбувається в нашій консолі.

console

Саме те, на що ми сподівалися! Як price, так і quantity дійсно реактивні. Увесь наш код запускається повторно кожен раз, коли значення будь-якої з наших властивостей оновлюється.

Vue робить усе складніше, але тепер ви знаєте основи.

Що ми вивчили?

  • Як організувати Dep class, що збирає залежності (depend) та перезапускає їх (notify).

  • Як створити watcher аби керувати кодом, що виконується. watcher може потребувати додавання target як залежності.

  • Як використовувати Object.defineProperty(), щоб створити гетери та сетери.

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.6K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація