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

Комментарии (0)