Прошло уже достаточно времени с выхода обновленного Angular. В настоящее время множество проектов завершено. От "getting started" множество разработчиков уже перешло к осмысленному использованию этого фреймворка, его возможностей, научились обходить подводные камни. Каждый разработчик и/или команда либо уже сформировали свои style guides и best practice либо используют чужие. Но в тоже время часто приходится сталкиваться с большим количеством кода на Angular, в котором не используются многие возможности этого фреймворка и/или написанного в стиле AngularJS.
В данной статье представлены некоторые возможности и особенности использования фреймворка Angular, которые, по скромному мнению автора, недостаточно освещены в руководствах или не используются разработчиками. В статье рассматривается использование "перехватчиков" (Interceptors) HTTP запросов, использование Route Guards для ограничения доступа пользователям. Даны некоторые рекомендации по использованию RxJS и управлению состоянием приложения. Также представлены некоторые рекомендации по оформлению кода проектов, которые возможно позволят сделать код проектов чище и понятнее. Автор надеется, что данная статья будет полезна не только разработчикам, которые только начинают знакомство с Angular, но и опытным разработчикам.
Работа с HTTP
Построение любого клиентского Web приложения производится вокруг HTTP запросов к серверу. В этой части рассматриваются некоторые возможности фреймворка Angular по работе с HTTP запросами.
Используем Interceptors
В некоторых случаях может потребоваться изменить запрос до того, как он попадет на сервер. Или необходимо изменить каждый ответ. Начиная с версии Angular 4.3 появился новый HttpClient. В нем добавлена возможность перехватывать запрос с помощью interceptors (Да, их наконец-то вернули только в версии 4.3!, это была одна из наиболее ожидаемых недостающих возможностей AngularJs, которые не перекочевали в Angular). Это своего рода промежуточное ПО между http-api и фактическим запросом.
Одним из распространенных вариантов использования может быть аутентификация. Чтобы получить ответ с сервера, часто нужно добавить какой-то механизм проверки подлинности в запрос. Эта задача с использованием interceptors решается достаточно просто:
import { Injectable } from "@angular/core";
import { Observable } from "rxjs/Observable";
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from @angular/common/http";
@Injectable()
export class JWTInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>>
{
req = req.clone({
setHeaders: {
authorization: localStorage.getItem("token")
}
});
return next.handle(req);
}
}
Поскольку приложение может иметь несколько перехватчиков, они организованы в цепочку. Первый элемент вызывается самим фреймворком Angular. Впоследствии мы несем ответственность за передачу запроса следующему перехватчику. Чтобы это сделать, мы вызываем метод handle следующего элемента в цепочке, как только мы закончим. Подключаем interceptor:
import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { AppComponent } from "./app.component";
import { HttpClientModule } from "@angular/common/http";
import { HTTP_INTERCEPTORS } from "@angular/common/http";
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, HttpClientModule],
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: JWTInterceptor,
multi: true
}
],
bootstrap: [AppComponent]
})
export class AppModule {}
Как видим подключение и реализация interceptors достаточно проста.
Отслеживание прогресса
Одной из особенностей HttpClient
является возможность отслеживания хода выполнения запроса. Например, если необходимо загрузить большой файл, то, вероятно, возникает желание сообщать о ходе загрузки пользователю. Чтобы получить прогресс, необходимо установить для свойства reportProgress
объекта HttpRequest
значение true
. Пример сервиса реализующего данный подход:
import { Observable } from "rxjs/Observable";
import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { HttpRequest } from "@angular/common/http";
import { Subject } from "rxjs/Subject";
import { HttpEventType } from "@angular/common/http";
import { HttpResponse } from "@angular/common/http";
@Injectable()
export class FileUploadService {
constructor(private http: HttpClient) {}
public post(url: string, file: File): Observable<number> {
var subject = new Subject<number>();
const req = new HttpRequest("POST", url, file, {
reportProgress: true
});
this.httpClient.request(req).subscribe(event => {
if (event.type === HttpEventType.UploadProgress) {
const percent = Math.round((100 * event.loaded) / event.total);
subject.next(percent);
} else if (event instanceof HttpResponse) {
subject.complete();
}
});
return subject.asObservable();
}
}
Метод post возвращает объект наблюдателя (Observable
), представляющий ход загрузки. Все что теперь нужно, это выводить ход выполнения загрузки в компоненте.
Маршрутизация. Используем Route Guard
Маршрутизация позволяет сопоставлять запросы к приложению с определенными ресурсами внутри приложения. Довольно часто приходится решать задачу ограничения видимости пути, по которому располагаются определенные компоненты, в зависимости от некоторых условий. В этих случаях в Angular есть механизм ограничения перехода. В качестве примера, приведен сервис, который будет реализовывать route guard. Допустим, в приложении аутентификация пользователя реализована с использованием JWT. Упрощенный вариант сервиса, который выполняет проверку авторизован ли пользователь, можно представить в виде:
@Injectable()
export class AuthService {
constructor(public jwtHelper: JwtHelperService) {}
public isAuthenticated(): boolean {
const token = localStorage.getItem("token");
// проверяем не истек ли срок действия токена
return !this.jwtHelper.isTokenExpired(token);
}
}
Для реализации route guard необходимо реализовать интерфейс CanActivate
, который состоит из единственной функции canActivate
.
@Injectable()
export class AuthGuardService implements CanActivate {
constructor(public auth: AuthService, public router: Router) {}
canActivate(): boolean {
if (!this.auth.isAuthenticated()) {
this.router.navigate(["login"]);
return false;
}
return true;
}
}
Реализация AuthGuardService
использует описанный выше AuthService
для проверки авторизации пользователя. Метод canActivate
возвращает логическое значение, которое может быть использовано в условии активации маршрута.
Теперь мы можем применить созданный Route Guard к любому маршруту или пути. Для этого при объявлении Routes
мы указываем наш сервис, наследующий CanActivate
интерфейс, в секции canActivate
:
export const ROUTES: Routes = [
{ path: "", component: HomeComponent },
{
path: "profile",
component: UserComponent,
canActivate: [AuthGuardService]
},
{ path: "**", redirectTo: "" }
];
В этом случае маршрут /profile
имеет дополнительное конфигурационное значение canActivate
. AuthGuard
, описанный ранее передается аргументом в данное свойство canActivate
. Далее метод canActivate
будет вызываться каждый раз, когда кто-нибудь попытается получить доступ к пути /profile
. Если пользователь авторизован он получит доступ к пути /profile
, в противном случае он будет перенаправлен на путь /login
.
Следует знать, что canActivate
по прежнему позволяет активировать компонент по данному пути, но не позволяет перейти на него. Если нужно защитить активацию и загрузку компонента, то для такого случая можем использовать canLoad
. Реализация CanLoad
может быть сделана по аналогии.
Готовим RxJS
Angular построен на основе RxJS. RxJS — это библиотека для работы с асинхронными и основанными на событиях потоками данных, с использованием наблюдаемых последовательностей. RxJS — это реализация ReactiveX API на языке JavaScript. В основной своей массе ошибки, возникающие при работе с данной библиотекой, связаны с поверхностными знаниями основ её реализации.
Используем async вместо подписывания на события
Большое число разработчиков, которые только недавно пришли к использованию фреймворка Angular, используют функцию subscribe
у Observable
, чтобы получать и сохранять данные в компоненте:
@Component({
selector: "my-component",
template: `
<span>{{localData.name}} : {{localData.value}}</span>`
})
export class MyComponent {
localData;
constructor(http: HttpClient) {
http.get("api/data").subscribe(data => {
this.localData = data;
});
}
}
Вместо этого мы можем подписываться через шаблон, используя async pipe:
@Component({
selector: "my-component",
template: `
<p>{{data.name | async}} : {{data.value | async}}</p>`
})
export class MyComponent {
data;
constructor(http: HttpClient) {
this.data = http.get("api/data");
}
}
Подписываясь через шаблон, мы избегаем утечек памяти, потому что Angular автоматически отменяет подписку на Observable
, когда компонент разрушается. В данном случае для HTTP запросов использование async pipe практически не предоставляет никаких преимуществ, кроме одного — async отменит запрос, если данные больше не нужны, а не завершит обработку запроса.
Многие возможности Observables
не используются при подписке вручную. Поведение Observables
может быть расширено повтором (например, retry в http запросе), обновлением на основе таймера или предварительным кешированием.
Используем $
для обозначения observables
Следующий пункт связан с оформлением исходных кодов приложения и вытекает из предыдущего пункта. Для того чтобы различать Observable
от простых переменных довольно часто можно услышать совет использовать знак “$
” в имени переменной или поля. Данный простой трюк позволит исключить путаницу в переменных при использовании async.
import { Component } from "@angular/core";
import { Observable } from "rxjs/Rx";
import { UserClient } from "../services/user.client";
import { User } from "../services/user";
@Component({
selector: "user-list",
template: `
<ul class="user_list" *ngIf="(users$ | async).length">
<li class="user" *ngFor="let user of users$ | async">
{{ user.name }} - {{ user.birth_date }}
</li>
</ul>`
})
export class UserList {
public users$: Observable<User[]>;
constructor(public userClient: UserClient) {}
public ngOnInit() {
this.users$ = this.client.getUsers();
}
}
Когда нужно отписываться (unsubscribe)
Наиболее частый вопрос, который возникает у разработчика при недолгом знакомстве с Angular — когда все таки нужно отписываться, а когда нет. Для ответа на этот вопрос сначала нужно определиться какой вид Observable
в данный момент используется. В Angular существуют 2 вида Observable
— финитные и инфинитные, одни производят конечное, другие, соответственно, бесконечное число значений.
Http
Observable
— финитный, а слушатели/наблюдатели (listeners) DOM событий — это инфинитные Observable
.
Если подписка на значения инфинитного Observable
производится вручную (без использования async pipe), то в обязательном порядке должна производится отписка. Если подписываемся в ручном режиме на финитный Observable, то отписываться не обязательно, об этом позаботится RxJS. В случае финитных Observables
можем производить отписку, если Observable
имеет более длительный срок исполнения, чем необходимо, например, кратно повторяющийся HTTP запрос.
Пример финитных Observables
:
export class SomeComponent {
constructor(private http: HttpClient) { }
ngOnInit() {
Observable.timer(1000).subscribe(...);
this.http.get("http://api.com").subscribe(...);
}
}
Пример инфинитных Observables
export class SomeComponent {
constructor(private element : ElementRef) { }
interval: Subscription;
click: Subscription;
ngOnInit() {
this.interval = Observable.interval(1000).subscribe(...);
this.click = Observable.fromEvent(this.element.nativeElement, "click").subscribe(...);
}
ngOnDestroy() {
this.interval.unsubscribe();
this.click.unsubscribe();
}
}
Ниже, более детально приведены случаи, в которых нужно отписываться
- Необходимо отписываться от формы и от отдельных контролов, на которые подписались:
export class SomeComponent {
ngOnInit() {
this.form = new FormGroup({...});
this.valueChangesSubs = this.form.valueChanges.subscribe(...);
this.statusChangesSubs = this.form.statusChanges.subscribe(...);
}
ngOnDestroy() {
this.valueChangesSubs.unsubscribe();
this.statusChangesSubs.unsubscribe();
}
}
- Router. Согласно документации Angular должен сам отписываться, однако этого не происходит. Поэтому во избежание дальнейших проблем производим отписывание самостоятельно:
export class SomeComponent {
constructor(private route: ActivatedRoute, private router: Router) { }
ngOnInit() {
this.route.params.subscribe(..);
this.route.queryParams.subscribe(...);
this.route.fragment.subscribe(...);
this.route.data.subscribe(...);
this.route.url.subscribe(..);
this.router.events.subscribe(...);
}
ngOnDestroy() {
// Здесь мы должны отписаться от всех подписанных observables
}
}
- Бесконечные последовательности. Примерами могут служить последовательности созданные с помощью
interva()
или слушатели события(fromEvent())
:
export class SomeComponent {
constructor(private element : ElementRef) { }
interval: Subscription;
click: Subscription;
ngOnInit() {
this.intervalSubs = Observable.interval(1000).subscribe(...);
this.clickSubs = Observable.fromEvent(this.element.nativeElement, "click").subscribe(...);
}
ngOnDestroy() {
this.intervalSubs.unsubscribe();
this.clickSubs.unsubscribe();
}
}
takeUntil и takeWhile
Для упрощения работы с инфинитными Observables
в RxJS существует две удобные функции — это takeUntil
и takeWhile
. Они производят одно и тоже действие — отписку от Observable
по окончании какого-нибудь условия, разница лишь в принимаемых значениях. takeWhile
принимает boolean
, а takeUntil
— Subject
.
Пример takeWhile
:
export class SomeComponent implements OnDestroy, OnInit {
public user: User;
private alive: boolean = true;
public ngOnInit() {
this.userService
.authenticate(email, password)
.takeWhile(() => this.alive)
.subscribe(user => {
this.user = user;
});
}
public ngOnDestroy() {
this.alive = false;
}
}
В этом случае при изменении флага alive
произойдет отписка от Observable
. В данном примере отписываемся при уничтожении компонента.
Пример takeUntil
:
export class SomeComponent implements OnDestroy, OnInit {
public user: User;
private unsubscribe: Subject<void> = new Subject(void);
public ngOnInit() {
this.userService.authenticate(email, password)
.takeUntil(this.unsubscribe)
.subscribe(user => {
this.user = user;
});
}
public ngOnDestroy() {
this.unsubscribe.next();
this.unsubscribe.complete();
}
}
В данном случае для отписки от Observable
мы сообщаем, что subject
принимает следующее значение и завершаем его.
Использование этих функций позволит избежать утечек и упростит работу с отписками от данных. Какую из функций использовать? В ответе на данный вопрос нужно руководствоваться личными предпочтениями и текущими требованиями.
Управление состоянием в Angular приложениях, @ngrx/store
Довольно часто при разработке сложных приложений мы сталкиваемся с необходимостью хранить состояние и реагировать на его изменения. Для приложений, разрабатываемых на фреймворке ReactJs существует множество библиотек, позволяющих управлять состоянием приложения и реагировать на его изменения — Flux, Redux, Redux-saga и т.д. Для Angular приложений существует контейнер состояний на основе RxJS вдохновленный Redux — @ngrx/store. Правильное управление состоянием приложения избавит разработчика от множества проблем при дальнейшем расширении приложения.
Почему Redux?
Redux позиционирует себя как предсказуемый контейнер состояния (state) для JavaScript приложений. Redux вдохновлен Flux и Elm.
Redux предлагает думать о приложении, как о начальном состоянии модифицируемом последовательностью действий (actions), что может являться хорошим подходом при построении сложных веб-приложений.
Redux не связан с каким-то определенным фреймворком, и хотя разрабатывался для React, может использоваться с Angular или jQuery.
Основные постулаты Redux:
- одно хранилище для всего состояния приложения
- состояние доступно только для чтения
- изменения делаются «чистыми» функциями, к которым предъявляются следующие требования:
- не должны делать внешних вызовов по сети или базе данных;
- возвращают значение, зависящее только от переданных параметров;
- аргументы являются неизменяемыми, т.е. функции не должны их изменять;
- вызов чистой функции с теми же аргументами всегда возвращает одинаковый результат;
Пример функции управления состоянием:
// counter.ts
import { ActionReducer, Action } from "@ngrx/store";
export const INCREMENT = "INCREMENT";
export const DECREMENT = "DECREMENT";
export const RESET = "RESET";
export function counterReducer(state: number = 0, action: Action) {
switch (action.type) {
case INCREMENT:
return state + 1;
case DECREMENT:
return state - 1;
case RESET:
return 0;
default:
return state;
}
}
В основном модуле приложения импортируется Reducer и с использованием функции StoreModule.provideStore(reducers)
делаем его доступным для Angular инжектора:
// app.module.ts
import { NgModule } from "@angular/core";
import { StoreModule } from "@ngrx/store";
import { counterReducer } from "./counter";
@NgModule({
imports: [
BrowserModule,
StoreModule.provideStore({ counter: counterReducer })
]
})
export class AppModule { }
Далее производится внедрение Store
сервиса в необходимые компоненты и сервисы. Для выбора "среза" состояния используется функция store.select():
// app.component.ts
...
interface AppState {
counter: number;
}
@Component({
selector: "my-app",
template: `
<button (click)="increment()">Increment</button>
<div>Current Count: {{ counter | async }}</div>
<button (click)="decrement()">Decrement</button>
<button (click)="reset()">Reset Counter</button>`
})
class AppComponent {
counter: Observable<number>;
constructor(private store: Store<AppState>) {
this.counter = store.select("counter");
}
increment() {
this.store.dispatch({ type: INCREMENT });
}
decrement() {
this.store.dispatch({ type: DECREMENT });
}
reset() {
this.store.dispatch({ type: RESET });
}
}
@ngrx/router-store
В некоторых случаях удобно связывать состояние приложения с текущим маршрутом приложения. Для этих случаев существует модуль @ngrx/router-store. Чтобы приложение использовало router-store
для сохранения состояния, достаточно подключить routerReducer
и добавить вызов RouterStoreModule.connectRoute
в основном модуле приложения:
import { StoreModule } from "@ngrx/store";
import { routerReducer, RouterStoreModule } from "@ngrx/router-store";
@NgModule({
imports: [
BrowserModule,
StoreModule.provideStore({ router: routerReducer }),
RouterStoreModule.connectRouter()
],
bootstrap: [AppComponent]
})
export class AppModule { }
Теперь добавляем RouterState
в основное состояние приложения:
import { RouterState } from "@ngrx/router-store";
export interface AppState {
...
router: RouterState;
};
Дополнительно можем указать начальное состояние приложения при объявлении store:
StoreModule.provideStore(
{ router: routerReducer },
{
router: {
path: window.location.pathname + window.location.search
}
}
);
Поддерживаемые действия:
import { go, replace, search, show, back, forward } from "@ngrx/router-store";
//Навигация с новым состоянием в истории
store.dispatch(go(["/path", { routeParam: 1 }], { query: "string" }));
// Навигация с заменой текущего состояния в истории
store.dispatch(replace(["/path"], { query: "string" }));
// Навигация без добавления нового состояния в историю
store.dispatch(show(["/path"], { query: "string" }));
// Навигация только с изменением параметров запроса
store.dispatch(search({ query: "string" }));
// Навигация назад
store.dispatch(back());
// Навигация вперед
store.dispatch(forward());
Использование контейнера состояния избавит от многих проблем при разработке сложных приложений. Однако, важно делать управление состоянием как можно проще. Довольно часто приходится сталкиваться с приложениями, в которых присутствует излишняя вложенность состояний, что лишь усложняет понимание работы приложения.
Организация кода
Избавляемся от громоздких выражений в import
Многим разработчикам известна ситуация, когда выражения в import
довольно громоздкие. Особенно это заметно в больших приложениях, где много повторно используемых библиотек.
import { SomeService } from "../../../core/subpackage1/subpackage2/some.service";
Что еще плохо в этом коде? В случае, когда понадобиться перенести наш компонент в другую директорию, выражения в import
будут не действительны.
В данном случае использование псевдонимов позволит уйти от громоздких выражений в import
и сделать наш код гораздо чище. Для того чтобы подготовить проект к использованию псевдонимов необходимо добавить baseUrl и path свойства вtsconfig.json
:
/ tsconfig.json
{
"compilerOptions": {
...
"baseUrl": "src",
"paths": {
"@app/*": ["app/*"],
"@env/*": ["environments/*"]
}
}
}
С этими изменениями достаточно просто управлять подключаемыми модулями:
import { Component, OnInit } from "@angular/core";
import { Observable } from "rxjs/Observable";
/* глобально доступные компоненты */
import { SomeService } from "@app/core";
import { environment } from "@env/environment";
/* локально доступные компоненты используют относительный путь*/
import { LocalService } from "./local.service";
@Component({
/* ... */
})
export class ExampleComponent implements OnInit {
constructor(
private someService: SomeService,
private localService: LocalService
) { }
}
В данном примере импорт SomeService
производится напрямую из @app/core
вместо громоздкого выражения (например @app/core/some-package/some.service
). Это возможно благодаря ре-экспорту публичных компонентов в основном файле index.ts
. Желательно создать файл index.ts
на каждый пакет в котором нужно произвести реэкспорт всех публичных модулей:
// index.ts
export * from "./core.module";
export * from "./auth/auth.service";
export * from "./user/user.service";
export * from "./some-service/some.service";
Core, Shared и Feature модули
Для более гибкого управления составными частями приложения довольно часто в литературе и различных интернет ресурсах рекомендуют разносить видимость его компонентов. В этом случае управление составными частями приложения упрощается. Наиболее часто используется следующее разделение: Core, Shared и Feature модули.
CoreModule
Основное предназначение CoreModule — описание сервисов, которые будут иметь один экземпляр на все приложение (т.е. реализуют паттерн синглтон). К таким часто относятся сервис авторизации или сервис для получения информации о пользователе. Пример CoreModule:
import { NgModule, Optional, SkipSelf } from "@angular/core";
import { CommonModule } from "@angular/common";
import { HttpClientModule } from "@angular/common/http";
/* сервисы */
import { SomeSingletonService } from "./some-singleton/some-singleton.service";
@NgModule({
imports: [CommonModule, HttpClientModule],
declarations: [],
providers: [SomeSingletonService]
})
export class CoreModule {
/* удостоверимся что CoreModule импортируется только одним NgModule the AppModule */
constructor(
@Optional()
@SkipSelf()
parentModule: CoreModule
) {
if (parentModule) {
throw new Error("CoreModule is already loaded. Import only in AppModule");
}
}
}
SharedModule
В данном модуле описываются простые компоненты. Эти компоненты не импортируют и не внедряют зависимости из других модулей в свои конструкторы. Они должны получать все данные через атрибуты в шаблоне компонента. SharedModule
не имеет никакой зависимости от остальной части нашего приложения.Это также идеальное место для импорта и реэкспорта компонентов Angular Material или других UI библиотек.
import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";
import { MdButtonModule } from "@angular/material";
/*экспортируемые компоненты */
import { SomeCustomComponent } from "./some-custom/some-custom.component";
@NgModule({
imports: [CommonModule, FormsModule, MdButtonModule],
declarations: [SomeCustomComponent],
exports: [
/* компоненты Angular Material*/
CommonModule,
FormsModule,
MdButtonModule,
/* компоненты проекта */
SomeCustomComponent
]
})
export class SharedModule { }
FeatureModule
Здесь можно повторить Angular style guide. Для каждой независимой функции приложения создается отдельный FeatureModule. FeatureModule должны импортировать сервисы только из CoreModule
. Если некоторому модулю понадобилось импортировать сервис из другого модуля, возможно, этот сервис необходимо вынести в CoreModule
.
В некоторых случаях возникает потребность в использовании сервиса только некоторыми модулями и нет необходимости выносить его в CoreModule
. В этом случае можно создать особый SharedModule
, который будет использоваться только в этих модулях.
Основное правило, используемое при создании модулей — попытаться создать модули, которые не зависят от каких-либо других модулей, а только от сервисов, предоставляемых CoreModule
и компонентов, предоставляемых SharedModule
.
Это позволит коду разрабатываемых приложений быть более чистым, простым в поддержке и расширении. Это также уменьшает усилия, необходимые для рефакторинга. Если следовать данному правилу, можно быть уверенным, что изменения одном модуле не смогут повлиять или разрушить остальную часть нашего приложения.
Список литературы
- https://github.com/ngrx/store
- http://stepansuvorov.com/blog/2017/06/angular-rxjs-unsubscribe-or-not-unsubscribe/
- https://medium.com/@tomastrajan/6-best-practices-pro-tips-for-angular-cli-better-developer-experience-7b328bc9db81
- https://habr.com/post/336280/
- https://angular.io/docs
Автор: avkazakov