Quelqu'un n'a pas aimé Redux dans React à cause de son implémentation sur JS?
Je n'aimais pas les commutateurs maladroits dans les réducteurs, il existe des langages avec une correspondance de modèle plus pratique, et les types sont de meilleurs événements de modélisation et un modèle. Par exemple, F #.
Cet article est une explication du périphérique de messagerie dans Elmish .
Je vais donner un exemple d'une application console écrite sur cette architecture, en utilisant son exemple, il sera clair comment utiliser cette approche, puis nous comprendrons l'architecture Elmish.
J'ai écrit une application console simple pour lire des poèmes, en graine il y a plusieurs poèmes, un pour chaque auteur, qui sont affichés sur la console.
La fenêtre ne contient que 4 lignes de texte, vous pouvez faire défiler le poème en appuyant sur les boutons "Haut" et "Bas", les boutons numériques changent la couleur du texte et les boutons gauche et droit vous permettent de parcourir l'historique des actions, par exemple, l'utilisateur a lu le poème de Pouchkine, est passé au poème de Yesenin, a changé la couleur du texte, puis a pensé que la couleur n'était pas très bonne et il n'aimait pas Esenin, a double-cliqué sur la flèche gauche et est retourné à l'endroit où il avait fini de lire Pouchkine.
Ce miracle ressemble à ceci:

