Добрый день! Меня зовут Александр, я работаю frontend-разработчиком в компании Nord Clan. В прошлой статье мы рассмотрели процесс компиляции Vue, а теперь надо как-то «пристроить» результат этой самой компиляции в процесс рендеринга. Давайте для начала вспомним основные пакеты:
В процессе рендеринга будут использоваться пакеты runtime-dom и runtime-core. При этом, runtime-dom будет обращаться к своему старшему брату runtime-core, который более мудрый и знает как, когда, и где использовать api из runtime-dom.
Начнем как обычно издалека, а именно сначала будем создавать наброски схем процесса рендеринга, а далее разбираться в них.
Стрелки в этих схемах будут указывать на вызовы других функций, а пунктирные линии на код, выполняемый внутри функций. Скажем так, это будет джуманджи в мира фронтенда, и джунгли кода будут все гуще и гуще...
Создание контекста рендеринга
Возьмем мощный микроскоп и рассмотрим то, как эти пакеты (runtime-dom и runtime-core) взаимодействуют между собой с последующим пошаговым и детальным описанием данной схемы:
Пользователь из app (берем любое Vue-приложение) вызывает функцию createApp(), и первым делом данная функция вызывает ensureRenderer:
export const createApp = (...args) => {
const app = ensureRenderer();
}
ensureRenderer либо создаст новый контекст рендеринга, либо использует уже созданный:
function ensureRenderer() {
return (
renderer ||
(renderer = createRenderer(rendererOptions))
)
}
Функция createrRenderer используется из пакета runtime-core и принимает в качестве аргумента объект с набором методов для работы с DOM (rendererOptions), которые будут использоваться в процессе монтирования виртуальных нод внутри runtime-core.
Заметим, что renderer изначально не установлен, так как тип renderer может отличаться в зависимости от сред выполнения кода:
let renderer: Renderer<Element | ShadowRoot> | HydrationRenderer
В нашем случае вызовется функция createRenderer, так как пока что никакого renderer создано не было:
(renderer = createRenderer(rendererOptions))
Эта функция в свою очередь вызовет baseCreateRenderer из пакета runtime-core, который отвечает за создание нового контекста рендеринга.
Функции patch, render, mount и т.д. будут использовать переданные из runtime-dom методы (hostInsert, hostRemove и т.д):
function baseCreateRenderer(
options: RendererOptions,
) {
const {
insert: hostInsert,
remove: hostRemove,
createElement: hostCreateElement,
createText: hostCreateText,
// ...
} = options
const patch = () => {
// ...
}
const render = () => {
// ...
}
const mount = () => {
// ...
}
// ...
}
Конечно «портянка» кода в этой функции намного больше, но мне не охота пугать вас так же, как испугался я при ее виде. Однако же, именно эта функция позволяет обучится шаблонам рефакторинга произвести рендеринг.
Функция baseCreateRenderer возвратит ключевую функцию render, а также функцию createAppAPI:
function baseCreateRenderer(
options: RendererOptions,
) {
// ...
return {
render,
createApp: createAppAPI(render)
}
}
createAppAPI возвращает функцию, которая создаст контекст приложения и предоставит методы, которые можно будет использовать в app, например, createApp().mount() или createApp().unmount():
export function createAppAPI<HostElement>(
render: RootRenderFunction,
): CreateAppFunction<HostElement> {
return function createApp(rootComponent) {
const app = {
mount() {
// ...
},
unmount() {
// ...
},
}
return app
}
}
То есть разработчик как раз вызовет createApp, а далее с радостью использует метод mount, даже не подозревая о тех страшных вещах, которые произойдут в «черном-черном ящике»...
Возьмем старый пример из прошлой статьи. В качестве rootComponent передадим литерал объекта, содержащий свойства и методы следующего компонента:
Vue
.createApp({
data: () => ({
dynamic: 1
}),
template: `
<div>
<div>foo</div>
<div>bar</div>
<div>{{ dynamic }}</div>
</div>
`,
})
.mount('#app')
Вернемся к функции createApp. Как раз она и вызовется через Vue.createApp, а ранее baseCreaterenderer (помним, создается в ensureRenderer) уже любезно предоставил возможность создать контекст через вызов createApp:
export const createApp = (...args) => {
const app = ensureRenderer().createApp();
}
Итак, первый этап пройден, контекст создан, перейдем к следующему этапу - монтированию, созданию видеокурса по написанию своей реактивной библиотеки.
Компиляция шаблона, корневая vnode (или initialVNode) и patch-функция
app.createApp() уже создала контекст и имеет все необходимые методы для продолжения рендеринга, а именно метод mount, который будет перезаписан на уровне runtime-dom:
export const createApp = (...args) => {
const app = ensureRenderer().createApp();
const { mount } = app
app.mount = (containerOrSelector: Element | ShadowRoot | string) => {
// Здесь проверка containerOrSelector на валидность
const proxy = mount(containerOrSelector)
return proxy
}
}
Метод mount вызовет как раз тот самый метод, который создал createApp, используя функции из baseCreateRenderer, передав в качестве аргумента селектор контейнера или сам контейнер, куда будет смонтировано Vue-приложение.
Перейдем в методу mount, который создавался в createAppAPI. Метод mount создаст корневую vnode на основе переданного template, data и т.д.:
export function createAppAPI(
render: RootRenderFunction,
): CreateAppFunction {
return function createApp(rootComponent, rootProps = null) {
// ...
const app = {
mount(rootContainer) {
// Создание корневой vnode (initialVNode)
const vnode = createVNode(rootComponent)
render(vnode, rootContainer)
},
}
return app
}
}
Корневая vnode будет выглядеть примерно так:
const vnode = {
dynamicChildren: null,
dynamicProps: null,
patchFlag: 0,
shapeFlag: 4,
data: () => ({ dynamic: 1 }),
template: `
\n<div>\n
<div>foo</div>\n
<div>bar</div>\n
<div>{{ dynamic }}</div>\n
</div>\n `
}
shapeFlag со значением «4» означает STATEFUL_COMPONENT. patchFlag будет также нужен в дальнейшем в процессе перерасчета. Проверки для shapeFlag и patchFlag реализованы через побитовую маску для удобства, кхм, простите, проверок и производительности.
Вернемся к методу mount, подставим сюда эту самую корневую vnode:
mount(rootContainer) {
const vnode = {
dynamicChildren: null,
dynamicProps: null,
patchFlag: 0,
shapeFlag: 4,
data: () => ({ dynamic: 1 }),
template: `
\n<div>\n
<div>foo</div>\n
<div>bar</div>\n
<div>{{ dynamic }}</div>\n
</div>\n `
}
render(vnode, rootContainer)
}
Функция render, как помним, была объявлена в baseCreateRenderer. Она вызывает процесс «патчинга» новой vnode в container (#app):
function baseCreateRenderer(
options: RendererOptions,
) {
const patch = () => {
// ...
}
const render = () => {
patch(container._vnode || null, vnode, container)
}
}
Пожалуй функция 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:
const patch: PatchFn = (
n1,
n2,
container,
) => {
// ...
const { type, ref, shapeFlag } = n2
switch (type) {
// ...
default:
// ...
if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(n1, n2, container)
}
}
}
Логика работы функций-обработчиков нужного типа vnode схожа между собой, будь то processComponent, processText, processElement и т.д. Проверяется наличие n1 (обновленная vnode), и если она есть, то запускается процесс перерасчета, а если нет — процесс монтирования.
В нашем случае происходит первичное монтирование, поэтому вызовется функция mountComponent:
const processComponent = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
) => {
// n1 равняется null, значит монтируется новый компонент
if (n1 == null) {
mountComponent(
n2,
container,
)
} else {
updateComponent(n1, n2)
}
}
Что делает функция 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:
const mountComponent: MountComponentFn = (
initialVNode,
container,
) => {
// ...
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
}
}
В первую очередь приезжает пояснительная бригада извлекается компонент:
export function finishComponentSetup(
instance: ComponentInternalInstance,
) {
const Component = instance.type // { template: "<div>...", data: () => { ... } }
// ...
}
Далее идет проверка на наличие зарегистрированного компилятора 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-функция установится на инстансе компонента:
export function finishComponentSetup(
instance: ComponentInternalInstance,
) {
// ...
instance.render = Component.render()
}
finishComponentSetup завершился и установил render-функцию в instance. В дальнейшем вызов этой функции создаст VDOM.
Теперь пришло время перевести render-функцию «на бумагу» с помощью функции setupRenderEffect (здесь могла бы быть ваша реклама барабанной дроби):
const mountComponent: MountComponentFn = (
initialVNode,
container,
) => {
// ...
setupRenderEffect(
instance,
container,
)
}
Передается в нее instance с корневой vnode, а также container (#app), куда надо будет отрендерить VDOM.
setupRenderEffect вызовет render-функцию, которая построит VDOM. В самом начале вызовется renderComponentRoot, который создает VDOM-дерево, которое может включать поддеревья, по которым будет произведен обход:
const setupRenderEffect: SetupRenderEffectFn = () => {
// ...
const subTree = (instance.subTree = renderComponentRoot(instance))
// ...
}
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
}
render-функция вернет следующее VDOM-дерево:
{
type: "div",
shapeFlag: 17,
patchFlag: 0,
children: [
{ shapeFlag: 9, patchFlag: -1, children: “foo”, type: 'div' },
{ shapeFlag: 9, patchFlag: -1, children: “bar”, type: 'div' },
{ shapeFlag: 9, patchFlag: 1, children: “1”, type: 'div' },
]
}
Схематично структуру vnode можно представить как дерево component- и host- элементов, где host-элементы являются конечными узлами дерева, которые могут быть сразу же отрендерены:
То есть корневая vnode div сразу же «запульнется» в DOM-дерево. Однако же остались еще и дочерние vnode-узлы.
Как идти по ним, да и вообще по VDOM? Конечно же рекурсивно (react >= 16 загрустил). Отставим в сторону react-флэшбэки и рассмотрим последний этап — рендеринг VDOM.
Рендеринг VDOM
Вызов patch с корневой vnode приведет к вызову processElement:
const setupRenderEffect: SetupRenderEffectFn = () => {
const subtree = {
type: "div",
shapeFlag: 17,
patchFlag: 0,
children: [
// ...
]
}
patch(
null,
subtree
)
// ...
}
Как помним, эта функция 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:
const mountElement = (
vnode: VNode, // { type: "div", children: [...] }
container: RendererElement,
) => {
let el: RendererElement
el = vnode.el = hostCreateElement(
vnode.type,
)
// ...
}
Далее проверяем, имеет ли текущая 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 для каждой из них:
const mountChildren: MountChildrenFn = (
children,
container,
) => {
for (let i = start; i < children.length; i++) {
const child = children[i]
patch(
null,
child,
container,
)
}
}
Если у последующих дочерних элементов будут также children, то и для них функция patch вызовет mountChildren, но в кач-ве container уже будет указан дочерний элемент, который и содержит эти children.
Схематично это можно представить так:
Резюмируем, 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.