August 2, 2021

Взгляд на компиляцию в фреймворках JavaScript

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

Если вы посмотрите на весь спектр инструментов сборки, которые мы используем, каждый фреймворк улучшен за счет некоторого предварительного процесса сборки. И если вы хотите использовать его в естественных условиях, вы можете остановиться, как это сделал @swyx в своей статье. Языковые серверы - это новые фреймворки, вплоть до самого языка.

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

Что такое скомпилированный фреймворк JavaScript?

Те, где код конечного пользователя прогоняется через компилятор для получения конечного результата. Честно говоря, это, возможно, слишком свободное определение, но я хочу показать, что подход представляет собой спектр, а не единую цель. Чаще всего этот термин ассоциируется с такими фреймворками, как Svelte или Marko, где все в конечном итоге обрабатывается. Но почти все популярные фреймворки используют ту или иную форму опережающей компиляции (AOT) в своих шаблонах.

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

Несмотря на то, что на протяжении многих лет в составленном лагере использовалось несколько подходов, в настоящее время выделяются два основных. Языки шаблонов, ориентированные на HTML, такие как Svelte, Vue и Marko, и языки шаблонов, ориентированные на JavaScript, такие как JSX.

<section>
  <h1>My favorite color</h1>
  <div>${input.color.toUpperCase()}</div>
</section>
<shared-footer/>

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

export default FavoriteColor(props) {
  return <>
    <section>
      <h1>My favorite color</h1>
      <div>{props.color.toUpperCase()}</div>
    </section>
    <SharedFooter />
  </>;
}

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

Оптимизация шаблонов

Мотивация для создания скомпилированных фреймворков была вызвана желанием дополнительно оптимизировать эти шаблоны. Но многое можно сделать с помощью базового языка шаблонов. Они могут быть скомпилированы по-разному для сервера и браузера. Они могут служить средством обнаружения признаков агрессивного "tree shaking" (удаление неиспользуемого кода). И многие фреймворки используют языки шаблонов как способ заблаговременного статического анализа для оптимизации генерируемого кода для повышения производительности.

Большая часть кода, генерируемого шаблоном, - это логика создания, будь то набор узлов VDOM или настоящие узлы DOM. Глядя на шаблон, вы почти сразу можете определить, какие части никогда не изменятся, например, буквальные значения в атрибутах или фиксированные группы элементов. Это "низко висящий плод" для любого подхода к созданию шаблонов.

Библиотека VDOM, такая как Inferno, использует эту информацию для компиляции своего JSX напрямую в предварительно оптимизированные структуры узлов. Marko поднимает свои статические узлы VDOM за пределы своих компонентов, чтобы они не несли накладные расходы на их воссоздание при каждом рендеринге. Vue увеличивает динамические узлы, собирая ставки, сокращая последующие обновления только до этих узлов.

Svelte разделяет свой код между жизненными циклами создания и обновления. Solid делает еще один шаг вперед, превращая создание DOM в клонируемые элементы Template, которые создают целые части DOM за один вызов, кстати, метод времени выполнения, используемый библиотеками Tagged Template Literal, такими как uhtml и Lit от @webreflection.

// Solid's compiled output
const _tmpl$ = template(
  `<section><h1>My favorite color</h1><div></div></section>`
);

function FavoriteColor(props) {
  const _el$ = _tmpl$.cloneNode(true),
        _el$2 = _el$.firstChild,
        _el$3 = _el$2.nextSibling;

  insert(_el$3, () => props.color.toUpperCase());
  return [_el$, createComponent(SharedFooter, {})];
}

export default FavoriteColor;

С библиотеками, не относящимися к VDOM, такими как Svelte или Solid, мы можем дополнительно оптимизировать обновления, поскольку фреймворк не построен на движке разницы (diff - имеется ввиду разница виртуального дома). Мы можем использовать статически известную информацию, такую как атрибуты, и напрямую связывать с ними выражения шаблона, не обязательно разбираясь в этих выражениях. Вместо того, чтобы перебирать список неизвестных свойств, мы компилируем встроенные выражения обновления. Вы можете думать об этом так:

if (isDirty(title)) el.setAttribute("title", title);

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

