Share This
Связаться со мной
Крути в низ
Categories
//Кастомизация сборки Angular-проекта

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

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

kastomizacija sborki angular proekta 7bfebb9 - Кастомизация сборки Angular-проекта

Мария, старший Angular-разработчик Noveo

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

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

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

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

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

Вебинар «Разработка библиотеки компонентов на React + Storybook»

22 сентября в 20:00, Онлайн, Беcплатно

tproger.ru События и курсы на tproger.ru

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

Гнев и npm script-ы

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

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

Что нужно знать, чтобы стать веб-разработчиком: интерактивная карта со ссылками на ресурсы для изученияtproger.ru

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

Где-то здесь стало понятно, что без кастомизации сборки не обойтись. Быстро выяснилось, что в 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. Соответственно, чтобы посмотреть, как он устроен, можно заглянуть сюда (в пакет @angular-devkit/build-angular).

Каким же образом 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), и, наконец, просто описания. Таким образом, видим, что код Angular-сборщика представлен тут.

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

Webpack на практике: с нуля до создания автотестовtproger.ru

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

Для создания любого компоновщика требуется метод 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. И выглядит так, как будто бы удалить будет не сложно, ежели что 😀

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

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

  • Alexander Poshtaruk Angular CLI flows. Big picture;
  • JeB Barabanov. Angular CLI under the hood — builders demystified v2;
  • JeB Barabanov. Customizing Angular CLI build — an alternative to ng eject (v2);
  • Angular CLI Builders API official Docs.

Хинт для программистов: если зарегистрируетесь на соревнования Huawei Honor Cup, бесплатно получите доступ к онлайн-школе для участников. Можно прокачаться по разным навыкам и выиграть призы в самом соревновании.

Перейти к регистрации

  • 8 views
  • 0 Comment

Leave a Reply

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.

Связаться со мной
Close