Skip to content

Механизмы отрисовки

Как Vue берет шаблон и превращает его в реальные узлы DOM? Как Vue эффективно обновляет эти узлы DOM? Мы попытаемся ответить на эти вопросы, погрузившись во внутренний механизм рендеринга Vue.

Виртуальный DOM

Вы наверняка слышали о термине "виртуальный DOM", на котором основана система рендеринга Vue.

Виртуальный DOM (VDOM) - это концепция программирования, при которой идеальное, или "виртуальное", представление пользовательского интерфейса хранится в памяти и синхронизируется с "реальным" DOM. Впервые эта концепция была применена в React, а затем адаптирована во многих других фреймворках с различными реализациями, включая Vue.

Виртуальный DOM - это скорее паттерн, чем конкретная технология, поэтому не существует единой канонической реализации. Мы можем проиллюстрировать эту идею на простом примере:

js
const vnode = {
  type: 'div',
  props: {
    id: 'hello'
  },
  children: [
    /* большее количество виртуальных узлов */
  ]
}

Здесь, vnode - это обычный JavaScript-объект ("виртуальный узел"), представляющий элемент <div>. Он содержит всю информацию, необходимую для создания реального элемента. Он также содержит множество дочерних vnode, что делает его корнем виртуального дерева DOM.

Рендерер во время выполнения может обращаться к виртуальному DOM-дереву и строить из него реальное DOM-дерево. Этот процесс называется mount.

Если у нас есть две копии виртуальных деревьев DOM, рендерер также может пройтись и сравнить эти два дерева, выявить различия и применить эти изменения к реальному DOM. Этот процесс называется patch, также известный как "diffing" или "reconciliation".

Основное преимущество виртуального DOM заключается в том, что он дает разработчику возможность программно создавать, инспектировать и компоновать желаемые структуры пользовательского интерфейса в декларативном виде, оставляя непосредственную работу с DOM рендереру.

Render Pipeline

На высшем уровне это то, что происходит при монтировании компонента Vue:

  1. Compile: Шаблоны Vue компилируются в функции рендеринга: функции, возвращающие виртуальные деревья DOM. Этот шаг может быть выполнен как заранее с помощью шага сборки, так и "на лету" во время выполнения компилятором.

  2. Mount: Рендерер во время выполнения вызывает функции рендеринга, обходит возвращаемое виртуальное дерево DOM и создает на его основе реальные узлы DOM. Этот шаг выполняется как реактивный эффект, поэтому отслеживаются все реактивные зависимости, которые были использованы.

  3. Patch: При изменении зависимости, используемой при монтировании, эффект запускается повторно. На этот раз создается новое, обновленное виртуальное дерево DOM. Рендерер во время исполнения обращается к новому дереву, сравнивает его со старым и применяет необходимые обновления к реальному DOM.

render pipeline

Шаблоны в сравнении с функциями рендеринга

Шаблоны Vue компилируются в виртуальные функции рендеринга DOM. Vue также предоставляет API, позволяющие пропустить этап компиляции шаблонов и напрямую создавать функции рендеринга. Функции рендеринга являются более гибкими, чем шаблоны, при работе с высокодинамичной логикой, поскольку позволяют работать с vnode, используя всю мощь JavaScript.

Почему же Vue по умолчанию рекомендует использовать шаблоны? На это есть несколько причин:

  1. Шаблоны ближе к реальному HTML. Это облегчает повторное использование существующих компонентов HTML, применение лучших практик обеспечения доступности, стилизацию с помощью CSS, а также понимание и модификацию дизайнерами.

  2. Шаблоны легче поддаются статическому анализу благодаря их более детерминированному синтаксису. Это позволяет компилятору шаблонов Vue применять множество оптимизаций во время компиляции для повышения производительности виртуальной DOM (о чем мы поговорим ниже).

На практике шаблонов достаточно для большинства случаев использования в приложениях. Функции рендеринга обычно используются только в многократно используемых компонентах, которые должны иметь дело с высокодинамичной логикой рендеринга. Более подробно использование функций рендеринга рассматривается в разделе рендер функции и JSX.

Виртуальный DOM на основе данных компилятора

Реализация виртуального DOM в React и большинстве других реализаций виртуального DOM является чисто исполнительной: алгоритм согласования не может делать никаких предположений о входящем виртуальном DOM-дереве, поэтому для обеспечения корректности ему приходится полностью обходить это дерево и дифферинцировать входные параметры каждого vnode. Кроме того, даже если часть дерева никогда не изменяется, при каждом повторном рендеринге для нее всегда создаются новые vnode, что приводит к излишней нагрузке на память. Это один из наиболее критикуемых аспектов виртуального DOM: в результате грубого выполнения процесса согласования приходится жертвовать эффективностью в обмен на декларативность и корректность.

Но это не обязательно должно быть так. Во Vue фреймворк контролирует и компилятор, и время выполнения. Это позволяет нам реализовать множество оптимизаций во время компиляции, которыми может воспользоваться только тесно связанный рендерер. Компилятор может статически анализировать шаблон и оставлять подсказки в сгенерированном коде, чтобы время выполнения могло использовать короткие пути, когда это возможно. В то же время мы сохраняем возможность для пользователя опускаться на уровень функций рендеринга для более прямого управления в крайних случаях. Мы называем этот гибридный подход виртуальный DOM на основе данных компилятора.

