Сквозь тернии к core-у или процесс компиляции Vue

6492bc666db767dcb9fae6bb84a13f17.jpeg

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

Сегодня Vue является довольно популярным frontend-фреймворком. Он имеет на своем вооружении удобные шаблоны, однофайловые компоненты, а также хранилище состояний и роутинг «из коробки».

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

В моей серии статей будет затронута тема компиляции шаблонов в Vue, которая включает в себя парсинг шаблона, преобразование его в AST-дерево, оптимизация AST-дерева (блоки, hoisting), генерация render-функции на основе codegenNode.

Начнем с небольшого введения в структуру пакетов Vue, необходимую для компиляции.

Структура пакетов в Vue

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

f48ae5a1c10aa7678ffde7234f66070e.png

Взглянув на схему можно увидеть, что Vue поделен таким образом, что на верхнем уровне находится Api из runtime-dom, который непосредственно используется пользователем. Этот Api обращается уже к Api из пакета runtime-core, который ответственен за рендеринг приложения и перерасчет компонентов.

На самом низком уровне находится ядро компилятора compiler-core, который ответственен за парсинг шаблонов, создание AST-дерева и генерацию render-функций в runtime.

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

ced703e8f5886c9196b2d50198091524.png

Ну и находится все это в папке packages, которая окружена многочисленными файлами с конфигурациями, инструкциями и конечно же тяжеленной папкой node_modules.

Структура папок VueСтруктура папок Vue

Сразу же возникает робкий вопрос:  «С чего начать посреди всего этого бедлама?». А начнем мы с поиска точки входа.

Точка входа и пакеты

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

Но не так страшен черт как его малюют, на помощь пришел package.json, в котором для develop указан файл scripts/dev.js.

Файл dev.js для сборки приложенияФайл dev.js для сборки приложения

В этом файле вызывается функция build из сборщика esbuild и уже тут в ключе entryPoint находим желанный файл — vue/src/index.ts.

Точка входа в приложениеТочка входа в приложение

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

Встречаем функцию преобразования compileToFunction. Конечно, пришлось ее немного покоцать, чтобы можно было уловить ее основную суть.

e6ffc8d3c68249028e256f7df3ca742c.png

Взглянув на функцию можно выделить три этапа.

Сначала переданный шаблон компилируется, создается render-функция.

Далее сгенерированный в виде строки код будет преобразован в настоящую функцию через new Function ().

95a26e3069815ca24b3bd3de1ab32c80.png

Переменная GLOBAL определяет, имеется ли в текущем запуске приложения глобальная область видимости global, и, если она есть, то окружение сгенерированной функции будет обращаться к глобальной области видимости, чтобы использовать методы Vue (мы же знаем, что инициализация new Function всегда будет смотреть на глобальную область видимости).

Если же нет, то экземпляр Vue передается вручную, а также еще используются вспомогательные функции из runtime-dom пакета.

И, наконец, нашу свежескомпилированную render-функцию записываем в компонент в кэш компиляции и возвращаем, она запишется в свойство render определенного компонента.

e062fd0153c368d85dd82f0560aed57c.png

Далее следует установка этой функции в качестве глобального компилятора и производится реэкспорт файлов из пакета vue/runtime-dom, который в дальнейшем будет использоваться для таких вещей как createApp, mount и т.п.

Регистрация глобального компилятораРегистрация глобального компилятора

Итак, мы нашли точку входа и разобрались в ее содержимом, теперь рассмотрим что происходит при вызове createApp и mount.

Процесс компиляции

Допустим, у нас есть до боли знакомый код (хотя тем, кто еще не перешел на Vue 3 он может быть не так знаком).

До боли знакомый кодДо боли знакомый код

createApp создаст контекст приложения со всей необходимой функциональностью для маунтинга, перерасчета и обновления виртуального дерева, пакет runtime-core. Мы рассмотрим этот процесс более подробно в следующей статья, а сейчас можно иметь в виду то, что mount вызовет метод render, который создаст instance корневого компонента, а после этого завершит настройку новоиспеченного компонента путем вызова функции finishComponentSetup.

Первым делом извлекается наш объект компонента, который мы передали в createApp.

2c4c1f986fec2a5c87e5e98cb1a5d550.png

Далее вызывается функция compile. Вспомним, что она находится в глобальной области видимости и была установлена туда через registerRuntimeCompiler.