Все еще существуют ограничения на то, что можно проанализировать заранее. При спредах (spread) все равно мы вынуждены обращаться к подходу, когда действия происходят во время выполнения, как и динамические компоненты, такие как <svelte:component> от Svelte или <component> от Vue.

Другие динамические части, такие как циклы и условные выражения, всегда выполняются во время выполнения в каждом фреймворке. Мы не можем делать какие-либо различия в DOM или VDOM во время сборки. Мы можем просто сузить возможности для среды выполнения. Но для таких вещей, как управление списками, нет ярлыков. Их методы согласования составляют значительную часть времени выполнения для любого фреймворка. И, да, даже у скомпилированных фреймворков есть время выполнения.

Помимо шаблонов

Теперь можно спорить, когда у вас есть однофайловые компоненты, должны ли мы рассматривать весь файл как шаблон? Библиотека, такая как Svelte или Marko, в основном рассматривает его как таковой. Есть определенные предположения, которые можно сделать, если вы знаете, что ваш файл представляет собой один компонент.

В случае Svelte это определяет границу реактивного отслеживания. Все реактивные атомы, объявленные в файле при изменении, сообщают компоненту об обновлении. Таким образом, Svelte может в основном скомпилировать свою реактивную систему, устраняя необходимость управлять любыми подписками, просто дополняя каждое назначение вызовом обновления component ($invalidate).

// выдержка из скомпилированного вывода Svelte
function instance($self, $props, $invalidate) {
  let { color } = $props;

  $self.$set = $props => {
    if ("color" in $props)
      $invalidate(0, color = $props.color);
  };
  return [color];
}

Это относительно просто для статического анализа, поскольку решение можно принять, посмотрев, где переменные определены в области видимости, и обновить все места, где они используются. Но это гораздо сложнее сделать автоматически, когда эти реактивные "атомы" должны выходить за пределы шаблона. Svelte использует соглашение об именовании $ для обозначения хранилищ, чтобы компилятор знал, как настроить подписки.

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

Оба эти подхода используют специфический синтаксис для обозначения понимания природы своего состояния. Их данные стали частью их языка. Хотя это не является обязательным, вы когда-нибудь задумывались о потенциальной ценности префикса use в хуках React?

За пределами модулей?

Самым большим ограничением компиляции является объем того, что она может анализировать. Хотя мы можем использовать некоторые приемы для информирования компилятора, такие как $ в Svelte, мы, как правило, не видим дальше операторов импорта. Это означает, что мы должны предполагать худшее, глядя на то, какие входные данные поступают в наши компоненты (динамически ли это?). Мы не знаем, используют ли дочерние компоненты наши данные с отслеживанием состояния динамически.

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

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

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

Вывод

Мы только поверхностно касаемся компилируемых JavaScript-фреймворков, но методы, которые мы ассоциируем с чисто компилируемыми фреймворками, находят свое применение и в других. Например, Vue исследует новый язык уровня данных в своих однофайловых компонентах. И это легко, поскольку основа уже есть.

Подход (HTML-first vs JS-first), который каждый фреймворк применяет к шаблонизации, является в основном поверхностным отличием. Здесь очень мало значимых различий. Но дьявол кроется в деталях, когда речь идет о поддержке функций. У каждого фреймворка есть места, где у него нет выбора, кроме как опираться на свои среды выполнения, и эти границы часто пересекаются в любом значительном приложении. Поэтому даже размер кода не является очевидным преимуществом.

Где компиляция преуспевает, так это в абстрагировании сложности. От более простого синтаксиса для взаимодействия с данными и обновлениями до специализированного вывода для сервера и браузера. Это инструмент DX, такой же, как Hot Module Replacement на Dev Server вашего бандлера. Он обеспечивает лучшую поддержку IDE, поскольку программа лучше понимает ваши намерения. Кроме того, это может дать прирост производительности.

Сегодня самым большим ограничением компилируемых подходов является их модульная привязка. Если компилируемые подходы хотят масштабироваться как подходы времени выполнения, это препятствие придется преодолеть. На данный момент гибридные подходы могут быть лучшим решением. Но даже сегодня компиляторы способны на многое, поэтому трудно представить себе будущее без их значительной роли.