Verwalten des Anwendungsstatus mit RxJS / Immer als einfache Alternative zu Redux / MobX

"Sie werden verstehen, wann Sie Flux brauchen. Wenn Sie nicht sicher sind, ob Sie es brauchen, brauchen Sie es nicht." Pete jagen


Um den Status der Anwendung zu kontrollieren, verwende ich normalerweise Redux. Es ist jedoch nicht immer erforderlich, das Action \ Reducer-Modell zu verwenden, schon allein aufgrund der mühsamen Anwendung zum Schreiben der einfachsten Funktion. Nehmen Sie einen gewöhnlichen Zähler als Beispiel. Am Ende wollte ich eine einfache und praktische Lösung finden, die es uns ermöglicht, das Zustandsmodell und einige Methoden zu beschreiben, die es ändern:


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

Es scheint, als könne MobX eine solche Lösung anbieten. Warum also nicht? Nachdem ich eine Weile mit MobX gearbeitet hatte, kam ich zu dem Schluss, dass es für mich persönlich einfacher ist, mit einer Sequenz unveränderlicher Zustände (wie Redux) als mit der Logik eines veränderlichen Zustands (wie MobX) zu arbeiten, und ich würde seine interne Küche nicht einfach nennen.


Im Allgemeinen wollte ich eine einfache, auf Immunität basierende Zustandsverwaltungslösung finden, die in Angular \ React verwendet und in TypeScript implementiert werden kann. Ein kurzer Rückblick auf Github-Freiflächen ergab keine geeignete Lösung. Nehmen wir also RxJS / Immer und versuchen Sie, unsere eigene Lösung zu finden.


Wir verwenden RxJS


Wir werden BehaviorSubjet als Basis nehmen, das den Fluss von Statusänderungen modelliert {value: 0} -> {value: 1} -> {value: 2} und das auch eine getValue Methode hat, mit der Sie den aktuellen Status getValue können. Wenn Sie die BehaviorSubject API mit Redux vergleichen


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

Möglicherweise stellen Sie fest, dass sie sich sehr ähnlich sind. Der Hauptunterschied besteht darin, dass in BehaviorSubject anstelle von Action/Reducer neue Status mithilfe der next() -Methode festgelegt werden kann.


Für das obige Zählerbeispiel könnte eine Implementierung folgendermaßen aussehen:


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

this.state.next die Redundanz von Wiederholungen aus this.state.next und die Umständlichkeit von Zustandsänderungen. Dies state.value += 1 stark vom gewünschten Ergebniszustand state.value += 1


Immer hinzufügen


Um die Änderung des Immunstatus zu vereinfachen, verwenden wir die Immer-Bibliothek. Immer ermöglicht es Ihnen, einen neuen unveränderlichen Zustand aufgrund der Mutation des Stroms zu erstellen. Es funktioniert so:


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

Bündel von BehaviorSubject und Immer


Binden Sie die Verwendung von BehaviorSubject und Immer in unsere eigene Klasse ein und nennen Sie sie 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) } } } 

Mit RxState schreiben wir RxState neu:


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 + }) } 

Es sieht ein bisschen besser aus als die erste Option, aber es besteht immer noch die Notwendigkeit, jedes Mal updateState . Um dieses Problem zu lösen, erstellen Sie eine weitere Klasse und nennen Sie sie SimpleImmutableStore . Sie ist die Basis für die Story.


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

Wir implementieren den Stor mit seiner Hilfe:


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

Wie Sie sehen, hat sich nichts wesentlich geändert, aber jetzt haben alle Methoden einen gemeinsamen Code in Form eines Wrappers this.updateState . Um diese Duplizierung zu updateState , schreiben wir eine Funktion, die alle Klassenmethoden in einem updateState Aufruf 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) } } 

und wir werden es im Konstruktor aufrufen (falls gewünscht, kann diese Methode auch als Dekorator für die Klasse implementiert werden)


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

Counterstore


Die endgültige Version der Geschichte. setValue zur Demonstration eine kleine Logik zum decrease und einige weitere Methoden hinzu, indem Sie den Parameter setValue und setValue Asynchronität von setValue :


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

Verwenden Sie mit Angular


Da RxJS die Basis des resultierenden Stacks ist, kann es mit Angular in Verbindung mit einer async Pipe verwendet werden:


 <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


Mit React verwenden


Für React schreiben wir einen benutzerdefinierten Hook:


 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 } 

Komponente


 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


Fazit


Das Ergebnis ist eine relativ einfache und funktionale Lösung, die ich regelmäßig in meinen Projekten verwende. Wenn Sie möchten, können Sie dieser Seite verschiedene nützliche Funktionen hinzufügen: Middleware, Status-Slicing, Updaterollback - dies würde jedoch den Rahmen dieses Artikels sprengen. Das Ergebnis solcher Ergänzungen kann auf dem Github https://github.com/simmor-store/simmor gefunden werden


Für Anregungen und Kommentare wäre ich dankbar.

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


All Articles