Прекрасно! Теперь компонент обзавелся своей render-функцией, которая выведет его в свет.

Под конец в instance также будет установлена эта render-функция, больше для удобства с внутренней работой экземпляра.

Установка рендер-функции в метод renderУстановка рендер-функции в метод render

Теперь все сводится к следующим вопросам: что такое render-функции и как формируется AST-дерево? Что ж, постараемся ответить на эти вопросы.

Процесс формирования AST-дерева

Надеюсь, все помнят функцию compileToFunction. Настал ее черед войти в игру.

6d1fed861d4ca0139a4fde5c6c4d21e1.png

Первым делом вызовется функция compile, которая начнет парсить template и вернет AST-дерево.

Функция парсинга Vue-шаблонаФункция парсинга Vue-шаблона

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

669df8554e4df2d2186125cd28f888a6.png

Например, сейчас, в начале парсинга, context будет следующим:

Стартовый контекст парсингаСтартовый контекст парсинга

Далее getCursor получит текущую позицию курсора из свойств column, line, offset.

d57cb8985d10624449ff9b95b94d7ef5.png

Тем самым, можно начинать парсинг шаблона, имея базовую информацию о нем. За парсинг шаблон тут отвечает функция parseChildren, принцип которой можно описать одним предложением: «парсим до победного конца!».

На вход эта функция получит context, в переменную nodes будут записываться спарсенные данные. isEnd проверяет наличие закрытия тега, например,»».

aa0f3caaa851e93ac468ac69cfec9016.png

Порой, while можно сравнить с бесконечным барабаном и хочется сказать: «Вращайте барабан!». Барабан начинает вращаться, создается node, в которую будет записан результат шага парсинга, а также присутствуют два важных условия, которые проверяют, находится ли текущий курсор на интерполяции динамического значения или на открытии нового тега. По сути эти два условия и диктуют основную логику парсинга.

Псевдокод парсинга составляющих Vue-шаблонаПсевдокод парсинга составляющих Vue-шаблона

Смотрим в наш context и видим, что сейчас позиция курсора стоит на кавычке, а это значит, что ни по одному из условий этот символ не проходит.

d799e3325d056f46d52cac420c0d35a7.png

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

c8caf03ecfd1452814b6c8a352b8afef.png

После этого node будет занесен в nodes, а context обновлен, то есть будет установлена новая позиция курсора, а также из шаблона удалится уже спарсенный контент.

28ba251671ebd7869bffd1132e564cbc.png

Вернемся к нашим двум китам, основополагающим условиям.

f6d4ba8d1df485fefff54da245aac3cf.png

Сейчас курсор установлен на открытии тега, а значит теперь настало время делать «дофига» проверок.

a0f645894d3636eff8a38fc9b459e7b2.png

В нашем случае будем парсить тэг div. Будут найдены границы тэга, а также спарсены его атрибуты.

38911a33ce4cda23035cf58f3ff3e765.png

Так, путем этого нехитрого алгоритма парснига обычных тэгов и спец. символов после определенного кол-ва итераций цикл дойдет до интерполяции.

Парсинг динамического значения Vue-шаблонаПарсинг динамического значения Vue-шаблона

Здесь стоит взглянуть на этот процесс парсинга интерполяции поподробнее, ведь в будущем интерполированное значение может динамически измениться.

За парсинг интерполированного значения возьмется функция parseInterpolation, которая первым делом найдет границы начала интерполяции и продвинет курсор до них.

Функция парсинга динаического значенияФункция парсинга динаического значения

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

e759badf6fab66be449e3e725196868d.png

Как извлечь dynamic? Очень просто: найти индекс начала закрытия интерполяции и отнять от него длину открывающих фигурных скобок open.length.

7071e968ece43fed5cf7e12a195d4f25.png

Далее в переменную content запишется результат вызова функции parseTextData, которая попросту извлечет dynamic через .slice (0, rowContentLength).

28aeb390406264673ade5099bc00496b.png

Теперь, все карты на руках, возвращаем новенькую AST-ноду с типом INTERPOLATION.

Создание нового AST-узла с динамическим значениемСоздание нового AST-узла с динамическим значением

После того как цикл будет завершен parseChildren вернет новое AST-дерево.

AST-деревоAST-дерево

Теперь, после формирования AST-дерева следуют не менее важные этапы: оптимизация и генерация render-функции.

