Написать пост

Кастомизация сборки Angular-проекта

Аватар Типичный программист

При сборке Angular-приложения со временем возникает задача, которую не решить с помощью того, что доступно из коробки. Узнаём, что делать в таком случае.

Рано или поздно может наступить ситуация, когда при сборке вашего Angular-приложения возникнет задача, выходящая за рамки того, что предлагает вам сборка Angular из коробки. Старший Angular-разработчик Noveo Мария рассказывает, как в такой ситуации быть.

Как вы уже поняли, такое случилось и со мной. В один прекрасный день я захотела оптимизировать наше приложение, чтобы повысить его оценки в Lighthouse, и тут же столкнулась с классический задачей, универсального решения которой в Angular почему-то до сих пор не изобрели. Для всех кастомных шрифтов, которые мы используем, требовалось добавить для браузера информацию, необходимую для их предварительной загрузки через . Это позволило бы начать загрузку шрифтов, не дожидаясь загрузки содержимого CSS, и повысило бы производительность. Задачка, прямо скажем, не сильно критичная, но, сталкиваясь с ней из проекта в проект, я захотела решить её раз и навсегда.

Итак, что же требовалось сделать? Получить информацию о шрифтах проекта и добавить эту информацию в index.html, сгенерированный Angular. Делов-то! Но, как водится, для того, чтобы написать кастомизацию сборки, потребовалось пройти через все стадии принятия неизбежного (и если вам не хочется проходить через них вместе со мной, просто отправляйтесь к секции с готовым решением).

Отрицание. Решение задачи без каких-либо кастомизаций

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

Уникальные имена нам не требовались: в ближайшие много лет мы вряд ли планировали менять шрифты на нашем проекте, и никаких проблем с кешированием возникнуть не могло. Сделав абсолютные пути до ассетов, можно было прописать папку со шрифтами в настройку assets в angular.json и захардкодить нужные нам ссылки прямо в html. Но, как вы уже поняли, этой статьи не было бы, если бы всё было так просто ?

Поскольку мы используем встроенную в Angular 9 локализацию, мы генерируем проект в несколько папок под разные языки. Попытка указать абсолютные пути до шрифтов приводила к поломанным путям, ведь ассеты лежали в папках с именами локалей, языковой префикс не добавлялся к путям до ассетов, а глобальный <base href="/fr"> не работал с абсолютными путями нигде, кроме Chrome (что, согласитесь, странно, ведь и там он работать не должен))). Таким образом, попытка обойтись старым добрым хардкодом провалилась.

Гнев и npm script-ы

Раз уж ссылки не получилось захардкодить, возникла мысль постфактум прочитать имена ассетов, сгенерировать недостающий кусок html-кода и добавить его в предусмотрительно созданный билдом Angluar index.html. C помощью post-команд можно выполнять npm-скрипт после каждой сборки автоматически, и задача будет решена.

Подводные камни на этом пути оказались не очень подводными и лежали на поверхности. Во-первых, скрипт надо было написать так, чтобы его могли выполнить коллеги на разных операционных системах. Во-вторых, мы опять упирались в локализацию: под каждый язык сборщик Angular создавал отдельную папку, каждая папка содержала свой index.html и свои шрифты, а ходить по всем папкам и менять каждый html-файл — это было как-то уж слишком. Моя природная лень не была готова к этому испытанию, и я отправилась на поиски следующего решения.

Торг. Готовые пакеты на гитхабе

Где-то здесь стало понятно, что без кастомизации сборки не обойтись. Быстро выяснилось, что в Angular CLI, начиная с версии 8, появилось CLI Builder API, позволяющее создавать свои компоновщики. У него довольно обширная документация, и даже на русском есть! Но камон, ребята, кто же хочет читать длинную скучную документацию, когда кто-нибудь уже наверняка написал за вас быстрое, готовое и не очень оптимальное решение (которое к тому же вытащит к вам в проект десяток-другой ненужных зависимостей) и выложил его на гитхаб?

И действительно, быстрый поиск по гитхабу тут же предложил мне npx-build-plus, у которого на момент написания этой статьи 915 звездочек и последний коммит был 5 месяцев назад, и @angular-builders/custom-webpack, который обновлялся буквально пару дней назад, но вот звездочек у него было только 736.

Звездочки перевесили, и я решила дать шанс ngx-build-plus, но быстро выяснилось, что генерация index.html в Angular не входит в стандартную сборку вебпака, а значит, никаких Preload Webpack Plugin уже не подключить, ведь тогда придется генерировать свой собственный index.html с base href-ами и языками, а это даже звучит слишком сложно.

