Хабрахабр

[Из песочницы] Несколько советов по Angular

В настоящее время множество проектов завершено. Прошло уже достаточно времени с выхода обновленного Angular. Каждый разработчик и/или команда либо уже сформировали свои style guides и best practice либо используют чужие. От "getting started" множество разработчиков уже перешло к осмысленному использованию этого фреймворка, его возможностей, научились обходить подводные камни. Но в тоже время часто приходится сталкиваться с большим количеством кода на Angular, в котором не используются многие возможности этого фреймворка и/или написанного в стиле AngularJS.

В статье рассматривается использование "перехватчиков" (Interceptors) HTTP запросов, использование Route Guards для ограничения доступа пользователям. В данной статье представлены некоторые возможности и особенности использования фреймворка Angular, которые, по скромному мнению автора, недостаточно освещены в руководствах или не используются разработчиками. Также представлены некоторые рекомендации по оформлению кода проектов, которые возможно позволят сделать код проектов чище и понятнее. Даны некоторые рекомендации по использованию RxJS и управлению состоянием приложения. Автор надеется, что данная статья будет полезна не только разработчикам, которые только начинают знакомство с Angular, но и опытным разработчикам.

Работа с HTTP

В этой части рассматриваются некоторые возможности фреймворка Angular по работе с HTTP запросами. Построение любого клиентского Web приложения производится вокруг HTTP запросов к серверу.

Используем Interceptors

Или необходимо изменить каждый ответ. В некоторых случаях может потребоваться изменить запрос до того, как он попадет на сервер. 3 появился новый HttpClient. Начиная с версии Angular 4. 3!, это была одна из наиболее ожидаемых недостающих возможностей AngularJs, которые не перекочевали в Angular). В нем добавлена возможность перехватывать запрос с помощью interceptors (Да, их наконец-то вернули только в версии 4. Это своего рода промежуточное ПО между http-api и фактическим запросом.

Чтобы получить ответ с сервера, часто нужно добавить какой-то механизм проверки подлинности в запрос. Одним из распространенных вариантов использования может быть аутентификация. Эта задача с использованием interceptors решается достаточно просто:

import 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

Довольно часто приходится решать задачу ограничения видимости пути, по которому располагаются определенные компоненты, в зависимости от некоторых условий. Маршрутизация позволяет сопоставлять запросы к приложению с определенными ресурсами внутри приложения. В качестве примера, приведен сервис, который будет реализовывать route guard. В этих случаях в Angular есть механизм ограничения перехода. Упрощенный вариант сервиса, который выполняет проверку авторизован ли пользователь, можно представить в виде: Допустим, в приложении аутентификация пользователя реализована с использованием 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; }
}

Метод canActivate возвращает логическое значение, которое может быть использовано в условии активации маршрута. Реализация AuthGuardService использует описанный выше AuthService для проверки авторизации пользователя.

Для этого при объявлении Routes мы указываем наш сервис, наследующий CanActivate интерфейс, в секции canActivate: Теперь мы можем применить созданный Route Guard к любому маршруту или пути.

export const ROUTES: Routes = [ { path: "", component: HomeComponent }, { path: "profile", component: UserComponent, canActivate: [AuthGuardService] }, { path: "**", redirectTo: "" }
];

AuthGuard, описанный ранее передается аргументом в данное свойство canActivate. В этом случае маршрут /profile имеет дополнительное конфигурационное значение canActivate. Если пользователь авторизован он получит доступ к пути /profile, в противном случае он будет перенаправлен на путь /login. Далее метод canActivate будет вызываться каждый раз, когда кто-нибудь попытается получить доступ к пути /profile.

Если нужно защитить активацию и загрузку компонента, то для такого случая можем использовать canLoad. Следует знать, что canActivate по прежнему позволяет активировать компонент по данному пути, но не позволяет перейти на него. Реализация CanLoad может быть сделана по аналогии.

Готовим RxJS

RxJS — это библиотека для работы с асинхронными и основанными на событиях потоками данных, с использованием наблюдаемых последовательностей. Angular построен на основе 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"); }
}