Оптимизация и генерация render-функции

Сформированное AST-дерево будет передано в функцию transform, которая сгенерирует codegenNode, а также отметит статические элементы как hoisted и сразу создаст vnode для них, вынеся за пределы render-функции, так как они не будут далее участвовать в перерасчетах (patch-process).

Трансформация (опимизация) AST-дереваТрансформация (опимизация) AST-дерева

Внутри вызова transform создается свой контекст с необходимыми утилитами для трансформации AST-дерева.

Создание контекста с hoisted-нодамиСоздание контекста с hoisted-нодами

Здесь интересны данные методы и свойства, которые помогут в дальнейшем отметить статичные AST-ноды.

Структура контекстаСтруктура контекста

Контекст создан, а значит можно приступить приступить к главному — выносу статичных узлов с помощью функции hoistStatic.

2c7cc6cd9195f5ab92e2d32e2f7daac2.png

Функция hoistStatic вызывает функцию walk, которая рекурсивно обойдет все AST-дерево и отметит модифицирует элементы, пригодные для hoisting.

Рассмотрим первый шаг функции walk. Извлекаем массив AST-нод.

42b4bc691acbe710f73a568579e39ae5.png

Начинаем идти циклом по каждой AST-ноде.

75991a7c32f881a2a7bd3a6eb329825e.png

Проверяем, можем ли мы отметить текущую AST-ноду как hoisted, и, если можем, «проталкиваем» итерацию вперед.

0a5609ffdbb1dd481ac2d3a35774279f.png

Если же текущую AST-ноду нельзя отметить как hoisted, тогда возможно она имеет вложенные AST-ноды, которые можно оптимизировать.

e9852d5e4fe8af2eea689783a1290ec0.png

Такой незамысловатой рекурсией будут по возможности оптимизированы все вложенные AST-ноды, а в codegenNode будет назначена hoisted node. Постойте, а что делает вызов context.hoist? Возьмем, к примеру,»

foo
». Эта AST-нода будет передана в вызов context.hoist.

c791bf904fe4b7ab1559ce5bd030a5ec.png

При передаче в вызов context.hoist, первым делом на запишется в массив hoists.

69f7044aeb4ea045d6a20c57204cf1c8.png

Далее вызов функции createSimpleExpression вернет новую codegenNode, которая будет оптимизирована в процессе создания render-функции.

Данная нода будет иметь constType равный двум, что означает, что она может быть hoisted.

9b3112151fae610f0b1f75ee9d72c305.png

Наконец, последним этапом будет идти генерация render-функции. Оптимизированное AST-дерево передается в функцию generate.

5c043237a584b6488ed8aaaae32354c7.png

Так как generate также содержит в себе много кода, я упростил ее представление, выделив основные операции — вынос hoisted-элементов и генерация блоков.

ec40844a244349a164248344f33f9759.png

Функция genFunctionPreample генерирует получение createElementVNode из Vue, а также создание vnode из hoisted-элементов и начальной render-функции.

77799aea5974d2431b7df8e588d13a5c.png

Далее генерируются блоки с оставшимися codegenNode.

f7bbb7688c105da77e3ff21391864a4e.png

Рассмотрим три функции: _openBlock, _createElementBlock, closeBlock.

Обычно открытие любого блока начинается с вызова _openBlock. Эта функция создает новый контекст в виде массива, в который будут установлены vnode-элементы после вызова _createElementBlock.

3610540343a3be257dc17ec22dce6bb1.png

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

Далее происходит закрытие блока через closeBlock, удаляется массив vnode для текущего блока, берется следующий блок.

Вспомним про ключевую функцию mountComponent.

88bebf1e11f8b39d22d634646256729e.png

По итогу эта функция завершает свою работу и в instance записывается сгенерированная render-функция, которая в дальнейшем будет использована в функции setupRenderEffect для того, чтобы произвести рендеринг vnode-элементов, которые  будут возвращены из render-функции.

Подведем итоги. Поначалу разбор внутренней работы компилятора Vue может показаться чем-то вроде сюжета книги «Координаты чудес» Шекли с поиском истинной Земли среди множества копий, где каждый закуток кода может привести к ложному представлению. Однако, нам удалось описать четкие шаги компиляции и даже немного углубиться в структуру устройства пакетов Vue, которые помогают в компиляции на разных уровнях.

© Habrahabr.ru