Mengelola status aplikasi dengan RxJS / Immer sebagai alternatif sederhana untuk Redux / MobX

"Kamu akan mengerti kapan kamu membutuhkan Flux. Jika kamu tidak yakin bahwa kamu membutuhkannya, kamu tidak membutuhkannya." Perburuan pete


Untuk mengontrol keadaan aplikasi, saya biasanya menggunakan Redux. Tetapi tidak selalu ada kebutuhan untuk menggunakan model Action \ Reducer, jika hanya karena kesusahan penerapannya untuk menulis fungsional paling sederhana. Ambil penghitung biasa sebagai contoh. Pada output, saya ingin mendapatkan solusi sederhana dan praktis yang memungkinkan kami untuk menggambarkan model keadaan dan beberapa metode yang mengubahnya, seperti ini:


state = {value: 0} increase() { state.value += 1 } decrease() { state.value -= 1 } 

Sepertinya MobX dapat memberikan solusi seperti itu, jadi mengapa tidak menggunakannya? Setelah bekerja dengan MobX untuk sementara waktu, saya sampai pada kesimpulan bahwa lebih mudah bagi saya secara pribadi untuk beroperasi dengan urutan keadaan tidak berubah (seperti Redux) daripada dengan logika keadaan berubah-ubah (seperti MobX), dan saya tidak akan menyebut dapur internalnya sederhana.


Secara umum, saya ingin menemukan solusi manajemen negara sederhana berdasarkan kekebalan, dengan kemampuan untuk menggunakannya dalam Angular \ React dan diimplementasikan pada TypeScript. Tinjauan singkat tentang ruang terbuka github tidak memberikan solusi yang cocok, jadi mari kita ambil RxJS / Immer dan coba lakukan sendiri.


Kami menggunakan RxJS


Kami akan mengambil BehaviorSubjet sebagai dasar, yang akan memodelkan aliran perubahan negara {value: 0} -> {value: 1} -> {value: 2} dan yang juga memiliki metode getValue yang dengannya Anda bisa mendapatkan status saat ini. Jika Anda membandingkan BehaviorSubject API dengan Redux


  • getValue() / getState() //
  • subscribe() / subscribe() //
  • next(value) / dispatch(action), replaceReducer(nextReducer) //

Anda mungkin memperhatikan bahwa mereka sangat mirip. Perbedaan utama adalah bahwa dalam BehaviorSubject bukannya Action/Reducer negara baru dapat diatur menggunakan metode next() .


Untuk contoh penghitung di atas, implementasi mungkin terlihat seperti ini:


CounterService V1


 class CounterServiceV1 { state = new BehaviorSubject({value: 0}) increase() { this.state.next({value: this.state.value.value + 1}) } decrease() { this.state.next({value: this.state.value.value - 1}) } } 

Redundansi pengulangan dari this.state.next dan banyaknya perubahan status sangat mencolok. Ini sangat berbeda dari keadaan hasil yang diinginkan.nilai state.value += 1


Tambahkan Immer


Untuk menyederhanakan perubahan dalam status kekebalan, kita akan menggunakan perpustakaan Immer. Immer memungkinkan Anda untuk membuat keadaan tidak berubah baru karena mutasi saat ini. Cara kerjanya seperti ini:


 const state = {value: 0} //     const draft = createDraft(state) //      draft.value += 1 //    const newState = finishDraft(draft) 

Bundel Subjek Perilaku dan Immer