В данном случае для HTTP запросов использование async pipe практически не предоставляет никаких преимуществ, кроме одного — async отменит запрос, если данные больше не нужны, а не завершит обработку запроса. Подписываясь через шаблон, мы избегаем утечек памяти, потому что Angular автоматически отменяет подписку на Observable, когда компонент разрушается.

Поведение Observables может быть расширено повтором (например, retry в http запросе), обновлением на основе таймера или предварительным кешированием. Многие возможности Observables не используются при подписке вручную.

Используем $ для обозначения 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)

Для ответа на этот вопрос сначала нужно определиться какой вид Observable в данный момент используется. Наиболее частый вопрос, который возникает у разработчика при недолгом знакомстве с Angular — когда все таки нужно отписываться, а когда нет. В Angular существуют 2 вида Observable — финитные и инфинитные, одни производят конечное, другие, соответственно, бесконечное число значений.

Http Observable — финитный, а слушатели/наблюдатели (listeners) DOM событий — это инфинитные Observable.

Если подписываемся в ручном режиме на финитный Observable, то отписываться не обязательно, об этом позаботится RxJS. Если подписка на значения инфинитного Observable производится вручную (без использования async pipe), то в обязательном порядке должна производится отписка. В случае финитных 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(); }
}

Ниже, более детально приведены случаи, в которых нужно отписываться

  1. Необходимо отписываться от формы и от отдельных контролов, на которые подписались:

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(); }
}

  1. 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 }
}

  1. Бесконечные последовательности. Примерами могут служить последовательности созданные с помощью 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

Они производят одно и тоже действие — отписку от Observable по окончании какого-нибудь условия, разница лишь в принимаемых значениях. Для упрощения работы с инфинитными Observables в RxJS существует две удобные функции — это takeUntil и takeWhile. takeWhile принимает boolean, а takeUntilSubject.
Пример 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; }
}

В данном примере отписываемся при уничтожении компонента.
Пример takeUntil: В этом случае при изменении флага alive произойдет отписка от Observable.

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 вдохновлен Flux и Elm. Почему Redux?
Redux позиционирует себя как предсказуемый контейнер состояния (state) для JavaScript приложений.

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.select(): Далее производится внедрение Store сервиса в необходимые компоненты и сервисы.

// 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 будут не действительны. Что еще плохо в этом коде?

Для того чтобы подготовить проект к использованию псевдонимов необходимо добавить baseUrl и path свойства вtsconfig.json: В данном случае использование псевдонимов позволит уйти от громоздких выражений в import и сделать наш код гораздо чище.

/ 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 ) { }
}

Это возможно благодаря ре-экспорту публичных компонентов в основном файле index.ts. В данном примере импорт SomeService производится напрямую из @app/core вместо громоздкого выражения (например @app/core/some-package/some.service). Желательно создать файл 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

Для каждой независимой функции приложения создается отдельный FeatureModule. Здесь можно повторить Angular style guide. Если некоторому модулю понадобилось импортировать сервис из другого модуля, возможно, этот сервис необходимо вынести в CoreModule. FeatureModule должны импортировать сервисы только из CoreModule.

В этом случае можно создать особый SharedModule, который будет использоваться только в этих модулях.
Основное правило, используемое при создании модулей — попытаться создать модули, которые не зависят от каких-либо других модулей, а только от сервисов, предоставляемых CoreModule и компонентов, предоставляемых SharedModule. В некоторых случаях возникает потребность в использовании сервиса только некоторыми модулями и нет необходимости выносить его в CoreModule.

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

Список литературы

  1. https://github.com/ngrx/store
  2. http://stepansuvorov.com/blog/2017/06/angular-rxjs-unsubscribe-or-not-unsubscribe/
  3. https://medium.com/@tomastrajan/6-best-practices-pro-tips-for-angular-cli-better-developer-experience-7b328bc9db81
  4. https://habr.com/post/336280/
  5. https://angular.io/docs
Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть