Did someone not like Redux in React because of its implementation on JS?
I did not like clumsy switch-cases in reducer, there are languages with more convenient pattern matching, and types are better modeling events and models. For example, F #.
This article is an explanation of the messaging device in Elmish .
I will give an example of a console application written on this architecture, with his example it will be clear how to use this approach, and then we will understand the architecture of Elmish.
I wrote a simple console application for reading poems, in seed'e there are several poems, one for each author, which are displayed on the console.
The window contains only 4 lines of text, by pressing the "Up" and "Down" buttons you can flip through a poem, the numeric buttons change the text color, and the left and right buttons allow you to move through the action history, for example, the user read Pushkin's poem, switched to Yesenin's poem, changed the color of the text, and then I thought that the color was not very good and he did not like Yesenin, double-clicked the arrow to the left and returned to the place where Pushkin had finished reading.
This miracle looks like this:
Consider the implementation.
If you think through all the options, it is clear that all the user can do is to press a button, by pressing it, you can determine what the user wants, and he may wish:
Since the user must be able to go back to the version, you need to fix his actions and remember the model , as a result, all possible messages are described as follows:
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
In the model, you need to store information about the text that is now in the console, the history of user actions and the number of actions to which the user will roll back in order to know which particular model to show.
type Model = { viewTextInfo: ViewTextInfo countVersionBack: int history: ViewTextInfo list } type ViewTextInfo = { text: string; formatText: string; countLines: int; positionY: int; color: ConsoleColor }
The architecture of Elmish - model-view-update, the model has already been considered, we turn to the view:
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)
This is one of the views, it is drawn based on viewTextInfo , waits for the user's reaction, and sends this message to the update function.
Later we will examine in detail what exactly happens when you call dispatch , and what kind of function it is.
Update :
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, []
Depending on the type of msg , which function will be used to process the message.
This is an update to the user's action, a button-to-message mapping, the last case - the WaitUserAction event returns - ignoring the click and waiting for further user actions.
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
We change the author, note that countVersionBack is immediately reset to 0, which means that if the user rolls back in his history and then wants to change color, this action will be treated as new and will be added to history .
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
We also send a message to RememberModel , whose handler updates history by adding the current model.
let updateModelHistory model = { model with history = model.history @ [ model.viewTextInfo ] }, Cmd.ofMsg WaitUserAction
The rest of the update you can see here , they are similar to those considered.
To test the program, I will give tests for several scenarios:
The run method takes a structure in which the Messages list is stored and returns the model after they are processed.
[<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
To do this, use the library FsCheck, which provides the ability to generate data.
Now consider the core of the program, the code in Elmish was written for all occasions, I simplified it ( original code) :
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
The type of Dispath <'msg> is exactly the dispatch that is used in the view , it takes a Message and returns a unit
Sub <'msg> is the subscriber function, accepts dispatch and returns unit , we generate a Sub list when using ofMsg :
let ofMsg<'msg> (msg: 'msg): Cmd<'msg> = [ fun (dispatch: Dispatch<'msg>) -> dispatch msg ]
After calling ofMsg , such as Cmd.ofMsg RememberModel, at the end of the updateChangeAuthor method, a subscriber will be called after some time and the message will be sent to the update method
Cmd <'msg> - Sheet Sub <' msg>
Let's go to the Program type, it is a generic type, it accepts the model type, the messages and the view , in the console application there is no need to return something from the view , but in Elmish.React view it returns the F # structure of the DOM tree.
The init field is called at the start of elmish, this function returns the initial model and the first message, in my case I return Cmd.ofMsg RememberModel
Update is the main update function, you are already familiar with it.
SetState - in standard Elmish accepts only the model and dispatch and calls the view , but I need to send msg to replace the view depending on the message, I will show its implementation after we look at the exchange of messages.
The runWith function, gets the configuration, then calls init , the model and the first message are returned, on lines 2,3 two modified objects are declared, the first in which the state will be stored, the second is needed for the dispatch function.
At line 4, buffer is declared - you can take it as a queue, first went in - first came out (in fact, the implementation of RingBuffer is very interesting, I took it from the library, I advise you to read it on github )
Next comes the recursive dispatch function itself, the same one that is called in the view , on the first call, we bypass if on line 6 and immediately get into the loop, set reented to true , so that subsequent recursive calls do not enter this cycle again, but add new message in buffer .
On line 9, we execute the update method, from which we take the modified model and the new message (for the first time this message is RememberModel )
Line 10 draws a model; the SetState method looks like this:
As you can see, different messages cause different views.
This is a necessary measure not to block the flow, because a call to Console.ReadLine blocks the program's flow, and events such as RememberModel, ChangeColor (which are initiated inside the program, and not by the user) will wait each time the user presses the button, although they just have to change Colour.
The first time, the OnlyShowView function will be called, which will simply draw the model.
If instead of RememberModel the WaitUserAction message came to the method , then the ShowAndUserActionView function would be called , which would draw the model and block the stream, waiting for the button to be pressed, as soon as the button was pressed, the dispatch method would be called again, and the message would run in buffer (because reenvited = false )
Next, you need to process all messages that come from the update method, otherwise we will lose them, recursive calls will get into the loop only if reented becomes false. The 11th line looks complicated, but in fact it’s just pushing all the messages in buffer :
let exec<'msg> (dispatch: Dispatch<'msg>) (cmd: Cmd<'msg>) = cmd |> List.map (fun sub -> sub dispatch)
For all subscribers returned by the update method, dispatch will be called, thus these messages will be added to the buffer .
On the 12th line, we update the model, fetch a new message and return the reented value to false, when the buffer is not empty, but if there are no elements left and dispatch can only be called from the view , it makes sense. Again, in our case, when everything is synchronous, it does not make sense, since we expect a synchronous call to dispatch on line 10, but if there are asynchronous calls in the code, you can call dispatch from the callback and need to be able to continue executing the program.
Well, that's all the description of the dispatch function, on line 15 it is called and on 16 it returns state .
In a console application, an exit occurs when buffer becomes empty. In the original version, runWith returns nothing, but without this testing is impossible.
Program for testing is different, the createProgram function accepts a list of messages that the user would initiate, and in SetState they replace the usual click:
Another difference of my modified version from the original one is that the update function is called first, and then only setState , in the original version, on the contrary, it first draws and then processes the messages, I had to go for this because of the blocking call Console.ReadKey (the need to change view )
I hope I managed to explain how Elmish and similar systems are arranged, quite a lot of Elmish functionality left behind, if you are interested in this topic, I advise you to look at their website .
Thanks for attention!
Source: https://habr.com/ru/post/459870/