Со вторым плагином все получилось: он позволял указать и расширение для стандартного вебпак-конфига, и трансформацию для index.html. И в том, и в другом случае надо было добавить в настройки пути до файлов с нужными трансформациями. Беда заключалась в том, что в одном файле нам надо было получить список ассетов из вебпака, а в другом — добавить информацию о них в html, но для этого решено было временно воспользоваться объектом global.

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

			class FontPreloadPlugin {
 apply(compiler) {
   compiler.hooks.emit.tap('FontPreloadPlugin', (compilation) => {
     const preloadHtml = Object.keys(compilation.assets)
       .filter((fileName) => /\.woff2$/.test(fileName))
       .reduce((acc, fileName) => {
         const toInclude = `\n`;
         return acc + toInclude;
       }, '');
     global.preloadHtml = preloadHtml;
   });
 }
}
		

А файл с расширением вебпак-конфига выглядел следующим образом:

			module.exports = {
 plugins: [new FontPreloadPlugin()],
};
		

Для трансформации html я воспользовалась регулярными выражениями и просто добавляла полученный html либо перед первой ссылкой на стили, либо перед закрывающим тегом </head>:

			module.exports = (targetOptions, indexHtml) => {
 return indexHtml.replace(/((\<link[^\>]*rel="stylesheet")|(\<\/head))/, 
                          `${global.preloadHtml}$1`)
};
		

Тут сразу видны все минусы: для каждой настройки нам требуется отдельный файл, использовать global не очень-то хорошая идея, а значит, потребуется оркестратор. Получаем три файла плюс сторонний пакет, с поддержкой которого могут возникнуть проблемы в будущем. Конечно, плагин универсален и позволяет разными способами расширять конфигурацию, но нам эта универсальность не нужна: всё, что мы хотим, — повторить стандартную сборку, добавив информацию о шрифтах в html.

Тут мы плавно подошли к необходимости написать свой велосипед.

Депрессия и неизбежное чтение документации

За выполнение задач-компоновщиков (builder-ов) в Angular CLI отвечает специальный инструмент под названием Architect, который можно найти в пакете @angular-devkit/architect. Задачи-компоновщики – это специальные функции, которые выполняют задачи по сборке и обслуживанию нашего кода. Именно они скрываются за такими командами, как ng build, ng lint, ng test.

За связь между командами в консоли и компоновщиками отвечает специальная секция конфигурации angular.json под названием architect. Здесь настраивается связь между командами и конкретными компоновщиками, выполняющимися по ним.

Для каждой команды доступно три параметра настройки. По ключу builder указывается, какой компоновщик будет использован, в options задаются параметры по умолчанию, а опциональный configurations позволяет переопределить дефолтные параметры для различных конфигураций. При этом имя компоновщика в builder состоит из двух частей: имени npm-пакета и непосредственно имени компоновщика внутри этого пакета.

Например,  интересующая нас больше прочих команда build (как мы знаем, именно билд запускает вебпак и генерирует index.html) использует компоновщик @angular-devkit/build-angular:browser.

Каким же образом architect находит в таком пакете нужную задачу и как понимает, какие параметры ему требуются? В package.json любого проекта с компоновщиками должно быть специальное свойство builders, в котором прописывается путь до JSON-файла (обычно, но не обязательно builders.json), а уже в этом файле указана информация обо всех задачах-компоновщиках в текущем проекте по этой схеме.

Да-да, в сундуке (angular.json) заяц (architect), в зайце (architect.build.builder) — утка (@angular-devkit/build-angular:browser), в утке (@angular-devkit/build-angular) — яйцо (builders.json, вы находитесь здесь), в яйце игла (код сборщика). То есть остался последний шаг к тому, чтобы наконец-то найти код нужного нам сборщика. Если вы еще не до конца запутались, едем дальше ?

В нашем builders.json обязательно должен присутствовать ключ builders. Его значением будет являться объект, ключи которого — имена отдельных задач компоновщиков (в частности, для @angular-devkit/build-angular в таком объекте мы обязательно найдем ключ browser), а значения — информация о том, где брать детали реализации.

Детали реализации состоят из трех частей: пути до кода (implementation), пути до файла с описанием схемы параметров, требуемых компоновщику (schema), и, наконец, просто описания.

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

Итак, пара слов о реализации.

Для создания любого компоновщика требуется метод createBuilder(), предоставляемый @angular-devkit/architect. Этот метод принимает асинхронную функцию, выполняющую всю логику нашего компоновщика. Для сборщика browser из @angular-devkit/build-angular это выглядит так:

			import { createBuilder } from '@angular-devkit/architect';
 
export default createBuilder<json.JsonObject & BrowserBuilderSchema>(buildWebpackBrowser);
		

При этом buildWebpackBrowser позднее экспортируется наружу под именем executeBrowserBuilder (а это значит — его можно переиспользовать!).

