Всем привет! В прошлом посте мы с вами разобрали механизмы создания библиотек с помощью Angular libs, а также научились настраивать их работу в приложении и друг с другом и даже вынесли наши ассеты в одну из таких библиотек.
В этом посте хочу продолжить тему разделения функционала на библиотеки и, как апофеоз, рассказать о сборке двух, похожих визуально, но разных по функциональности приложений из одного набора библиотек. В одном из проектов Каруны мы применили такой подход, благодаря чему дали возможность разработчикам из разных команд вести свои библиотеки и собирать приложения под себя, не изменяя чужую кодовую базу.
В ходе статьи на примере уже созданного нами проекта из прошлого поста мы с вами разберём:
как настроить tree shaking библиотек;
как создать core-библиотеку, которая будет нужна везде;
как собрать два клона из одного набора библиотек, но с разным функционалом.
Оглавление
Tree Shaking или как написать ещё больше кода
Прежде чем мы перейдем к созданию и настройке новых библиотек, поговорим о том, как с ними работает “встряхивание дерева”. На самом деле ответ очень простой — никак. Tree shaking практически не работает с библиотеками из коробки: если вы собираете какую-то либу, а потом приложение с её использованием, то вся она попадёт в итоговый бандл. Дело в том, что наш сборщик ng-packagr собирает всю библиотеку в общий чанк, а значит, при загрузке одного кусочка пользователь невольно загрузит вообще всё. Печально, но не спешите унывать! Есть весьма элегантное решение — правда, для него придётся написать некоторое количество шаблонного кода.
Для решения проблемы мы будем использовать все тот же ng-packagr, который способен создавать так называемые sub-entries внутри библиотек. Если говорить просто, то под каждый отдельный функционал библиотеки мы можем собрать свой чанк, и уже эти чанки будут спокойно работать с tree shaking и подгружаться только когда нужны. За этот механизм отвечают файлы public-api.ts и package.json, они у нас уже есть в библиотеках. А теперь нам нужно будет добавить их ещё и в модули. На выходе мы должны будем получить примерно вот такую структуру:
some-lib
├── src
| └── lib
| ├── sub-entry-a
| | ├── *.ts
| | ├── index.ts
| | ├── package.json
| | └── public-api.ts
| ├── sub-entry-b
| | ├── *.ts
| | ├── index.ts
| | ├── package.json
| | └── public-api.ts
| ├── sub-entry-*
| └── public-api.ts
└── package.json
Давайте немного пройдёмся по ней.
some-lib/src/public-api.ts — этот файл всё так же будет использоваться для экспорта всех наших sub-entries сущностей. Фактически он будет отвечать за то, чтобы мы могли импортировать сущности из библиотеки внутрь приложения без огромной вложенности, вместо этого будет простой импорт — import { any } from 'some-lib'.
some-lib/src/lib/sub-entry-* — директория, которая как раз таки описывает чанк.
some-lib/src/lib/sub-entry-a/public-api.ts — файл, в котором должны быть перечислены все сущности, которые относятся к данному чанку и могут быть использованы в приложении.
some-lib/src/lib/sub-entry-a/index.ts — тут мы просто реэкспортируем всё из файла выше. Также этот индексный файл необходимо импортировать в some-lib/src/public-api.ts.
some-lib/src/lib/sub-entry-a/package.json — файл, который позволяет работать ng-packagr и генерировать отдельные чанки. Внутри обязательно нужно указать ссылку на наш some-lib/src/lib/sub-entry-a/public-api.ts.
Как вы уже наверняка догадались, some-lib/src/lib/sub-entry-a/index.ts и some-lib/src/lib/sub-entry-a/package.json практически не будут меняться, и их можно спокойно копировать из чанка в чанк.
Теперь об использовании одного sub-entry в другом и возможных ошибках.
Периодически может возникать необходимость использовать один вложенный чанк в другом. Использовать абсолютные пути при текущих настройках у вас не получится, и как правило указываются относительные пути с ../../
, однако, при сборке библиотеки можно увидеть ошибку:
ERROR: Unable to write a reference to SomeComponent in example-path/some-lib/src/lib/sub-entry-a/some.component.ts from example-path/some-lib/src/lib/sub-entry-a/some.module.ts
Это связано с одним ограничением: sub-entry может использовать только те относительные пути, которые не выходят за пределы его директории. Во всех остальных случаях нам нужно использовать абсолютный путь.
Кроме того, несмотря на то, что в приложении мы все импортируем сразу из some-lib
, например, import { any } from 'some-lib', абсолютные пути в библиотеках должны быть указаны до конкретного индексного файла:
import { SomeModule } from 'some-lib/src/lib/sub-entry-a';
Для этого нам потребуется провести предварительные манипуляции с tsconfig.lib.json — нужно добавить правильный алиас для библиотек:
ui-lib-example/tsconfig.lib.json
"paths": {
"assets-library/*": [
"dist/assets-library/*",
"dist/assets-library"
],
"assets-library": [
"dist/assets-library/*",
"dist/assets-library"
],
"ui-lib-example/*": [
"libs/ui-lib-example/*",
"libs/ui-lib-example"
],
"ui-lib-example": [
"libs/ui-lib-example/*",
"libs/ui-lib-example"
]
}
А также немного изменить алиасы библиотек в tsconfig.json.
tsconfig.json
"paths": {
"assets-library/*": [
"dist/assets-library/*",
"dist/assets-library"
],
"assets-library": [
"dist/assets-library/*"
"dist/assets-library"
],
"ui-lib-example/*": [
"dist/ui-lib-example/*",
"dist/ui-lib-example"
],
"ui-lib-example": [
"dist/ui-lib-example/*",
"dist/ui-lib-example"
]
}
Также вы можете встретить ещё одну ошибку, связанную с rootDir. Выглядеть она будет примерно вот так:
Error File
sub-entry-a/some.service.ts
is not under 'rootDir'sub-entry-b
.rootDir
is expected to contain all source files.
Данная ошибка, по своей сути, является тем же, что и в предыдущем пункте: из sub-entry пытаемся относительным путем достучаться до сервиса, который расположен за пределами доступности.
Ну и решение будет точно таким же, как и выше: оборачиваем сервис в свой чанк, если вы ещё этого не сделали, и используем абсолютный путь.
В соответствии с заголовком этой части статьи нам действительно нужно написать чуть больше кода, однако такой подход имеет свои весомые преимущества. В основном это разбиение большого куска кода на мелкие части и работающий tree shaking, а также использование sub-entries позволяет получить более "прозрачный" вывод ошибок.
Например, циклические зависимости не дадут собраться библиотеке. А вот если бы мы собирали один общий чанк, то ng-packagr не обратил бы на это внимания.
Если для работы с иконками вы выбрали вариант использования svg-icon, то обратите внимание, что нам нужно поменять место для сгенерированного файла svg-icons.ts, теперь он должен генерироваться сразу в модуль icons. Это связано с тем, что нам нужно использовать дефолтный импорт иконок, но сделать такой дефолтный экспорт из другого чанка не получится.
Результатом выполнения будет создание нескольких чанков в билде библиотеки вместо одного.
“Основы основ”
Отлично, мы разобрались с tree shaking и подключением библиотек, теперь самое время создать нашу core-library. Уверен, что при работе с Angular вы либо часто импортируете одни и те же модули в другие модули (например, CommonModule), либо создаете некий общий модуль на уровне приложения, включающий в себя все повторяющиеся в импортах основные модули и импортируете везде только его. Однако такой вариант не очень хорошо работает с библиотеками.
Если мы создадим некий CoreModule
на уровне приложения, то попытки сделать его импорт в библиотеки будут выглядеть по меньшей мере странно. А если создать такой модуль и в приложении, и в библиотеке, то помимо дублирования кода мы ещё получим несколько по сути идентичных инстансов внутри билда, а оно нам надо? Явно не ради этого мы заморачивались с тряской дерева.
Поэтому вполне логичным будет выглядеть создание отдельной библиотеки, включающей в себя этот самый CoreModule
и возможно ещё какие-то модули, отвечающие за важные части приложения или библиотек — например, за запросы к api. Давайте этим и займёмся. Для начала создадим основной модуль, включающий в себя CommonModule
из Angular и, допустим, FormsModule
.
Ахтунг! Это — не пример для подражания, а всего лишь пример для демонстрации механизма. Подходите с умом к созданию такого CoreModule и обязательно проверяйте, везде ли вы используете помещённые в него внешние модули!
Для этого создадим библиотеку через cli и провернём все те манипуляции, что и в предыдущем пункте. В итоге у нас должна получиться вот такая структура:
Теперь попробуем добавить какой-нибудь инпут и структурную директиву в ui-lib-example.component.html.
ui-lib-example.component.html
<div *ngIf="true">
<form [formGroup]="form">
<input type="text" formControlName="name"/>
</form>
</div>
А также доработаем ui-lib-example.component.ts.
ui-lib-example.component.ts
@Component({
selector: 'lib-ui-lib-example',
templateUrl: './ui-lib-example.component.html',
styleUrls: ['./ui-lib-example.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UiLibExampleComponent {
form: FormGroup;
private name = 'Ricardo Milos';
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
name: [this.name, Validators.required],
});
}
}
Если закомментить форму, то в консоли мы увидим ошибку "NG0303: Can't bind to 'ngIf' since it isn't a known property of 'div'." , а с формой у нас упадет билд ui-lib-example.
Теперь давайте импортируем в ui-lib-example.module.ts наш CommonLibraryModule
, не забудьте только собрать перед этим core-library. Обратите внимание, что мы добавили CUSTOM_ELEMENT_SCHEMA
, потому что с добавлением CommonModule
Angular не может понять что это за тег такой - <svg-icon>
.
ui-lib-example.module.ts
import { CommonLibraryModule } from 'core-library';
@NgModule({
declarations: [
UiLibExampleComponent,
],
imports: [
CommonLibraryModule,
IconsLibraryModule,
],
exports: [
UiLibExampleComponent,
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class UiLibExampleModule { }
Конечно, нужно будет дописать paths для core-library в angular-libs-examples/libs/ui-lib-example/tsconfig.lib.json.
ui-lib-example/tsconfig.lib.json
"core-library/*": [
"libs/core-library/*",
"libs/core-library"
],
"core-library": [
"libs/core-library/*",
"libs/core-library"
]
Теперь можно перезапустить сборку ui-lib-example (если вы ранее дописали paths и билдили с флагом --watch, то у вас должно завестить и без рестарта). В итоге увидим, что в консоли нет ошибок, а в браузере появилась наша форма.
Ну ладно, со стандартными модулями Angular всё было и так более менее понятно, а что насчёт наших собственных модулей? Для них будут действовать те же самые условия, что и для CommonLibraryModule
. То есть, нам нужна единая точка экспорта для приложения и библиотек, иначе рискуем получить несколько экземпляров одного модуля в итоговом билде.
Наиболее актуально, как и говорил выше, это выносить в core-library, например, какой-нибудь api.service, упрощающий взаимодействие с API, сервисы по работе с лэйаутами (модальные окна, нотификации), сами лэйауты (основы для страниц, сайдбары, хедеры) и прочие подобные вещи.
Давайте добавим ещё один модуль, который будет содержать как раз сервис по работе с api. Только перед этим добавим в корень проекта proxy.conf.js
proxy.conf.js
// Перечислим, с каких урлов мы хотим проксировать запрос
const PROXY_URLS = [
'/api.test/*',
];
// Опишем дефолтный конфиг
const PROXY_CONFIG = {
target: 'https://slack.com/api',
changeOrigin: true,
secure: false,
logLevel: 'debug',
}
module.exports = {
...PROXY_URLS.reduce(
(proxies, url) =>
Object.assign(proxies, {
[url]: PROXY_CONFIG,
}),
{},
),
};
А также строку "proxyConfig": "proxy.conf.js"
в раздел serve в angular.json.
Теперь добавим наш ApiModule
вместе с ApiService
. Я не буду приводить тут весь код, так как получится довольно много текста. Вы можете посмотреть их сразу в коммите или структуре проекта.
Давайте убедимся, что он работает и в приложении, и в библиотеках. Нужно обязательно добавить ApiModule в импорты AppModule, а также добавим для этого один файл test.service.ts к app.module.ts и к ui-lib-example.module.ts
test.service.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService, Query } from 'core-library';
@Injectable()
export class TestService {
private readonly query: Query;
constructor(private api: ApiService) {
this.query = this.api.query(‘api.test’);
}
getTest(): Observable<any> {
return this.query('');
}
}
Теперь можно добавить их в массив providers
модулей и вызвать метод getTest
на хуке OnInit
.
ngOnInit(): void {
this.service.getTest().pipe(take(1))
.subscribe(res => console.log('Result’, res));
}
Самое время пересобрать ui-lib-example и выполнить yarn start. В консоли мы увидим следующее:
Всё работает, а вы снова восхитительны!
Сборка двух проектов и lazy loading
Предлагаю довести до логического завершения использование возможностей нашего монорепозитория и собрать два приложения с lazy-loading на роутере.
Для начала нам нужно изменить структуру папки app таким образом, чтобы в ней хранилось чуть больше проектов, чем сейчас. Фактически внутри должны содержаться несколько директорий приложений со своими папками app и настройками, каждое приложение в этом случаем вполне спокойно может иметь свои настройки proxy.conf, сервис воркеры, линтеры и т.д., но у нас они будут общие. Но обратите внимание, что tsconfig у каждого приложения должен быть свой и включать локальные main.ts и polyfill.ts.
{
"extends": "../../../tsconfig.json",
"files": [
"./polyfills.ts",
"./main.ts"
],
}
В итоге у нас должно получиться что-то вроде такого, где first-app будет нашим приложением, которое мы мучали все это время, а second-app — новое, с импортом UiLibExampleComponent
и ещё одним компонентом из другой библиотеки.
Так как мы не можем создать новый проект с помощью cli, потому что у нас уже есть angular.json
, то в большей степени добавление новых приложений — это копипаста рутовых файлов из проекта в проект. Однако, важно будет также изменить наш angular.json
, во-первых потому что у нас переехало одно приложение, а во-вторых, потому что в него нужно добавить информацию о втором. Думаю, из-за объёма я не буду приводить тут весь код, однако вот вам коммит с изменениями.
Теперь мы можем проверить сборку всех библиотек, а также попробовать запустить команды yarn build <project_name>
и yarn start <project_name>
и убедиться, что всё собирается.
Давайте теперь займёмся созданием библиотеки для second-app. Уверен, что тут у вас не возникнет сложностей, поэтому просто оставлю вам коммит. Добавим туда один компонент для примера. И займёмся настройкой lazy-loading в second-app.
Создадим отдельный TestModule
, он как раз будет подгружаться “лениво”. И импортируем в него SecondAppLibraryModule
, а UiLibExampleModule
импортируем в AppModule
. Шаблоны компонентов будут выглядеть максимально просто:
<!--App.component.html-->
<lib-ui-lib-example></lib-ui-lib-example>
<router-outlet></router-outlet>
<!--Test.component.html-->
<lib-second-app-library></lib-second-app-library>
Сам механизм lazy-loading не является темой данной статьи, так что подробно останавливаться на нём не буду, точно так же оставлю коммит на него. В итоге у нас получилась вот такая структура второго приложения, где мы имеем импорт из двух разных библиотек-доменов, один из которых в ленивом модуле.
Теперь, если запустим приложение и посмотрим в браузер, то увидим, что подгрузился компонент из одной библиотеки, при этом во вкладке source в консоли мы не увидим лишних модулей.
А если перейдем на страницу ‘/test’, увидим, что теперь подгружается и компонент из библиотеки second-app-library, при этом во вкладке source появился наш lazy module.
Если сбилдить все библиотеки и приложения, то в директории dist мы увидим следующее:
При этом мы можем спокойно деплоить только наши приложения, так как все необходимые части из библиотек они уже содержат. И все эти части будут совершенно спокойно тришейкаться и не увеличивать первую загрузку.
Получается, что мы только что собрали два несвязанных приложения из одного набора библиотек, с возможностью использовать одни и те же core-сервисы без необходимости копировать код или публиковать библиотеки. Теперь мы можем собирать клоны приложений, иметь единую основу для всех, а ещё миксовать функциональность в зависимости от потребностей заказчика. И всё это, используя встроенный инструмент Angular.
Заключение
Как видите, подобный подход может применяться как для одного приложения с целью разделить функциональность на своего рода домены, так и для создания нескольких однотипных приложений с разным набором возможностей. В первом варианте удобно применить подход DDD, а во втором вы ещё и сможете разным заказчикам собирать разные приложения без необходимости настраивать дикие условия сборки. К примеру, если вы разрабатываете несколько админок для разных отделов или компаний.
В следующий раз расскажу о подключении JEST к таким проектам и о нюансах тестирования библиотек, а также о том, как настроить CI для такого подхода разработки.
P.S. Хотел еще раз напомнить, что вы...
Ссылки на библиотеки и документацию:
Angular libs - https://angular.io/guide/libraries
Angular CLI - https://angular.io/cli
ng-packagr - https://www.npmjs.com/package/ng-packagr