Процесс рендеринга Vue

от автора

Добрый день! Меня зовут Александр, я работаю frontend-разработчиком в компании Nord Clan. В прошлой статье мы рассмотрели процесс компиляции Vue, а теперь надо как-то «пристроить» результат этой самой компиляции в процесс рендеринга. Давайте для начала вспомним основные пакеты:

Структура основных пакетов Vue
Структура основных пакетов 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-элементы являются конечными узлами дерева, которые могут быть сразу же отрендерены:

VDOM-дерево с host-элементами
VDOM-дерево с host-элементами

То есть корневая vnode div сразу же «запульнется» в DOM-дерево. Однако же остались еще и дочерние vnode-узлы.

Как идти по ним, да и вообще по VDOM? Конечно же рекурсивно (react >= 16 загрустил). Отставим в сторону react-флэшбэки и рассмотрим последний этап — рендеринг VDOM.

Рендеринг VDOM

Рекурсивный рендеринг 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
Рендеринг каждой 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.


ссылка на оригинал статьи https://habr.com/ru/company/nordclan/blog/699356/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *