Привет, Хабр! Меня зовут Александр. Я работаю frontend-разработчиком в компании Nord Clan.
Из предыдущей статьи мы уже знаем как происходит первичный рендеринг компонента. Однако, теперь мы зададимся вопросом: как Vue отследит то, что изменилось состояние компонента и сделает последующий перерендеринг, иначе, как работает реактивность в Vue?
Хоть сами разработчики Vue немного приподнимают вуаль, за которой скрыта работа реактивности, но приподнята она не настолько, чтобы получить более полную картину.
Давайте исследуем этот вопрос поступательно, так как процесс обновления компонента довольно-таки обширен. И начнем мы с рассмотрения того, как Vue «подготавливает почву» для последующих обновлений.
Стоит сразу отметить, что работа реактивности как во Vue, так и в других библиотеках и фрейворках, обширна, и данная статья несет больше обзорный характер, чтобы раскрыть основную суть работы реактивности.
В качестве отправной точки будет использован следующий пример – обычный компонент-счетчик из предыдущих статей:
Vue
.createApp({
data: () => ({
dynamic: 1
}),
template: `
<div>
<div>foo</div>
<div>bar</div>
<div>{{ dynamic }}</div>
<button @click="() => {
dynamic += 1
}">increment</button>
</div>
`,
})
.mount('#app')
Также этот компонент будет немного модифицирован в дальнейшем, а именно переписан на Composition API с целью более подробного раскрытия подробностей обновления состояния.
Создание proxy-обертки над instance.ctx
Все начинается еще на этапе компиляции шаблона, которая запускается в mountComponent.
Как помним, функция mountComponent делает три вещи: создает начальный instance компонента, компилирует шаблон и создает render-функцию, а далее производит рендеринг, вызывая render-функцию и patch на ее результате:
const mountComponent: MountComponentFn = (
initialVNode,
container,
) => {
// создание начального instance компонента
const instance: ComponentInternalInstance = (
initialVNode.component = createComponentInstance(initialVNode)
)
// компиляция и оптимизация произойдет здесь
setupComponent(instance)
// а здесь произойдет рендеринг
setupRenderEffect(
instance,
container,
)
}
setupComponent (отмечен красным как инициатор последующих процессов) помимо компиляции и оптимизации создает proxy-обертку над instance.ctx и состоянием data:
proxy-обертка для data создается с помощью функции reactive:
instance.data = reactive(data)
Функция reactive попросту создает новый proxy-объект из data, на который устанавливает обработчики на ловушки set, get, has, ownKeys, defineProperty:
const mutableHandlers: ProxyHandler<object> = {
get,
set,
deleteProperty,
has,
ownKeys
}
const proxy = new Proxy(
target,
mutableHandlers
)
Теперь при изменении или обращении к свойству data.dynamic будут срабатывать proxy-ловушки set и get соответственно.
Проблема реактивности в Vue 2
Кстати, возможно вы помните, что в Vue 2 есть проблема с реактивностью при переназначении или добавлении свойств в массиве?
Например, если в data есть массив names и мы в каком-то методе пытаемся сделать «this.names[0] = // …», то отображение не будет реагировать на эти изменения.
Proxy решает эту проблему, так как может отслеживать такие изменения, в то время как дескриптор Object.defineProperty с get- и set- методами в Vue 2 не реагирует на них.
Более того, реализация реактивности в Vue 2 не позволяет отслеживать динамическое добавление новых свойств после создания инстанса компонента, так как «дарование» свойствам реактивности через Object.defineProperty происходит на этапе создания инстанса компонента.
Также setupComponent оборачивает весь контекст instance.ctx в proxy и создает accessCache, который будет кэшировать обращения к data, props, setupState и т.п.:
// создание легковесного dictionary для хранения свойств
// и их типов доступа AccessTypes
instance.accessCache = Object.create(null)
const PublicInstanceProxyHandlers: ProxyHandler<any> = {
get({ _: instance }: ComponentRenderContext, key: string) {
// кэширование обращений к key в accessCache
},
set(
{ _: instance }: ComponentRenderContext,
key: string,
value: any
): boolean {
// обновление key в setupState, data, props, context
},
}
instance.proxy = new Proxy(instance.ctx, PublicInstanceProxyHandlers)
Например, свойству dynamic при записи в accessCache будет назначен AccessTypes.DATA, значение «2».
PublicInstanceProxyHandlers – это объект-заменитель, который содержит те же самые обработчики ловушек get, set и т.д., но только на более высоком уровне, то есть при обращении к свойству dynamic сначала вызовется getter на PublicInstanceProxyHandlers, который сделает базовую обработку (получение dynamic по accessCache или запись dynamic в accessCache при первом обращении, определение setupState, data и т.п.), а уже потом getter из mutableHandlers на самой data.
Итак, теперь на этапе вызова render-функции будут вызываться getter-ы из PublicInstanceProxyHandlers и mutableHandlers, которые отметят свойство data.dynamic как отслеживаемое и установят зависимости, которые должны будут инициализироваться при изменении этого свойства. Давайте рассмотрим этот процесс детальнее в следующей главе.
Создание эффекта рендеринга и установка отслеживания свойств состояния
setupRenderEffect создает новый эффект рендеринга, ReactiveEffect, который будет «разруливать» последующий рендеринг компонента при первоначальном запуске и после перерасчета:
const effect = new ReactiveEffect(
componentUpdateFn,
() => queueJob(update),
instance.scope
)
componentUpdateFn – функция рендеринга компонента, которая вызывает patch, отвечающий за перенаправление обработки тех или иных vnode согласно их типам на функции-обработчики, например, processComponent, processElement, processText и т.п. Подробнее об этом было написано в предыдущей статье.
() => queueJob(update)
– функция, которая поставит в очередь микротасок эффект рендеринга (вызов componentUpdateFn). Грубо говоря, это некий «планировщик» рендеринга после обновлений.
watch
Для полноты картины скажу, что помимо queueJob есть и другие функции для работы с микротасками. Например, queuePreFlushCb и queuePostFlushCb, которые используются в watch, чтобы установить срабатывание watch (опция flush) до или после рендеринга в микротаске.
instance.scope – область применения эффектов ограничивается своим scope, то есть текущим создаваемым или обновляемым компонентом.
new ReactiveEffect()
запишет в scope новый эффект – componentUpdateFn:
class ReactiveEffect {
constructor(
public fn: () => T,
scope?: EffectScope
) {
// fn – функция рендеринга componentUpdateFn
scope.effects.push(fn)
}
}
Например, scope может быть полезен, для того, чтобы остановить какой-нибудь watch.
Далее объявляется «запускатор» эффектов, то есть в instance.update записывается функция запуска эффекта:
const effect = new ReactiveEffect(
// …
)
const update: SchedulerJob = () => effect.run()
update()
update вызовет effect.run, который запустит установит эффект рендеринга как текущий активный эффект, activeEffect:
let activeEffect: ReactiveEffect | undefined
class ReactiveEffect {
run() {
activeEffect = this
return this.fn()
}
}
this.fn вызовет функцию рендеринга componentUpdateFn, которая в свою очередь вызовет render-функцию, внутри которой будут происходить обращения к dynamic:
return function render(_ctx, _cache) {
with (_ctx) {
// ...
return (_openBlock(), _createElementBlock("div", null, [
// ...
// обращение к dynamic вызовет обработчики get-ловушек на instance.ctx и $data
_createElementVNode("div", null, _toDisplayString(dynamic), 1 /* TEXT */),
])
)
}
}
Вспомнить все
Да, кстати, я забыл упомянуть в первой статье, что dynamic берется путем предоставления контекста через with, которое создает область видимости «на лету», так как мы помним, что сама render-функция составляется из строки и было бы неудобно создавать обращения к каждому свойству через точечную нотацию.
Обращение к dynamic вызовет proxy-ловушку get из instance.ctx, которая при первом обращении к dynamic запишет его в accessCache, присвоив ему соответствующий AccessType, чтобы в дальнейшем определять, откуда брать этот ключ и обеспечить сложность алгоритма O(n):
{
get(target: Target, key: string | symbol) {
// первое обращение к ключу из data проходит через проверку hasOwn
if (data !== EMPTY_OBJ && hasOwn(data, key)) {
accessCache![key] = AccessTypes.DATA
return data[key]
}
}
}
А при следующем обращении dynamic возьмется из accessCache:
// второе обращение к ключу берется из accessCache
const n = accessCache![key]
// проверки hasOwn больше не нужны, так как они проводились при первом обращении к ключу, то есть устанавливается константная сложность алгоритма
if (n !== undefined) {
switch (n) {
case AccessTypes.SETUP:
return setupState[key]
case AccessTypes.DATA:
// получение data.dynamic произойдет здесь
return data[key]
case AccessTypes.CONTEXT:
return ctx[key]
case AccessTypes.PROPS:
return props![key]
}
}
Помним, data также была обернута в proxy, то есть обращение к data[key] будет перехвачено в mutableHandlers на get-ловушке, которая установит слежение за dynamic:
{
get(target: Target, key: string | symbol) {
// target равный { dynamic: 1 }
// key равный dynamic
track(target, TrackOpTypes.GET, key)
{
}
Так, теперь внимательно следим за ловкостью рук без мошенничества, так как мне потребовалось какое-то время, чтобы понять смысл данного кода.
Функция track проверит, есть ли текущий отслеживаемый объект состояния data в зависимостях, и, если нет, установит его в WeakMap.
WeakMap позволит не держать в памяти те состояния, компоненты которых были, например, размонтированы, то есть когда ни один компонент не будет ссылаться на это состояние (а вот ссылка, чтобы освежить вашу память о ссылках в JS :D):
const targetMap = new WeakMap<any, KeyToDepMap>()
function track(target: object, type: TrackOpTypes, key: unknown) {
// получить зависимости по ключу target равному { dynamic: 1 }
let depsMap = targetMap.get(target)
// если нет коллекции зависимостей по ключу { dynamic: 1 }, создать ее
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
// ...
}
То есть targetMap будет выглядеть так после записи в него data, где в качестве ключа будет состояние data, а в качестве значения – зависимости:
Далее будет произведена попытка получить зависимости для dynamic и, если их нет, будет создана новая:
function track(target: object, type: TrackOpTypes, key: unknown) {
// …
// получить зависимости для ключа равного dynamic
let dep = depsMap.get(key)
// если нет зависимостей для dynamic, создать их
if (!dep) {
dep = createDep()
depsMap.set(key, dep)
}
}
И теперь targetMap будет выглядеть вот так:
Теперь для { dynamic: 1 }
будет записана новая зависимость dynamic, у которой в качестве значения будет Set-коллекция возможных эффектов.
Наконец, будет вызвана trackEffect, которая добавит для dynamic возможные эффекты:
function track(target: object, type: TrackOpTypes, key: unknown) {
// ...
let dep = depsMap.get(key)
// ...
trackEffects(dep)
}
Так как обращение к dynamic произошло в процессе работы эффекта рендеринга, то trackEffects добавит для dynamic текущий активный эффект, эффект рендеринга, который был ранее создан в setupRenderEffect:
function trackEffects(dep: Dep) {
// fn – эффект рендеринга, вызывающий функцию componentUpdateFn
dep.add(activeEffect!)
}
Теперь при изменении свойства dynamic будет запущен эффект рендеринга.
Итак, был создан ключевой эффект, эффект рендеринга, на этапе вызова которого произошло обращение к свойству data.dynamic, которое было записано в accessCache для оптимизации.
Также, в data.dynamic была добавлена зависимость эффекта рендеринга, то есть при дальнейших изменениях data.dynamic будет запущен эффект рендеринга.
Работа эффектов на примере Composition API
Чтобы лучше понять работу эффектов, на один миг перепишем компонент в стиле Composition API:
Vue
.createApp({
setup() {
const dynamic = Vue.ref(1)
const dynamicSquare = Vue.computed(() => dynamic.value ** 2);
return {
dynamic,
dynamicSquare,
}
},
template: `
<div>
<div>foo</div>
<div>bar</div>
<div>{{ dynamic }}</div>
<div>{{ dynamicSquare }}</div>
<button @click="() => {
dynamic += 1
}">increment</button>
</div>
`,
})
.mount('#app')
Как видите, dynamic обернули в ref, а также добавили производное от dynamic – dynamicSquare, обернутое в computed. То есть dynamicSquare по сути будет находиться в зависимостях у dynamic, так как обращается к dynamic.value.
Схематично это можно представить так:
ref и computed создадут инстансы классов RefImpl и ComputedRefImpl соответственно, которые будет иметь свои get- и set- методы. Однако ComputedRefImpl помимо простого отслеживания и вызова эффектов, также создает свой ReactiveEffect, так как перерасчет значения dynamic.value ** 2
вызовет эффект рендеринга, который должен выполняться в очереди:
class ComputedRefImpl<T> {
constructor(
getter: ComputedGetter<T>,
) {
// getter равный () => dynamic.value ** 2
this.effect = new ReactiveEffect(getter, () => {
// функция записи эффекта рендеринга в очередь
triggerRefValue(this)
})
}
get value() {
// запустить getter равный () => dynamic.value ** 2
self._value = self.effect.run()
return self._value
}
}
Vue.ref и Vue.computed вызываются внутри метода setup. В работе метода setup ничего сложного нет. Это тот же data, но возвращает он только свойства, которые вы уже сами оборачиваете в реактивную оболочку (RefImpl и ComputedRefImpl).
setup вызовется на этапе setupComponent:
const { setup } = Component
if (setup) {
// вернет { dynamic: RefImpl, dynamicSquare: ComputedRefImpl }
const setupResult = setup();
}
handleSetupResult(setupResult)
Функция handleSetupResult запишет в setupState результат вызова setup – setupResult:
instance.setupState = setupResult
В остальном рендеринг и отслеживание изменений идут примерно по тому же сценарию.
Первым вызовется эффект рендеринга, и при вызове render-функции и обращении к dynamic и к dynamicSquare этот эффект запишется в качестве их зависимостей:
Также при обращении к dynamicSquare запуститься эффект () => dynamic.value ** 2
. И так как в теле этой функции будет обращения к dynamic.value, то сработает ее getter, который обновит зависимости dynamic, добавит функцию перерасчета () => dynamic.value **
в качестве зависимости:
То есть теперь при изменении dynamic запуститься эффект рендеринга и эффект () => dynamic.value ** 2
.
Теперь давайте сядем в Делориан и вернемся в прошлое к нашему старому компоненту с data и последуем призыву группы «Технология» нажав на кнопку increment, чтобы изменить состояние компонента.
Изменение состояния компонента
Вспомним обработчик клика в render-функции:
onClick: () => {
// обращение к dynamic вызовет getter-ы из proxy
// далее присвоение вызовет setter-ы из proxy
dynamic += 1
}
Обращение к dynamic во второй раз (первый был при первичном рендеринге) достанет значение этого свойства через getter уже по accessCache.
А далее вызовется первая proxy-ловушка на set из PublicInstanceProxyHandlers, где будет определено, куда записать новое значение для ключа dynamic: в setupState, data и т.п.
После придет черед для второго set из mutableHandlers, который установит новое значение для dynamic через рефлексию и вызовет обновления эффектов через trigger:
{
set(target: Target, key: string | symbol, value: unknown, receiver: Object) {
// установить ключу dynamic новое значение в { dynamic: 1 }
Reflect.set(target, key, value, receiver)
trigger(target, TriggerOpTypes.SET, key, value, oldValue)
}
}
Функция trigger получит обновленное состояние { dynamic: 2 }
из targetMap:
function trigger(
target: object,
key?: unknown,
) {
const depsMap = targetMap.get(target)
// …
}
Далее в deps будут получены все зависимости для ключа dynamic из depsMap :
function trigger(
target: object,
key?: unknown,
) {
// …
let deps: (Dep | undefined)[] = []
deps.push(depsMap.get(key))
}
Помним, что у dynamic лишь одна зависимость – эффект рендеринга. Следовательно, активация эффекта рендеринга будет вызвана на deps[0]:
function trigger( target: object, key?: unknown,) {
// …
let deps: (Dep | undefined)[] = []
// …
triggerEffects(deps[0])
}
Эффект рендеринга будет вызван в функции triggerEffects. Сначала произойдет нормализация данных для использования цикла на effects, а далее в цикле будет вызвана функция triggerEffect:
function triggerEffects(dep: Dep | ReactiveEffect[]) {
// нормализация данных
const effects = isArray(dep) ? dep : [...dep]
for (const effect of effects) {
triggerEffect(effect)
}
}
Функция triggerEffect запустит sheduler () => queueJob(update)
, который мы ранее видели в setupRenderEffect при создании эффекта рендеринга:
function triggerEffect(effect: ReactiveEffect) {
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
}
Если же у эффекта нет sheduler, то он будет вызван сразу же.
На данном этапе мы рассмотрели что происходит после нажатия на кнопку increment: срабатывают setter-ы для dynamic, а далее вызываются эффекты-зависимости, которые есть у dynamic… Нуу, вернее вызывает их sheduler, если таковой есть.
Мы уже сталкивались с упоминанием очередей ранее и у нас не могли не возникнуть следующие вопросы: что же за очереди; для чего они нужны и какую роль играет в процессе ререндеринга компонента? На все эти вопросы мы ответим, рассказав о sheduler.
Создание очереди эффектов через sheduler
Начнем с вопроса «Для чего?».
Вспомним пример с Composition API. Есть dynamic и производное от него dynamicSquare. И у первого, и у второго в зависимостях будет указан эффект рендеринга. То есть при изменении dynamic по логике вещей должен будет произойти ререндер, далее будет вычислено dynamicSquare и опять произойдет ререндер…
Как этого избежать? Конечно же путем batch updates.
Для начала, выстраивание очереди эффектов происходит посредством создания микротаски (особенно рекомендую обратить внимание на раздел Batching operations), так как это позволяет производить batch updates.
Знаем, что изменение dynamic вызовет triggerEffect, первой зависимостью будет эффект перерасчета dynamicSquare.
triggerEffect поставит задачу перерасчета () => dynamic.value ** 2
в очередь с помощью effect.sheduler, который вызовет queueJob. queueJob проверит наличие очереди и отсутствие в ней добавляемой job-ы и, если job-ы нет, то добавит в очередь новую job-у и отправит ее на batch update, вызвав queueFlush:
function queueJob(job: SchedulerJob) {
if (
!queue.length || !queue.includes( job)
) {
queue.push(job)
queueFlush()
}
}
queueFlush проверяет, нет ли текущих выполняемых flush updates или ожидания на выполнения нового flush updates и, если нет, то ставит в очередь микротасков новое batch-обновление, которое будет работать с queue:
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
Теперь, Vue пройдется по остальным эффектам из зависимостей dynamic, взяв эффект рендеринга. Однако, этот эффект не поставится в очередь, так как она уже заполнена эффектом перерасчета dynamicSquare.
Поэтому произойдет свертывание callstack-а, а значит наступит очередь микротасок, в которые и попадет функция flushJobs:
function flushJobs() {
// currentFlushPromise переходит в статус fulfill
isFlushPending = false
isFlushing = true
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
// вызвать зависимость из dynamicSquare – эффект рендеринга
callWithErrorHandling(job)
}
} finally {
// очистить очередь эффектов
queue.length = 0
isFlushing = false
currentFlushPromise = null
}
}
flushJobs вызовет зависимость из dynamicSquare – эффект рендеринга, а после сделает сброс всех значений для последующих batch updates.
То есть теперь процесс обновления получается более плоским. Рендеринг будет вызываться после того, как отработает основной callstack из тасок, то есть после того как применятся все эффекты, обновится состояние и т.д.:
Немного о nextTick
Кстати, помните о методе nextTick? Иногда приходилось вызывать его, чтобы получить доступ к обновленному DOM. В своей реализации nextTick как раз обращается к currentFlushPromise, ждет того момента, когда этот промис завершиться, то есть когда отработают все микротаски в flushJobs, которые обновят DOM:
function nextTick<T = void>(
this: T,
fn?: (this: T) => void
): Promise<void> {
const p = currentFlushPromise
// если был передан callback, то вызвать его после выполнения промиса
// иначе вернуть currentFlushPromise для его ручной обработки
return fn ? p.then(this ? fn.bind(this) : fn) : p
}
Схематично это можно было бы представить вот так:
Резюме
Теперь мы знаем как работает реактивность в Vue в общих чертах:
При первичном рендеринге компонента его состояние data оборачивается в proxy, далее обращения в render-функции к свойствам data вызывает getter-ы на proxy, которые помечают свойства как отслеживаемые;
каждое отслеживаемое свойство имеет зависимости, и так как отслеживаемые свойства были прочитаны во время рендеринга, то значит они получат зависимость – эффект рендеринга
при изменении какого-либо свойства сработает setter на proxy и вызовет зависимости свойства, то есть эффект рендеринга
эффект рендеринга будет выполнен в микротасках в так называемой очереди batch updates, после того как завершиться основная работа, связанная с перерасчетом