Итак, как говаривал герой Джима Керри: «Доброе утро! И на случай, если я вас больше не увижу – добрый день, добрый вечер и доброй ночи!». Меня зовут Александр и я работаю frontend-разработчиком в компании Nord Clan. Сколько себя помню, меня всегда интересовали детали различных процессов и вещей, и, уже будучи frontend-разработчиком, мне стали интересны детали реализации Vue.
Сегодня Vue является довольно популярным frontend-фреймворком. Он имеет на своем вооружении удобные шаблоны, однофайловые компоненты, а также хранилище состояний и роутинг «из коробки».
Однако, несмотря на большую популярность Vue, я с большим удивлением обнаружил, что почти никто не освещает внутреннюю работу Vue, а такая информация была бы бесспорно полезна для разработчиков, желающих углубится в архитектуру реактивных фреймворков и библиотек или внести вклад в сообщество.
В моей серии статей будет затронута тема компиляции шаблонов в Vue, которая включает в себя парсинг шаблона, преобразование его в AST-дерево, оптимизация AST-дерева (блоки, hoisting), генерация render-функции на основе codegenNode.
Начнем с небольшого введения в структуру пакетов Vue, необходимую для компиляции.
Структура пакетов в Vue
Для того, чтобы мы имели общее представление о том, с чем работаем, я составил схему, которая отображает основные используемые для компиляции пакеты, а также их зависимость друг от друга.
Взглянув на схему можно увидеть, что Vue поделен таким образом, что на верхнем уровне находится Api из runtime-dom, который непосредственно используется пользователем. Этот Api обращается уже к Api из пакета runtime-core, который ответственен за рендеринг приложения и перерасчет компонентов.
На самом низком уровне находится ядро компилятора compiler-core, который ответственен за парсинг шаблонов, создание AST-дерева и генерацию render-функций в runtime.
Также, в случае использования однофайловых компонентов, компиляция шаблона будет происходить до создания приложения через createApp и его «маунтинга».
Ну и находится все это в папке packages, которая окружена многочисленными файлами с конфигурациями, инструкциями и конечно же тяжеленной папкой node_modules, где нас больше всего интересует папка packages.
Сразу же возникает робкий вопрос: «С чего начать посреди всего этого бедлама?». А начнем мы с поиска точки входа.
Точка входа и пакеты
В начале пришлось немного покопаться, чтобы найти точку входа в приложение, так как будучи серьезно настроенным на хардкорный код, я думал, что придется окунутся в кучу бинарного кода, который будет мелькать на моем экране как в фильме Матрица.
Но не так страшен черт как его малюют, на помощь пришел package.json, в котором для develop указан файл scripts/dev.js.
В этом файле вызывается функция build из сборщика esbuild и уже тут в ключе entryPoint находим желанный файл – vue/src/index.ts.
build({
entryPoints: [resolve(__dirname, `../packages/${target}/src/index.ts`)],
// ...
})
Кода в этом файле не так много, как можно было бы подумать в начале, но именно этот код отвечает за то, как обычная строка шаблона преобразуется в виртуальные ноды, а потом передает свой результат процессу рендеринга страницы.
Встречаем функцию преобразования compileToFunction. Конечно, пришлось ее немного покоцать, чтобы можно было уловить ее основную суть.
function compileToFunction(template: string): RenderFunction {
const { code } = compile(
template,
)
const render = (
__GLOBAL__ ? new Function(code)() : new Function('Vue', code)(runtimeDom)
) as RenderFunction
return (compileCache['componentName'] = render)
}
Взглянув на функцию можно выделить три этапа.
Сначала переданный шаблон компилируется, создается render-функция.
const { code } = compile(
template,
)
Далее сгенерированный в виде строки код будет преобразован в настоящую функцию через new Function().
const render = (
__GLOBAL__ ? new Function(code)() : new Function('Vue', code)(runtimeDom)
)
Переменная GLOBAL определяет, имеется ли в текущем запуске приложения глобальная область видимости global, и, если она есть, то окружение сгенерированной функции будет обращаться к глобальной области видимости, чтобы использовать методы Vue (мы же знаем, что инициализация new Function всегда будет смотреть на глобальную область видимости).
Если же нет, то экземпляр Vue передается вручную, а также еще используются вспомогательные функции из runtime-dom пакета.
И, наконец, нашу свежескомпилированную render-функцию записываем в компонент в кэш компиляции и возвращаем, она запишется в свойство render определенного компонента.
return (compileCache[key] = render)
Далее следует установка этой функции в качестве глобального компилятора и производится реэкспорт файлов из пакета vue/runtime-dom, который в дальнейшем будет использоваться для таких вещей как createApp, mount и т.п.
registerRuntimeCompiler(compileToFunction)
export * from '@vue/runtime-dom'
Итак, мы нашли точку входа и разобрались в ее содержимом, теперь рассмотрим что происходит при вызове createApp и mount.
Процесс компиляции
Допустим, у нас есть до боли знакомый код (хотя тем, кто еще не перешел на Vue 3 он может быть не так знаком).
Vue
.createApp({
data: () => ({
dynamic: 1
}),
template: `
<div>
<div>foo</div>
<div>bar</div>
<div>{{ dynamic }}</div>
</div>
`,
})
.mount('#app')
createApp создаст контекст приложения со всей необходимой функциональностью для маунтинга, перерасчета и обновления виртуального дерева, пакет runtime-core. Мы рассмотрим этот процесс более подробно в следующей статье, а сейчас можно иметь в виду то, что mount вызовет метод render, который создаст instance корневого компонента, а после этого завершит настройку новоиспеченного компонента путем вызова функции finishComponentSetup.
export function finishComponentSetup(
instance: ComponentInternalInstance,
) {
const Component = instance.type as ComponentOptions
Component.render = compile(template)
instance.render = (Component.render || NOOP) as InternalRenderFunction
}
Первым делом извлекается наш объект компонента, который мы передали в createApp.
const Component = instance.type as ComponentOptions
Далее вызывается функция compile. Вспомним, что она находится в глобальной области видимости и была установлена туда через registerRuntimeCompiler.
Component.render = compile(template)
Прекрасно! Теперь компонент обзавелся своей render-функцией, которая выведет его в свет.
Под конец в instance также будет установлена эта render-функция, больше для удобства с внутренней работой экземпляра.
instance.render = (Component.render) as InternalRenderFunction
Теперь все сводится к следующим вопросам: что такое render-функции и как формируется AST-дерево? Что ж, постараемся ответить на эти вопросы.
Процесс формирования AST-дерева
Надеюсь, все помнят функцию compileToFunction. Настал ее черед войти в игру.
function compileToFunction(template: string): RenderFunction {
const { code } = compile(
template,
)
const render = (
__GLOBAL__ ? new Function(code)() : new Function('Vue', code)(runtimeDom)
) as RenderFunction
return (compileCache['componentName'] = render)
}
Первым делом вызовется функция compile, которая начнет парсить template и вернет AST-дерево.
export function baseParse(
content: string,
options: ParserOptions = {}
): RootNode {
const context = createParserContext(content, options)
const start = getCursor(context)
return createRoot(
parseChildren(context, TextModes.DATA, []),
getSelection(context, start)
)
}
Через вызов createParserContext создается контекст парсера, в котором, например, будут отслеживаться текущая строка и колонка, на которой находится парсер, а также в source сохранится шаблон, который будет изменятся в процессе парсинга.
export function baseParse(
content: string
): RootNode {
const context = createParserContext(content, options)
}
Например, сейчас, в начале парсинга, context будет следующим:
{
column: 1,
inPre: false,
inVPre: false,
line: 1,
offset: 0
}
Далее getCursor получит текущую позицию курсора из свойств column, line, offset.
const start = getCursor(context)
Тем самым, можно начинать парсинг шаблона, имея базовую информацию о нем. За парсинг шаблона тут отвечает функция parseChildren, принцип которой можно описать одним предложением: “парсим до победного конца!”.
На вход эта функция получит context, в переменную nodes будут записываться спарсенные данные. isEnd проверяет наличие закрытия тега, например, «</» или «]]>».
function parseChildren(
context
) {
const nodes = []
while (!isEnd(context, mode, ancestors)) {
// ...
}
}
Порой, while можно сравнить с бесконечным барабаном и хочется сказать: «Вращайте барабан!». Барабан начинает вращаться, создается node, в которую будет записан результат шага парсинга, а также присутствуют два важных условия, которые проверяют, находится ли текущий курсор на интерполяции динамического значения или на открытии нового тега. По сути эти два условия и диктуют основную логику парсинга.
while (!isEnd(context, mode)) {
let node = undefined
if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
if (курсор установлен на интерполяции {{) {
node = получитьСпарсеннуюИнтерполяцию()
} else if (курсор установлен на открытии тега <) {
cдenaть дофига проверок, спарсить, сохранить в node
}
}
}
Смотрим в наш context и видим, что сейчас позиция курсора стоит на кавычке, а это значит, что ни по одному из условий этот символ не проходит.
А это значит, что тогда этот символ и все пространство до открывающего тега будет парсится как обычный текст, ниже в коде после условий.
if (элемент принадлежит к обычному тексту) {
node = получитьДанныеДоТегаИлиИнтерполяции()
}
После этого node будет занесен в nodes, а context обновлен, то есть будет установлена новая позиция курсора, а также из шаблона удалится уже спарсенный контент.
Вернемся к нашим двум китам, основополагающим условиям.
while (!isEnd(context, mode)) {
let node = undefined
if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
if (курсор установлен на интерполяции {{) {
node = получитьСпарсеннуюИнтерполяцию()
} else if (курсор установлен на открытии тега <) {
cдenaть дофига проверок, спарсить, сохранить в node
}
}
}
Сейчас курсор установлен на открытии тега, а значит теперь настало время делать «дофига» проверок.
В нашем случае будем парсить тэг div. Будут найдены границы тэга, а также спарсены его атрибуты.
else if (буквенный тэг) {
node = получитьСпарсенныйДОМЭлемент()
}
Так, путем этого нехитрого алгоритма парснига обычных тэгов и спец. символов после определенного кол-ва итераций цикл дойдет до интерполяции.
Здесь стоит взглянуть на этот процесс парсинга интерполяции поподробнее, ведь в будущем интерполированное значение может динамически измениться.
За парсинг интерполированного значения возьмется функция parseInterpolation, которая первым делом найдет границы начала интерполяции и продвинет курсор до них.
function parseInterpolation(
context
) {
const [open, close] = context.options.delimiters // [{{, }}]
advanceBy(context, open.length)
// ...
}
По сути произошел вход внутрь интерполяции, осталось извлечь контент.
Как извлечь dynamic? Очень просто: найти индекс начала закрытия интерполяции и отнять от него длину открывающих фигурных скобок open.length.
function parseInterpolation(
context
) {
const [open, close] = context.options.delimiters // [{{, }}]
// ...
const rawContentLength = closeIndex - open.length
// ...
}
Далее в переменную content запишется результат вызова функции parseTextData, которая попросту извлечет dynamic через .slice(0, rowContentLength).
const content = parseTextData(context, rawContentLength)
Теперь, все карты на руках, возвращаем новенькую AST-ноду с типом INTERPOLATION.
function parseInterpolation(
context
) {
// ...
const rawContentLength = closeIndex - open.length
const content = parseTextData(context, rawContentLength)
return {
type: NodeTypes.INTERPOLATION,
content: {
content
}
}
}
После того как цикл будет завершен parseChildren вернет новое AST-дерево.
[
{ type: 1, ns: 0, tag: 'div', tagType: 0, props: [] },
{ type: 1, ns: 0, tag: 'div', tagType: 0, props: [] },
{
children: [
{
content: { type: 4, isStatic: false, constType: 0, content: 'dynamic' }
}
]
}
]
Теперь, после формирования AST-дерева следуют не менее важные этапы: оптимизация и генерация render-функции.
Оптимизация и генерация render-функции
Сформированное AST-дерево будет передано в функцию transform, которая сгенерирует codegenNode, а также отметит статические элементы как hoisted и сразу создаст vnode для них, вынеся за пределы render-функции, так как они не будут далее участвовать в перерасчетах (patch-process).
const ast = baseParse(template, options)
transform(ast)
Внутри вызова transform создается свой контекст с необходимыми утилитами для трансформации AST-дерева.
export function transform(root) {
const context = createTransformContext(root)
// ...
}
Здесь интересны данные методы и свойства, которые помогут в дальнейшем отметить статичные AST-ноды.
{
hoist: hoist(exp),
hoistStatic: true,
hoists: [],
// ...
}
Контекст создан, а значит можно приступить приступить к главному - выносу статичных узлов с помощью функции hoistStatic.
export function transform(root) {
// ...
if(options.hoistStatic) {
hoistStatic(root, context)
}
}
Функция hoistStatic вызывает функцию walk, которая рекурсивно обойдет все AST-дерево и отметит модифицирует элементы, пригодные для hoisting.
Рассмотрим первый шаг функции walk. Извлекаем массив AST-нод.
function walk(
node,
) {
const { children } = node
}
Начинаем идти циклом по каждой AST-ноде.
function walk(
node,
) {
const { children } = node
for (let i = 0; i < children.length; i++) {
const child = children[i]
// ...
}
}
Проверяем, можем ли мы отметить текущую AST-ноду как hoisted, и, если можем, «проталкиваем» итерацию вперед.
// ...
const child = children[i]
if(child статичный тэг или текст) {
child.codegenNode = context.hoist(child.codegenNode)
continue
}
// ...
Если же текущую AST-ноду нельзя отметить как hoisted, тогда возможно она имеет вложенные AST-ноды, которые можно оптимизировать.
// ...
const child = children[i]
if(child имеет вложенные элементы) {
walk(child)
}
// ...
Такой незамысловатой рекурсией будут по возможности оптимизированы все вложенные AST-ноды, а в codegenNode будет назначена hoisted node. Постойте, а что делает вызов context.hoist? Возьмем, к примеру, «<div>foo</div>». Эта AST-нода будет передана в вызов context.hoist.
{
children: {
content: 'foo',
},
isComponent: false,
1oc: {
source: '<div>foo</div>'
}
}
При передаче в вызов context.hoist, первым делом на запишется в массив hoists.
hoist(exp) {
context.hoists.push(exp)
}
Далее вызов функции createSimpleExpression вернет новую codegenNode, которая будет оптимизирована в процессе создания render-функции.
hoist(exp) {
context.hoists.push(exp)
const identifier = createSimpleExpression(
`_hoisted_${context.hoists.length}`,
false,
exp.loc,
ConstantTypes.CAN_HOIST
)
return identifier
}
Данная нода будет иметь constType равный двум (hoisted), что означает, что она может быть hoisted.
{
constType: 2,
content: "hoisted 1",
loc: {
source: "<div>foo</div>"
}
}
Наконец, последним этапом будет идти генерация render-функции. Оптимизированное AST-дерево передается в функцию generate.
const ast = baseParse(template, options)
transform(ast)
return generate(ast)
Так как generate также содержит в себе много кода, я упростил ее представление, выделив основные операции - вынос hoisted-элементов и генерация блоков.
function generate(
ast: RootNode,
options: CodegenOptions,
): CodegenResult {
const context = createCodegenContext(ast, options);
genFunctionPreamble(ast, preambleContext)
if (ast.codegenNode) {
genNode(ast.codegenNode, context)
}
return {
ast,
code: context.code,
}
}
Функция genFunctionPreample генерирует получение createElementVNode из Vue, а также создание vnode из hoisted-элементов и начальной render-функции в виде строки.
"
const _Vue = Vue
const { createElementVNode: _createElementVNode } = _Vue
const _hoisted_1 = _createElementVNode("div", null, "foo", -1 /* HOIDTED */)
const _hoisted_1 = _createElementVNode("div", null, "bar", -1 /* HOIDTED */)
return function render(_ctx, _cache) {
with (_ctx) {
const { createElementVNode: _createElementVNode } = _Vue
return // ...
"
Далее генерируются блоки с оставшимися codegenNode.
// ...
return (_openBlock(), createElementBlock("div", null, [
_hoisted_1,
_hoisted_2,
_createElementVNode("div", null, _toDisplayString(dynamic), 1 /* TEXT */)
]))"
Рассмотрим три функции: _openBlock, _createElementBlock, closeBlock.
Обычно открытие любого блока начинается с вызова _openBlock. Эта функция создает новый контекст в виде массива currentBlock, в который будут установлены vnode-элементы после вызова _createElementBlock и setupBlock.
function setupBlock(vnode: VNode) {
vnode.dynamicChildren =
isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR as any) : null
// ...
}
В корневую vnode сохраняется массив dynamicChildren, то есть тем самым перерасчеты внутри такого блока будут затрагивать только непосредственно сам блок.
Далее происходит закрытие блока через closeBlock, удаляется массив vnode для текущего блока, берется следующий блок.
function setupBlock(vnode: VNode) {
// ...
closeBlock()
return vnode
}
Вспомним про ключевую функцию mountComponent.
const mountComponent: MountComponentFn = (
container,
) => {
setupComponent(instance)
setupRenderEffect(
instance,
container,
)
}
По итогу эта функция завершает свою работу и в instance записывается сгенерированная render-функция, которая в дальнейшем будет использована в функции setupRenderEffect для того, чтобы произвести рендеринг vnode-элементов, которые будут возвращены из render-функции.
Подведем итоги. Поначалу разбор внутренней работы компилятора Vue может показаться чем-то вроде сюжета книги «Координаты чудес» Шекли с поиском истинной Земли среди множества копий, где каждый закуток кода может привести к ложному представлению. Однако, нам удалось описать четкие шаги компиляции и даже немного углубиться в структуру устройства пакетов Vue, которые помогают в компиляции на разных уровнях.
stgunholy
Спасибо! интересно всегда почитать про детали имплементации