Всем привет! Меня зовут Игорь, я — фронтэндер в Tinkoff.ru. И, как ни странно, я давно и безнадежно прикипел к Angular и ко всему, что с ним связано.


Очень хорошо помню свои первые проблемы со сборкой приложения на Angular: как передать в приложение переменные окружения или изменить алгоритмы сборки стилей? Я так к этому привык при работе с React. И сначала это решали с помощью ng eject: конфигурация webpack просто извлекалась из недр Angular CLI и изменялась как душе разработчика угодно. Выглядело это как костыль — webpack.config.js был раздутым и сложным. Но, когда Angular CLI v8.0.0 принес нам стабильный CLI Builders API, который позволяет кастомизировать, заменять или даже создавать новые CLI команды, все стало проще.


Сейчас самый популярный билдер для кастомизации конфигурации webpack — @angular-builders/custom-webpack. Если заглянуть в исходники всех билдеров, поставляемых пакетом, можно увидеть очень компактные решения, не превышающие и 30 строк кода.


Го тогда запилим свой? Challenge Accepted!


Данный материал подразумевает, что вы уже знакомы с Angular и Angular CLI, знаете, что такое rxjs и как с ним жить, а также готовы прочитать 50 строк кода.


Так что же такое эти билдеры?


Билдер — это всего лишь функция, которая может быть вызвана с помощью Angular CLI. Она вызывается с двумя параметрами:


  1. JSON-подобный объект-конфигурация
  2. BuilderContext — объект, обеспечивающий доступ, например, к логгеру.

Функция может быть как синхронной, так и асинхронной. Ну и бонусом можно вернуть Observable. Но в любом случае и Promise, и Observable должны испускать BuilderOutput.


Упакованная в npm-пакет определенным образом эта функция может использоваться при настройке таких CLI команд как build, test, lint, deploy и любых других из секции architect конфигурационного файла angular.json.


Опять будет просто пример из документации?


Нет. Конечно же я сначала сделал пример, максимально похожий на пример из документации. Такой билдер я использовал, например, при работе с NX и деплоем только измененных приложений. Но мне внезапно понадобился билдер, который умеет запускать команды из angular.json в определенном порядке и в зависимости друг от друга.


Более реальный пример: вам вдруг понадобился запущенный dev-server вашего приложения в тестах. Есть разные консольные утилиты и npm-пакеты для запуска и ожидания старта сервера, но почему бы не сделать билдер, которые сможет запускать dev-server в watch-режиме перед запуском тестов и убивать dev-server, как только тесты отработают?


С чего начать?


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


npx create-nx-workspace ng-builders
cd ./ng-builders
npx ng g @nrwl/node:library build

Для автоматизации релизов и версионирования был подключен semantic-release. В роли CI был использован Github Actions.


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


Конфигурация билдера


Вот так я видел конфигурацию, которая решала мою проблему:


// angular.json
{
  "version": 1,
  "projects": {
    "app": {
      "architect": {
        "stepper": {
          "builder": "@ng-builders/build:stepper",
          "options": {
            "targets": { // описание целей
              "jest": { // название цели и его конфигурация
                "target": "app:jest", // существующая в angular.json задача 
                "deps": ["server"] // зависимые цели, которые нужно запустить перед основной
              },
              "server": {
                "target": "app:serve",
                "watch": true // watch-режим
              }
            },
            "steps": ["jest"] // список целей для выполнения
          }
        }
      }
    }
  }
}

В теории, такую задачу в angular.json можно выполнить с помощью команды:


ng run app:stepper

Немного поработав над спецификацией я пришел к таким интерфейсам:


export interface Target {
  /**
   * Список targetId, которые необходимо выполнить перед запуском задачи
   *
   * Отличается от Schema#steps тем, что задача не ждет полного 
   * выполнения зависимых задач
   */
  deps?: string[];
  /**
   * Цель для выполнения
   */
  target: string;
  /**
   * Включение watch-режима
   */
  watch?: boolean;
  /**
   * Переопределение конфигурационных параметров цели
   */
  overrides?: { [key: string]: any };
}

export interface Targets {
  // targetId - имя задачи
  [targetId: string]: Target;
}

export interface Schema {
  /**
   * Строгая последовательность выполнения задач, в массиве 
   * указываются targetId из Targets
   *
   * Следующая задача запускается только после завершения предыдущей
   */
  steps: string[];
  targets: Targets;
}

Из основного вроде все. Конечно, схему можно еще расширить и, например, добавить выбор конфигурации (production, dev и т.д.), но для v1.0 этого будет достаточно.


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


Let's code


Итак, у нас есть интерфейс конфигурации. При запуске задачи через Angular CLI все чудесным образом должно работать.


Для начала напишем функцию runStepper и создадим StepperBuilder.


// index.ts
export function runStepper(
  input: Schema,
  context: BuilderContext
): BuilderOutputLike {
  return buildSteps(input, context).pipe(
    map(() => ({
      success: true
    })),
    catchError(error => {
      return of({ error: error.toString(), success: false });
    })
  );
}

export const StepperBuilder = createBuilder(runStepper);

export default StepperBuilder;

Обратите внимание, что тип первого аргумента функции runStepper — это та самая Schema из спецификации конфигурации выше. Функция возвращает Observable<BuilderOutput> .


Дальше мы реализуем функцию buildSteps, которая будет отвечать за выполнение шагов


// index.ts
function buildSteps(config: Schema, context: BuilderContext): Observable<any> {
  return concat(
    config.steps.map(step => buildStep(step, config.targets, context))
  );
}

Кажется ничего сложного. Каждый следующий шаг выполняется после завершения предыдущего.


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


// index.ts
function buildStep(
  stepName: string,
  targets: Targets,
  context: BuilderContext
): Observable<any> {
  const { deps = [], overrides, target, watch }: Target = targets[stepName];

  const deps$ = deps.length
    ? combineLatest(deps.map(depName => buildStep(depName, targets, context)))
    : of(null);

  return deps$.pipe(
    concatMap(() => {
      return scheduleTargetAndForget(context, targetFromTargetString(target), {
        watch,
        ...overrides
      });
    }),
    watch ? tap(noop) : take(1)
  );
}

В этой функции есть несколько интересных моментов:


  1. Зависимости шага запускаются параллельно, а главная задача шага — только после того, как каждая из зависимостей испустит хотя бы одно событие. Это дает нам гарантию, что dev-server (если задача на его запуск в списке зависимостей текущего шага) запуститься перед запуском тестов (если это главная задача шага).
  2. Функция scheduleTargetAndForget, импортируемая из @angular-devkit/architect. Эта функция позволяет запускать цели из angular.json и оверрайдить их настройки. Возвращает эта функция Observable, отписка от которого останавливает выполняемую задачу.
  3. Если параметр watch имеет положительное значение, то главная задача шага не завершится после первого испущенного события, следовательно текущая задача будет жить вечно, пока не завершится сама, либо пока не произойдет отписка от вернувшегося Observable, либо не будет завершен процесс.

Собственно это все, что касается самого билдера. Полный вариант можно посмотреть тут. Уложились в 56 строк кода. Неплохо, правда?


Последний интересующий и важный для нас момент — это файл builders.json


{
  "$schema": "../../@angular-devkit/architect/src/builders-schema.json",
  "builders": {
    "stepper": {
      "implementation": "./stepper",
      "schema": "./stepper-schema.json",
      "description": "Stepper"
    }
  }
}

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


Затем ищем package.json и добавляем свойство builders с относительным путем до файла builders.json


{
    "name": "@ng-builders/build",
    "builders": "./builders.json",
    …
}

Осталось только собрать пакет:


npm run build

Закидываем все в коммит и пушим всю эту красоту на Github.


И это все?


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


Билдер можно неспокойно (как только появятся тесты обязательно уберу не) использовать в своих проектах.


npm i @ng-builders/build -D

Итоги


CLI Builders API — это мощный инструмент для расширения и кастомизации Angular CLI. Билдер, созданный нами, решает не самые популярные проблемы, но на создание всего пакета ушел всего лишь 1 час. Что нам это говорит? А то что создать кастомный билдер для решения частных проблем не такая уж и сложная задача. По этому дерзайте, фантазируйте и создавайте. И помните, что тесты сами себя не напишут!


P.S.:


Angular CLI Builders прекрасно используются и работают в NX Workspace даже без Angular. Пример этого чуда я обязательно покажу вам в будущем. А пока можете читать меня в Twitter, писать мне в Telegram и говорить обо мне только хорошее.