Уже сложно представить наши приложения без такой оптимизации, как tree shaking.
Tree-shaking — «встряхивание дерева», удаление неиспользуемого кода из бандла приложения во время сборки.
Почему же я хочу уделить особое внимание standalone компонентам?
Просто существуют некоторые нюансы при встряхивании, о которых стоит знать при работе с такими компонентами. Как говорится, предупрежден — значит вооружен.
Далее можете почитать немного теории или сразу перейти к основной мысли.
Давным-давно...
История tree-shaking в Angular интересна.
До 6 версии фреймворка была возможность указать провайдеры только на уровне модуля в самом модуле и на уровне компонента/директивы.
С 6 версии появляются tree-shakable провайдеры: указываются через Injectable декоратор + provideIn в самом сервисе.
@Injectable({ providedIn: TestModule }) export class TestApiService {...
Теперь модулю необязательно объявлять сервис у себя в providers. Соответственно, больше нет ссылки на этот сервис => встряхивание дерева отработает ожидаемо: если сервис нигде не используется, то он не будет включен в финальный бандл.
Затем приходит новый движок рендеринга Ivy (до этого был View Engine), который использует концепцию incremental dom и улучшает возможности tree-shaking. Теперь для каждого компонента имеются инструкции по созданию/обновлению DOM-дерева, что позволяет встряхивать еще больше кода в процессе tree-shaking.
В общем, tree-shaking есть и он работает. Стоит только помнить, что указывая провайдеры в providers модуля, мы лишаем себя возможности встряхнуть такие сервисы во время билда.
Standalone компоненты
В Angular 14 выпускают standalone components, которые больше не нужно объявлять в модулях. Стоит только добавить standalone: true флаг.
Standalone компоненты отличаются явным подключением зависимостей внутри себя: в этом плане они ведут себя как модули. Они могут импортировать в себя и модули, и другие standalone компоненты и сами быть импортированы в модуль или standalone компонент.
У таких компонентов достаточно плюшек (меньше кода, directive composition api, тестирование, сторибук и так далее), но это уже тема отдельной статьи.
Standalone компоненты и tree-shaking
И вот мы начинаем активно создавать standalone компоненты, импортировать их то в модули, то в такие же компоненты. И однажды замечаем, что production bundle содержит неиспользуемые standalone компоненты…
Да, иногда tree-shaking не встряхивает такие компоненты, и размер приложения может постепенно увеличиваться. А если ты еще и создатель какой-то публичной библиотеки, то эта тема крайне актуальна для тебя.
Разберем на конкретных примерах:
Standalone component встряхивается
Условия:
Есть standalone компонент (SC1), он ничего не импортирует
AppModule импортирует SC1
standalone1.component.ts
import { Component } from '@angular/core';
import {CommonModule} from "@angular/common";
@Component({
selector: 'app-standalone-1',
templateUrl: './standalone-1.component.html',
standalone: true, // указываем, что это standalone component
imports: [], // ничего не импортируем
})
export class Standalone1Component {
}
app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { Standalone1Component } from "./standalone1/standalone1.component";
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
Standalone1Component, // импортируем, но затем нигде не используем
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {
}
Так как standalone компонент ничего не импортирует и нигде не используется, то при tree-shaking он будет удален.
Аналогично и здесь:
standalone компонент (SC1) импортирует другой standalone компонент (SC2)
AppModule импортирует SC1
standalone2.component.ts
import { Component } from '@angular/core';
import {CommonModule} from "@angular/common";
@Component({
selector: 'app-standalone-2',
templateUrl: './standalone-2.component.html',
standalone: true, // указываем, что это standalone component
imports: [], // ничего не импортируем
})
export class Standalone2Component {
}
standalone1.component.ts
import { Component } from @angularr/core';
import {CommonModule} from @angularr/common";
@Component({
selector: 'app-standalone-1',
templateUrl: './standalone-1.component.html',
standalone: true, // указываем, что это standalone component
imports: [Standalone2Component], // импортируем другой компонент
})
export class Standalone1Component {
}
app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { Standalone1Component } from "./standalone1/standalone1.component";
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
Standalone1Component, // импортируем, но затем нигде не используем
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {
}
Так как ни один из standalone компонентов не импортирует модули, а Standalone1Component нигде не используется (кроме импорта в AppModule), то при tree-shaking такие компоненты (Standalone1Component, Standalone2Component) будут удалены.
Standalone component не встряхивается
Условия:
standalone компонент (SC1) импортирует другие модули
AppModule импортирует SC1, но нигде не использует его
standalone1.component.ts
import { Component } from '@angular/core';
import {CommonModule} from "@angular/common";
@Component({
selector: 'app-standalone-1',
templateUrl: './standalone-1.component.html',
standalone: true, // указываем, что это standalone component
imports: [CommonModule], // импортируем любой модуль
})
export class Standalone1Component {
}
app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { Standalone1Component } from "./standalone1/standalone1.component";
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
Standalone1Component, // импортируем, но затем нигде не используем
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {
}
Так как компонент нигде не используется, кроме импорта в модуль, то не хотелось бы увидеть его в бандле приложения, но...
Запускаем ng build и в директории dist в main.js файле — видим код нашего standalone компонента:
return new(r||e)},e.\u0275cmp=fs({type:e,selectors:[["app-standalone-1"]],standalone:!0
Почему так получилось?
Фреймворку достаточно сложно определить, имеет ли импортируемый модуль внутри себя массив providers. Вероятно, провайдеры есть и они могут использоваться в приложении. Поэтому встряхивать компоненты, которые импортируют модули, может быть опасно.
И как было упомянуто в самом начале про tree-shakable providers: в случае указания провайдеров именно в модуле, мы лишаем себя возможности встряхивания.
Кстати, аналогичная ситуация (standalone component не встряхивается) будет и здесь: если компонент импортирует не модуль, а другой standalone component, который уже импортирует модуль.
Что можем сделать
Не хочется же иметь неиспользуемые зависимости у себя в приложении?
Тогда нужно:
Внимательно относиться к imports в standalone компонентах.
Импортируем то, что действительно требуется компоненту. Например, CommonModule — необязательно импортировать весь модуль, есть NgIf, NgForOf, AsyncPipe и другие standalone компоненты, использование которых улучшает процесс встряхивания.Экспортируемые модули создавать небольшими или вовсе экспортировать только коллекцию компонентов.
Хорошая практика — использовать provideIn вместо providers модуля.
Заключение
Иногда долгожданные и удобные новинки в фреймворках таят в себе сюрпризы. Но это не повод отказываться от них, а лишь напоминание о том, что полезно иногда копнуть глубже. Вооружившись этой информацией, мы получили новый пункт в задаче оптимизации размера бандла: проверить зависимости в standalone компонентах.