Про Angular Elements сейчас пишут много статей и регулярно читают доклады. Мол, больше не нужно разворачивать несколько полноценных ангуляров — достаточно собрать веб-компоненты и использовать их на своей странице.
Но, как правило, эти материалы ограничиваются рассмотрением довольно утопичной ситуации: мы делаем отдельный проект, создаем angular-компонент, настраиваем проект на сборку Elements и, наконец, компилируем несколько JS-файлов, подключение которых к обычной странице даст нам необходимый результат. Ура, компонент работает!..
На практике же возникает потребность вытащить несколько компонентов из готового работающего angular-проекта, да еще желательно так, чтобы не влиять на его текущую разработку и использование. Эта статья получилась как раз благодаря одной из таких ситуаций: я хотел не просто собрать отдельные элементы проекта, а сделать процесс компиляции целой UI-библиотеки на Angular в набор файлов с нативными веб-компонентами.
Для начала давайте вспомним, как должен выглядеть модуль для компиляции Angular Elements.
Нам необходимо:
Первый пункт — это обозначение самого компонента и его зависимостей, а остальные три — процесс, необходимый для появления веб-компонента в браузере. Такое разделение позволяет разместить логику создания элемента отдельно, в абстрактном суперклассе:
Суперкласс умеет собрать из инжектора, компонента и названия полноценный нативный компонент и зарегистрировать его. Модуль для создания конкретного элемента будет выглядеть следующим образом:
В этом примере мы собираем в метаданные NgModule модуль нашей кнопки и объявляем компонент из этого модуля в entryComponents, а также получаем инжектор из механизма внедрения зависимостей Angular.
Модуль готов к сборке и выдаст нам набор JS-файлов, которые можно сложить в отдельный веб-компонент. Таким образом мы можем создать несколько модулей и по очереди собирать из них веб-компоненты.
Теперь нам нужно наладить процесс бутстрапа получившихся модулей. Мне больше всего нравится идея вынести эту логику в отдельный исполняемый файл, который будет отвечать за компиляцию конкретного модуля.
Структура элементов выходит примерно такой:
А отдельный файл компиляции в самом простом варианте будет выглядеть так:
Такой подход поможет легко обходить заготовленные модули и поддерживать структуру проекта с Elements понятной и простой.
В настройках билда angular.json укажем путь собранного файла в некую временную папку внутри dist:
Туда будет падать набор выходных файлов после сборки модуля.
Для самой сборки воспользуемся обычной командой build в angular-cli:
Отдельный элемент будет финальным продуктом, поэтому включаем флаги production с Ahead-of-Time-компиляцией, а после подставляем путь к исполняемому файлу, который состоит из проекта и названия компонента.
Теперь соберем полученный результат в отдельный файл, который и будет финальным бандлом нашего отдельного веб-компонента. Для этого воспользуемся обычным cat’ом:
Тут важно заметить, что мы не закладываем файл polyfills.js в бандл каждого компонента, потому что получим дублирование, если будем использовать несколько компонентов на одной странице в дальнейшем. Разумеется, стоит отключать опцию outputHashing в angular.json.
Получившийся бандл перенесем из временной папки в папку для складирования компонентов. Например, так:
Осталось только собрать всё воедино — и скрипт компиляции готов:
Теперь у нас есть аккуратная папочка с набором веб-компонентов:
Наши собранные веб-компоненты можно независимо вставлять на страницу, подключая их JS-бандлы по мере необходимости:
Чтобы не тащить весь zone.js с каждым компонентом, мы единожды подключаем его в начале документа:
Компонент отображается на странице, и всё хорошо.
А давайте добавим еще и кнопку:
Запускаем страничку и…
Ой, всё сломалось!
Если мы заглянем в бандл, то обнаружим там такую неприметную строчку:
Вебпак патчит window, чтобы не дублировать подгрузку одних и тех же модулей. Выходит, что лишь первый добавленный на страницу компонент может добавить себя в customElements.
Для решения этой проблемы нам необходимо использовать custom-webpack:
Можно снова запускать скрипт сборки — новые компоненты отлично уживаются друг с другом на одной страничке.
В наших компонентах используются глобальные CSS-переменные, задающие цветовую тему и размеры компонентам.
В angular-приложениях, использующих нашу библиотеку, они закладываются в корневом компоненте проекта. С независимыми веб-компонентами такой возможности нет, поэтому просто скомпилируем стили и подключим к странице, где используются веб-компоненты.
Мы используем less, поэтому просто компилируем наши переменные lessc и кладем получившийся файл в папку helpers.
Такой подход позволяет управлять стилизацией всех веб-компонентов страницы без необходимости их перекомпиляции.
Фактически весь описанный выше процесс сборки элементов можно свести к набору действий:
Осталось только вызывать этот скрипт из основного package.json, чтобы свести весь процесс компиляции актуальных angular-компонентов к запуску одной команды.
Все описанные выше скрипты, а также демо странички использования компонентов angular и нативных компонентов, можно найти на github.
Мы организовали процесс, при котором добавление нового веб-компонента занимает буквально пару минут, сохраняя при этом структуру основных angular-проектов, из которых они берутся.
Любой разработчик сможет добавить компонент в набор элементов и собрать их в набор отдельных JS-бандлов веб-компонентов, не вникая в специфику работы с Angular Elements.
Но, как правило, эти материалы ограничиваются рассмотрением довольно утопичной ситуации: мы делаем отдельный проект, создаем angular-компонент, настраиваем проект на сборку Elements и, наконец, компилируем несколько JS-файлов, подключение которых к обычной странице даст нам необходимый результат. Ура, компонент работает!..
На практике же возникает потребность вытащить несколько компонентов из готового работающего 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() {}
}
Нам необходимо:
- Добавить в entryComponents компонент, который мы планируем сделать angular-элементом, импортировать необходимые для компонента модули.
- Создать angular-элемент с помощью createCustomElement и инжектора.
- Объявить веб-компонент в customElements браузера.
- Переопределить метод ngDoBootstrap на пустой.
Первый пункт — это обозначение самого компонента и его зависимостей, а остальные три — процесс, необходимый для появления веб-компонента в браузере. Такое разделение позволяет разместить логику создания элемента отдельно, в абстрактном суперклассе:
export abstract class MyElementModule {
constructor(injector: Injector, component: InstanceType<any>, 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-файлов, которые можно сложить в отдельный веб-компонент. Таким образом мы можем создать несколько модулей и по очереди собирать из них веб-компоненты.
Собираем несколько компонентов
Теперь нам нужно наладить процесс бутстрапа получившихся модулей. Мне больше всего нравится идея вынести эту логику в отдельный исполняемый файл, который будет отвечать за компиляцию конкретного модуля.
Структура элементов выходит примерно такой:
А отдельный файл компиляции в самом простом варианте будет выглядеть так:
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}`);
}
Теперь у нас есть аккуратная папочка с набором веб-компонентов:
Подключаем компоненты на обычную страницу
Наши собранные веб-компоненты можно независимо вставлять на страницу, подключая их JS-бандлы по мере необходимости:
<my-elements-input id="input">Поле ввода</<my-elements-input>
<script src="my-input.js"></script>
Чтобы не тащить весь zone.js с каждым компонентом, мы единожды подключаем его в начале документа:
<script src="zone.min.js"></script>
Компонент отображается на странице, и всё хорошо.
А давайте добавим еще и кнопку:
<my-elements-button size="l" onclick="onClick()">Кнопка</my-elements-button>
<script src="my-button.js"></script>
Запускаем страничку и…
Ой, всё сломалось!
Если мы заглянем в бандл, то обнаружим там такую неприметную строчку:
window.webpackJsonp=window.webpackJsonp||[]
Вебпак патчит window, чтобы не дублировать подгрузку одних и тех же модулей. Выходит, что лишь первый добавленный на страницу компонент может добавить себя в customElements.
Для решения этой проблемы нам необходимо использовать custom-webpack:
- Добавляем custom-webpack к проекту с elements:
ng add @angular-builders/custom-webpack --project=elements
- Конфигурируем angular.json:
"builder": "@angular-builders/custom-webpack:browser", "options": { "customWebpackConfig": { "path": "./projects/elements/elements-webpack.config.js" }, ...
- Создаем файл с конфигурацией 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.