Добрый день! Меня зовут Александр, я работаю frontend-разработчиком в компании Nord Clan. В прошлой статье мы рассмотрели процесс компиляции Vue, а теперь надо как-то «пристроить» результат этой самой компиляции в процесс рендеринга. Давайте для начала вспомним основные пакеты:
Структура основных пакетов Vue
В процессе рендеринга будут использоваться пакеты runtime-dom и runtime-core. При этом, runtime-dom будет обращаться к своему старшему брату runtime-core, который более мудрый и знает как, когда, и где использовать api из runtime-dom.
Начнем как обычно издалека, а именно сначала будем создавать наброски схем процесса рендеринга, а далее разбираться в них.
Стрелки в этих схемах будут указывать на вызовы других функций, а пунктирные линии на код, выполняемый внутри функций. Скажем так, это будет джуманджи в мира фронтенда, и джунгли кода будут все гуще и гуще…
Создание контекста рендеринга
Возьмем мощный микроскоп и рассмотрим то, как эти пакеты (runtime-dom и runtime-core) взаимодействуют между собой с последующим пошаговым и детальным описанием данной схемы:
Создание контекста рендеринга
Пользователь из app (берем любое Vue-приложение) вызывает функцию createApp (), и первым делом данная функция вызывает ensureRenderer:
Функция createrRenderer используется из пакета runtime-core и принимает в качестве аргумента объект с набором методов для работы с DOM (rendererOptions), которые будут использоваться в процессе монтирования виртуальных нод внутри runtime-core.
Заметим, что renderer изначально не установлен, так как тип renderer может отличаться в зависимости от сред выполнения кода:
let renderer: Renderer | HydrationRenderer
В нашем случае вызовется функция createRenderer, так как пока что никакого renderer создано не было:
(renderer = createRenderer(rendererOptions))
Эта функция в свою очередь вызовет baseCreateRenderer из пакета runtime-core, который отвечает за создание нового контекста рендеринга.
Функции patch, render, mount и т.д. будут использовать переданные из runtime-dom методы (hostInsert, hostRemove и т.д):
Конечно «портянка» кода в этой функции намного больше, но мне не охота пугать вас так же, как испугался я при ее виде. Однако же, именно эта функция позволяет обучится шаблонам рефакторинга произвести рендеринг.
Функция baseCreateRenderer возвратит ключевую функцию render, а также функцию createAppAPI:
createAppAPI возвращает функцию, которая создаст контекст приложения и предоставит методы, которые можно будет использовать в app, например, createApp ().mount () или createApp ().unmount ():
То есть разработчик как раз вызовет createApp, а далее с радостью использует метод mount, даже не подозревая о тех страшных вещах, которые произойдут в «черном-черном ящике»…
Возьмем старый пример из прошлой статьи. В качестве rootComponent передадим литерал объекта, содержащий свойства и методы следующего компонента:
Вернемся к функции createApp. Как раз она и вызовется через Vue.createApp, а ранее baseCreaterenderer (помним, создается в ensureRenderer) уже любезно предоставил возможность создать контекст через вызов createApp:
Итак, первый этап пройден, контекст создан, перейдем к следующему этапу — монтированию, созданию видеокурса по написанию своей реактивной библиотеки.
Компиляция шаблона, корневая vnode (или initialVNode) и patch-функция
Монтирование и рендеринг компонента
app.createApp () уже создала контекст и имеет все необходимые методы для продолжения рендеринга, а именно метод mount, который будет перезаписан на уровне runtime-dom:
Метод mount вызовет как раз тот самый метод, который создал createApp, используя функции из baseCreateRenderer, передав в качестве аргумента селектор контейнера или сам контейнер, куда будет смонтировано Vue-приложение.
Перейдем в методу mount, который создавался в createAppAPI. Метод mount создаст корневую vnode на основе переданного template, data и т.д.:
shapeFlag со значением »4» означает STATEFUL_COMPONENT. patchFlag будет также нужен в дальнейшем в процессе перерасчета. Проверки для shapeFlag и patchFlag реализованы через побитовую маску для удобства, кхм, простите, проверок и производительности.
Вернемся к методу mount, подставим сюда эту самую корневую vnode:
Пожалуй функция patch является одной из самых ключевых функций, big boss в своем пакете.
Эта функция «проксирует» обработку той или иной vnode нужному обработчику (пардон за тавтологию), определяя тип vnode по shapeFlag, а также тип обновления по patchFlag.
То есть она отвечает за управление тем, как тот или узел VDOM будет обработан в процессе обхода VDOM-дерева:
const patch: PatchFn = (
n1,
n2,
container,
) => {
if (n1 === n2) {
// обновляемая виртуальная нода n1 идентична виртуальной ноде n2
// ничего не делать
}
if (n1 && !isSameVNodeType(n1, n2)) {
// обновляемая внода n1 не является одним и тем типом с n2
// размонтировать весь n1, чтобы смонтировать заново без перерасчета
}
const { type, ref, shapeFlag } = n2
switch (type) {
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
// обновить n1 в n2 как елемент
} else if (shapeFlag & ShapeFlags.COMPONENT) {
// обновить n1 в n2 как компонент
}
}
}
Проверок и обработчиков намного больше, но лучше сфокусироваться на самом основном.
Как помним, наша корневая vnode имеет shapeFlag равный STATEFUL_COMPONENT, а значит пора выходить на остановке processComponent:
Логика работы функций-обработчиков нужного типа vnode схожа между собой, будь то processComponent, processText, processElement и т.д. Проверяется наличие n1 (обновленная vnode), и если она есть, то запускается процесс перерасчета, а если нет — процесс монтирования.
В нашем случае происходит первичное монтирование, поэтому вызовется функция mountComponent:
Что делает функция mountComponent? Она уже была упомянута в предыдущей статье про компиляцию в Vue, где эта функция помогает произвести компиляцию шаблона компонента в runtime, а потом отрендерить результат компиляции, освежим память:
const mountComponent: MountComponentFn = (
initialVNode,
container,
) => {
const instance: ComponentInternalInstance = (
initialVNode.component = createComponentInstance(initialVNode)
)
// компиляция и оптимизация произойдет здесь
setupComponent(instance)
// а здесь произойдет рендеринг
setupRenderEffect(
instance,
container,
)
}
createComponentInstance создаст контекст инициализации компонента. Полей намного больше, выделим основные:
const instance: ComponentInternalInstance = {
uid: uid++,
vnode, // корневая vnode
type, // { data: { ... }, template: `` }
appContext, // контекст приложения (mount, render, directives)
render: null, // render-функция, будет установлена после парсинга template
isMounted: false, // флаг проверки состояния mounted
isUnmounted: false, // флаг проверки состояния unmounted
}
Далее вызовется функция setupComponent, которая примет новый instance:
Функция setupComponent после некоторых приготовлений вызовет finishSetupComponent, которая скомпилирует шаблон в render-функцию и установит ее в instance.render. Условий много, но скоро прибудет пояснительная бригада:
export function finishComponentSetup(
instance: ComponentInternalInstance,
) {
const Component = instance.type
if (!instance.render) {
if (compile && !Component.render) {
if (Component.template) {
Component.render = compile(template, finalCompilerOptions)
}
}
instance.render = Component.render
}
}
В первую очередь приезжает пояснительная бригада извлекается компонент:
Далее идет проверка на наличие зарегистрированного компилятора compile, шаблона template и установленных render-функций. При успешных проверках запуститься функция compile, результатом которой будет новая рендер-функция:
export function finishComponentSetup(
instance: ComponentInternalInstance,
) {
// может уже есть render-функция?
if (!instance.render) {
// render-функции нет, а компилятор есть!?
if (compile && !Component.render) {
// отлично, нужен еще template…
if (Component.template) {
Component.render = compile(template, finalCompilerOptions)
}
}
// ...
}
Новая render-функция установится на инстансе компонента:
Передается в нее instance с корневой vnode, а также container (#app), куда надо будет отрендерить VDOM.
setupRenderEffect вызовет render-функцию, которая построит VDOM. В самом начале вызовется renderComponentRoot, который создает VDOM-дерево, которое может включать поддеревья, по которым будет произведен обход:
renderComponentRoot вызовет заветную render-функцию, которая была создана на этапе компиляции, передав в нее Proxy-свойства компонента, для отслеживания их изменений и дальнейших перерасчетов. Например, в прокси-объекте будут $props, $data и т.д.
Конечно, как сказал бы Каневский, это совсем другая история, а поэтому вернемся к renderComponentRoot:
export function renderComponentRoot(
instance: ComponentInternalInstance
): VNode {
const {
proxy,
} = instance
let result: VNode
// Создать новое VDOM-дерево
result = render!.call(
proxy
)
return result
}
Схематично структуру vnode можно представить как дерево component- и host- элементов, где host-элементы являются конечными узлами дерева, которые могут быть сразу же отрендерены:
VDOM-дерево с host-элементами
То есть корневая vnode div сразу же «запульнется» в DOM-дерево. Однако же остались еще и дочерние vnode-узлы.
Как идти по ним, да и вообще по VDOM? Конечно же рекурсивно (react >= 16 загрустил). Отставим в сторону react-флэшбэки и рассмотрим последний этап — рендеринг VDOM.
Рендеринг VDOM
Рекурсивный рендеринг VDOM
Вызов patch с корневой vnode приведет к вызову processElement:
Как помним, эта функция processElement, как и другие функции-обработчики, могла бы вызвать update-функцию для перерасчета vnode-узла, но пока что перерасчитывать нечего, а поэтому vnode смонтируется через вызов mountElement в processElement:
const mountElement = (
vnode: VNode,
container: RendererElement,
) => {
let el: RendererElement
el = vnode.el = hostCreateElement(
vnode.type,
)
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(el, vnode.children as string)
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(
vnode.children as VNodeArrayChildren,
el,
)
}
}
Сначала создается новый экземпляр DOM-элемента, то есть HTMLDivElement:
Далее проверяем, имеет ли текущая vnode «детей» (ох, и тут я понял насколько странно применять это слово в данном контексте), или это конечный текстовый host-элемент, который можно просто отрендерить:
const mountElement = (
vnode: VNode,
container: RendererElement,
) => {
let el: RendererElement
// ...
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(el, vnode.children as string)
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(
vnode.children as VNodeArrayChildren,
el,
)
}
}
В нашем случае текущая корневая vnode («div») является «многодетной» (казалось, страннее чем «дети» vnode-ы ничего быть не может), а значит вызовется mountChildren:
mountChildren(
vnode.children,
el
)
mountChildren по сути просто выполняет проход по всех дочерним vnode-ам, вызывая patch для каждой из них:
Если у последующих дочерних элементов будут также children, то и для них функция patch вызовет mountChildren, но в кач-ве container уже будет указан дочерний элемент, который и содержит эти children.
Схематично это можно представить так:
Рендеринг каждой vnode
Резюмируем, vnode root div — корневая vnode (выделена красным), который содержит children, вставляется в DOM, а далее вызывается mountChildren, который примет vnode root div в кач-ве контейнера для children.
В вызов patch будут переданы vnode из children и vnode root div и patch отрендерит каждую дочернюю vnode в vnode root div.
Так, раз за разом, из каждой vnode будет создан свой DOM-элемент и вставлен в корневой DOM-элемент (выделены жирным текстом для каждой итерации).
Стоит заметить, что здесь рассмотрена только самая базовая обработка vnode, когда vnode-ы из children являются хост-элементами.
Итак, теперь мы знаем (хоть и поверхностно, но все же) как работает этот «черный ящик» под названием рендеринг во Vue. В следующей статье мы рассмотрим процесс перерасчета VDOM во Vue.