Bungkus penggunaan BehaviorSubject dan Immer di kelas kita sendiri dan sebut RxState :


 class RxState<TState> { private subject$: BehaviorSubject<TState> private currentDraft?: Draft<TState> get state() { return this.subject$.value } get state$() { return this.subject$ } get draft(): Draft<TState> { if (this.currentDraft !== undefined) { return this.currentDraft } throw new Error("draft doesn't exists") } constructor(readonly initialState: TState) { this.subject$ = new BehaviorSubject(initialState) } public updateState(recipe: (draft: Draft<TState>) => void) { let topLevelUpdate = false //     updateState if (!this.currentDraft) { this.currentDraft = createDraft(this.state) topLevelUpdate = true } recipe(this.currentDraft) if (!topLevelUpdate) { return } const newState = finishDraft(this.currentDraft, () => {}) as TState this.currentDraft = undefined if (newState !== this.state) { this.subject$.next(newState) } } } 

Menggunakan RxState , kami menulis ulang CounterService :


CounterService V2


 class CounterServiceV2 { state = new RxState({value: 0}) increase() { this.state.updateState(draft => { draft.value += 1 }) } decrease() { this.state.updateState(draft => { draft.value -= 1 }) } } 

Diff
 - state = new BehaviorSubject({value: 0}) + state = new RxState({value: 0}) increase() { - this.state.next({value: this.state.value.value + 1}) + this.state.updateState(draft => { + draft.value += 1 + }) } decrease() { - this.state.next({value: this.state.value.value - 1}) + this.state.updateState(draft => { + draft.value -= 1 + }) } 

Ini terlihat sedikit lebih baik daripada opsi pertama, tetapi masih ada kebutuhan untuk memanggil updateState setiap waktu. Untuk mengatasi masalah ini, buat kelas lain dan menyebutnya SimpleImmutableStore , itu akan menjadi dasar untuk cerita.


 class SimpleImmutableStore<TState> { rxState!: RxState<TState> get draft() { return this.rxState.draft } constructor(initialState: TState) { this.rxState = new RxState<TState>(initialState) } public updateState(recipe: (draft: Draft<TState>) => void) { this.rxState.updateState(recipe) } } 

Kami menerapkan penyimpanan dengan bantuannya:


CounterStore V1


 class CounterStoreV1 extends SimpleImmutableStore<{value: number}> { constructor(){ super({value: 0}) } increase() { this.updateState(() => { this.draft.value += 1 }) } decrease() { this.updateState(() => { this.draft.value -= 1 }) } } 

Diff
 -class CounterServiceV2 { - state = new RxState({value: 0}) +class CounterStoreV1 extends SimpleImmutableStore<{value: number}> { + constructor(){ + super({value: 0}) + } increase() { - this.state.updateState(draft => { - draft.value += 1 + this.updateState(() => { + this.draft.value += 1 }) } decrease() { - this.state.updateState(draft => { - draft.value -= 1 + this.updateState(() => { + this.draft.value -= 1 }) } } 

Seperti yang Anda lihat, tidak ada yang berubah secara signifikan, tetapi sekarang semua metode memiliki kode umum dalam bentuk wrapper this.updateState . Untuk menghilangkan duplikasi ini, kami menulis fungsi yang membungkus semua metode kelas dalam panggilan updateState :


 const wrapped = Symbol() //     function getMethodsNames(constructor: any) { const names = Object.getOwnPropertyNames(constructor.prototype).filter( x => x !== "constructor" && typeof constructor.prototype[x] === "function", ) return names } function wrapMethodsWithUpdateState(constructor: any) { if (constructor[wrapped]) { return } constructor[wrapped] = true for (const propertyName of getMethodsNames(constructor)) { const descriptor = Object.getOwnPropertyDescriptor( constructor.prototype, propertyName, )! const method = descriptor.value descriptor.value = function(...args: any[]) { const store = this as SimpleImmutableStore<any> let result: any store.updateState(() => { //     updateState result = method.call(store, ...args) }) return result } Object.defineProperty(constructor.prototype, propertyName, descriptor) } } 

dan kami akan menyebutnya di konstruktor (jika diinginkan, metode ini juga dapat diimplementasikan sebagai dekorator untuk kelas)


  constructor(initialState: TState ) { this.rxState = new RxState<TState>(initialState) wrapMethodsWithUpdateState(this.constructor) } 

Counterstore


Versi akhir cerita. Untuk menunjukkan, tambahkan sedikit logika untuk decrease dan beberapa metode lainnya dengan meneruskan parameter setValue dan setValue asynchronyWithDelay:


 class CounterStore extends SimpleImmutableStore<{ value: number }> { constructor() { super({value: 0}) } increase() { this.draft.value += 1 } decrease() { const newValue = this.draft.value - 1 if (newValue >= 0) { this.draft.value = newValue } } setValue(value: number) { this.draft.value = value } increaseWithDelay() { setTimeout(() => this.increase(), 300) } } 

Gunakan dengan Angular


Karena RxJS adalah dasar dari stack yang dihasilkan, ia dapat digunakan dengan Angular bersamaan dengan pipa async :


 <div *ngIf="store.rxState.state$ | async as state"> <span>{{state.value}}</span> <button (click)="store.increase()">+</button> <button (click)="store.decrease()">-</button> <button (click)="store.setValue(0)">Reset</button> <button (click)="store.increaseWithDelay()">Increase with delay</button> </div> 

Demo


Gunakan dengan Bereaksi


Untuk Bereaksi, kami akan menulis kait khusus:


 function useStore<TState, TResult>( store: SimpleImmutableStore<TState>, project: (store: TState) => TResult, ): TResult { const projectRef = useRef(project) useEffect(() => { projectRef.current = project }, [project]) const [state, setState] = useState(projectRef.current(store.rxState.state)) useEffect(() => { const subscription = store.rxState.state$.subscribe(value => { const newState = projectRef.current(value) if (!shallowEqual(state, newState)) { setState(newState) } }) return () => { subscription.unsubscribe() } }, [store, state]) return state } 

Komponen


 const Counter = () => { const store = useMemo(() => new CounterStore(), []) const value = useStore(store, x => x.value) return ( <div className="counter"> <span>{value}</span> <button onClick={() => store.increase()}>+</button> <button onClick={() => store.decrease()}>-</button> <button onClick={() => store.setValue(0)}>Reset</button> <button onClick={() => store.increaseWithDelay()}>Increase with delay</button> </div> ) } 

Demo


Kesimpulan


Hasilnya adalah solusi yang cukup sederhana dan fungsional, yang saya gunakan secara berkala dalam proyek saya. Jika mau, Anda dapat menambahkan berbagai manfaat ke sisi ini: middleware, state slicing, pembaruan rollback - tetapi ini sudah di luar cakupan artikel ini. Hasil penambahan tersebut dapat ditemukan di github https://github.com/simmor-store/simmor


Saya akan berterima kasih atas saran dan komentar.

Source: https://habr.com/ru/post/id483526/


All Articles