Сквозь тернии к core-у или процесс компиляции Vue
Итак, как говаривал герой Джима Керри: «Доброе утро! И на случай, если я вас больше не увижу — добрый день, добрый вечер и доброй ночи!». Меня зовут Александр и я работаю 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.
Структура папок Vue
Сразу же возникает робкий вопрос: «С чего начать посреди всего этого бедлама?». А начнем мы с поиска точки входа.
Точка входа и пакеты
В начале пришлось немного покопаться, чтобы найти точку входа в приложение, так как будучи серьезно настроенным на хардкорный код, я думал, что придется окунутся в кучу бинарного кода, который будет мелькать на моем экране как в фильме Матрица.
Но не так страшен черт как его малюют, на помощь пришел package.json, в котором для develop указан файл scripts/dev.js.
Файл dev.js для сборки приложения
В этом файле вызывается функция build из сборщика esbuild и уже тут в ключе entryPoint находим желанный файл — vue/src/index.ts.
Точка входа в приложение
Кода в этом файле не так много, как можно было бы подумать в начале, но именно этот код отвечает за то, как обычная строка шаблона преобразуется в виртуальные ноды, а потом передает свой результат процессу рендеринга страницы.
Встречаем функцию преобразования compileToFunction. Конечно, пришлось ее немного покоцать, чтобы можно было уловить ее основную суть.
Взглянув на функцию можно выделить три этапа.
Сначала переданный шаблон компилируется, создается render-функция.
Далее сгенерированный в виде строки код будет преобразован в настоящую функцию через new Function ().
Переменная GLOBAL определяет, имеется ли в текущем запуске приложения глобальная область видимости global, и, если она есть, то окружение сгенерированной функции будет обращаться к глобальной области видимости, чтобы использовать методы Vue (мы же знаем, что инициализация new Function всегда будет смотреть на глобальную область видимости).
Если же нет, то экземпляр Vue передается вручную, а также еще используются вспомогательные функции из runtime-dom пакета.
И, наконец, нашу свежескомпилированную render-функцию записываем в компонент в кэш компиляции и возвращаем, она запишется в свойство render определенного компонента.
Далее следует установка этой функции в качестве глобального компилятора и производится реэкспорт файлов из пакета vue/runtime-dom, который в дальнейшем будет использоваться для таких вещей как createApp, mount и т.п.
Регистрация глобального компилятора
Итак, мы нашли точку входа и разобрались в ее содержимом, теперь рассмотрим что происходит при вызове createApp и mount.
Процесс компиляции
Допустим, у нас есть до боли знакомый код (хотя тем, кто еще не перешел на Vue 3 он может быть не так знаком).
До боли знакомый код
createApp создаст контекст приложения со всей необходимой функциональностью для маунтинга, перерасчета и обновления виртуального дерева, пакет runtime-core. Мы рассмотрим этот процесс более подробно в следующей статья, а сейчас можно иметь в виду то, что mount вызовет метод render, который создаст instance корневого компонента, а после этого завершит настройку новоиспеченного компонента путем вызова функции finishComponentSetup.
Первым делом извлекается наш объект компонента, который мы передали в createApp.
Далее вызывается функция compile. Вспомним, что она находится в глобальной области видимости и была установлена туда через registerRuntimeCompiler.
Прекрасно! Теперь компонент обзавелся своей render-функцией, которая выведет его в свет.
Под конец в instance также будет установлена эта render-функция, больше для удобства с внутренней работой экземпляра.
Установка рендер-функции в метод render
Теперь все сводится к следующим вопросам: что такое render-функции и как формируется AST-дерево? Что ж, постараемся ответить на эти вопросы.
Процесс формирования AST-дерева
Надеюсь, все помнят функцию compileToFunction. Настал ее черед войти в игру.
Первым делом вызовется функция compile, которая начнет парсить template и вернет AST-дерево.
Функция парсинга Vue-шаблона
Через вызов createParserContext создается контекст парсера, в котором, например, будут отслеживаться текущая строка и колонка, на которой находится парсер, а также в source сохранится шаблон, который будет изменятся в процессе парсинга.
Например, сейчас, в начале парсинга, context будет следующим:
Стартовый контекст парсинга
Далее getCursor получит текущую позицию курсора из свойств column, line, offset.
Тем самым, можно начинать парсинг шаблона, имея базовую информацию о нем. За парсинг шаблон тут отвечает функция parseChildren, принцип которой можно описать одним предложением: «парсим до победного конца!».
На вход эта функция получит context, в переменную nodes будут записываться спарсенные данные. isEnd проверяет наличие закрытия тега, например,»» или «]]>».
Порой, while можно сравнить с бесконечным барабаном и хочется сказать: «Вращайте барабан!». Барабан начинает вращаться, создается node, в которую будет записан результат шага парсинга, а также присутствуют два важных условия, которые проверяют, находится ли текущий курсор на интерполяции динамического значения или на открытии нового тега. По сути эти два условия и диктуют основную логику парсинга.
Псевдокод парсинга составляющих Vue-шаблона
Смотрим в наш context и видим, что сейчас позиция курсора стоит на кавычке, а это значит, что ни по одному из условий этот символ не проходит.
А это значит, что тогда этот символ и все пространство до открывающего тега будет парсится как обычный текст, ниже в коде после условий.
После этого node будет занесен в nodes, а context обновлен, то есть будет установлена новая позиция курсора, а также из шаблона удалится уже спарсенный контент.
Вернемся к нашим двум китам, основополагающим условиям.
Сейчас курсор установлен на открытии тега, а значит теперь настало время делать «дофига» проверок.
В нашем случае будем парсить тэг div. Будут найдены границы тэга, а также спарсены его атрибуты.
Так, путем этого нехитрого алгоритма парснига обычных тэгов и спец. символов после определенного кол-ва итераций цикл дойдет до интерполяции.
Парсинг динамического значения Vue-шаблона
Здесь стоит взглянуть на этот процесс парсинга интерполяции поподробнее, ведь в будущем интерполированное значение может динамически измениться.
За парсинг интерполированного значения возьмется функция parseInterpolation, которая первым делом найдет границы начала интерполяции и продвинет курсор до них.
Функция парсинга динаического значения
По сути произошел вход внутрь интерполяции, осталось извлечь контент.
Как извлечь dynamic? Очень просто: найти индекс начала закрытия интерполяции и отнять от него длину открывающих фигурных скобок open.length.
Далее в переменную content запишется результат вызова функции parseTextData, которая попросту извлечет dynamic через .slice (0, rowContentLength).
Теперь, все карты на руках, возвращаем новенькую AST-ноду с типом INTERPOLATION.
Создание нового AST-узла с динамическим значением
После того как цикл будет завершен parseChildren вернет новое AST-дерево.
AST-дерево
Теперь, после формирования AST-дерева следуют не менее важные этапы: оптимизация и генерация render-функции.
Оптимизация и генерация render-функции
Сформированное AST-дерево будет передано в функцию transform, которая сгенерирует codegenNode, а также отметит статические элементы как hoisted и сразу создаст vnode для них, вынеся за пределы render-функции, так как они не будут далее участвовать в перерасчетах (patch-process).
Трансформация (опимизация) AST-дерева
Внутри вызова transform создается свой контекст с необходимыми утилитами для трансформации AST-дерева.
Создание контекста с hoisted-нодами
Здесь интересны данные методы и свойства, которые помогут в дальнейшем отметить статичные AST-ноды.
Структура контекста
Контекст создан, а значит можно приступить приступить к главному — выносу статичных узлов с помощью функции hoistStatic.
Функция hoistStatic вызывает функцию walk, которая рекурсивно обойдет все AST-дерево и отметит модифицирует элементы, пригодные для hoisting.
Рассмотрим первый шаг функции walk. Извлекаем массив AST-нод.
Начинаем идти циклом по каждой AST-ноде.
Проверяем, можем ли мы отметить текущую AST-ноду как hoisted, и, если можем, «проталкиваем» итерацию вперед.
Если же текущую AST-ноду нельзя отметить как hoisted, тогда возможно она имеет вложенные AST-ноды, которые можно оптимизировать.
Такой незамысловатой рекурсией будут по возможности оптимизированы все вложенные AST-ноды, а в codegenNode будет назначена hoisted node. Постойте, а что делает вызов context.hoist? Возьмем, к примеру,»
При передаче в вызов context.hoist, первым делом на запишется в массив hoists.
Далее вызов функции createSimpleExpression вернет новую codegenNode, которая будет оптимизирована в процессе создания render-функции.
Данная нода будет иметь constType равный двум, что означает, что она может быть hoisted.
Наконец, последним этапом будет идти генерация render-функции. Оптимизированное AST-дерево передается в функцию generate.
Так как generate также содержит в себе много кода, я упростил ее представление, выделив основные операции — вынос hoisted-элементов и генерация блоков.
Функция genFunctionPreample генерирует получение createElementVNode из Vue, а также создание vnode из hoisted-элементов и начальной render-функции.
Далее генерируются блоки с оставшимися codegenNode.
Рассмотрим три функции: _openBlock, _createElementBlock, closeBlock.
Обычно открытие любого блока начинается с вызова _openBlock. Эта функция создает новый контекст в виде массива, в который будут установлены vnode-элементы после вызова _createElementBlock.
В корневую vnode сохраняется массив dynamicChildren, то есть тем самым перерасчеты внутри такого блока будут затрагивать только непосредственно сам блок.
Далее происходит закрытие блока через closeBlock, удаляется массив vnode для текущего блока, берется следующий блок.
Вспомним про ключевую функцию mountComponent.
По итогу эта функция завершает свою работу и в instance записывается сгенерированная render-функция, которая в дальнейшем будет использована в функции setupRenderEffect для того, чтобы произвести рендеринг vnode-элементов, которые будут возвращены из render-функции.
Подведем итоги. Поначалу разбор внутренней работы компилятора Vue может показаться чем-то вроде сюжета книги «Координаты чудес» Шекли с поиском истинной Земли среди множества копий, где каждый закуток кода может привести к ложному представлению. Однако, нам удалось описать четкие шаги компиляции и даже немного углубиться в структуру устройства пакетов Vue, которые помогают в компиляции на разных уровнях.