Ниже мы рассмотрим несколько основных оптимизаций, выполненных компилятором шаблонов Vue для повышения производительности виртуального DOM во время выполнения.

Статический подъем

Довольно часто в шаблоне встречаются части, не содержащие динамических привязок:

template
<div>
  <div>foo</div> <!-- поднятый -->
  <div>bar</div> <!-- поднятый -->
  <div>{{ dynamic }}</div>
</div>

Проверить в обозревателе шаблонов

Разделы foo и bar статичны - повторное создание vnode и их изменение при каждом рендере не требуется. Компилятор Vue автоматически выносит вызовы создания vnode из функции рендеринга и повторно использует одни и те же vnode при каждом рендеринге. Кроме того, рендерер может полностью пропустить их изменение, когда замечает, что старый и новый vnode - это один и тот же vnode.

Кроме того, при наличии достаточного количества последовательных статических элементов они сжимаются в один "статический vnode", который содержит обычную HTML-строку для всех этих узлов (пример). Эти статические узлы монтируются путем непосредственного указания innerHTML. Они также кэшируют соответствующие им DOM-узлы при первоначальном монтировании - если тот же самый фрагмент контента будет повторно использован в другом месте приложения, новые DOM-узлы будут созданы с помощью встроенной функции cloneNode(), что очень эффективно.

Флаги патчей

Для отдельного элемента с динамическими связями мы также можем вывести много информации из него во время компиляции:

template
<!-- только привязка к классам -->
<div :class="{ active }"></div>

<!-- только привязки к id и value -->
<input :id="id" :value="value">

<!-- только для текста -->
<div>{{ dynamic }}</div>

Проверить в обозревателе шаблонов

При генерации кода функции рендеринга для этих элементов Vue кодирует тип обновления, который требуется каждому из них, непосредственно в вызове создания vnode:

js
createElementVNode("div", {
  class: _normalizeClass({ active: _ctx.active })
}, null, 2 /* КЛАСС */)

Последний аргумент, 2, является флагом патча. Элемент может иметь несколько флагов патча, которые будут объединены в одно число. Рендерер во время выполнения может проверить флаги с помощью побитовых операций, чтобы определить, нужно ли ему выполнять определенную работу:

js
if (vnode.patchFlag & PatchFlags.CLASS /* 2 */) {
  // обновление класса элемента
}

Побитовые проверки выполняются чрезвычайно быстро. С помощью флагов патча Vue может выполнять минимальный объем работы, необходимый при обновлении элементов с динамическими привязками.

Vue также кодирует тип дочерних узлов. Например, шаблон, имеющий несколько корневых узлов, представляется в виде фрагмента. В большинстве случаев мы точно знаем, что порядок этих корневых узлов никогда не изменится, поэтому эта информация также может быть предоставлена среде выполнения в качестве флага патча:

js
export function render() {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    /* дочерние элементы */
  ], 64 /* СТАБИЛЬНЫЙ_ФРАГМЕНТ */))
}

Таким образом, время выполнения может полностью пропустить согласование дочерних фрагментов для корневого фрагмента.

Сплющивание дерева

Если еще раз взглянуть на сгенерированный код из предыдущего примера, то можно заметить, что корень возвращаемого виртуального DOM-дерева создается с помощью специального вызова createElementBlock():

js
export function render() {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    /* дочерние элементы */
  ], 64 /* СТАБИЛЬНЫЙ_ФРАГМЕНТ */))
}

Концептуально "блок" - это часть шаблона, имеющая стабильную внутреннюю структуру. В данном случае весь шаблон имеет один блок, поскольку не содержит структурных директив типа v-if и v-for.

Каждый блок отслеживает все узлы-потомки (а не только прямые дочерние узлы), имеющие флаги патча. Например:

template
<div> <!-- корневой блок -->
  <div>...</div>         <!-- не отслеживается -->
  <div :id="id"></div>   <!-- отслеживается -->
  <div>                  <!-- не отслеживается -->
    <div>{{ bar }}</div> <!-- отслеживается -->
  </div>
</div>

В результате получается сглаженный массив, содержащий только динамические узлы-потомки:

div (корневой блок)
- div с привязкой :id
- div с привязкой {{ bar }}

При повторном рендеринге компонента вместо полного дерева ему приходится обходить только сплющенное дерево. Это называется Сплющивание дерева, и значительно сокращает количество узлов, которые необходимо обходить при виртуальном выравнивании DOM. При этом статические части шаблона фактически пропускаются.

Директивы v-if и v-for создают новые узлы блока:

template
<div> <!-- корневой блок -->
  <div>
    <div v-if> <!-- если блок -->
      ...
    <div>
  </div>
</div>

Дочерний блок отслеживается внутри массива динамических потомков родительского блока. Это позволяет сохранить стабильную структуру родительского блока.

Влияние на гидратацию SSR

И флаги патчей, и сплющивание деревьев также значительно улучшают работу Vue в SSR Hydration:

  • При гидратации отдельных элементов могут использоваться быстрые пути, основанные на флаге патча соответствующего vnode.

  • При гидратации необходимо обходить только блочные узлы и их динамических потомков, что позволяет добиться частичной гидратации на уровне шаблона.

Механизмы отрисовкиУже загружено