Gestion de l'état des applications avec RxJS / Immer comme alternative simple à Redux / MobX

"Vous comprendrez quand vous avez besoin de Flux. Si vous n'êtes pas sûr d'en avoir besoin, vous n'en avez pas besoin." Chasse aux animaux


Pour contrôler l'état de l'application, j'utilise généralement Redux. Mais il n'est pas toujours nécessaire d'utiliser le modèle Action \ Reducer, ne serait-ce qu'en raison de la lourdeur de son application pour écrire la fonction la plus simple. Prenez un compteur ordinaire comme exemple. À la sortie, je voulais obtenir une solution simple et pratique qui nous permettrait de décrire le modèle d'état et quelques méthodes qui le changent, comme ceci:


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

Il semble tout de suite que MobX puisse fournir une telle solution, alors pourquoi ne pas l'utiliser? Après avoir travaillé avec MobX pendant un certain temps, je suis arrivé à la conclusion qu'il était plus facile pour moi de fonctionner personnellement avec une séquence d'états immuables (comme Redux) qu'avec la logique d'un état mutable (comme MobX), et je n'appellerais pas sa cuisine interne simple.


En général, je voulais trouver une solution de gestion d'état simple basée sur l'immunité, avec la possibilité de l'utiliser dans Angular \ React et implémentée sur TypeScript. Un examen rapide des espaces ouverts de github n'a pas donné de solution appropriée, alors prenons RxJS / Immer et essayons de faire le nôtre.


Nous utilisons RxJS


Nous prendrons BehaviorSubjet comme base, qui modélisera le flux des changements d'état {value: 0} -> {value: 1} -> {value: 2} et qui a également une méthode getValue avec laquelle vous pouvez obtenir l'état actuel. Si vous comparez l'API BehaviorSubject avec Redux


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

Vous remarquerez peut-être qu'ils sont assez similaires. La principale différence est que dans BehaviorSubject au lieu d' Action/Reducer nouvel état peut être défini à l'aide de la méthode next() .


Pour l'exemple de compteur ci-dessus, une implémentation pourrait ressembler à ceci:


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

La redondance des répétitions de this.state.next et la lourdeur des changements d'état sont frappantes. Ceci est très différent de l'état de résultat souhaité. state.value += 1


Ajouter Immer


Pour simplifier le changement de l'état immunitaire, nous utiliserons la bibliothèque Immer. Immer vous permet de créer un nouvel état immuable dû à la mutation du courant. Cela fonctionne de cette façon:


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

Ensemble de comportements et de sujets Immer


Enveloppez l'utilisation de BehaviorSubject et Immer dans notre propre classe et appelez-la 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) } } } 

En utilisant RxState , nous 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 + }) } 

Cela semble un peu mieux que la première option, mais il faut toujours appeler updateState chaque fois. Pour résoudre ce problème, créez une autre classe et appelez-la SimpleImmutableStore , ce sera la base de l'histoire.


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

Nous implémentons le magasin avec son aide:


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

Comme vous pouvez le voir, rien n'a changé de manière significative, mais maintenant toutes les méthodes ont un code commun sous la forme d'un wrapper this.updateState . Pour se débarrasser de cette duplication, nous écrivons une fonction qui encapsule toutes les méthodes de classe dans un appel 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) } } 

et nous l'appellerons dans le constructeur (si vous le souhaitez, cette méthode peut également être implémentée comme décorateur pour la classe)


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

Comptoir


La version finale de l'histoire. Pour illustrer cela, ajoutez un peu de logique pour decrease et quelques autres méthodes en passant le paramètre setValue et en setValue asynchronie 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) } } 

Utiliser avec Angular


Étant donné que RxJS est la base de la pile résultante, il peut être utilisé avec Angular en conjonction avec un tuyau 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> 

Démo


Utiliser avec React


Pour React, nous écrirons un crochet personnalisé:


 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 } 

Composant


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

Démo


Conclusion


Le résultat est une solution assez simple et fonctionnelle, que j'utilise périodiquement dans mes projets. Si vous le souhaitez, vous pouvez ajouter diverses utilités de ce côté: middleware, découpage d'état, restauration de la mise à jour - mais cela dépasse déjà le cadre de cet article. Le résultat de ces ajouts peut être trouvé sur le github https://github.com/simmor-store/simmor


Je serais reconnaissant pour toutes suggestions et commentaires.

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


All Articles