Sinyal akan mengubah Angular menjadi lebih baik
Dari NgRx ComponentStore hingga SignalStore: kesimpulan utama dari proyek demo saya
Bagaimana mempersiapkan migrasi secara efektif
Saya yakin Signals in Angular akan mengubah cara kita membuat aplikasi Angular secara mendasar. Artikel ini adalah bagian pertama dari seri yang bertujuan untuk menunjukkan kepada Anda potensi fitur baru ini, dan pada saat yang sama membantu Anda bersiap menghadapi perubahan ini secara efektif: saat Signals berada dalam pratinjau pengembang dan penyimpanan berbasis sinyal NgRx hanyalah sebuah prototipe, Anda dapat mulai membuat dan memfaktorkan ulang komponen sedemikian rupa sehingga migrasi menjadi sangat lancar bagi Anda. Di bagian pertama ini, saya menunjukkan kepada Anda bagaimana saya menggunakan aplikasi demo untuk menunjukkan perbedaan antara ComponentStore
dan model berbasis sinyal yang baru. Di bagian selanjutnya dari seri ini, saya akan menawarkan beberapa panduan tentang cara menavigasi perubahan ini. Jadi pertama-tama izinkan saya memperkenalkan Signalsdan NgRx SignalStore.
Angular Signals adalah model reaktivitas baru di Angular 16. Sinyalmembantu kami melacak perubahan status dalam aplikasi kami dan memicu rendering template yang dioptimalkan pembaruan. Jika Anda baru mengenal Signal, berikut beberapa artikel yang sangat direkomendasikan:
- Dokumentasi Resmi Sinyal Sudut
- “Sinyal dalam Angular — Cara Menulis Kode yang Lebih Reaktif” oleh Deborah Kurata
- “Sudut & sinyal. Segala sesuatu yang perlu Anda ketahui” oleh Robin Goetz
Tim NgRx dan Marko Stanimirović membuka RFC (Permintaan Komentar) baru untuk solusi manajemen negara berbasis sinyal, SignalStore. Ini memiliki pendekatan yang mirip dengan @ngrx/component-store
. Implementasi awal dengan dokumentasi API tersedia di repo taman bermain NgRx SignalStore.
Seperti yang saya sebutkan, saya yakin bahwa Signal akan mengubah cara kami mengembangkan aplikasi Angular. Untuk mendapatkan lebih banyak pengetahuan tentang fitur baru ini dan dampaknya di masa depan, saya telah membuat dua versi komponen “daftar artikel”. Saya telah membuat yang berbasis ComponentStore
terlebih dahulu, lalu memigrasikannya ke yang berbasis SignalStore
. Dalam artikel ini, saya menjelaskan langkah-langkah penerapan dan perbedaan utama yang saya temukan, sehingga Anda dapat lebih memahami cara kerja SignalStore
s sebenarnya.
Kode sumber lengkap tersedia di sini:
Aplikasi ini menggunakan gaya dan backend yang dihosting publik dari proyek RealWorld.
Aplikasi ini memiliki fitur berikut:
- Menusederhana untuk beralih antara daftar artikel berbasis
ComponentStore
danSignalStore
- Dua daftar artikel, salah satunya berbasis
ComponentStore
, yang lainnya berbasisSignalStore
. Mereka menunjukkan penulis artikel, tanggal publikasi, jumlah, tag, dan prospek. Mereka memuat daftar artikel dari server, sehingga mengalami pemuatan dan status kesalahan - Komponen paginasi di bawah setiap daftar artikel. Pengguna juga dapat mengubah pagination berdasarkan parameter URL, misalnya:
http://localhost:4200/article-list-component-store?selectedPage=3&pageSize=2
. Jika pengguna mengubah parameter URL atau mengklik komponen penomoran halaman, daftar artikel akan dimuat ulang.
Arsitektur aplikasi
Saya menggunakan Angular v16 dengan komponen mandiri. Karena Signals belum berfungsi dalam aplikasi tanpa zona, saya menggunakan strategi deteksi perubahan OnPush
dengan pipa async
.
Aplikasi ini mem-bootstrap AppComponent
dengan router-outlet
dan dua item menu untuk dua versi daftar artikel:
ArticleListComponent_CS
adalah versi daftar artikel berbasisComponentStore
. Itu terhubung denganArticleListComponentStore
.ArticleListComponent_SS
adalah versi daftar artikel berbasisSignalStore
. Itu terhubung denganArticleListSignalStore
.
Kedua implementasi “daftar artikel” menggunakan penyimpanan tingkat komponen dan bergantung pada komponen UI berikut:
UiArticleListComponent
menampilkan daftar artikel (UiArticleLisItemComponent
)UiPaginationComponent
menangani penomoran halaman
Struktur direktorinya adalah sebagai berikut:
src/ |-- app/ | |-- article-list-ngrx-component-store/ => ArticleListComponent_CS | |-- article-list-ngrx-signal-store/ => ArticleListComponent_SS | |-- models/ | |-- services/ | |-- ui-components/ | |-- app.component.ts | |-- app.routes.ts |-- libs/signal-store/
Komponen daftar artikel
Kode kelas kedua komponen daftar artikel hampir sama:
- kami menyuntikkan router dan toko,
- kami memperbarui parameter penomoran halaman di penyimpanan setelah komponen dibuat. Kami juga memperbarui parameter jika parameter pada URL berubah
Satu-satunya perbedaan di antara keduanya adalah kelas penyimpanan yang disuntikkan: ArticleListComponentStore
dan ArticleListSignalStore
:
export class ArticleListComponent_CS { readonly store = inject(ArticleListComponentStore); readonly route = inject(ActivatedRoute); constructor( ) { this.route.queryParams.pipe(takeUntilDestroyed()).subscribe( routeParams => { this.store.setPaginationSettings(routeParams); this.store.loadArticles(); }); } } export class ArticleListComponent_SS { readonly store = inject(ArticleListSignalStore); readonly route = inject(ActivatedRoute); constructor( ) { this.route.queryParams.pipe(takeUntilDestroyed()).subscribe( routeParams => { this.store.setPaginationSettings(routeParams); this.store.loadArticles(); }); } }
Templat komponennya juga serupa. Perbedaan mendasar adalah cara kita membaca data dari penyimpanan:
- kami menggunakan pipa
async
untuk membaca dari pemilihdariComponentStore
, dan - kita cukup mendapatkan nilai sinyaldi
SignalStore
@Component({ selector: ‘app-article-list-cs’, // ... providers: [ArticleListComponentStore], template: ` <ng-container *ngIf="(store.httpRequestState$ | async) === ‘FETCHING’"> Loading... </ng-container> <ng-container *ngIf="store.httpRequestState$ | async | httpRequestStateErrorPipe as errorMessage"> {{ errorMessage }} </ng-container> <ng-container *ngIf="(store.httpRequestState$ | async) === ‘FETCHED’"> <ng-container *ngIf="store.articles$ | async as articles"> <app-ui-article-list [articles]="articles"/> </ng-container> <ng-container *ngIf="store.pagination$ | async as pagination"> <app-ui-pagination [selectedPage]="pagination.selectedPage" [totalPages]="pagination.totalPages" (onPageSelected)="store.setSelectedPage($event); store.loadArticles();" /> </ng-container> </ng-container> ` }) @Component({ selector: ‘app-article-list-ss’, // ... providers: [ArticleListSignalStore], template: ` <ng-container *ngIf="store.httpRequestState() === ‘FETCHING’"> Loading... </ng-container> <ng-container *ngIf="store.httpRequestState() | httpRequestStateErrorPipe as errorMessage"> {{ errorMessage }} </ng-container> <ng-container *ngIf="store.httpRequestState() === ‘FETCHED’"> <ng-container *ngIf="store.articles() as articles"> <app-ui-article-list [articles]="articles"/> </ng-container> <ng-container *ngIf="store.pagination() as pagination"> <app-ui-pagination [selectedPage]="pagination.selectedPage()" [totalPages]="pagination.totalPages()" (onPageSelected)="store.setSelectedPage($event); store.loadArticles();" /> </ng-container> </ng-container> ` })
Negara
Saya menerapkan struktur data yang tidak dapat diubah yang sama untuk menyimpan status di kedua penyimpanan (HttpRequestState
dan Articles
juga merupakan tipe yang tidak dapat diubah):
export type ArticleListState = { readonly selectedPage: number, readonly pageSize: number, readonly httpRequestState: HttpRequestState, readonly articles: Articles, readonly articlesCount: number, }
Properti selectedPage
menentukan halaman yang terlihat saat ini, properti pageSize
menentukan berapa banyak artikel yang terlihat. Pengguna dapat mengubah nilai-nilai ini dengan menggunakan komponen penomoran halaman atau dengan menerapkan parameter URL.
Properti httpRequestState
berisi status permintaan daftar artikel:
export type HttpRequestState = DeepReadonly< 'EMPTY' | 'FETCHING' | 'FETCHED' | { errorMessage: string } >;
Awalnya, nilainya adalah `EMPTY`
. Kami mengubahnya menjadi `FETCHING`
tepat sebelum kami mengirim permintaan ke server. Saat respons server tiba, kami menetapkan nilainya menjadi `FETCHED`
. Jika server mengirimkan respons kesalahan atau ada kesalahan selama permintaan, kami menetapkan status permintaan ke objek { errorMessage: string }
dengan pesan kesalahan.
Respons server berisi jumlah total artikel dan artikel itu sendiri, kami menyimpannya di properti articlesCount
dan articles
.
Setelah kita membuat komponen daftar artikel, penyimpanannya memiliki status awal:
export const initialArticleListState: ArticleListState = { selectedPage: 0, pageSize: 3, httpRequestState: ‘EMPTY’, articles: [], articlesCount: 0 }
Toko
Saya memperluas ArticleListComponentStore
dari ComponentStore
:
@Injectable() export class ArticleListComponentStore extends ComponentStore<ArticleListState> { readonly selectedPage$: Observable<number> = /* ... */; readonly pageSize$: Observable<number> = /* ... */; readonly httpRequestState$: Observable<HttpRequestState> = /* ... */; readonly articles$: Observable<DeepReadonly<Articles>> = /* ... */; readonly articlesCount$: Observable<number> = /* ... */; readonly totalPages$: Observable<number> = /* ... */; readonly pagination$: Observable<{ selectedPage: number, totalPages: number }> = /* ... */; readonly articlesService = inject(ArticlesService); constructor( ) { super(initialArticleListState); } setPaginationSettings = this.updater( (state, s: RouteParamsPaginatonState) => /* ... */); readonly loadArticles = this.effect<void>(/* ... */); setRequestStateLoading = this.updater( (state) => /* ... */); setRequestStateSuccess = this.updater( (state, params: ArticlesResponseType) => /* ... */); setRequestStateError = this.updater( (state, error: string): => /* ... */); setSelectedPage = this.updater( (state, selectedPage: number) => /* ... */); }
Saya membuat `ArticleListSignalStore` dengan fungsi `signalStore()`. Ia menerima serangkaian fitur toko, saya akan menjelaskannya lebih detail:
export const ArticleListSignalStore = signalStore( { debugId: ‘ArticleListSignalStore’ }, withState<ArticleListState>(initialArticleListState), withComputed(({ articlesCount, pageSize }) => ({ /* ... */ })), withComputed(({ selectedPage, totalPages }) => ({ /* ... */ })), withUpdaters(({ update }) => ({ setPaginationSettings: (s: RouteParamsPaginatonState) => /* ... */, setRequestStateLoading: () => /* ... */ , setRequestStateSuccess: => /* ... */ , setRequestStateError: (error: string) => /* ... */ , setSelectedPage: (selectedPage: number) => /* ... */, withEffects( ( { selectedPage, pageSize, setRequestStateLoading, setRequestStateSuccess, setRequestStateError }, ) => { const articlesService = inject(ArticlesService) // ... } ) );
Penyeleksi
ArticleListComponentStore
menyimpan negara bagian dalam store$
subjeknya. Subyek ini mengeluarkan nilai pada setiap perubahan state. Untuk mengamati modifikasi properti negara satu per satu, kami membuat pemilih terpisah untuk masing-masing properti berikut:
readonly selectedPage$: Observable<number> = this.select(state => state.selectedPage); readonly pageSize$: Observable<number> = this.select(state => state.pageSize); readonly httpRequestState$: Observable<HttpRequestState> = this.select(state => state.httpRequestState); readonly articles$: Observable<DeepReadonly<Articles>> = this.select(state => state.articles); readonly articlesCount$: Observable<number> = this.select(state => state.articlesCount);
SignalStore
secara otomatis membuat signal
terpisah untuk semua properti root negara. Kami menyebutnya sebagai negara parsial. Kita dapat mengakses sebagian negara bagian ini dengan:
ArticleListSignalStore.selectedPage()
ArticleListSignalStore.pageSize()
ArticleListSignalStore.httpRequestState()
ArticleListSignalStore.articles()
danArticleListSignalStore.articlesCount()
Saya membuat pemilih gabungan tambahan di ArticleListComponentStore
untuk menghitung jumlah halaman:
readonly totalPages$: Observable<number> = this.select( this.articlesCount$, this.pageSize$, (articlesCount, pageSize) => Math.ceil(articlesCount / pageSize));
Untuk melakukan hal yang sama di ArticleListSignalStore
, saya menggunakan fungsi withComputed()
. Saya memberikan sinyal articlesCount
dan pageSize
sebagai parameter fungsi, dan menghitung jumlah total halaman:
withComputed(({ articlesCount, pageSize }) => ({ totalPages: computed(() => Math.ceil(articlesCount() / pageSize())), })),
Kita juga perlu menambahkan pemilih “model tampilan” ke komponen penomoran halaman. Ini adalah kode untuk selector di ArticleListComponentStore
:
readonly pagination$: Observable<{ selectedPage: number, totalPages: number }> = this.select( this.selectedPage$, this.totalPages$, (selectedPage, totalPages) => ({ selectedPage, totalPages }) );
Dan ini juga merupakan pemilih yang sama di ArticleListSignalStore
:
withComputed(({ selectedPage, totalPages }) => ({ pagination: computed(() => ({ selectedPage, totalPages })), })),
Pembaru
Di dalam updaters
dari ComponentStore
, kami selalu membuat objek status baru yang tidak dapat diubah dengan nilai yang diperbarui dan mengembalikannya. Objek status yang dikembalikan berisi semua properti dari status, baik yang diperbarui maupun yang tidak diubah.
Misalnya, ini adalah cara kami menangani respons server:
setRequestStateSuccess = this.updater((state, params: ArticlesResponseType): ArticleListState => { return { ...state, httpRequestState: ‘FETCHED’, articles: params.articles, articlesCount: params.articlesCount } });
Parameter params
berisi nilai articles
dan articlesCount
dari respon server:
export type ArticlesResponseType = { articles: Articles, articlesCount: number }
Di ArticleListSignalStore
, kita membuat updater
s dengan fungsi withUpdaters()
. Dalam updater
s ini, kami membuat objek baru yang tidak dapat diubah hanya dari properti yang diperbarui, jadi tidak ada …state
di sini. SignalStore
memperbarui sebagian status dengan menggunakan nilai properti yang dikembalikan berikut:
withUpdaters(({ update }) => ({ setPaginationSettings: (s: RouteParamsPaginatonState) => update(() => ({ // ... setRequestStateSuccess: (params: ArticlesResponseType) => update(() => ({ httpRequestState: ‘FETCHED’, articles: params.articles, articlesCount: params.articlesCount })) // ... }))
Efek
Toko memiliki satu effect
yang mengambil daftar artikel dari server. Ini adalah effect
dari ArticleListComponentStore
:
readonly loadArticles = this.effect<void>((trigger$: Observable<void>) => { return trigger$.pipe( withLatestFrom(this.selectedPage$, this.pageSize$), tap(() => this.setRequestStateLoading()), switchMap(([, selectedPage, pageSize]) => { return this.articlesService.getArticles({ limit: pageSize, offset: selectedPage * pageSize }).pipe( tapResponse( (response) => { this.setRequestStateSuccess(response); }, (errorResponse: HttpErrorResponse) => { this.setRequestStateError(‘Request error’); } ), ); }), ); });
Di SignalStore
, kami mengimplementasikan efek dengan fungsi withEffects()
. SignalStores
mendukung dua jenis efek berbeda: efek berbasis RxJs dan efek berbasis Promise
. Efek berbasis RxJs terlihat sangat mirip dengan efek yang kami gunakan di ComponentStore
:
withEffects( ( { selectedPage, pageSize, setRequestStateLoading, setRequestStateSuccess, setRequestStateError }, ) => { const articlesService = inject(ArticlesService) return { loadArticles: rxEffect<void>( pipe( tap(() => setRequestStateLoading()), switchMap(() => articlesService.getArticles({ limit: pageSize(), offset: selectedPage() * pageSize() })), tapResponse( (response) => { setRequestStateSuccess(response); }, (errorResponse: HttpErrorResponse) => { setRequestStateError(‘Request error’); } ) ) ) } } )
Efek berbasis Promise
berguna ketika Promise
memiliki fungsionalitas yang memadai dan kita tidak memerlukan kekuatan RxJs. Dalam hal mengambil data dari server, ada kelemahannya: tidak mendukung logika pembatalan:
withEffects( ( { selectedPage, pageSize, setRequestStateLoading, setRequestStateSuccess, setRequestStateError }, ) => { const articlesService = inject(ArticlesService) return { async loadArticles() { setRequestStateLoading(); try { const response = await lastValueFrom(articlesService.getArticles({ limit: pageSize(), offset: selectedPage() * pageSize() })); setRequestStateSuccess(response); } catch(e) { setRequestStateError(‘Request error’); } } } } )
Ringkasan
Singkatnya, perbedaan utama antara ComponentStore
dan SignalStore
adalah sebagai berikut:
ComponentStore
memiliki statusnya di subjekstate$
.SignalStore
memiliki sinyal terpisah untuk semua properti root negara (keadaan parsial)- Di
SignalStore
, kita tidak memerlukan penyeleksi untuk mengakses properti tingkat akar negara bagian. Ia menyimpannya dalam sinyal terpisah, sehingga dapat diakses secara langsung. - Baik
ComponentStore
danSignalStore
mendukung efek berbasis RxJs, selain ituSignalStore
juga mendukung efek berbasisPromise
.
Meskipun implementasi SignalStore
saat ini hanyalah sebuah prototipe, dan mungkin ada perubahan API di masa mendatang, saya sangat menikmati bekerja dengannya. API-nya fleksibel dan mudah dipahami, karena mengikuti konsep dasar ComponentStore
, tetapi dengan cara yang lebih maju.
Untuk mempermudah men-debug perubahan status, updaters
dan effects
, saya menambal kode SignalStore
asli dengan beberapa kode debug dari proyek ngx-ngrx-component-store-debug-tools saya.
Pertanyaan utama yang saya miliki sekarang adalah bagaimana membuat ComponentStores
sedemikian rupa sehingga ketika SignalStore
siap produksi dirilis, proses migrasi dapat dikelola dengan mudah.
Di bagian selanjutnya dari seri artikel saya, saya akan menjelaskan beberapa pedoman yang akan membantu kita mencapai tujuan ini. Selain itu, saya akan memeriksa beberapa skenario kompleks dan membandingkan kedua pendekatan ini, misalnya cara kerja pembatalan permintaan HTTP di SignalStore
dan ComponentStore
.
Terima kasih telah membaca, saya harap artikel saya bermanfaat bagi Anda, beri tahu saya jika Anda memiliki masukan!
👨💻Tentang penulis
Nama saya Gergely Szerovay, saya bekerja sebagai pemimpin bab pengembangan frontend. Mengajar (dan belajar) Angular adalah salah satu minat saya. Saya mengonsumsi konten yang terkait dengan Angular setiap hari — artikel, podcast, pembicaraan konferensi, apa saja.
Saya membuat Buletin Angular Addict sehingga saya dapat mengirimi Anda sumber daya terbaik yang saya temukan setiap bulan. Baik Anda seorang Angular Addict berpengalaman atau pemula, saya siap membantu Anda.
Di samping buletin, saya juga memiliki publikasi berjudul — Anda dapat menebaknya — Angular Addicts. Ini adalah kumpulan sumber daya yang menurut saya paling informatif dan menarik. Beri tahu saya jika Anda ingin dimasukkan sebagai penulis.
Mari belajar Angular bersama! Berlangganan di sini 🔥
Ikuti saya di “Medium”, “Twitter” atau “LinkedIn” untuk mempelajari lebih lanjut tentang Angular!