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

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

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

Когда простой JavaScript-объект передаётся в экземпляр Vue в качестве опции data, Vue обходит все его поля и превращает их в пары геттер/сеттер, используя Object.defineProperty. Эта возможность появилась в JavaScript только начиная с версии ES5, и в более ранних версиях её эмулировать не получится — по этой-то причине Vue и не поддерживает IE8 и ниже.

Геттеры и сеттеры не видны пользователю, но именно они являются тем внутренним механизмом, который позволяет Vue отслеживать зависимости и изменения данных. К сожалению, при таком подходе выведенные в консоль браузера геттеры и сеттеры выглядят не так, как обычные объекты, поэтому для более наглядной визуализации лучше использовать инструменты разработчика Vue-devtools.

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

Цикл реактивности

Особенности отслеживания изменений

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

Для объектов

Vue не может обнаружить добавление или удаление свойства. Так как Vue добавляет геттер/сеттер на этапе инициализации экземпляра, свойство должно присутствовать в объекте data для того чтобы Vue преобразован его и сделал реактивным. Например:

var vm = new Vue({
  data: {
    a: 1
  }
})
// теперь `vm.a` — реактивное поле

vm.b = 2
// `vm.b` НЕ реактивно

Во Vue нельзя динамически добавлять новые корневые реактивные свойства в уже существующий экземпляр. Тем не менее, можно добавить реактивное свойство во вложенные объекты, используя метод Vue.set(object, propertyName, value):

Vue.set(vm.someObject, 'b', 2)

Также можно использовать метод экземпляра vm.$set, который представляет собой псевдоним к глобальному Vue.set:

this.$set(this.someObject, 'b', 2)

Иногда нужно добавить несколько свойств в существующий объект, например, с помощью Object.assign() или _.extend(). Если так поступить, добавленные свойства не станут реактивными. Для решения этой задачи придётся создать новый объект, содержащий поля как оригинального объекта, так и объекта-примеси:

// вместо `Object.assign(this.someObject, { a: 1, b: 2 })`
this.someObject = Object.assign({}, this.someObject, { a: 1, b: 2 })

Для массивов

Vue не может отследить следующие изменения в массиве:

  1. Прямую установку элемента по индексу: vm.items[indexOfItem] = newValue
  2. Явное изменение длины массива: vm.items.length = newLength

Например:

var vm = new Vue({
  data: {
    items: ['a', 'b', 'c']
  }
})
vm.items[1] = 'x' // НЕ РЕАКТИВНО
vm.items.length = 2 // НЕ РЕАКТИВНО

Решить первую проблему можно двумя способами, оба дадут эффект аналогичный vm.items[indexOfItem] = newValue, плюс запустят реактивные обновления состояния приложения:

// Использовать Vue.set
Vue.set(vm.items, indexOfItem, newValue)
// Использовать Array.prototype.splice
vm.items.splice(indexOfItem, 1, newValue)

Можно использовать метод экземпляра vm.$set, который является псевдонимом для глобального Vue.set:

vm.$set(vm.items, indexOfItem, newValue)

Для решения второй проблемы используйте splice:

vm.items.splice(newLength)

Объявление реактивных свойств

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

var vm = new Vue({
  data: {
    // объявляем поле message, содержащее пустую строку
    message: ''
  },
  template: '<div>{{ message }}</div>'
})
// впоследствии задаём значение `message`
vm.message = 'Привет!'

Если не задать поле message в опции data, Vue выведет предупреждение, что функция отрисовки пытается получить доступ к несуществующему свойству.

Существуют технические причины для этого ограничения: оно позволяет исключить целый класс граничных случаев в системе учёта зависимостей, а также упростить взаимодействие Vue с системами проверки типов. Но более важно то, что с этим ограничением становится проще поддерживать код, так как теперь объект data можно рассматривать как схему состояния компонента. Код, в котором реактивные свойства компонента перечислены заранее, намного проще для понимания.

Асинхронная очередь обновлений

На всякий случай напомним, что во Vue обновление DOM выполняется асинхронно. Каждый раз, когда обнаруживается изменение в данных, создаётся очередь, которая используется в качестве буфера для этого и последующих изменений, происходящих в текущей итерации (“tick”) цикла событий. Если один и тот же наблюдатель срабатывает несколько раз, в очередь он попадёт всё равно лишь единожды. Благодаря использованию буфера и устранению дубликатов, вычисления и манипуляции DOM сводятся к минимуму. В следующей итерации цикла событий Vue разбирает очередь и выполняет актуальные (уже не содержащие дубликатов) обновления. На низком уровне для асинхронной постановки задач в очередь используются Promise.then, MutationObserver и setImmediate, а если они недоступны, то setTimeout(fn, 0).

Итак, если выполнить код vm.someData = 'новое значение', компонент не будет отрисован сразу же. Он обновится в следующей итерации при разборе очереди. Чаще всего эту особенность можно не принимать в расчёт, но иногда бывает нужно дождаться состояния, в которое DOM перейдёт после обновления данных. Хотя прямая манипуляция DOM нежелательна, а системы в целом предпочтительнее проектировать так, чтобы в них были первичные данные, иногда всё же её не избежать. Чтобы выполнить какой-нибудь код только после того, как завершится обновление DOM, можно передать коллбэк в метод Vue.nextTick(callback) после изменения данных. Он будет вызван после обновления DOM. Например:

<div id="example">{{ message }}</div>
var vm = new Vue({
  el: '#example',
  data: {
    message: '123'
  }
})
vm.message = 'новое сообщение' // изменяем данные
vm.$el.textContent === 'новое сообщение' // false
Vue.nextTick(function () {
  vm.$el.textContent === 'новое сообщение' // true
})

Существует также метод экземпляра vm.$nextTick(), особенно подходящий для использования внутри компонентов, поскольку он не требует обращения к глобальной переменной Vue, а также автоматически связывает контекст this коллбэка с текущим экземпляром Vue:

Vue.component('example', {
  template: '<span>{{ message }}</span>',
  data: function () {
    return {
      message: 'не обновлено'
    }
  },
  methods: {
    updateMessage: function () {
      this.message = 'обновлено'
      console.log(this.$el.textContent) // => 'не обновлено'
      this.$nextTick(function () {
        console.log(this.$el.textContent) // => 'обновлено'
      })
    }
  }
})

Поскольку $nextTick() возвращает Promise, вы можете достичь того же, используя новый синтаксис async/await из ES2017:

methods: {
  updateMessage: async function () {
    this.message = 'обновлено'
    console.log(this.$el.textContent) // => 'не обновлено'
    await this.$nextTick()
    console.log(this.$el.textContent) // => 'обновлено'
  }
}