Добрый день! Меня зовут Александр, я работаю 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.
