Angular vs. React: co jest lepsze do tworzenia stron internetowych?
Opublikowany: 2022-03-11Istnieje niezliczona ilość artykułów, w których dyskutuje się, czy React czy Angular to lepszy wybór do tworzenia stron internetowych. Czy potrzebujemy jeszcze jednego?
Powodem, dla którego napisałem ten artykuł, jest to, że żaden z opublikowanych już artykułów – chociaż zawierają świetne spostrzeżenia – nie jest wystarczająco dogłębny, aby praktyczny programista front-end mógł zdecydować, który z nich może odpowiadać jego potrzebom.
W tym artykule dowiesz się, w jaki sposób Angular i React mają na celu rozwiązywanie podobnych problemów front-endowych, choć przy użyciu bardzo różnych filozofii, i czy wybór jednej lub drugiej jest tylko kwestią osobistych preferencji. Aby je porównać, zbudujemy tę samą aplikację dwa razy, raz za pomocą Angulara, a potem ponownie za pomocą Reacta.
Nieterminowe ogłoszenie Angulara
Dwa lata temu napisałem artykuł o React Ecosystem. W artykule argumentowano między innymi, że Angular padł ofiarą „śmierci przez zapowiedź”. W tamtych czasach wybór między Angularem a prawie wszystkim innym był łatwy dla każdego, kto nie chciał, aby jego projekt działał na przestarzałym frameworku. Angular 1 był przestarzały, a Angular 2 nie był nawet dostępny w wersji alfa.
Z perspektywy czasu obawy były mniej lub bardziej uzasadnione. Angular 2 zmienił się diametralnie, a nawet przeszedł gruntowną przeróbkę tuż przed ostatecznym wydaniem.
Dwa lata później mamy Angular 4 z obietnicą względnej stabilności odtąd.
Co teraz?
Angular vs. React: porównanie jabłek i pomarańczy
Niektórzy twierdzą, że porównywanie Reacta i Angulara jest jak porównywanie jabłek do pomarańczy. Podczas gdy jedna jest biblioteką zajmującą się widokami, druga jest pełnoprawnym frameworkiem.
Oczywiście większość programistów Reacta doda kilka bibliotek do Reacta, aby przekształcić go w kompletny framework. Z drugiej strony, wynikowy przepływ pracy tego stosu często nadal bardzo różni się od Angulara, więc porównywalność jest nadal ograniczona.
Największa różnica polega na zarządzaniu państwem. Angular jest dostarczany z dołączonym wiązaniem danych, podczas gdy obecnie React jest zwykle rozszerzany przez Redux, aby zapewnić jednokierunkowy przepływ danych i pracować z niezmiennymi danymi. Są to przeciwstawne podejścia same w sobie, a teraz toczą się niezliczone dyskusje na temat tego, czy zmienne/wiązanie danych jest lepsze czy gorsze niż niezmienne/jednokierunkowe.
Równe pole gry
Ponieważ React jest znany z łatwiejszego hackowania, zdecydowałem, aby na potrzeby tego porównania zbudować konfigurację React, która dość dokładnie odzwierciedla Angulara, aby umożliwić równoległe porównanie fragmentów kodu.
Niektóre funkcje Angulara, które wyróżniają się, ale domyślnie nie są dostępne w React to:
Funkcja | Pakiet kątowy | Biblioteka reakcji |
---|---|---|
Wiązanie danych, wstrzykiwanie zależności (DI) | @kątowy/rdzeń | MobX |
Obliczone właściwości | rxjs | MobX |
Routing oparty na komponentach | @kątowy/router | React Router v4 |
Elementy konstrukcyjne materiałów | @kątowe/materiałowe | Reaguj przybornik |
CSS ograniczony do komponentów | @kątowy/rdzeń | Moduły CSS |
Walidacje formularzy | @kątowe/formy | Stan formularza |
Generator projektów | @kątowe/cli | Reaguj skrypty TS |
Wiązanie danych
Powiązanie danych jest prawdopodobnie łatwiejsze niż podejście jednokierunkowe. Oczywiście można by pójść w zupełnie odwrotnym kierunku i użyć Reduxa lub mobx-state-tree z Reactem, a ngrx z Angularem. Ale to byłby temat na inny post.
Obliczone właściwości
Jeśli chodzi o wydajność, zwykłe gettery w Angular po prostu nie wchodzą w rachubę, ponieważ są wywoływane przy każdym renderowaniu. Możliwe jest użycie BehaviorSubject z RsJS, który wykonuje zadanie.
Dzięki React możliwe jest użycie @computed z MobX, który osiąga ten sam cel, z prawdopodobnie nieco ładniejszym interfejsem API.
Wstrzykiwanie zależności
Wstrzykiwanie zależności jest dość kontrowersyjne, ponieważ jest sprzeczne z obecnym paradygmatem React dotyczącym programowania funkcjonalnego i niezmienności. Jak się okazuje, pewien rodzaj wstrzykiwania zależności jest prawie niezbędny w środowiskach wiążących dane, ponieważ pomaga w odsprzęganiu (a tym samym mockowaniu i testowaniu) tam, gdzie nie ma oddzielnej architektury warstwy danych.
Kolejną zaletą DI (obsługiwanej w Angular) jest możliwość posiadania różnych cykli życia różnych sklepów. Większość obecnych paradygmatów Reacta używa pewnego rodzaju globalnego stanu aplikacji, który mapuje do różnych komponentów, ale z mojego doświadczenia wynika, że bardzo łatwo jest wprowadzić błędy podczas czyszczenia stanu globalnego po odmontowaniu komponentu.
Posiadanie sklepu, który jest tworzony po zamontowaniu komponentu (i jest bezproblemowo dostępny dla dzieci tego komponentu) wydaje się być naprawdę użytecznym i często pomijanym pomysłem.
Po wyjęciu z pudełka w Angularze, ale dość łatwo odtwarzalny również z MobX.
Rozgromienie
Routing oparty na komponentach umożliwia komponentom zarządzanie własnymi trasami podrzędnymi zamiast posiadania jednej dużej globalnej konfiguracji routera. Takie podejście w końcu dotarło do react-router
w wersji 4.
Wygląd materiału
Zawsze fajnie jest zacząć od niektórych komponentów wyższego poziomu, a projektowanie materiałów stało się czymś w rodzaju powszechnie akceptowanego wyboru domyślnego, nawet w projektach innych niż Google.
Celowo wybrałem React Toolbox zamiast zwykle zalecanego interfejsu Material UI, ponieważ Material UI ma poważne problemy z wydajnością związane z podejściem inline-CSS, które planują rozwiązać w następnej wersji.
Poza tym PostCSS/cssnext używany w React Toolbox zaczyna i tak zastępować Sass/LESS.
CSS w zakresie
Klasy CSS są czymś w rodzaju zmiennych globalnych. Istnieje wiele podejść do organizowania CSS w celu zapobiegania konfliktom (w tym BEM), ale istnieje wyraźna tendencja do korzystania z bibliotek, które pomagają przetwarzać CSS w celu zapobiegania tym konfliktom bez potrzeby opracowywania przez programistę front-end skomplikowanych systemów nazewnictwa CSS.
Walidacja formularza
Walidacja formularzy to nietrywialna i bardzo szeroko stosowana funkcja. Dobrze mieć te objęte biblioteką, aby zapobiec powtarzaniu kodu i błędom.
Generator projektów
Posiadanie generatora CLI dla projektu jest nieco wygodniejsze niż klonowanie szablonów z GitHub.
Ta sama aplikacja, zbudowana dwa razy
Dlatego stworzymy tę samą aplikację w React i Angular. Nic spektakularnego, po prostu Shoutboard, który pozwala każdemu publikować wiadomości na wspólnej stronie.
Możesz wypróbować aplikacje tutaj:
- Deskorolka kątowa
- Reaguj na tablicę
Jeśli chcesz mieć cały kod źródłowy, możesz go pobrać z GitHub:
- Źródło kątowe tablicy rozdzielczej
- Źródło reakcji na tablicę
Zauważysz, że użyliśmy również TypeScript dla aplikacji React. Zalety sprawdzania typu w TypeScript są oczywiste. A teraz, gdy w TypeScript 2 pojawiła się lepsza obsługa importu, async/await i rest, co pozostawia Babel/ES7/Flow w kurzu.
Dodajmy również klienta Apollo do obu, ponieważ chcemy używać GraphQL. To znaczy, REST jest świetny, ale po mniej więcej dekadzie starzeje się.
Bootstrap i routing
Najpierw przyjrzyjmy się punktom wejścia obu aplikacji.
Kątowy
const appRoutes: Routes = [ { path: 'home', component: HomeComponent }, { path: 'posts', component: PostsComponent }, { path: 'form', component: FormComponent }, { path: '', redirectTo: '/home', pathMatch: 'full' } ] @NgModule({ declarations: [ AppComponent, PostsComponent, HomeComponent, FormComponent, ], imports: [ BrowserModule, RouterModule.forRoot(appRoutes), ApolloModule.forRoot(provideClient), FormsModule, ReactiveFormsModule, HttpModule, BrowserAnimationsModule, MdInputModule, MdSelectModule, MdButtonModule, MdCardModule, MdIconModule ], providers: [ AppService ], bootstrap: [AppComponent] })
@Injectable() export class AppService { username = 'Mr. User' }
W zasadzie wszystkie komponenty, których chcemy użyć w aplikacji, muszą przejść do deklaracji. Wszystkie biblioteki innych firm do importu i wszystkie sklepy globalne do dostawców. Komponenty dla dzieci mają dostęp do tego wszystkiego, z możliwością dodania większej ilości lokalnych rzeczy.
Reagować
const appStore = AppStore.getInstance() const routerStore = RouterStore.getInstance() const rootStores = { appStore, routerStore } ReactDOM.render( <Provider {...rootStores} > <Router history={routerStore.history} > <App> <Switch> <Route exact path='/home' component={Home as any} /> <Route exact path='/posts' component={Posts as any} /> <Route exact path='/form' component={Form as any} /> <Redirect from='/' to='/home' /> </Switch> </App> </Router> </Provider >, document.getElementById('root') )
Komponent <Provider/>
jest używany do wstrzykiwania zależności w MobX. Zapisuje sklepy w kontekście, aby komponenty React mogły je później wstrzyknąć. Tak, kontekst React może być (prawdopodobnie) bezpiecznie używany.
Wersja React jest nieco krótsza, ponieważ nie ma deklaracji modułów - zwykle wystarczy importować i jest gotowa do użycia. Czasami tego rodzaju twarda zależność jest niepożądana (testowanie), więc dla globalnych sklepów singleton musiałem użyć tego kilkudziesięcioletniego wzorca GoF:
export class AppStore { static instance: AppStore static getInstance() { return AppStore.instance || (AppStore.instance = new AppStore()) } @observable username = 'Mr. User' }
Router Angulara można wstrzykiwać, dzięki czemu można go używać z dowolnego miejsca, nie tylko z komponentów. Aby osiągnąć to samo w React, używamy pakietu mobx-react-router i wstrzykujemy routerStore
.
Podsumowanie: Bootstrapowanie obu aplikacji jest dość proste. React ma tę przewagę, że jest prostszy, używając tylko importów zamiast modułów, ale, jak zobaczymy później, te moduły mogą być całkiem przydatne. Ręczne robienie singli jest trochę uciążliwe. Jeśli chodzi o składnię deklaracji routingu, JSON vs. JSX to tylko kwestia preferencji.
Linki i imperatywna nawigacja
Tak więc są dwa przypadki zmiany trasy. Deklaratywny, wykorzystujący elementy <a href...>
i imperatywny, wywołujący bezpośrednio API routingu (a tym samym lokalizacji).
Kątowy
<h1> Shoutboard Application </h1> <nav> <a routerLink="/home" routerLinkActive="active">Home</a> <a routerLink="/posts" routerLinkActive="active">Posts</a> </nav> <router-outlet></router-outlet>
Angular Router automatycznie wykrywa, który routerLink
jest aktywny i umieszcza na nim odpowiednią klasę routerLinkActive
, dzięki czemu można go stylizować.
Router używa specjalnego elementu <router-outlet>
do renderowania tego, co nakazuje bieżąca ścieżka. Możliwe jest posiadanie wielu <router-outlet>
, ponieważ zagłębiamy się w podkomponenty aplikacji.
@Injectable() export class FormService { constructor(private router: Router) { } goBack() { this.router.navigate(['/posts']) } }
Moduł routera może zostać wstrzyknięty do dowolnej usługi (w połowie magicznie przez jego typ TypeScript), a następnie private
deklaracja przechowuje go w instancji bez konieczności jawnego przypisywania. Użyj metody navigate
, aby przełączyć adresy URL.
Reagować
import * as style from './app.css' // … <h1>Shoutboard Application</h1> <div> <NavLink to='/home' activeClassName={style.active}>Home</NavLink> <NavLink to='/posts' activeClassName={style.active}>Posts</NavLink> </div> <div> {this.props.children} </div>
React Router może również ustawić klasę aktywnego łącza za pomocą activeClassName
.
W tym przypadku nie możemy podać nazwy klasy bezpośrednio, ponieważ została ona stworzona przez kompilator modułów CSS i musimy użyć helpera style
. Więcej o tym później.
Jak widać powyżej, React Router używa elementu <Switch>
wewnątrz elementu <App>
. Ponieważ element <Switch>
po prostu zawija i montuje bieżącą trasę, oznacza to, że pod-trasy bieżącego komponentu to po prostu this.props.children
. Więc to też można komponować.
export class FormStore { routerStore: RouterStore constructor() { this.routerStore = RouterStore.getInstance() } goBack = () => { this.routerStore.history.push('/posts') } }
Pakiet mobx-router-store
umożliwia również łatwe wstrzykiwanie i nawigację.
Podsumowanie: Oba podejścia do routingu są dość porównywalne. Angular wydaje się być bardziej intuicyjny, podczas gdy React Router ma nieco prostszą możliwość komponowania.
Wstrzykiwanie zależności
Udowodniono już, że korzystne jest oddzielenie warstwy danych od warstwy prezentacji. To, co staramy się tutaj osiągnąć za pomocą DI, to sprawić, aby komponenty warstw danych (tutaj nazywane modelem/store/service) podążały za cyklem życia komponentów wizualnych, a tym samym umożliwić wykonanie jednej lub wielu instancji takich komponentów bez konieczności dotykania globalnych stan. Ponadto powinno być możliwe mieszanie i dopasowywanie kompatybilnych warstw danych i wizualizacji.
Przykłady w tym artykule są bardzo proste, więc wszystkie rzeczy związane z DI mogą wydawać się przesadą, ale przydaje się, gdy aplikacja się rozrasta.
Kątowy
@Injectable() export class HomeService { message = 'Welcome to home page' counter = 0 increment() { this.counter++ } }
Tak więc każdą klasę można uczynić @injectable
, a jej właściwości i metody udostępnić komponentom.
@Component({ selector: 'app-home', templateUrl: './home.component.html', providers: [ HomeService ] }) export class HomeComponent { constructor( public homeService: HomeService, public appService: AppService, ) { } }
Rejestrując HomeService
u providers
komponentu, udostępniamy go wyłącznie temu komponentowi. Nie jest to teraz singleton, ale każda instancja komponentu otrzyma nową kopię, świeżą na montowaniu komponentu. Oznacza to brak przestarzałych danych z poprzedniego użycia.
W przeciwieństwie do tego, AppService
został zarejestrowany w app.module
(patrz wyżej), więc jest singletonem i pozostaje taki sam dla wszystkich komponentów, przez cały czas życia aplikacji. Możliwość kontrolowania cyklu życia usług z komponentów to bardzo przydatna, ale niedoceniana koncepcja.
DI działa, przypisując wystąpienia usługi do konstruktora składnika, identyfikowanego przez typy TypeScript. Dodatkowo public
słowa kluczowe automatycznie przypisują parametry do this
, dzięki czemu nie musimy już pisać tych nudnych this.homeService = homeService
.
<div> <h3>Dashboard</h3> <md-input-container> <input mdInput placeholder='Edit your name' [(ngModel)]='appService.username' /> </md-input-container> <br/> <span>Clicks since last visit: {{homeService.counter}}</span> <button (click)='homeService.increment()'>Click!</button> </div>
Składnia szablonu Angulara, prawdopodobnie dość elegancka. Podoba mi się skrót [()]
, który działa jak dwukierunkowe wiązanie danych, ale pod maską jest to właściwie wiązanie atrybutu + zdarzenie. Zgodnie z cyklem życia naszych usług, homeService.counter
zostanie zresetowany za każdym razem, gdy opuścimy /home
, ale nazwa appService.username
pozostanie i będzie dostępna z dowolnego miejsca.
Reagować
import { observable } from 'mobx' export class HomeStore { @observable counter = 0 increment = () => { this.counter++ } }
W MobX musimy dodać dekorator @observable
do każdej właściwości, którą chcemy, aby była obserwowalna.
@observer export class Home extends React.Component<any, any> { homeStore: HomeStore componentWillMount() { this.homeStore = new HomeStore() } render() { return <Provider homeStore={this.homeStore}> <HomeComponent /> </Provider> } }
Aby prawidłowo zarządzać cyklem życia, musimy wykonać nieco więcej pracy niż w przykładzie Angulara. Zawijamy HomeComponent
wewnątrz Provider
, który otrzymuje nową instancję HomeStore
na każdym montowaniu.
interface HomeComponentProps { appStore?: AppStore, homeStore?: HomeStore } @inject('appStore', 'homeStore') @observer export class HomeComponent extends React.Component<HomeComponentProps, any> { render() { const { homeStore, appStore } = this.props return <div> <h3>Dashboard</h3> <Input type='text' label='Edit your name' name='username' value={appStore.username} onChange={appStore.onUsernameChange} /> <span>Clicks since last visit: {homeStore.counter}</span> <button onClick={homeStore.increment}>Click!</button> </div> } }
HomeComponent
używa dekoratora @observer
do nasłuchiwania zmian we właściwościach @observable
.
Mechanizm tego pod maską jest dość interesujący, więc przyjrzyjmy się temu pokrótce tutaj. Dekorator @observable
zastępuje właściwość w obiekcie getterem i setterem, co pozwala mu przechwytywać wywołania. Kiedy funkcja renderowania rozszerzonego komponentu @observer
jest wywoływana, te właściwości są wywoływane i zachowują odniesienie do komponentu, który je wywołał.
Następnie, po wywołaniu settera i zmianie wartości, wywoływane są funkcje renderowania komponentów, które używały właściwości podczas ostatniego renderowania. Teraz dane o tym, które właściwości są używane, gdzie są aktualizowane, i cały cykl można rozpocząć od nowa.
Bardzo prosty mechanizm, a do tego całkiem wydajny. Więcej szczegółowych wyjaśnień tutaj.
Dekorator @inject
służy do wstrzykiwania instancji appStore
i homeStore
do właściwości HomeComponent
. W tym momencie każdy z tych sklepów ma inny cykl życia. appStore
działa tak samo w czasie życia aplikacji, ale homeStore
jest tworzony na nowo przy każdej nawigacji do trasy „/home”.
Zaletą tego jest to, że nie jest konieczne ręczne czyszczenie właściwości, jak to ma miejsce, gdy wszystkie sklepy są globalne, co jest uciążliwe, jeśli trasa jest stroną „szczegółów”, która za każdym razem zawiera zupełnie inne dane.
Podsumowanie: Ponieważ zarządzanie cyklem życia dostawcy jest nieodłączną cechą DI Angulara, oczywiście łatwiej jest to osiągnąć. Wersja React jest również użyteczna, ale zawiera znacznie więcej schematów.
Obliczone właściwości
Reagować
Zacznijmy od Reacta, który ma prostsze rozwiązanie.
import { observable, computed, action } from 'mobx' export class HomeStore { import { observable, computed, action } from 'mobx' export class HomeStore { @observable counter = 0 increment = () => { this.counter++ } @computed get counterMessage() { console.log('recompute counterMessage!') return `${this.counter} ${this.counter === 1 ? 'click' : 'clicks'} since last visit` } }
Mamy więc obliczoną właściwość, która wiąże się z counter
i zwraca wiadomość w liczbie mnogiej. Wynik counterMessage
jest buforowany i przeliczany tylko w przypadku zmiany counter
.
<Input type='text' label='Edit your name' name='username' value={appStore.username} onChange={appStore.onUsernameChange} /> <span>{homeStore.counterMessage}</span> <button onClick={homeStore.increment}>Click!</button>
Następnie odwołujemy się do właściwości (i metody increment
) z szablonu JSX. Pole wejściowe jest sterowane przez powiązanie z wartością i zezwolenie na obsługę zdarzenia użytkownika przez metodę z appStore
.
Kątowy
Aby osiągnąć ten sam efekt w Angularze, musimy być nieco bardziej pomysłowi.
import { Injectable } from '@angular/core' import { BehaviorSubject } from 'rxjs/BehaviorSubject' @Injectable() export class HomeService { message = 'Welcome to home page' counterSubject = new BehaviorSubject(0) // Computed property can serve as basis for further computed properties counterMessage = new BehaviorSubject('') constructor() { // Manually subscribe to each subject that couterMessage depends on this.counterSubject.subscribe(this.recomputeCounterMessage) } // Needs to have bound this private recomputeCounterMessage = (x) => { console.log('recompute counterMessage!') this.counterMessage.next(`${x} ${x === 1 ? 'click' : 'clicks'} since last visit`) } increment() { this.counterSubject.next(this.counterSubject.getValue() + 1) } }
Musimy zdefiniować wszystkie wartości, które służą jako podstawa obliczonej właściwości jako BehaviorSubject
. Sama obliczona właściwość jest również BehaviorSubject
, ponieważ każda obliczona właściwość może służyć jako dane wejściowe dla innej obliczonej właściwości.
Oczywiście RxJS
potrafi znacznie więcej niż tylko to, ale byłby to temat na zupełnie inny artykuł. Niewielkim minusem jest to, że to trywialne użycie RxJS tylko dla obliczonych właściwości jest nieco bardziej szczegółowe niż przykład React i musisz ręcznie zarządzać subskrypcjami (jak tutaj w konstruktorze).
<md-input-container> <input mdInput placeholder='Edit your name' [(ngModel)]='appService.username' /> </md-input-container> <span>{{homeService.counterMessage | async}}</span> <button (click)='homeService.increment()'>Click!</button>
Zwróć uwagę, jak możemy odwołać się do tematu RxJS za pomocą | async
rura | async
. To miły akcent, znacznie krótszy niż konieczność subskrypcji komponentów. Składnik input
jest sterowany przez dyrektywę [(ngModel)]
. Pomimo tego, że wygląda dziwnie, jest całkiem elegancki. Po prostu cukierek składniowy do wiązania danych wartości z appService.username
i automatycznego przypisywania wartości ze zdarzenia wejściowego użytkownika.
Podsumowanie: Obliczone właściwości są łatwiejsze do zaimplementowania w React/MobX niż w Angular/RxJS, ale RxJS może zapewnić kilka bardziej przydatnych funkcji FRP, które mogą być docenione później.

Szablony i CSS
Aby pokazać, jak szablony układają się względem siebie, użyjmy komponentu Posty, który wyświetla listę postów.
Kątowy
@Component({ selector: 'app-posts', templateUrl: './posts.component.html', styleUrls: ['./posts.component.css'], providers: [ PostsService ] }) export class PostsComponent implements OnInit { constructor( public postsService: PostsService, public appService: AppService ) { } ngOnInit() { this.postsService.initializePosts() } }
Ten składnik po prostu łączy ze sobą HTML, CSS i wstrzyknięte usługi, a także wywołuje funkcję do ładowania postów z interfejsu API podczas inicjalizacji. AppService
to singleton zdefiniowany w module aplikacji, podczas gdy PostsService
jest przejściowy, z nowym wystąpieniem tworzonym za każdym razem, gdy tworzony jest składnik. CSS, do którego odwołuje się ten składnik, jest objęty zakresem tego składnika, co oznacza, że zawartość nie może wpływać na nic poza składnikiem.
<a routerLink="/form" class="float-right"> <button md-fab> <md-icon>add</md-icon> </button> </a> <h3>Hello {{appService.username}}</h3> <md-card *ngFor="let post of postsService.posts"> <md-card-title>{{post.title}}</md-card-title> <md-card-subtitle>{{post.name}}</md-card-subtitle> <md-card-content> <p> {{post.message}} </p> </md-card-content> </md-card>
W szablonie HTML odwołujemy się głównie do komponentów z Angular Material. Aby były dostępne, konieczne było uwzględnienie ich w imporcie app.module
(patrz wyżej). Dyrektywa *ngFor
jest używana do powtarzania komponentu md-card
dla każdego postu.
Lokalny CSS:
.mat-card { margin-bottom: 1rem; }
Lokalny CSS tylko rozszerza jedną z klas obecnych w komponencie md-card
.
Globalny CSS:
.float-right { float: right; }
Ta klasa jest zdefiniowana w globalnym pliku style.css
, aby była dostępna dla wszystkich komponentów. Można się do niego odwoływać w standardowy sposób, class="float-right"
.
Skompilowany CSS:
.float-right { float: right; } .mat-card[_ngcontent-c1] { margin-bottom: 1rem; }
W skompilowanym CSS możemy zobaczyć, że lokalny CSS został ograniczony do renderowanego komponentu za pomocą selektora atrybutu [_ngcontent-c1]
. Każdy renderowany komponent Angular ma wygenerowaną klasę taką jak ta dla celów określania zakresu CSS.
Zaletą tego mechanizmu jest to, że możemy normalnie odwoływać się do klas, a ustalanie zakresu odbywa się „pod maską”.
Reagować
import * as style from './posts.css' import * as appStyle from '../app.css' @observer export class Posts extends React.Component<any, any> { postsStore: PostsStore componentWillMount() { this.postsStore = new PostsStore() this.postsStore.initializePosts() } render() { return <Provider postsStore={this.postsStore}> <PostsComponent /> </Provider> } }
W React ponownie musimy użyć podejścia Provider
, aby zależność PostsStore
„przejściowa”. Importujemy również style CSS, określane jako style
i appStyle
, aby móc używać klas z tych plików CSS w JSX.
interface PostsComponentProps { appStore?: AppStore, postsStore?: PostsStore } @inject('appStore', 'postsStore') @observer export class PostsComponent extends React.Component<PostsComponentProps, any> { render() { const { postsStore, appStore } = this.props return <div> <NavLink to='form'> <Button icon='add' floating accent className={appStyle.floatRight} /> </NavLink> <h3>Hello {appStore.username}</h3> {postsStore.posts.map(post => <Card key={post.id} className={style.messageCard}> <CardTitle title={post.title} subtitle={post.name} /> <CardText>{post.message}</CardText> </Card> )} </div> } }
Oczywiście JSX działa znacznie bardziej w JavaScript niż szablony HTML Angulara, co może być dobre lub złe, w zależności od upodobań. Zamiast dyrektywy *ngFor
używamy konstrukcji map
do iteracji po postach.
Teraz Angular może być frameworkiem, który najbardziej zachwala TypeScript, ale w rzeczywistości to JSX naprawdę błyszczy. Dzięki dodaniu modułów CSS (zaimportowanych powyżej) naprawdę zmienia kodowanie szablonu w zen uzupełniania kodu. Każda rzecz jest sprawdzana pod kątem typu. Komponenty, atrybuty, a nawet klasy CSS ( appStyle.floatRight
i style.messageCard
, patrz poniżej). I oczywiście szczupła natura JSX zachęca do dzielenia na komponenty i fragmenty nieco bardziej niż szablony Angulara.
Lokalny CSS:
.messageCard { margin-bottom: 1rem; }
Globalny CSS:
.floatRight { float: right; }
Skompilowany CSS:
.floatRight__qItBM { float: right; } .messageCard__1Dt_9 { margin-bottom: 1rem; }
Jak widać, moduł ładujący moduły CSS dodaje do każdej klasy CSS losowy przyrostek, co gwarantuje unikalność. Prosty sposób na uniknięcie konfliktów. Klasy są następnie przywoływane przez obiekty zaimportowane z pakietu webpack. Jedną z możliwych wad tego może być to, że nie można po prostu stworzyć CSS z klasą i ją rozszerzyć, jak to zrobiliśmy w przykładzie Angulara. Z drugiej strony może to być naprawdę dobra rzecz, ponieważ zmusza cię do właściwego enkapsulacji stylów.
Podsumowanie: Osobiście lubię JSX trochę bardziej niż szablony Angular, szczególnie ze względu na wsparcie uzupełniania kodu i sprawdzania typu. To naprawdę zabójcza funkcja. Angular ma teraz kompilator AOT, który również może wykryć kilka rzeczy, uzupełnianie kodu działa również dla około połowy rzeczy, ale nie jest tak kompletne jak JSX/TypeScript.
GraphQL — ładowanie danych
Dlatego zdecydowaliśmy się użyć GraphQL do przechowywania danych dla tej aplikacji. Jednym z najłatwiejszych sposobów tworzenia zaplecza GraphQL jest użycie niektórych BaaS, takich jak Graphcool. Więc to właśnie zrobiliśmy. Zasadniczo po prostu definiujesz modele i atrybuty, a twoje CRUD jest gotowe.
Wspólny kod
Ponieważ część kodu związanego z GraphQL jest w 100% taka sama dla obu implementacji, nie powtarzajmy tego dwa razy:
const PostsQuery = gql` query PostsQuery { allPosts(orderBy: createdAt_DESC, first: 5) { id, name, title, message } } `
GraphQL to język zapytań mający na celu zapewnienie bogatszego zestawu funkcjonalności w porównaniu z klasycznymi punktami końcowymi RESTful. Przeanalizujmy to konkretne zapytanie.
-
PostsQuery
to tylko nazwa, do której zapytanie będzie się odwoływać później, może mieć dowolną nazwę. -
allPosts
jest najważniejszą częścią - odwołuje się do funkcji, która odpytuje wszystkie rekordy za pomocą modelu `Post`. Ta nazwa została stworzona przez Graphcool. -
orderBy
ifirst
to parametry funkcjiallPosts
.createdAt
to jeden z atrybutów modeluPost
.first: 5
oznacza, że zwróci tylko 5 pierwszych wyników zapytania. -
id
,name
,title
imessage
to atrybuty modeluPost
, które chcemy uwzględnić w wyniku. Inne atrybuty zostaną odfiltrowane.
Jak już widać, jest dość potężny. Sprawdź tę stronę, aby lepiej zapoznać się z zapytaniami GraphQL.
interface Post { id: string name: string title: string message: string } interface PostsQueryResult { allPosts: Array<Post> }
Tak, jako dobrzy obywatele TypeScriptu, tworzymy interfejsy dla wyników GraphQL.
Kątowy
@Injectable() export class PostsService { posts = [] constructor(private apollo: Apollo) { } initializePosts() { this.apollo.query<PostsQueryResult>({ query: PostsQuery, fetchPolicy: 'network-only' }).subscribe(({ data }) => { this.posts = data.allPosts }) } }
Zapytanie GraphQL jest obserwowalnym RxJS i subskrybujemy je. Działa to trochę jak obietnica, ale nie do końca, więc nie mamy szczęścia przy użyciu async/await
. Oczywiście wciąż jest do Obietnicy, ale i tak nie wygląda to na Angulara. Ustawiamy fetchPolicy: 'network-only'
ponieważ w tym przypadku nie chcemy buforować danych, ale za każdym razem pobieramy ponownie.
Reagować
export class PostsStore { appStore: AppStore @observable posts: Array<Post> = [] constructor() { this.appStore = AppStore.getInstance() } async initializePosts() { const result = await this.appStore.apolloClient.query<PostsQueryResult>({ query: PostsQuery, fetchPolicy: 'network-only' }) this.posts = result.data.allPosts } }
Wersja React jest prawie identyczna, ale ponieważ apolloClient
używa tutaj obietnic, możemy skorzystać ze składni async/await
. Istnieją inne podejścia w React, które po prostu „przyklejają” zapytania GraphQL do komponentów wyższego rzędu, ale wydawało mi się, że mieszanie razem warstwy danych i prezentacji jest odrobinę za dużo.
Podsumowanie: Idee subskrypcji RxJS i async/await są naprawdę takie same.
GraphQL - Zapisywanie danych
Wspólny kod
Ponownie, trochę kodu związanego z GraphQL:
const AddPostMutation = gql` mutation AddPostMutation($name: String!, $title: String!, $message: String!) { createPost( name: $name, title: $title, message: $message ) { id } } `
Celem mutacji jest tworzenie lub aktualizacja rekordów. Dlatego korzystne jest zadeklarowanie niektórych zmiennych z mutacją, ponieważ w ten sposób przekazuje się do niej dane. Mamy więc zmienne name
, title
i message
wpisane jako String
, które musimy wypełnić za każdym razem, gdy wywołamy tę mutację. Funkcja createPost
ponownie jest zdefiniowana przez Graphcool. Określamy, że klucze modelu Post
będą miały wartości z naszych zmiennych mutacji, a także, że chcemy, aby w zamian został przesłany tylko id
nowo utworzonego Posta.
Kątowy
@Injectable() export class FormService { constructor( private apollo: Apollo, private router: Router, private appService: AppService ) { } addPost(value) { this.apollo.mutate({ mutation: AddPostMutation, variables: { name: this.appService.username, title: value.title, message: value.message } }).subscribe(({ data }) => { this.router.navigate(['/posts']) }, (error) => { console.log('there was an error sending the query', error) }) } }
Wywołując apollo.mutate
, musimy podać wywołaną przez nas mutację oraz zmienne. Otrzymujemy wynik w wywołaniu zwrotnym subscribe
i używamy wstrzykniętego router
, aby przejść z powrotem do listy postów.
Reagować
export class FormStore { constructor() { this.appStore = AppStore.getInstance() this.routerStore = RouterStore.getInstance() this.postFormState = new PostFormState() } submit = async () => { await this.postFormState.form.validate() if (this.postFormState.form.error) return const result = await this.appStore.apolloClient.mutate( { mutation: AddPostMutation, variables: { name: this.appStore.username, title: this.postFormState.title.value, message: this.postFormState.message.value } } ) this.goBack() } goBack = () => { this.routerStore.history.push('/posts') } }
Bardzo podobny do powyższego, z różnicą bardziej „ręcznego” wstrzykiwania zależności i użycia async/await
.
Podsumowanie: Znowu nie ma tu dużej różnicy. Subskrybuj vs. asynchroniczny/await to w zasadzie wszystko, co się różni.
Formularze
Za pomocą formularzy w tej aplikacji chcemy osiągnąć następujące cele:
- Wiązanie danych pól z modelem
- Komunikaty walidacyjne dla każdego pola, wiele reguł
- Wsparcie dla sprawdzenia poprawności całego formularza
Reagować
export const check = (validator, message, options) => (value) => (!validator(value, options) && message) export const checkRequired = (msg: string) => check(nonEmpty, msg) export class PostFormState { title = new FieldState('').validators( checkRequired('Title is required'), check(isLength, 'Title must be at least 4 characters long.', { min: 4 }), check(isLength, 'Title cannot be more than 24 characters long.', { max: 24 }), ) message = new FieldState('').validators( checkRequired('Message cannot be blank.'), check(isLength, 'Message is too short, minimum is 50 characters.', { min: 50 }), check(isLength, 'Message is too long, maximum is 1000 characters.', { max: 1000 }), ) form = new FormState({ title: this.title, message: this.message }) }
Tak więc biblioteka formstate działa w następujący sposób: Dla każdego pola formularza definiujesz FieldState
. Przekazywany parametr jest wartością początkową. Właściwość validators
przyjmuje funkcję, która zwraca „false”, gdy wartość jest poprawna, oraz komunikat walidacji, gdy wartość jest nieprawidłowa. Dzięki funkcjom pomocniczym check
i checkRequired
wszystko może wyglądać ładnie deklaratywnie.
Aby mieć walidację dla całego formularza, warto również otoczyć te pola instancją FormState
, która następnie zapewnia poprawność zagregowaną.
@inject('appStore', 'formStore') @observer export class FormComponent extends React.Component<FormComponentProps, any> { render() { const { appStore, formStore } = this.props const { postFormState } = formStore return <div> <h2> Create a new post </h2> <h3> You are now posting as {appStore.username} </h3> <Input type='text' label='Title' name='title' error={postFormState.title.error} value={postFormState.title.value} onChange={postFormState.title.onChange} /> <Input type='text' multiline={true} rows={3} label='Message' name='message' error={postFormState.message.error} value={postFormState.message.value} onChange={postFormState.message.onChange} />
Instancja FormState
udostępnia właściwości value
, onChange
i error
, których można łatwo używać z dowolnymi komponentami frontonu.
<Button label='Cancel' onClick={formStore.goBack} raised accent /> <Button label='Submit' onClick={formStore.submit} raised disabled={postFormState.form.hasError} primary /> </div> } }
When form.hasError
is true
, we keep the button disabled. The submit button sends the form to the GraphQL mutation presented earlier.
Angular
In Angular, we are going to use FormService
and FormBuilder
, which are parts of the @angular/forms
package.
@Component({ selector: 'app-form', templateUrl: './form.component.html', providers: [ FormService ] }) export class FormComponent { postForm: FormGroup validationMessages = { 'title': { 'required': 'Title is required.', 'minlength': 'Title must be at least 4 characters long.', 'maxlength': 'Title cannot be more than 24 characters long.' }, 'message': { 'required': 'Message cannot be blank.', 'minlength': 'Message is too short, minimum is 50 characters', 'maxlength': 'Message is too long, maximum is 1000 characters' } }
First, let's define the validation messages.
constructor( private router: Router, private formService: FormService, public appService: AppService, private fb: FormBuilder, ) { this.createForm() } createForm() { this.postForm = this.fb.group({ title: ['', [Validators.required, Validators.minLength(4), Validators.maxLength(24)] ], message: ['', [Validators.required, Validators.minLength(50), Validators.maxLength(1000)] ], }) }
Using FormBuilder
, it's quite easy to create the form structure, even more succintly than in the React example.
get validationErrors() { const errors = {} Object.keys(this.postForm.controls).forEach(key => { errors[key] = '' const control = this.postForm.controls[key] if (control && !control.valid) { const messages = this.validationMessages[key] Object.keys(control.errors).forEach(error => { errors[key] += messages[error] + ' ' }) } }) return errors }
To get bindable validation messages to the right place, we need to do some processing. This code is taken from the official documentation, with a few small changes. Basically, in FormService, the fields keep reference just to active errors, identified by validator name, so we need to manually pair the required messages to affected fields. This is not entirely a drawback; it, for example, lends itself more easily to internationalization.
onSubmit({ value, valid }) { if (!valid) { return } this.formService.addPost(value) } onCancel() { this.router.navigate(['/posts']) } }
Again, when the form is valid, data can be sent to GraphQL mutation.
<h2> Create a new post </h2> <h3> You are now posting as {{appService.username}} </h3> <form [formGroup]="postForm" (ngSubmit)="onSubmit(postForm)" novalidate> <md-input-container> <input mdInput placeholder="Title" formControlName="title"> <md-error>{{validationErrors['title']}}</md-error> </md-input-container> <br> <br> <md-input-container> <textarea mdInput placeholder="Message" formControlName="message"></textarea> <md-error>{{validationErrors['message']}}</md-error> </md-input-container> <br> <br> <button md-raised-button (click)="onCancel()" color="warn">Cancel</button> <button md-raised-button type="submit" color="primary" [disabled]="postForm.dirty && !postForm.valid">Submit</button> <br> <br> </form>
The most important thing is to reference the formGroup we have created with the FormBuilder, which is the [formGroup]="postForm"
assignment. Fields inside the form are bound to the form model through the formControlName
property. Again, we disable the “Submit” button when the form is not valid. We also need to add the dirty check, because here, the non-dirty form can still be invalid. We want the initial state of the button to be “enabled” though.
Summary: This approach to forms in React and Angular is quite different on both validation and template fronts. The Angular approach involves a bit more “magic” instead of straightforward binding, but, on the other hand, is more complete and thorough.
Bundle size
Oh, one more thing. The production minified JS bundle sizes, with default settings from the application generators: notably Tree Shaking in React and AOT compilation in Angular.
- Angular: 1200 KB
- React: 300 KB
Well, not much surprise here. Angular has always been the bulkier one.
When using gzip, the sizes go down to 275kb and 127kb respectively.
Just keep in mind, this is basically all vendor libraries. The amount of actual application code is minimal by comparison, which is not the case in a real-world application. There, the ratio would be probably more like 1:2 than 1:4. Also, when you start including a lot of third-party libraries with React, the bundle size also tends to grow rather quickly.
Flexibility of Libraries vs. Robustness of Framework
So it seems that we have not been able (again!) to turn up a clear answer on whether Angular or React is better for web development.
It turns out that the development workflows in React and Angular can be very similar, depending on which libraries we chose to use React with. Then it's a mainly a matter of personal preference.
If you like ready-made stacks, powerful dependency injection and plan to use some RxJS goodies, chose Angular.
If you like to tinker and build your stack yourself, you like the straightforwardness of JSX and prefer simpler computable properties, choose React/MobX.
Again, you can get the complete source code of the application from this article here and here.
Or, if you prefer bigger, RealWorld examples:
- RealWorld Angular 4+
- RealWorld React/MobX
Choose Your Programming Paradigm First
Programming with React/MobX is actually more similar to Angular than with React/Redux. There are some notable differences in templates and dependency management, but they have the same mutable/data binding paradigm.
React/Redux with its immutable/unidirectional paradigm is a completely different beast.
Don't be fooled by the small footprint of the Redux library. It might be tiny, but it's a framework nevertheless. Most of the Redux best practices today are focused on using redux-compatible libraries, like Redux Saga for async code and data fetching, Redux Form for form management, Reselect for memorized selectors (Redux's computed values). and Recompose among others for more fine-grained lifecycle management. Also, there's a shift in Redux community from Immutable.js to Ramda or lodash/fp, which work with plain JS objects instead of converting them.
A nice example of modern Redux is the well-known React Boilerplate. It's a formidable development stack, but if you take a look at it, it is really very, very different from anything we have seen in this post so far.
I feel that Angular is getting a bit of unfair treatment from the more vocal part of JavaScript community. Many people who express dissatisfaction with it probably do not appreciate the immense shift that happened between the old AngularJS and today's Angular. In my opinion, it's a very clean and productive framework that would take the world by storm had it appeared 1-2 years earlier.
Still, Angular is gaining a solid foothold, especially in the corporate world, with big teams and needs for standardization and long-term support. Or to put it in another way, Angular is how Google engineers think web development should be done, if that still amounts to anything.
As for MobX, similar assessment applies. Really great, but underappreciated.
In conclusion: before choosing between React and Angular, choose your programming paradigm first.
mutable/data-binding or immutable/unidirectional , that… seems to be the real issue.