Изучаем реактивность во Vue.js. Простое объяснение

Spread the love

В этой статье я попытаюсь:

  • Разобрать как работает реактивность во Vue.js
  • Рассмотреть архитектурные шаблоны Vue.js
  • Немного коснуться Watchers (Наблюдатели)
  • Рассказать о Dep классе (который так же присутствует в исходниках Vue.js)

Начнем рассмотрение с самого простого и типичного приложения Vue.js

var vm=new Vue({
  el: '#app',
  data: {
    price: 5.00,
    quantity: 2
  },
  computed: {
    totalPriceWithTax() {
      return this.price * this.quantity * 1.03
    }
  }
})

Соответствующий шаблон будет таким:

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

Как мы можем видеть переменные объявленные в коде просто отображаются в шаблоне. При этом при изменение значения переменных в коде они автоматически будут обновляется в шаблоне. Такой механизм и называется реактивность Vue.js. Возникает резонный вопрос как Vue.js понимает что значение переменной изменилось и его нужно обновить в шаблоне.

В этой статье мы построим систему реактивности с нуля что бы понять как это работает во Vue.js

Начнем с рассмотрения простого скрипта JavaScript:

let price = 5
let quantity = 2
let total = price * quantity

console.log(`total is ${total}`
\\ total = 10

\\ Далее если мы изменим price
price = 20
\\ и снова выведем значение total
console.log(`total is ${total}`
\\ оно не измениться и будет по прежнему равно 10

Возникает вопрос как нам сохранить вычисление total таким образом что бы мы могли снова его запустить когда значения price или quantity изменяться. Нам надо каким то образом сохранить код
let total = price * quantity в хранилище, а затем запускать его при необходимости. Для этого добавим функцию target

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

// целевая функция вычисления
target = () => {
  total = price * quantity
}

Далее добавим механизм сохранения функции вычисления:

let price = 5
let quantity = 2
let total = 0
let target = null
let storage = [] // переменная для хранения

// целевая функция вычисления
target = () => { total = price * quantity }

// функция сохранения
record = () => { storage.push(target) }
 // перезапускаем ранее сохраненный код
replay = () => { storage.forEach(run => run()) }

record()
target()

console.log(total)
// 10
price = 20
console.log(total)
// 10
replay()
console.log(total)
// 40
// Теперь значение total изменилось

И там мы рассмотрели общий принцип идеи реализации реактивности на самом простом примере.

Теперь давайте сделаем наше решение более масштабируемым, создадим класс для сохранения наших зависимостей. Мы сделаем наш класс на основе шаблона Observer

// Dep сокращение от Dependency (Зависимость)
class Dep {
    constructor() {
       // инициализируем наше хранилище
       this.subscribers = []
    }
    // функция сохранения наших зависимостей
    depend() {
        if (target && !this.subscribers.includes(target)) {
           this.subsribers.push(target)
        }
    }
    //  функция запуска всех зависимостей
    notify() {
        this.subscribers.forEach(sub => sub())
    }
}

Кстати во Vue.js то же есть класс Dep который по своему предназначению напоминает созданный нами класс.

Запустим наш код

const dep = new Dep()


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

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

dep.depend()
target()
console.log(price, total)
// 5 10
price = 20
console.log(price, total)
// 20 10

dep.notify()
console.log(price, total)
// 20 40

Сейчас мы отслеживаем изменения значения только у конкретной выбранной переменной.
Далее нам нужно будет сделать так что бы у каждой отслеживаемой переменной был экземпляр Dep класса. Для этого нам нужно как то соединить все переменные которые мы хотим отслеживать и класс Dep.

Код который мы использование ранее

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

Вместо него нам нужно функция watcher

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

Более универсальная функция:

function watcher(myFunc) {
  target = myFunc
  dep.depend()
  target()
  target = null
}

Вернемся к нашему примеру и добавим в него функцию watcher

...
function watcher(myFunc) {
...
}

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

console.log(total)
// 10
price = 20
// значение total  пока еще не изменилось
console.log(total)
// 10
dep.notify()
// теперь значение обновилось
console.log(total)
// 40

Как я заметил ранее нам нужно что бы у каждой отслеживаемой переменной был экземпляр Dep класса, что бы при изменений значения перезапускалось зависимые функции .

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

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

но может быть другой watcher

watcher(() => { salePrice = data.price * 0.9 })

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

Для этого нам нужен способ благодаря которому мы можем понять что значение переменной было изменено, и тогда мы можем вызвать в этот момент функцию обновления dep.notify()

Для этого во Vue.js 2.X используется Object.defineProperty()

Обратите внимание что Object.defineProperty() используется только в в первой и второй версии Vue.js В третьей версии для этих целей используется другой механизм основанный на Proxy.

Функция Object.defineProperty() — позволяет определять геттер и сеттер функции на любые свойства объекта

let data = { price: 5, quantity: 2 }
Object.defineProperty(data, "price",  {
    get() {
        console.log('Получение доступа к переменной'
    },
    set() {
        console.log('Изменения значения переменной'
    }
})

data.price
// Получение доступа к переменной
data.price = 20
// Изменения значения переменной

Обратите внимание что данный пример в реальности ничего не меняет, что бы это происходило нам нужно реализовать этот функционал:

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

Object.defineProperty(data, "price", {
   get() {
      console.log(`Получаем значение ${internalValue}`
      return internalValue
   },
   set(newVal) {
      console.log(`Устанавливаем значение price  ${newVal}`)
      internalValue = newVal
   }
})
data.price
// Получаем значение 5
data.price = 20
// Устанавливаем значение price 20

Далее нам нужно вызывать defineProperty для каждого значения в data

let data = { price: 5, quantity: 2 }
Object.keys(data).forEach(key => {
    // устанавливаем начальное значение
    let internalValue = data[key]
    Object.defineProperty(data, key, {
      get() {
        console.log(`Получаем значение ${key}: ${internalValue}`)
        return internalValue
      },
      set(newVal) {
        console.log(`Устанавливаем для ${key} значение ${newVal}`)
        internalValue = newVal
      }
    })
})

total = data.price * data.quantity
// Получаем значение price: 5
// Получаем значение quantity: 2
data.price = 20
// Устанавливаем для price значение 20

Теперь объединим рассмотренные выше две идеи watcher плюс класс Dep и функции defineProperty

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

Object.keys(data).forEach(key => {
    // устанавливаем начальное значение
    let internalValue = data[key]
    // у каждой переменной будет экземпляр класса Dep
    const dep = new Dep()

    Object.defineProperty(data, key, {
      get() {
        // при получение значения мы добавляем текущий target к subsribers
        dep.depend()
        return internalValue
      },
      set(newVal) {
        internalValue = newVal
        // запускаем все наши subsribers
        dep.notify()
      }
    })
})

Теперь взглянем на то как наш пример должен выглядеть целиком

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

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]
    const dep = new Dep()

    Object.defineProperty(data, key, {
      get() {
        dep.depend()
        return internalValue
      },
      set(newVal) {
        internalValue = newVal
        dep.notify()
      }
    })
})

function watcher(myFunc) {
  target = myFunc
  target()
  target = null
}

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

// Поэкспериментируем с нашим кодом
total
// 10
salePrice
// 4.5
data.price = 20
// Упс все поменялось
total
// 40
salePrice
// 18
data.quantity = 10
total
// 200

Теперь подумаем как это может быть реализовано во Vue.js. Рассмотрим классическую картинку из документации Vue показывающее устройство реактивности во Vue

Здесь у нас есть Data, Геттеры/Сеттеры, Watcher. Объект Data для своей работы использует Object.defineProperty. Когда геттер задействуется запускается метод dep.depend() класса Dep. А когда сеттер задействуется вызывается метод dep.notify() так же класса Dep. Тут так же есть Watcher устройство которого более подробно мы рассмотрим в другой моей статье

Реализации реактивности во Vue.js 3.0

Рассмотрим с помощью нашего примера реализацию реактивности в 3 версии Vue.js. Как уже было сказано оно реализуется через объект Proxy. Этот объект оборачивается вокруг другое объекта и позволяет перехватывать все действия в ним.

Используем Proxy в нашем примере:

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

let deps = new Map()

Object.keys(data).forEach(key => {
    deps.set(key, new Dep())
})

let data_without_proxy = data

data = new Proxy(data_without_proxy, {
    get(obj, key) {
       deps.get(key).depend();
       return obj[key]
    },
    set(obj, key, newVal) {
       obj[key] = newVal
       deps.get(key).notify()
       return true
    }
})

function watcher(myFunc) {
  target = myFunc
  target()
  target = null
}

let total = 0

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

// Поэкспериментируем с нашим кодом
console.log(total)
// 10
data.price = 20
// Упс все поменялось
console.log(total)
// 40

Теперь мы можем добавить новое реактивное свойство:

deps.set('discount', new Dep())
data['discount']  = 5

let salePrice = 0
watcher(() => {
  salePrice = data.price - data.discount
})

console.log(salePrice)
\\ 15
data.discount = 7.5
console.log(salePrice)
\\ 12.5

Внимание вопрос: Зачем понадобилось в 3 версии Vue.js переходить от defineProperty к Proxy?

На самом деле defineProperty не позволяет создавать полноценные прозрачные реактивные системы. При определенных условиях например в коллекциях измененные значения не обнаруживаются реактивной системой и поэтому значения внутри массива нужно менять используя метод $set. Объект Proxy не имеет таких недостатков.

Заключение

В этом уроке я попытался рассказать о том как:

  • Как создавать класс Dep который собирает все зависимости (Collect as Dependency) и перезапускает их по мере необходимости (notify)
  • Как используется watcher который управляет кодом который мы запускаем
  • Как использовать Object.defineProperty() для создания геттеров/сеттеров

Надеюсь моя статья поможет вам разобраться с тем как работает Vue.js немного лучше.

Была ли вам полезна эта статья?
[9 / 4.9]

Spread the love
Подписаться
Уведомление о
guest
1 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Анонимно
Анонимно
2 лет назад

Обратите внимание у вас в коде ошибка. Первый скрин ошибка в названии totalPriceWithTax записана так: «totalPriceWithTax()», а в блоке так: <div>Taxes: ${{ totalPriceWIthTax }}</div>. Слово With записано неккоректно. Буква i идёт заглавной, т.е записано как totalPriceWIthTax, а должно быть так totalPriceWithTax

Last edited 2 лет назад by Анонимно