Angular Libs как монорепозиторий 2: tree shaking и сборка клонов

от автора

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

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

В ходе статьи на примере уже созданного нами проекта из прошлого поста мы с вами разберём: 

  • как настроить tree shaking библиотек;

  • как создать core-библиотеку, которая будет нужна везде;

  • как собрать два клона из одного набора библиотек, но с разным функционалом.

Оглавление

  1. Tree Shaking или как написать еще больше кода.

  2. «Основы основ» (создание единой core-библиотеки).

  3. Сборка двух проектов и lazy loading.

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. Хотел еще раз напомнить, что вы…

Ссылки на библиотеки и документацию:

  1. Angular libs — https://angular.io/guide/libraries 

  2. Angular CLI — https://angular.io/cli 

  3. ng-packagr — https://www.npmjs.com/package/ng-packagr


ссылка на оригинал статьи https://habr.com/ru/articles/595815/


Комментарии

Добавить комментарий

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