Всем привет! В прошлом посте мы с вами разобрали механизмы создания библиотек с помощью 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

Комментарии (0)