Blog
Pisanie projektu w Angularze 1.5 w ES6/ES2015 [“NG 1.5 z placu boju” 5/7]
Ten post to 5-ta część serii “Angular 1.5 z placu boju”, prezentującej architekturę opartej na komponentach, “gotowej na NG 2” aplikacji w Angularze 1.5, nad którą pracujemy.
Wprowadzona w Angularze 1.5 metoda
module.component()
może wydawać się kosmetycznym dodatkiem, jednak podejście, które ta metoda promuje (a które wykorzystujemy “do oporu”) owocuje zupełnie innym typem architektury niż opisywana w większości “klasycznych” tutoriali do Angulara, mam więc nadzieję, że uda Ci się na bazie naszych doświadczeń paru rzeczy dowiedzieć.Spis Treści:
- Czy opłaca się startować z nową aplikacją w Angularze 1.5?
- Aplikacja w Angularze 1.5 jako drzewo komponentów
- Komunikacja pomiędzy komponentami w Angularze 1.5
- Elastyczna struktura projektu w Angularze 1.5 (architektura “fraktalna”)
- Pisanie projektu w Angularze 1.5 w ES6/ES2015 [TEN POST]
- Testowanie jednostkowe komponentów Angulara 1.5 – szczegółowy przewodnik [WKRÓTCE]
- Testy E2E opartej na komponentach aplikacji w Angularze 1.5 [WKRÓTCE]
Jak mogłeś zauważyć czytając poprzednie artykuły tej serii, używamy w naszym projekcie ES6. Zmienia to sposób pisania komponentów Angulara 1.5 (oraz innych konstruktów takich jak np. serwisy albo testy), przeznaczę więc ten post by pokrótce omówić jak główne elementy składowe NG 1.5 wyglądają, gdy używa się ES6-ki.
Najpierw jednak krótka dygresja:
Czemu ES6?
Istnieją dwie alternatywy, jeśli chodzi o NG 1.5: ES5 oraz TypeScript. Czemu więc akurat ES6, a nie któraś z tych alternatyw?
czemu nie ES5:
Tu akurat nie trzeba się zastanawiać. Jak za chwilę zobaczysz, ES6 skutkuje o wiele ładniejszą składnią Angulara i dodaje do Javascript-u tyle użytecznych funkcjonalności, że jedynym racjonalnym argumentem za ES5-ką może być chęć uniknięcia transpilacji. Jednak w nowoczesnym Angularowym projekcie transpilacja jest i tak nie do uniknięcia (np. w przypadku minifikacji, preprocesora CSS czy cache-owania szablonów Angulara), tak więc dodanie do niej kolejnego niewielkiego kroku by móc używać ES6 nie jest specjalnym utrudnieniem.
czemu nie TypeScript:
Jak wspomniałem w pierwszym poście tej serii, większość naszeg zespołu nie miała uprzedniej styczności z Angularem, nie chcieliśmy więc wprowadzać zbyt wielu nowości na raz. Ponadto, TS nie jest tak ściśle zintegrowany z NG 1.5 jak z NG 2, pojawiłby się więc dodatkowy narzut (związany z konfiguracją naszego pipeline buildowego, instalowaniem typowań itd.). Łatwo jest też zmigrować z ES6 do TS, zdecydowaliśmy więc zacząć od ES6-ki i potencjalnie przejść na TS w przyszłości jeśli nasza aplikacja stanie się dostatecznie złożona, by korzyści ze statycznego typowania przeważyły nad minusami bardziej skomplikowanej integracji.
Jak pisać Angularowy kod w ES6?
Przejdźmy przez wszystkie konstrukty, jakich używamy w NG 1.5 i przyjrzyjmy się, jak ES6 wpływa na sposób ich pisania:
Serwisy
Angularowy serwis bardzo fajnie mapuje się na “czystą” ES6-kową klasę.
export class SomeService { constructor(dependencyInjectedByAngularDI, anotherSuchDependency) {} someMethod() {...} get someGetter() {...} set someSetter(newValue) {...} }
Zależności, jak możesz zobaczyć w powyższym fragmencie kodu, wstrzykiwane są poprzez konstruktor klasy. Najlepszym sposobem, by zarejestrować taką klasę w systemie DI Angulara, jest użycie providera module().service
, tak jak poniżej:
import { SomeService } from 'some.service'; angular.module('some.module').service('someService', SomeService);
Angularowy provider module().factory
okazuje się niemal zbędny, gdy używamy ES6-ki (stosujemy go jedynie w pewnych specyficznych przypadkach brzegowych, np. gdy potrzebujemy zarejestrować funkcję z zależnościami – co będzie dokładniej opisane za chwilę).
Komponenty
Komponenty w NG 1.5 składają się z kilku elementów: szablonu, kontrolera i bindingów, nie mapują się więc tak dobrze na ES6-kowe klasy jak to było w przypadku serwisów – nadal musimy dostarczyć “klasyczny” obiekt konfiguracyjny dla komponentu:
const SOME_CONST = 'SOME CONST'; export const SomeComponent = { bindings: { someBinding: '<' }, controller: class { constructor(dependencyInjectedByAngularDI, anotherSuchDependency) {} someMethod() {...} get someGetter() {} }, template: `a multi-line template{{ $ctrl.someGetter }}${ SOME_CONST }` };
Jednakże, dzięki wykorzystaniu różnych elementów ES6 możemy sprawić, że obiekt konfiguracyjny komponentu będzie o wiele ładniejszy:
- dla kontrolera komponentu wykorzystujemy anonimową klasę ES6 (z zależnościami wstrzykiwanymi porzez konstruktor klasy, tak samo jak w przypadku serwisu)
- wykorzystujemy nowy ES6-kowy mechanizm template strings do wielolinijkowych, inlineowych szablonów
- używamy getterów by szablony były czytelniejsze (wprawdzie gettery były wprowadzone już w ES5, uważam jednak, że i tak warte są wzmianki)
- wykorzystujemy ES6-kową interpolację stringów by embedować stałe, wartości zwracane z funkcji pomocniczych albo by iterować po enumach w inlineowych szablonach, bez konieczności przekazywania ich poprzez kontroler komponentu
Filtry
Filtry są częścią Angulara, na którą składnia ES6 ma najmniejszy wpływ. Wciąż wyglądają niemal identycznie jak poprzednio (nie licząc nowej, zwięźlejszej skladni arrow function):
export function SomeFilter() { return (value) => { ... }; }
Funkcje i obiekty pomocnicze
Oprócz komponentów, serwisów i filtrów często wykorzystujemy w naszym projekcie “czyste” funkcje JS (a czasem także obiekty). Dzięki ES6 jest to naprawdę łatwe, zwłaszcza w najprostszym przypadku:
proste funkcje i obiekty, nie wymagające stubbingu
W takim przypadku w ogóle nie potrzebujemy Angularowego Dependency Injector-a / systemu modułów. Możemy po prostu wyeksportować nasze helpery przy użyciu modułów ES6, jak poniżej:
export function someHelperFunction(someParam) { ... };
export const SomeHelperObject = { someField: 'someValue', someMethod: (someParam) => { ... } };
A następnie zaimportować je w naszym komponencie – ponownie z użyciem systemu modułów ES6 – w taki sposób jak poniżej:
import { someHelperFunction } from 'some-helper-function'; import { SomeHelperObject } from 'some-helper-object'; export const SomeComponent = { controller: class { someMethod() { let someValue = someHelperFunction(someParam); let anotherValue = SomeHelperObject.someMethod(someParam); ... } }, template: ` ... ` };
Powyższy sposób jest najprostszy, ma jednak wadę – nasze helpery są zaszyte wewnątrz kodu naszego komponentu i w związku z tym nie mogą być stubowane ani mockowane, co może być problematyczne w przypadku bardziej rozbudowanych helperów. W takim przypadku możemy użyć innego podejścia:
złożone funkcje i obiekty, wymagające stubowania albo mockowania
W takim przypadku nie importujemy naszych helperów bezpośrednio, ale przy pomocy Angularowego systemu modułów / DI, jako stałe:
import { someHelperFunction } from 'some-helper-function'; import { SomeHelperObject } from 'some-helper-object'; angular.module('some.module') .constant('someHelperFunction', someHelperFunction) .constant('SomeHelperObject', SomeHelperObject);
Stałe te mogą być następnie wstrzyknięte do naszego komponentu, co sprawia, że da się je w teście zestubować:
export const SomeComponent = { controller: class { constructor(someHelperFunction, SomeHelperObject) { this.someHelperFunction = someHelperFunction; this.SomeHelperObject = SomeHelperObject; } someMethod() { let someValue = this.someHelperFunction(someParam); let anotherValue = this.SomeHelperObject.someMethod(someParam); ... } }, template: ` ... ` };
Ostatnim, najbardziej złożonym przypadkiem jest sytuacja, gdy nasze helpery także mają własne zależności. Wymaga ona jeszcze innego podejścia.
funkcje i obiekty pomocnicze z zależnościami
Helpery z zależnościami są jedynym (stosunkowo rzadkim) przypadkiem, w którym musimy użyć Angularowych fabryk (factory):
export function someHelperFunctionFactory(dependencyInjectedByAngularDI, anotherSuchDependency) { return (someParam) => { ... }; };
export function someHelperObjectFactory(dependencyInjectedByAngularDI, anotherSuchDependency) { return { someField: 'someValue', someMethod: (someParam) => { ... } }; };
import { someHelperFunctionFactory } from 'some-helper-function'; import { someHelperObjectFactory } from 'some-helper-object'; angular.module('some.module') .factory('someHelperFunction', someHelperFunctionFactory) .factory('SomeHelperObject', someHelperObjectFactory);
Wstrzykujemy następnie te fabryki do naszego komponentu i korzystamy z nich w identyczny sposób jak w przypadku helperów wstrzykiwanych jako stałe:
export const SomeComponent = { controller: class { constructor(someHelperFunction, SomeHelperObject) { this.someHelperFunction = someHelperFunction; this.SomeHelperObject = SomeHelperObject; } someMethod() { let someValue = this.someHelperFunction(someParam); let anotherValue = this.SomeHelperObject.someMethod(someParam); ... } }, template: ` ... ` };
Klasy nie będące singletonami
Innym specjalnym przypadkiem są klasy nie będące singletonami. W normalnym przypadku, klasy są doskonałymi kandydatami na Angularowe serwisy, jednakże serwisy NG cechuje jeden atrybut – są one singletonami. Jeśli potrzebujemy osobnej instancji klasy dla każdej instancji naszego komponentu, nie możemy niestety wykorzystać serwisu. W takim przypadku musimy skorzystać z podobnego zestawu technik jak w przypadku funkcji i obiektów pomocniczych:
proste klasy, których nie musimy stubować
W takim przypadku po prostu eksportujemy / importujemy klasę przy pomocy systemu modułów ES6, z pominięciem Angularowego injectora:
export class SomeHelperClass { constructor(someParam) { ... } someMethod() { ... } }
import { SomeHelperClass } from 'some-helper-class'; export const SomeComponent = { controller: class { constructor() { this.helperInstance = new SomeHelperClass(someParam); } someMethod() { let someValue = this.helperInstance.someMethod(); ... } }, template: ` ... ` };
Jedyna różnica pomiędzy klasą a funkcją lub obiektem jest taka, że w przypadku klasy dodatkowo musimy utworzyć jej instancję poprzez new
. Najlepszym do tego miejscem jest konstruktor kontrolera komponentu, gdzie tworzymy instancję klasy i przypisujemy jej do pola tego komponentu.
złożone klasy, wymagające stubowania w testach
W takim przypadku, jesteśmy zmuszeni polegać na Angularowym systemie DI. Jak poprzednio, w przypadku funkcji i obiektów pomocniczych, wstrzykujemy taką klasę jako stałą:
import { SomeHelperClass } from 'some-helper-class'; angular.module('some.module').constant('SomeHelperClass', SomeHelperClass);
export const SomeComponent = { controller: class { constructor(SomeHelperClass) { this.helperInstance = new SomeHelperClass(someParam); } someMethod() { let someValue = this.helperInstance.someMethod(); ... } }, template: ` ... ` };
klasy z zależnościami
Jeśli klasa nie będąca singletonem posiada swoje własne zależności, musimy enkapsulować ją wewnątrz funkcji-fabryki:
export function someHelperClassFactory(dependencyInjectedByAngularDI, anotherSuchDependency) { return class { constructor(someParam) { ... } }; };
Następnie, możemy ją wstrzyknąć jako Angularowe factory:
import { someHelperClassFactory } from 'some-helper-class'; angular.module('some.module').factory('SomeHelperClass', someHelperClassFactory);
I, tak jak poprzednio, musimy w naszym komponencie utworzyć jej instancję, poprzez ręczne wywołanie new
i przypisanie instancji klasy do pola komponentu:
export const SomeComponent = { controller: class { constructor(SomeHelperClass) { this.helperInstance = new SomeHelperClass(someParam); } someMethod() { let someValue = this.helperInstance.someMethod(); ... } }, template: ` ... ` };
Testy Jednostkowe
Ostatnim obszarem, w którym ES6 wpływa na sposób w jaki piszesz swój Angularowy kod, są Testy Jednostkowe.
Pierwszą i najwyraźniej dostrzegalną rzeczą jest ładniejsza, zwięźlejsza składnia, którą umożliwiają ES6-kowe arrow functions:
describe('SomeComponent', () => { beforeEach(() => { ... }); it('does something cool', () => { ... }); });
Kolejną rzeczą jest sposób w jaki możesz testować serwisy.
“Klasycznym” sposobem testowania serwisu jest pobranie jego instancji poprzez Angularowy injector:
describe('SomeService', () => { let service; beforeEach(angular.mock.module('some.module', ($provide) => { $provide.value('someDependency', mockedDependency); $provide.value('anotherDependency', anotherMockedDependency); })); beforeEach(angular.mock.inject((someService) => { service = someService; })); it('does something cool', () => { expect(service).toDoSomethingCool(); }); });
Wymaga to nieco scaffoldingu: użycia Angularowego mock.module
, dostarczenia m_mocków_ zależności testowanego serwisu poprzez Angularowy system DI (przy pomocy $provide
) oraz użycia Angularowego mock.inject
w celu pobrania instancji serwisu.
Z pomocą modułów ES6 i serwisami jako ES6-kowymi klasami, scaffolding staje się prostszy – potrzebujemy jedynie zaimportować nasz serwis i ręcznie utworzyć jego instancję przy pomocy słowa kluczowego new
, podając wszystkie zmockowane zależności poprzez konstruktor:
import { SomeService } from 'some.service'; describe('SomeService', () => { let service; beforeEach(() => { service = new SomeService(mockedDependency, anotherMockedDependency); }); it('does something cool', () => { expect(service).toDoSomethingCool(); }); });
Powyższy test nie wymaga nawet w ogóle Angulara.
Wadą takiego podejścia jest fakt, że wciąż jesteś zmuszony do użycia standardowego Angularowego scaffoldingu w pzypadku komponentów oraz niektórych serwisów z bardziej złożonymi zależnościami (np. jeśli chcesz wykorzystać httpBackend do mockowania AJAX-owych requestów). Pozostaje Twoim wyborem, czy wolisz maksymalną prostotę, czy raczej maksymalną spójność Twoich testów (w naszym zespole skłaniamy się nieco bardziej ku spójności).
(więcej o testowaniu komponentów NG 1.5 w kolejnym poście)
Wnioski
ES6 sprawia, że kod aplikacji w Angularze 1.5 staje się ładniejszy, czystszy i zwięźlejszy. ES6 powinien być Twoim domyślnym wyborem gdy startujesz z nowym projektem NG 1.5.
Co Ty o tym sądzisz?
Który dialekt Javascriptu wykorzystujesz w swoim Angularowym projekcie? Co sądzisz o używaniu ES6 vs TypeScript? Podziel się swoją opinią w komentarzach poniżej!