[Из песочницы] Сборка библиотеки angular-компонентов в виде веб-компонентов

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

Но, как правило, эти материалы ограничиваются рассмотрением довольно утопичной ситуации: мы делаем отдельный проект, создаем angular-компонент, настраиваем проект на сборку Elements и, наконец, компилируем несколько JS-файлов, подключение которых к обычной странице даст нам необходимый результат. Ура, компонент работает!…

image

На практике же возникает потребность вытащить несколько компонентов из готового работающего angular-проекта, да еще желательно так, чтобы не влиять на его текущую разработку и использование. Эта статья получилась как раз благодаря одной из таких ситуаций: я хотел не просто собрать отдельные элементы проекта, а сделать процесс компиляции целой UI-библиотеки на Angular в набор файлов с нативными веб-компонентами.

Подготовка модулей


Для начала давайте вспомним, как должен выглядеть модуль для компиляции Angular Elements.

@NgModule({
   imports: [BrowserModule],
   entryComponents: [SomeComponent],
})
export class AppModule {
   constructor(readonly injector: Injector) {
       const ngElement = createCustomElement(SomeComponent, {
           injector,
       });

       customElements.define('some-component', ngElement);
   }

   ngDoBootstrap() {}
}


Нам необходимо:

  1. Добавить в entryComponents компонент, который мы планируем сделать angular-элементом, импортировать необходимые для компонента модули.
  2. Создать angular-элемент с помощью createCustomElement и инжектора.
  3. Объявить веб-компонент в customElements браузера.
  4. Переопределить метод ngDoBootstrap на пустой.


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

export abstract class MyElementModule {
   constructor(injector: Injector, component: InstanceType, name: string) {
       const ngElement = createCustomElement(component, {
           injector,
       });

       customElements.define(`${MY_PREFIX}-${name}`, ngElement);
   }

   ngDoBootstrap() {}
}


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

@NgModule({
   imports: [BrowserModule, MyButtonModule],
   entryComponents: [MyButtonComponent],
})
export class ButtonModule extends MyElementModule {
   constructor(injector: Injector) {
       super(injector, MyButtonComponent, 'button');
   }
}


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

Модуль готов к сборке и выдаст нам набор JS-файлов, которые можно сложить в отдельный веб-компонент. Таким образом мы можем создать несколько модулей и по очереди собирать из них веб-компоненты.

Собираем несколько компонентов


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

Структура элементов выходит примерно такой:

image

А отдельный файл компиляции в самом простом варианте будет выглядеть так:

enableProdMode();

platformBrowserDynamic()
   .bootstrapModule(ButtonModule)
   .catch(err => console.error(err));


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

В настройках билда angular.json укажем путь собранного файла в некую временную папку внутри dist:

"outputPath": "projects/elements/dist/tmp"


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

Для самой сборки воспользуемся обычной командой build в angular-cli:

ng run elements:build:production --main='projects/elements/src/${project}/${component}/compile.ts'


Отдельный элемент будет финальным продуктом, поэтому включаем флаги production с Ahead-of-Time-компиляцией, а после подставляем путь к исполняемому файлу, который состоит из проекта и названия компонента.

Теперь соберем полученный результат в отдельный файл, который и будет финальным бандлом нашего отдельного веб-компонента. Для этого воспользуемся обычным cat«ом:

cat dist/tmp/runtime.js dist/tmp/main.js > dist/tmp/my-${component}.js


Тут важно заметить, что мы не закладываем файл polyfills.js в бандл каждого компонента, потому что получим дублирование, если будем использовать несколько компонентов на одной странице в дальнейшем. Разумеется, стоит отключать опцию outputHashing в angular.json.

Получившийся бандл перенесем из временной папки в папку для складирования компонентов. Например, так:

cp dist/tmp/my-${component}.js dist/components/


Осталось только собрать всё воедино — и скрипт компиляции готов:

// compileComponents.js

projects.forEach(project => {
   const components = fs.readdirSync(`src/${project}`);

   components.forEach(component => compileComponent(project, component));
});

function compileComponent(project, component) {
   const buildJsFiles = `ng run elements:build:production --aot --main='projects/elements/src/${project}/${component}/compile.ts'`;
   const bundleIntoSingleFile = `cat dist/tmp/runtime.js dist/tmp/main.js > dist/tmp/my-${component}.js`;
   const copyBundledComponent = `cp dist/tmp/my-${component}.js dist/components/`;

   execSync(`${buildJsFiles} && ${bundleIntoSingleFile} && ${copyBundledComponent}`);
}


Теперь у нас есть аккуратная папочка с набором веб-компонентов:

image

Подключаем компоненты на обычную страницу


Наши собранные веб-компоненты можно независимо вставлять на страницу, подключая их JS-бандлы по мере необходимости:

Поле ввода



Чтобы не тащить весь zone.js с каждым компонентом, мы единожды подключаем его в начале документа:



Компонент отображается на странице, и всё хорошо.

А давайте добавим еще и кнопку:

Кнопка



Запускаем страничку и…

image

Ой, всё сломалось!

Если мы заглянем в бандл, то обнаружим там такую неприметную строчку:

window.webpackJsonp=window.webpackJsonp||[]


image

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

Для решения этой проблемы нам необходимо использовать custom-webpack:

  1. Добавляем custom-webpack к проекту с elements:

    ng add @angular-builders/custom-webpack --project=elements

  2. Конфигурируем angular.json:
    "builder": "@angular-builders/custom-webpack:browser",
        "options": {
           "customWebpackConfig": {
               "path": "./projects/elements/elements-webpack.config.js"
           },
    ...
    

  3. Создаем файл с конфигурацией custom-webpack:
    module.exports = {
       output: {
           jsonpFunction: 'myElements-' + uuidv1(),
           library: 'elements',
       },
    };
    
    

    В нем нам необходимо генерировать уникальные id для каждой сборки любым удобным способом. Я воспользовался uuid.


Можно снова запускать скрипт сборки — новые компоненты отлично уживаются друг с другом на одной страничке.

Наводим красоту


В наших компонентах используются глобальные CSS-переменные, задающие цветовую тему и размеры компонентам.

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

// compileHelpers.js
compileMainTheme();

function compileMainTheme() {
   const pathFrom = `../../main-project/styles/themes`;
   const pathTo = `dist/helpers`;

   execSync(
       `lessc ${pathFrom}/theme-default-vars.less ${pathTo}/main-theme.css`,;
   );
}


Мы используем less, поэтому просто компилируем наши переменные lessc и кладем получившийся файл в папку helpers.

Такой подход позволяет управлять стилизацией всех веб-компонентов страницы без необходимости их перекомпиляции.

Финальный скрипт


Фактически весь описанный выше процесс сборки элементов можно свести к набору действий:

#!/bin/sh

rm -r -f dist/ &&
mkdir -p dist/components &&
node compileElements.js &&
node compileHelpers.js &&
rm -r -f dist/tmp


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

Все описанные выше скрипты, а также демо странички использования компонентов angular и нативных компонентов, можно найти на github.

Итого


Мы организовали процесс, при котором добавление нового веб-компонента занимает буквально пару минут, сохраняя при этом структуру основных angular-проектов, из которых они берутся.

Любой разработчик сможет добавить компонент в набор элементов и собрать их в набор отдельных JS-бандлов веб-компонентов, не вникая в специфику работы с Angular Elements.

© Habrahabr.ru