# Подробнее о реактивности

Настало время разобраться в теме поподробнее! Одной из отличительных особенностей Vue является его ненавязчивая система реактивности. Модели представляют собой проксированные JavaScript-объекты. По мере их изменения обновляется и представление данных. В итоге управление состоянием приложения становится простым и интуитивно понятным. Тем не менее, у механизма реактивности есть ряд особенностей, понимание которых позволит избежать распространённых ошибок. В этом разделе рассмотрим подробнее некоторые детали низкоуровневой реализации системы реактивности Vue.

Посмотрите бесплатное видео о подробностях реактивности на Vue Mastery

# Что такое реактивность?

В последнее время этот термин часто встречается в программировании, но что он значит? Реактивность — концепция, которая позволяет приспосабливаться к изменениям декларативным способом. Отличный канонический пример для демонстрации — электронная таблица Excel.

Если ввести цифру 2 в первую ячейку, а цифру 3 во вторую, а затем, с помощью встроенной в Excel функции SUM, запросить их сумму — таблица её рассчитает. Ничего неожиданного. Но если изменить число в первой ячейке, то сумма обновится автоматически.

Обычно JavaScript так не работает. Если попробовать реализовать похожее на JavaScript, то это могло бы выглядеть так:

let val1 = 2
let val2 = 3
let sum = val1 + val2

console.log(sum) // 5

val1 = 3

console.log(sum) // по-прежнему 5
1
2
3
4
5
6
7
8
9

Но если изменить первое число, то сумма не пересчитается с его учётом.

Как же этого добиться на JavaScript?

С высоты птичьего полёта, должна быть возможность сделать несколько вещей:

  1. Отслеживать когда значение считывается. Например, для выражения val1 + val2 будет считываться значение val1 и val2.
  2. Определять когда значение изменяется. Например, при присвоении val1 = 3.
  3. Перезапускать код, который считывал значения изначально. Например, снова выполнить sum = val1 + val2 для обновления значения sum.

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

Сначала разберёмся как Vue реализует перечисленные выше требования к реактивности.

# Как Vue определяет какой код выполнялся

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

const updateSum = () => {
  sum = val1 + val2
}
1
2
3

Но каким образом расскажем Vue об этой функции?

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

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

Что для начала требуется, так это что-то, что может обернуть вычисление суммы вот так:

createEffect(() => {
  sum = val1 + val2
})
1
2
3

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

// Стек для хранения запущенных эффектов
const runningEffects = []

