"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}
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
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()
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>
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> ) }
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.