Давайте наконец глянем на то, что представляет собой функция buildWebpackBrowser:

			export function buildWebpackBrowser(
 options: BrowserBuilderSchema,
 context: BuilderContext,
 transforms: {
   webpackConfiguration?: ExecutionTransformer<webpack.Configuration>;
   logging?: WebpackLoggingCallback;
   indexHtml?: IndexHtmlTransform;
 } = {},
) {
 // details
}
		

Не вдаваясь в подробности реализации, нетрудно заметить, что данный метод принимает опциональные параметры webpackConfiguration и indexHtml, где

			export type ExecutionTransformer<T> = (input: T) => T | Promise<T>;

export type IndexHtmlTransform = (content: string) => Promise<string>;
		

Что это значит? Стандартный сборщик Angular на самом деле позволяет добавить асинхронную трансформацию вебпак-конфига и html-контента и изобретать велосипед целиком не нужно! И вот оно: вооружившись полученными знаниями, вы наконец готовы увидеть, как я создала компоновщик и передала ему необходимую логику для предзагрузки ассетов.

Принятие. Пишем свой сборщик для ангуляра

Первый вопрос, требующий решения: куда поместить кастомный компоновщик (сборщик)? В официальной документации и большинстве примеров для этого рекомендуется создавать отдельный проект и паблишить его в npm. Но на самом деле – та-дам! – это не обязательно, и если вам не хочется маяться с поддержкой отдельного пакета, достаточно вспомнить, что у вас уже есть как минимум один репозиторий с package.json, готовый указать на требуемого компоновщика: собственно, ваш проект.

Поэтому прямо в package.json моего проекта я указала

			"builders": "builders.json",
		

И убедилась, что в моих зависимостях присутствуют @angular-devkit/architect и @angular-devkit/build-angular.

Так как я расширяла стандартный сборщик ангуляра, свой компоновщик я назвала так же: browser. Моему сборщику не требовалось каких-то новых параметров, отличных от стандартного сборщика, поэтому схему я тоже решила не создавать, а вместо этого нагло добавила ссылку на схему сборщика browser из @angular-devkit/build-angular и какое-никакое описание:

			"$schema": "./node_modules/@angular-devkit/architect/src/builders-schema.json",
 "builders": {
   "browser": {
     "implementation": "./custom-builder.js",
     "schema": "./node_modules/@angular-devkit/build-angular/src/browser/schema.json",
     "description": "Small description of preloading logic"
   }
 }
}
		

Для того, чтобы ng build брал кастомный сборщик вместо стандартного, в angular.json достаточно было заменить стандартный сборщик на новый (а так как конфигурация компоновщика находилась в текущем проекте, вместо имени пакета требовалось указать относительный путь до директории с package.json):

			"architect": {
       "build": {
         "builder": "./:browser",
		

Так как все параметры остались прежними, ничего больше менять не требовалось!

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

Кастомный сборщик, который не делал бы ничего нового, выглядел так:

			const { executeBrowserBuilder } = require('@angular-devkit/build-angular');
const { createBuilder } = require('@angular-devkit/architect');
 
function extendWebpackConfig(config) {
 return Promise.resolve(config);
}
 
function addPreloadLinks(indexHtml) {
 return Promise.resolve(indexHtml);
}
 
function buildCustomWebpackBrowser(options, context) {
 return executeBrowserBuilder(options, context, {
   webpackConfiguration: (browserWebpackConfig) =>
     extendWebpackConfig(browserWebpackConfig),
   indexHtml: (indexHtml) => addPreloadLinks(indexHtml),
 });
}
 
exports.default = createBuilder(buildCustomWebpackBrowser);
		

Здесь мы можем воспользоваться вебпак-плагином и методом добавления недостающей части html, написанными выше при использовании @angular-builders/custom-webpack, а использование global заменить на какую-нибудь локальную переменную (например, sharedSpace):

			const sharedSpace = {
 preloadHtml: '',
};
 
class FontPreloadPlugin {
 // имплементация плагина
}
 
function extendWebpackConfig(config) {
 return Promise.resolve({
   ...config,
   plugins: [...config.plugins, new FontPreloadPlugin()],
 });
}
 
function addPreloadLinks(indexHtml) {
 return indexHtml.replace(/((\<link[^\>]*rel="stylesheet")|(\<\/head))/, `${sharedSpace.preloadHtml}$1`)
}
		

Вот и все, наш сборщик готов и отлично справляется со своей новой задачей! Итого имеем: одну JSON-конфигурацию, один js-файлик с реализацией и пару изменений в package.json и angular.json. И выглядит так, как будто бы удалить будет не сложно, ежели что ?

А вы, если прочитали этот текст до конца, да еще и поняли что-нибудь, – большой молодец.

Пользуясь случаем, хочу поблагодарить этих классных ребят за их статьи о компоновщиках и посоветовать вам для дальнейшего изучения и статьи, и официальную доку (уж теперь-то вы её точно осилите):

Следите за новыми постами
Следите за новыми постами по любимым темам
5К открытий5К показов