const createEffect = fn => {
  // Оборачиваем переданную fn в функцию эффекта
  const effect = () => {
    runningEffects.push(effect)
    fn()
    runningEffects.pop()
  }

  // Автоматически сразу запускаем эффект
  effect()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

Запуск эффекта будет добавлять его в массив runningEffects перед вызовом fn. Теперь, если потребуется узнать какой эффект запущен в данный момент, достаточно проверить этот массив.

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

Несмотря на то, что публичный API Vue не позволяет создавать эффекты напрямую, он предоставляет доступ к функции watchEffect, которая ведёт себя очень похоже на createEffect из примера выше. Подробнее это обсудим далее в руководстве.

Но понимание какой код выполняется — лишь одна из частей головоломки. Как же Vue узнаёт какие значения использует эффект и как определяет что они изменились?

# Как Vue отслеживает изменения

Отследить переназначение локальных переменных в предыдущих примерах не получится, такого механизма просто нет в JavaScript. Можно лишь отслеживать изменения свойств объектов.

Поэтому, когда возвращается простой объект JavaScript из функции data компонента, Vue обернёт его в Proxy (opens new window) с обработчиками для get и set. Прокси были представлены в ES6 и позволяют Vue 3 избавиться от ограничений системы реактивности, которые существовали в предыдущих версиях Vue.

Демо выше — достаточно поверхностное объяснение, которое требует некоторых знаний о Proxy (opens new window) для понимания. Давайте немного углубимся. Есть много обучающих материалов о Proxy, но самое важное что нужно знать это то, что Proxy — объект, который содержит в себе другой объект или функцию и позволяет «перехватывать» их.

Они используются вот так: new Proxy(target, handler)

const dinner = {
  meal: 'tacos'
}

const handler = {
  get(target, property) {
    console.log('перехвачен!')
    return target[property]
  }
}

const proxy = new Proxy(dinner, handler)
console.log(proxy.meal)

// перехвачен!
// tacos
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

В примере показано как можно перехватить попытку прочитать свойство отслеживаемого объекта. Подобную функцию обработчика, также называют ловушкой. Есть несколько разных типов ловушек, каждая из которых обрабатывает свой тип взаимодействия.

Кроме сообщения в консоль можно сделать всё что угодно. Можно даже не возвращать значение, если это потребуется. Это и делает Proxy настолько мощными для создания API.

При использовании Proxy есть одна сложность — привязка к this. Хочется, чтобы любой метод был привязан к Proxy, а не к отслеживаемому объекту, чтобы была возможность перехватывать и их тоже. К счастью, ES6 предоставляет ещё одну возможность, названную Reflect, которая позволяет избежать этой проблемы с минимальными усилиями:







 








const dinner = {
  meal: 'tacos'
}

const handler = {
  get(target, property, receiver) {
    return Reflect.get(...arguments)
  }
}

const proxy = new Proxy(dinner, handler)
console.log(proxy.meal)

// tacos
1
2
3
4
5
6
7
8
9
10
11
12
13
14

Первым шагом на пути реализации реактивности с Proxy будет отслеживание считывания свойства. Это можно сделать в обработчике, в функции с названием track, в которую передаётся target и property:







 









const dinner = {
  meal: 'tacos'
}

const handler = {
  get(target, property, receiver) {
    track(target, property)
    return Reflect.get(...arguments)
  }
}

const proxy = new Proxy(dinner, handler)
console.log(proxy.meal)

// tacos
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

Реализация метода track здесь не показана. Она проверяет, какой эффект в данный момент запущен, и записывает его вместе с target и property. Таким образом Vue определяет, что свойство является зависимостью эффекта.

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










 
 
 
 







const dinner = {
  meal: 'tacos'
}

const handler = {
  get(target, property, receiver) {
    track(target, property)
    return Reflect.get(...arguments)
  },
  set(target, property, value, receiver) {
    trigger(target, property)
    return Reflect.set(...arguments)
  }
}

const proxy = new Proxy(dinner, handler)
console.log(proxy.meal)

// tacos
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

Помните этот список? Теперь уже есть некоторые ответы, как во Vue реализуются эти шаги:

  1. Отслеживать когда значение считывается: функция track в обработчике get прокси записывает свойство и текущий эффект.
  2. Определять когда значение изменяется: в прокси вызывается обработчик set.
  3. Перезапускать код, который считывал значения изначально: функция trigger ищет какие эффекты зависят от изменившегося свойства и запускает их.

Проксируемый объект невидим для пользователя, но под капотом он позволяет Vue отслеживать зависимости и уведомлять о считывании свойств или их изменениях. Небольшое отличие лишь в том, что проксированные объекты при выводе в консоль форматируются немного иначе, поэтому рекомендуем установить vue-devtools (opens new window), чтобы отслеживать их состояние в более удобном интерфейсе.

Если переписать оригинальный пример с использованием компонента, то получится так:

const vm = createApp({
  data() {
    return {
      val1: 2,
      val2: 3
    }
  },
  computed: {
    sum() {
      return this.val1 + this.val2
    }
  }
}).mount('#app')

console.log(vm.sum) // 5

vm.val1 = 3

console.log(vm.sum) // 6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

Объект, возвращаемый из data, будет обёрнут в реактивный прокси и сохранён как this.$data. Свойства this.val1 и this.val2 будут псевдонимами для this.$data.val1 и this.$data.val2 соответственно, поэтому они проходят через один и тот же прокси.

Vue обернёт функцию для sum в эффект. При попытке чтения this.sum, он запустит этот эффект для вычисления значения. Реактивный прокси вокруг $data позволит отследить, что свойства val1 и val2 были прочитаны во время выполнения этого эффекта.

Начиная с Vue 3, реактивность теперь доступна как отдельный пакет (opens new window). Функция, которая оборачивает $data в прокси называется reactive. Её можно вызвать напрямую, если нужно обернуть объект в реактивный прокси без необходимости использовать компонент:

const proxy = reactive({
  val1: 2,
  val2: 3
})
1
2
3
4

С возможностями, предоставляемыми пакетом реактивности, познакомимся подробнее в следующих разделах. Там будут рассмотрены функции reactive и watchEffect, которые уже повстречались, а также способы использования других функций реактивности, таких как computed и watch, без необходимости создания компонента.

# Проксированные объекты

Под капотом Vue отслеживает все объекты, которые были сделаны реактивными, поэтому для каждого объекта всегда возвращается свой Proxy.

Когда требуется доступ к вложенному объекту внутри реактивной Proxy, этот объект перед возвращением также преобразуется в Proxy.






 
 







const handler = {
  get(target, property, receiver) {
    track(target, property)
    const value = Reflect.get(...arguments)
    if (isObject(value)) {
      // Оборачиваем вложенный объект в собственный реактивный прокси
      return reactive(value)
    } else {
      return value
    }
  }
  // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# Proxy vs оригинальная сущность

При использовании Proxy нужно запомнить — проксируемый объект не будет равен оригинальному объекту в строгих сравнениях (===). Например:

const obj = {}
const wrapped = new Proxy(obj, handlers)

console.log(obj === wrapped) // false
1
2
3
4

Также будут затронуты и другие операции, основанные на строгом сравнении, например .includes() или .indexOf().

Хорошей практикой будет никогда не сохранять ссылку на оригинальный объект и работать только с реактивной версией.

const obj = reactive({
  count: 0
}) // никакой ссылки на оригинал
1
2
3

В таком случае можно гарантировать что поведение при сравнении и реактивность будут вести себя так, как и ожидается.

Обратите внимание, что Vue не оборачивает в Proxy примитивные значения (такие как числа или строки), поэтому === можно использовать напрямую с такими значениями:

const obj = reactive({
  count: 0
})

console.log(obj.count === 0) // true
1
2
3
4
5

# Как отрисовка реагирует на изменения

Шаблон компонента компилируется в render-функцию. Функция render создаёт дерево из VNode, которое описывает как компонент должен быть отрисован. Она также обёрнута в эффект, что позволяет Vue отслеживать свойства, которые считываются во время работы.

Функция render концептуально очень похожа на свойство computed. Vue не отслеживает каким именно образом зависимости используются, он только знает, что они использовались в какой-то момент во время работы функции. Если какое-либо из этих свойств изменится впоследствии, то это вызовет повторный запуск эффекта и перезапуск функции render для генерации нового дерева из VNode. После чего оно будет использовано для внесения необходимых изменений в DOM.

При использовании Vue 2.x или более ранних версий, есть дополнительные особенности отслеживания изменений, которые подробнее рассмотрены здесь.