Реактивность в деталях
Одна из самых характерных особенностей Vue — ненавязчивая система реактивности. Состояние компонента состоит из реактивных объектов JavaScript. Когда вы изменяете их, вид обновляется. Это делает управление состоянием простым и интуитивно понятным, но также важно понимать, как оно работает, чтобы избежать некоторых распространённых проблем. В этом разделе мы рассмотрим некоторые детали нижнего уровня системы реактивности Vue.
Что такое реактивность?
Этот термин часто встречается в программировании, но что люди имеют в виду, когда говорят о нем? Реактивность — это парадигма программирования, которая позволяет нам подстраиваться под изменения в декларативной манере. Канонический пример, который обычно показывают, потому что это отличный пример, — это электронная таблица Excel:
A | B | C | |
---|---|---|---|
0 | 1 | ||
1 | 2 | ||
2 | 3 |
Здесь ячейка A2 определяется формулой = A0 + A1
(вы можете щелкнуть на A2, чтобы просмотреть или отредактировать формулу), поэтому электронная таблица выдает нам 3. Ничего удивительного. Но если вы обновите A0 или A1, вы заметите, что A2 тоже автоматически обновится.
JavaScript обычно работает не так. Если бы мы написали нечто подобное на JavaScript:
js
let A0 = 1
let A1 = 2
let A2 = A0 + A1
console.log(A2) // 3
A0 = 2
console.log(A2) // Всё ещё 3
Когда мы мутируем A0
, A2
не изменяется автоматически.
Как же это сделать в JavaScript? Во-первых, чтобы повторно выполнить код, который обновляет A2
, давайте обернём его в функцию:
js
let A2
function update() {
A2 = A0 + A1
}
Затем нам нужно определить несколько терминов:
Функция
update()
производит побочный эффект, или эффект, потому что она изменяет состояние программы.A0
иA1
считаются зависимыми от эффекта, поскольку их значения используются для выполнения эффекта. Считается, что эффект является подписчиком своих зависимостей.
Нам нужна волшебная функция, которая может вызывать update()
(эффект) всякий раз, когда A0
или A1
(зависимости) изменяются:
js
whenDepsChange(update)
Эта функция whenDepsChange()
выполняет следующие задачи:
Отслеживает момент считывания переменной. Например, при вычислении выражения
A0 + A1
считываются иA0
, иA1
.Если переменная считывается, когда в данный момент выполняется эффект, эффект становится подписчиком этой переменной. Например, поскольку
A0
иA1
читаются во время выполненияupdate()
,update()
становится подписчиком иA0
, иA1
после первого вызова.Определяет, когда переменная мутирует. Например, когда
A0
будет присвоено новое значение, функция сообщает всем его эффектам-подписчикам о необходимости повторного выполнения.
Как работает реактивность во Vue
Мы не можем отслеживать чтение и запись локальных переменных, как в примере. В ванильном JavaScript просто нет механизма для этого. Но что мы можем сделать, так это перехватить чтение и запись свойств объекта.
Существует два способа перехвата доступа к свойствам в JavaScript: геттер / сеттер и Прокси. Vue 2 использовал геттеры/сеттеры исключительно из-за ограничений поддержки браузерами. В Vue 3 прокси используются для реактивных объектов, а геттеры/сеттеры — для реактивных ссылок. Вот псевдокод, иллюстрирующий их работу:
js
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key)
}
})
}
function ref(value) {
const refObject = {
get value() {
track(refObject, 'value')
return value
},
set value(newValue) {
value = newValue
trigger(refObject, 'value')
}
}
return refObject
}
Примечание
Сниппеты кода здесь и далее призваны объяснить основные концепции в максимально простой форме, поэтому многие детали опущены, а крайние случаи проигнорированы.
Это объясняет некоторые ограничения реактивных объектов, которые мы обсуждали в разделе «Основы»:
Когда вы присваиваете или деструктурируете свойство реактивного объекта локальной переменной, доступ или присвоение этой переменной становится нереактивным, поскольку больше не вызывает прокси-ловушки get/set на исходном объекте. Заметьте, что это «разъединение» влияет только на привязку переменной — если переменная указывает на непервичное значение, например, объект, мутирование объекта всё равно будет реактивным.
Возвращенный прокси из
reactive()
, хотя и ведёт себя так же, как и оригинал, имеет другую идентичность, если мы сравним его с оригиналом с помощью оператора===
.
Внутри track()
мы проверяем, запущен ли в данный момент эффект. Если таковой имеется, мы ищем эффекты подписчиков (хранящиеся в Set) для отслеживаемого свойства и добавляем эффект в Set:
js
// Это будет установлено непосредственно перед началом эффекта
// для запуска. Мы разберемся с этим позже.
let activeEffect
function track(target, key) {
if (activeEffect) {
const effects = getSubscribersForProperty(target, key)
effects.add(activeEffect)
}
}
Подписки на эффекты хранятся в глобальной структуре данных WeakMap<target, Map<key, Set<effect>>>
. Если для свойства (отслеживаемого впервые) не было найдено набора эффектов подписки, он будет создан. Вот что вкратце делает функция getSubscribersForProperty()
. Для простоты мы опустим её подробности.
Внутри trigger()
мы снова ищем эффекты подписчика для свойства. Но на этот раз мы обращаемся к ним:
js
function trigger(target, key) {
const effects = getSubscribersForProperty(target, key)
effects.forEach((effect) => effect())
}
Теперь давайте вернемся к функции whenDepsChange()
:
js
function whenDepsChange(update) {
const effect = () => {
activeEffect = effect
update()
activeEffect = null
}
effect()
}
Она оборачивает необработанную функцию update
в эффект, который устанавливает себя в качестве текущего активного эффекта перед выполнением фактического обновления. Это позволяет вызывать track()
во время обновления для определения местоположения текущего активного эффекта.
На данном этапе мы создали эффект, который автоматически отслеживает свои зависимости и запускается заново при изменении зависимости. Мы называем это реактивным эффектом.
Vue предоставляет API, позволяющий создавать реактивные эффекты: watchEffect()
. На самом деле, вы могли заметить, что он работает примерно так же, как магический whenDepsChange()
в примере. Теперь мы можем переделать исходный пример, используя актуальные API Vue:
js
import { ref, watchEffect } from 'vue'
const A0 = ref(0)
const A1 = ref(1)
const A2 = ref()
watchEffect(() => {
// отслеживает A0 и A1
A2.value = A0.value + A1.value
})
// вызывает эффект
A0.value = 2
Использование реактивного эффекта для изменения ссылки не самый интересный вариант использования — на самом деле, использование вычисляемого свойства делает его более декларативным:
js
import { ref, computed } from 'vue'
const A0 = ref(0)
const A1 = ref(1)
const A2 = computed(() => A0.value + A1.value)
A0.value = 2
Внутри computed
управляет своим аннулированием и повторным вычислением с помощью реактивного эффекта.
Итак, каков пример распространённого и полезного реактивного эффекта? Что ж, обновляем DOM! Мы можем реализовать простую «реактивную отрисовку» вот так:
js
import { ref, watchEffect } from 'vue'
const count = ref(0)
watchEffect(() => {
document.body.innerHTML = `Счётчик: ${count.value}`
})
// обновляет DOM
count.value++
На самом деле, это очень похоже на то, как компонент Vue поддерживает состояние и DOM в синхронизации — каждый экземпляр компонента создает реактивный эффект для отрисовки и обновления DOM. Конечно, компоненты Vue используют гораздо более эффективные способы обновления DOM, чем innerHTML
. Это обсуждается в главе Механизм отрисовки.
Время выполнения по сравнению с временем компиляции
Система реактивности Vue в основном основана на времени выполнения: отслеживание и срабатывание происходит во время выполнения кода непосредственно в браузере. Плюсы реактивности во время выполнения в том, что она может работать без этапа сборки, и в том, что в ней меньше крайних случаев. С другой стороны, это делает его ограниченным синтаксическими ограничениями JavaScript, что приводит к необходимости использования контейнеров значений, таких как Vue refs.
Некоторые фреймворки, например Svelte, решают преодолеть эти ограничения, реализуя реактивность во время компиляции. Он анализирует и преобразует код, чтобы смоделировать реактивность. Этап компиляции позволяет фреймворку изменять семантику самого JavaScript — например, неявно внедрять код, выполняющий анализ зависимостей и срабатывание эффектов при доступе к локально определённым переменным. Недостатком является то, что такие преобразования требуют этапа сборки, а изменение семантики JavaScript — это, по сути, создание языка, который выглядит как JavaScript, но компилируется во что-то другое.
Команда Vue исследовала это направление с помощью экспериментальной функции под названием Трансформация реактивности, но в итоге мы решили, что она не подходит для проекта по причине, описанной здесь.
Отладка реактивности
Замечательно, что система реактивности Vue автоматически отслеживает зависимости, но в некоторых случаях мы можем захотеть выяснить, что именно отслеживается или что вызывает повторную отрисовку компонента.
Отладочные хуки для компонентов
Мы можем отладить, какие зависимости используются во время отрисовки компонента и какая зависимость запускает обновление, используя такие хуки жизненного цикла, как onRenderTracked
и onRenderTriggered
. Оба хука получат событие отладчика, содержащее информацию о рассматриваемой зависимости. Рекомендуется поместить в обратные вызовы оператор debugger
для интерактивной проверки зависимости:
vue
<script setup>
import { onRenderTracked, onRenderTriggered } from 'vue'
onRenderTracked((event) => {
debugger
})
onRenderTriggered((event) => {
debugger
})
</script>
Примечание
Хуки отладки компонентов работают только в режиме разработки.
Объекты событий отладки имеют следующий тип:
ts
type DebuggerEvent = {
effect: ReactiveEffect
target: object
type:
| TrackOpTypes /* 'get' | 'has' | 'iterate' */
| TriggerOpTypes /* 'set' | 'add' | 'delete' | 'clear' */
key: any
newValue?: any
oldValue?: any
oldTarget?: Map<any, any> | Set<any>
}
Отладка вычисляемых свойств
Мы можем отлаживать вычисляемые свойства, передавая computed()
второй объект параметров с обратными вызовами onTrack
и onTrigger
:
onTrack
будет вызван, когда реактивное свойство или ссылка будет отслежена как зависимость.onTrigger
будет вызван, когда обратный вызов наблюдателя будет вызван мутацией зависимости.
Оба обратных вызова будут получать события отладчика в том же формате, что и отладочные хуки компонентов:
js
const plusOne = computed(() => count.value + 1, {
onTrack(e) {
// срабатывает, когда count.value отслеживается как зависимость
debugger
},
onTrigger(e) {
// срабатывает при изменении значения count.value
debugger
}
})
// доступ к plusOne, должен сработать onTrack
console.log(plusOne.value)
// мутация count.value, должен сработать onTrigger
count.value++
Примечание
Вычисляемые свойства onTrack
и onTrigger
работают только в режиме разработки.
Отладка наблюдателей
Подобно computed()
, наблюдатели также поддерживают свойства onTrack
и onTrigger
:
js
watch(source, callback, {
onTrack(e) {
debugger
},
onTrigger(e) {
debugger
}
})
watchEffect(callback, {
onTrack(e) {
debugger
},
onTrigger(e) {
debugger
}
})
Примечание
Свойства наблюдателя onTrack
и onTrigger
работают только в режиме разработки.
Интеграция с внешними системами управления состоянием
Система реактивности Vue работает за счёт глубокого преобразования обычных объектов JavaScript в реактивные прокси. Глубокое преобразование может быть ненужным, а иногда и вовсе нежелательным при интеграции с внешними системами управления состоянием (например, если внешнее решение также использует прокси).
Общая идея интеграции системы реактивности Vue с решением для управления состоянием состоит в том, чтобы хранить внешнее состояние в shallowRef
. Неглубокая ссылка реагирует только при доступе к её свойству .value
— внутреннее значение остаётся нетронутым. Когда внешнее состояние изменится, замените значение ref, чтобы запустить обновления.
Неизменяемые данные
Если вы реализуете функцию отмены/повтора, вам, вероятно, захочется делать снимок состояния приложения при каждом редактировании пользователем. Однако система изменяемой реактивности Vue не лучше всего подходит для этого, если дерево состояний велико, поскольку сериализация всего объекта состояния при каждом обновлении может быть дорогостоящей с точки зрения затрат как на процессор, так и на память.
Неизменяемые структуры данных решают эту проблему, никогда не изменяя объекты состояния — вместо этого создаются новые объекты, которые имеют одинаковые, неизменённые части со старыми. Существуют разные способы использования неизменяемых данных в JavaScript, но мы рекомендуем использовать Immer с Vue, поскольку он позволяет использовать неизменяемые данные, сохраняя при этом более эргономичный изменяемый синтаксис.
Мы можем интегрировать Immer с Vue с помощью простой компоновки:
js
import { produce } from 'immer'
import { shallowRef } from 'vue'
export function useImmer(baseState) {
const state = shallowRef(baseState)
const update = (updater) => {
state.value = produce(state.value, updater)
}
return [state, update]
}
Конечные автоматы
Конечный автомат — это модель для описания всех возможных состояний, в которых может находиться приложение, и всех возможных способов перехода из одного состояния в другое. Хотя это может быть излишним для простых компонентов, это может помочь сделать сложные потоки состояний более надёжными и управляемыми.
Одной из самых популярных реализаций конечного автомата в JavaScript является XState. Вот составной компонент, который с ним интегрируется:
js
import { createMachine, interpret } from 'xstate'
import { shallowRef } from 'vue'
export function useMachine(options) {
const machine = createMachine(options)
const state = shallowRef(machine.initialState)
const service = interpret(machine)
.onTransition((newState) => (state.value = newState))
.start()
const send = (event) => service.send(event)
return [state, send]
}
RxJS
RxJS — библиотека для работы с асинхронными потоками событий. Библиотека VueUse предоставляет надстройку @vueuse/rxjs
для подключения потоков RxJS к системе реактивности Vue.
Подключение к сигналам
Многие другие фреймворки ввели примитивы реактивности, похожие на ссылки из API композиции Vue, под термином «сигналы»:
По сути, сигналы — это тот же примитив реактивности, что и ссылки Vue. Это контейнер значений, который обеспечивает отслеживание зависимостей при доступе и срабатывание побочных эффектов при мутации. Эта парадигма, основанная на реактивных примитивах, не является особенно новой концепцией в мире внешнего интерфейса: она восходит к таким реализациям, как Knockout observables и Meteor Tracker, сделанные более десяти лет назад. API Vue Options и библиотека управления состоянием React MobX также основаны на тех же принципах, но скрывают примитивы за свойствами объекта.
Хотя это не является обязательным признаком для того, чтобы что-то можно было квалифицировать как сигнал, сегодня эта концепция часто обсуждается вместе с моделью отрисовки, где обновления выполняются посредством детальных подписок. Из-за использования Virtual DOM Vue в настоящее время полагается на компиляторы для достижения аналогичной оптимизации. Однако мы также изучаем новую стратегию компиляции, основанную на Solid, под названием режим Vapor, которая не полагается на Virtual DOM и использует больше преимуществ встроенной системы реактивности Vue.
Компромиссы при проектировании API
Дизайн сигналов Preact и Qwik очень похож на shallowRef Vue: все три предоставляют изменяемый интерфейс через свойство .value
. Мы сосредоточим обсуждение на сигналах Solid и Angular.
Сигналы Solid
В дизайне API createSignal()
в Solid особое внимание уделяется разделению чтения и записи. Сигналы предоставляются как геттер, доступный только для чтения, и отдельный сеттер:
js
const [count, setCount] = createSignal(0)
count() // доступ к значению
setCount(1) // обновление значения
Обратите внимание, как сигнал count
может передаваться без сеттера. Это гарантирует, что состояние никогда не сможет быть изменено, если только сеттер не будет явно предоставлен. Оправдывает ли эта гарантия безопасности более подробный синтаксис, может зависеть от требований проекта и личного вкуса — но если вы предпочитаете этот стиль API, вы можете легко воспроизвести его во Vue:
js
import { shallowRef, triggerRef } from 'vue'
export function createSignal(value, options) {
const r = shallowRef(value)
const get = () => r.value
const set = (v) => {
r.value = typeof v === 'function' ? v(r.value) : v
if (options?.equals === false) triggerRef(r)
}
return [get, set]
}
Сигналы Angular
Angular претерпевает некоторые фундаментальные изменения: отказ от грязных проверок и введение собственной реализации примитива реактивности. API сигналов в Angular выглядит следующим образом:
js
const count = signal(0)
count() // доступ к значению
count.set(1) // установка нового значения
count.update((v) => v + 1) // обновление на основе предыдущего значения
// мутация глубоких объектов с одинаковой идентичностью
const state = signal({ count: 0 })
state.mutate((o) => {
o.count++
})
Опять же, мы можем легко скопировать API во Vue:
js
import { shallowRef, triggerRef } from 'vue'
export function signal(initialValue) {
const r = shallowRef(initialValue)
const s = () => r.value
s.set = (value) => {
r.value = value
}
s.update = (updater) => {
r.value = updater(r.value)
}
s.mutate = (mutator) => {
mutator(r.value)
triggerRef(r)
}
return s
}
По сравнению с ссылками Vue, стиль API Solid и Angular, основанный на геттерах, обеспечивает некоторые интересные компромиссы при использовании в компонентах Vue:
()
немного менее подробен, чем.value
, но обновление значения более подробное.- Нет развёртывания ссылок: для доступа к значениям всегда требуется
()
. Это делает доступ к значениям единообразным повсюду. Это также означает, что вы можете передавать необработанные сигналы в качестве свойств компонента.
Подходят ли вам эти стили API, в некоторой степени субъективно. Наша цель здесь — продемонстрировать основное сходство и компромиссы между этими различными конструкциями API. Мы также хотим показать, что Vue является гибким: вы не привязаны к существующим API. При необходимости вы можете создать свой собственный примитивный Reactivity API для удовлетворения более конкретных потребностей.