Considérez la mise en œuvre.
Si vous réfléchissez à toutes les options, il est clair que tout ce que l'utilisateur peut faire est d'appuyer sur le bouton, en appuyant dessus, vous pouvez déterminer ce que l'utilisateur veut, et il peut souhaiter:
- Changer d'auteur
- Changer de couleur
- Défilement (haut / bas)
- Aller à la version précédente / suivante
Étant donné que l'utilisateur devrait pouvoir revenir à la version précédente, vous devez corriger ses actions et mémoriser le modèle.Par conséquent, tous les messages possibles sont décrits comme suit:
type Msg = | ConsoleEvent of ConsoleKey | ChangeAuthor of Author | ChangeColor of ConsoleColor | ChangePosition of ChangePosition | ChangeVersion of ChangeVersion | RememberModel | WaitUserAction | Exit type ChangeVersion = | Back | Forward type ChangePosition = | Up | Down type Author = | Pushkin | Lermontov | Blok | Esenin type Poem = Poem of string
Dans le modèle, vous devez stocker des informations sur le texte qui se trouve maintenant dans la console, l'historique des actions de l'utilisateur et le nombre d'actions que l'utilisateur annulera pour savoir quel modèle afficher.
type Model = { viewTextInfo: ViewTextInfo countVersionBack: int history: ViewTextInfo list } type ViewTextInfo = { text: string; formatText: string; countLines: int; positionY: int; color: ConsoleColor }
Architecture Elmish - model-view-update, le modèle a déjà été considéré, passons à la visualisation:
let SnowAndUserActionView (model: Model) (dispatch: Msg -> unit) = let { formatText = ft; color = clr } = model.viewTextInfo; clearConsoleAndPrintTextWithColor ft clr let key = Console.ReadKey().Key; Msg.ConsoleEvent key |> dispatch let clearConsoleAndPrintTextWithColor (text: string) (color: ConsoleColor) = Console.Clear(); Console.WriteLine() Console.ForegroundColor <- color Console.WriteLine(text)
Il s'agit d'une des vues, elle est dessinée en fonction de viewTextInfo , attend la réaction de l'utilisateur et envoie ce message à la fonction de mise à jour .
Plus tard, nous examinerons en détail ce qui se passe exactement lorsque le dispatch est appelé et de quel type de fonction il s'agit.
Mise à jour :
let update (msg: Msg) (model: Model) = match msg with | ConsoleEvent key -> model, updateConsoleEvent key | ChangeAuthor author -> updateChangeAuthor model author | ChangeColor color -> updateChangeColor model color | ChangePosition position -> updateChangePosition model position | ChangeVersion version -> updateChangeVersion model version | RememberModel -> updateAddEvent model | WaitUserAction -> model, []
Selon le type de msg, il sélectionne la fonction qui traitera le message.
Il s'agit d'une mise à jour de l'action de l'utilisateur, mappant le bouton sur le message, le dernier cas - renvoie l'événement WaitUserAction - ignore le clic et attend d'autres actions de l'utilisateur.
let updateConsoleEvent (key: ConsoleKey) = let msg = match key with | ConsoleKey.D1 -> ChangeColor ConsoleColor.Red | ConsoleKey.D2 -> ChangeColor ConsoleColor.Green | ConsoleKey.D3 -> ChangeColor ConsoleColor.Blue | ConsoleKey.D4 -> ChangeColor ConsoleColor.Black | ConsoleKey.D5 -> ChangeColor ConsoleColor.Cyan | ConsoleKey.LeftArrow -> ChangeVersion Back | ConsoleKey.RightArrow -> ChangeVersion Forward | ConsoleKey.P -> ChangeAuthor Author.Pushkin | ConsoleKey.E -> ChangeAuthor Author.Esenin | ConsoleKey.B -> ChangeAuthor Author.Blok | ConsoleKey.L -> ChangeAuthor Author.Lermontov | ConsoleKey.UpArrow -> ChangePosition Up | ConsoleKey.DownArrow -> ChangePosition Down | ConsoleKey.X -> Exit | _ -> WaitUserAction msg |> Cmd.ofMsg
Nous changeons l'auteur, notons que countVersionBack est immédiatement réinitialisé à 0, ce qui signifie que si l'utilisateur revient sur son histoire et souhaite ensuite changer la couleur, cette action sera traitée comme nouvelle et sera ajoutée à l' historique .
let updateChangeAuthor (model: Model) (author: Author) = let (Poem updatedText) = seed.[author] let updatedFormatText = getlines updatedText 0 3 let updatedCountLines = (splitStr updatedText).Length let updatedViewTextInfo = {model.viewTextInfo with text = updatedText; formatText = updatedFormatText; countLines = updatedCountLines } { model with viewTextInfo = updatedViewTextInfo; countVersionBack = 0 }, Cmd.ofMsg RememberModel
Nous envoyons également un message RememberModel , dont le gestionnaire met à jour l' historique , en ajoutant le modèle actuel.
let updateModelHistory model = { model with history = model.history @ [ model.viewTextInfo ] }, Cmd.ofMsg WaitUserAction
Le reste des mises à jour peut être vu ici , elles sont similaires à celles considérées.
Pour tester les performances du programme, je donnerai des tests pour plusieurs scénarios:
Les testsLa méthode d' exécution prend la structure dans laquelle la liste des messages est stockée et retourne le modèle après leur traitement.
[<Property(Verbose=true)>] let `` `` (authors: Author list) = let state = (createProgram (authors |> List.map ChangeAuthor) |> run) match (authors |> List.tryLast) with | Some s -> let (Poem text) = seed.[s] state.viewTextInfo.text = text | None -> true [<Property(Verbose=true)>] let `` `` changeColorMsg = let state = (createProgram (changeColorMsg|>List.map ChangeColor)|> run) match (changeColorMsg |> List.tryLast) with | Some s -> state.viewTextInfo.color = s | None -> true [<Property(Verbose=true,Arbitrary=[|typeof<ChangeColorAuthorPosition>|])>] let `` `` msgs = let tryLastSomeList list = list |> List.filter (Option.isSome) |> List.map (Option.get) |> List.tryLast let lastAuthor = msgs |> List.map (fun x -> match x with | ChangeAuthor a -> Some a | _ -> None) |> tryLastSomeList let lastColor = msgs |> List.map (fun x -> match x with | ChangeColor a -> Some a | _ -> None) |> tryLastSomeList let state = (createProgram msgs |> run) let colorTest = match lastColor with | Some s -> state.viewTextInfo.color = s | None -> true let authorTest = match lastAuthor with | Some s -> let (Poem t) = seed.[s]; state.viewTextInfo.text = t | None -> true authorTest && colorTest
Pour cela, la bibliothèque FsCheck est utilisée, ce qui permet de générer des données.
Considérez maintenant le cœur du programme, le code en Elmish a été écrit pour toutes les occasions, je l'ai simplifié (le code original) :
type Dispatch<'msg> = 'msg -> unit type Sub<'msg> = Dispatch<'msg> -> unit type Cmd<'msg> = Sub<'msg> list type Program<'model, 'msg, 'view> = { init: unit ->'model * Cmd<'msg> update: 'msg -> 'model -> ('model * Cmd<'msg>) setState: 'model -> 'msg -> Dispatch<'msg> -> unit } let runWith<'arg, 'model, 'msg, 'view> (program: Program<'model, 'msg, 'view>) = let (initModel, initCmd) = program.init() //1 let mutable state = initModel //2 let mutable reentered = false //3 let buffer = RingBuffer 10 //4 let rec dispatch msg = let mutable nextMsg = Some msg; //5 if reentered //6 then buffer.Push msg //7 else while Option.isSome nextMsg do // 8 reentered <- true // 9 let (model, cmd) = program.update nextMsg.Value state // 9 program.setState model nextMsg.Value dispatch // 10 Cmd.exec dispatch cmd |> ignore //11 state <- model; // 12 nextMsg <- buffer.Pop() // 13 reentered <- false; // 14 Cmd.exec dispatch initCmd |> ignore // 15 state //16 let run program = runWith program
Le type Dispath <'msg> est exactement la répartition qui est utilisée dans la vue , il prend Message et renvoie l' unité
Sub <'msg> - la fonction abonné, accepte l' envoi et renvoie l' unité , nous générons la liste Sub lorsque nous utilisons ofMsg :
let ofMsg<'msg> (msg: 'msg): Cmd<'msg> = [ fun (dispatch: Dispatch<'msg>) -> dispatch msg ]
Après avoir appelé ofMsg , tel que Cmd.ofMsg RememberModel à la fin de la méthode updateChangeAuthor , après un certain temps, l'abonné est appelé et le message entre dans la méthode de mise à jour
Cmd <'msg> - Sous- feuille <' msg>
Passons au type de programme , il s'agit d'un type générique, il prend le type de modèle, de message et de vue , dans l'application console, il n'est pas nécessaire de renvoyer quelque chose de la vue , mais dans Elmish.React view, il renvoie la structure F # de l'arborescence DOM.
Champ Init - appelé au début de elmish, cette fonction renvoie le modèle initial et le premier message, dans mon cas, je renvoie Cmd.ofMsg RememberModel
La mise à jour est la fonction principale de la mise à jour , vous la connaissez déjà.
SetState - dans le Elmish standard, il n'accepte que le modèle et la répartition et appelle la vue , mais j'ai besoin de passer msg pour remplacer la vue en fonction du message, je montrerai son implémentation après avoir considéré la messagerie.
La fonction runWith reçoit la configuration, puis appelle init , le modèle et le premier message sont renvoyés, sur les lignes 2,3, deux objets mutables sont déclarés, le premier - dans quel état sera stocké, le second est nécessaire à la fonction de répartition .
Sur la 4ème ligne, le buffer est déclaré - vous pouvez le prendre comme une file d'attente, le premier est entré - le premier est sorti (en fait l'implémentation de RingBuffer est très intéressante, je l'ai pris de la bibliothèque, je vous conseille de le lire sur github )
Vient ensuite la fonction de répartition récursive elle-même, la même que celle qui est appelée en vue , lorsque nous appelons pour la première fois, nous contournons si sur la ligne 6 et entrons immédiatement dans la boucle, définissons reented sur true afin que les appels récursifs suivants ne reviennent pas dans cette boucle, mais nouveau message dans le tampon .
Sur la ligne 9, nous exécutons la méthode de mise à jour , à partir de laquelle nous récupérons le modèle modifié et un nouveau message (pour la première fois, c'est le message RememberModel )
La ligne 10 dessine le modèle, la méthode SetState ressemble à ceci:

Comme vous pouvez le voir, différents messages provoquent des vues différentes
Il s'agit d'une mesure nécessaire pour ne pas bloquer le flux, car l'appel de Console.ReadLine bloque le flux du programme, et des événements tels que RememberModel, ChangeColor (qui sont déclenchés à l'intérieur du programme, pas par l'utilisateur) attendront chaque fois que l'utilisateur clique sur le bouton, bien qu'ils aient juste besoin de changer la couleur.
Pour la première fois, la fonction OnlyShowView sera appelée, qui dessinera simplement le modèle.
Si le message WaitUserAction arrivait à la méthode au lieu de RememberModel, la fonction ShowAndUserActionView serait appelée , ce qui dessinerait le modèle et bloquerait le flux, en attendant que le bouton soit pressé, dès que le bouton serait pressé, la méthode de répartition serait appelée à nouveau et le message serait envoyé dans la mémoire tampon (parce que reenvited = false )
Ensuite, vous devez traiter tous les messages provenant de la méthode de mise à jour , sinon nous les perdrons, les appels récursifs n'entreront dans la boucle que si la réapparition devient fausse. La ligne 11 semble compliquée, mais en fait, ce n'est qu'une poussée de tous les messages dans le tampon :
let exec<'msg> (dispatch: Dispatch<'msg>) (cmd: Cmd<'msg>) = cmd |> List.map (fun sub -> sub dispatch)
Pour tous les abonnés renvoyés par la méthode de mise à jour , la répartition sera appelée, ajoutant ainsi ces messages au tampon .
Sur la ligne 12, nous mettons à jour le modèle, obtenons un nouveau message et revenons à false lorsque le tampon n'est pas vide, ce n'est pas nécessaire, mais s'il n'y a plus d'éléments et que la répartition ne peut être appelée que depuis la vue , cela a du sens. Encore une fois, dans notre cas, lorsque tout est synchrone, cela n'a pas de sens, car nous attendons un appel de répartition synchrone sur la ligne 10, mais s'il y a des appels asynchrones dans le code, la répartition peut être appelée à partir du rappel et vous devez pouvoir continuer le programme.
Eh bien, c'est toute la description de la fonction de répartition , à la ligne 15, elle est appelée et l' état est renvoyé à la ligne 16.
Dans une application console, la sortie se produit lorsque le tampon devient vide. Dans la version originale, runWith ne renvoie rien, mais les tests sont impossibles sans cela.
Le programme de test est différent, la fonction createProgram accepte une liste de messages que l'utilisateur lancerait et dans SetState ils remplacent le clic habituel:

Une autre différence entre ma version modifiée et la version d'origine est que la fonction de mise à jour est appelée en premier, puis uniquement la fonction setState , au contraire, dans la version d'origine, en affichant d'abord et ensuite le traitement des messages, j'ai dû le faire en raison d'un appel bloquant à Console.ReadKey (besoin de changer vue )
J'espère que j'ai réussi à expliquer comment Elmish et les systèmes similaires fonctionnent, beaucoup de fonctionnalités Elmish ont été laissées, si vous êtes intéressé par ce sujet, je vous conseille de consulter leur site Web .
Merci de votre attention!