package fmlib_browser

  1. Overview
  2. Docs

doc/doc_single_page_application.html

Writing a single-page application

Getting Started Up Event Handler

Single-page applications

Motivation

On classical websites, changing to a different page means that a new HTML document is requested from the server. This is called a page load and can cause a noticeable delay and flickering as the new document is rendering.

E.g. for online banking there might be several pages on the server

    https://mybank.com/accounts/login.html
    https://mybank.com/accounts/main.html
    https://mybank.com/accounts/transfer.html
    https://mybank.com/accounts/order.html
    https://mybank.com/accounts/postbox.html
    ...

After the login sequence a main page is displayed which provides e.g. the financial status of your account. Next you might click on a link to make a money transfer to another account. Or might click on a link to buy some assets in your portfolio etc. From each specific page there is usually a link to the main page.

A clickable link on the different pages has the form

    <a href=transfer.html>Money transfer</a>

Usually relative links are used to request specific page of the account management. The browser resolves the relative link to the absolute link

    https://mybank.com/accounts/transfer.html

Each different activity on the account requires a separate page load. For the web browser all pages are completely independent. The web server has to manage state and session data which have to be transferred back and forth on each page load.

In single-page applications, there is only a single page load (hence the name): the inital one. Instead of downloading one HTML document per page, the frontend code is responsible for implementing a "virtual page" concept. When changing pages, a virtual DOM algorithm replaces only parts of the DOM which can speed up rendering and reduce flickering. Furthermore the browser can manage all state data.

Basic Mechanism

In single page applications the default action of a link (i.e. load a new page into the browser) is suppressed. All clicks on anchors are intercepted. A click on an anchor element is fed into a function on_url_request (see Fmlib_browser.application) which gets the resolved (i.e. absolute) url, analyzes it and generates a message which is fed into the update function of the application.

The update function decides what to do. If the update function ignores the message, then nothing happens. Usually the update function gets with the message enough information to decide if the clicked link belongs to the application or requires a page load. If the link does not point to the application then the update function can isssue a command to trigger a page load. If the link belongs to the application the update function usually issues a command to push the new url into the browser history.

The command push_url enters the new url to the browser history and generates an event which is fed into a function on_url_change (see Fmlib_browser.application). This function gets the new url, analyzes it and generates a message which is fed into the update function. By receiving this message the application switches to the new page.

Alternatively the command replace_url can be used to replace the current entry in the browser history with the new url. Then the new url generates via on_url_change a message which is fed into the update function and trigger the switching to the new page.

Switching to an external page by triggering a page load is a one step process. The link is clicked, it goes through the on_url_request function and then the page load is triggered.

Switching to an internal page is a two stage process. The link is clicked, it goes through the on_url_request and generates a push_url or replace_url command. This generates an event where the new url is fed into the on_url_change function which generates a message to change to the new virtual page.

Summary:

  1. User clicks on an anchor element with an href attribute
  2. Resolved url generates via on_url_request a message which is fed into the update function
  3. Update function decides what to do:

    • Load an external page
    • Generate an new entry in the browser history via push_url or replace the current entry in the browser history via replace_url
  4. In case a new url is pushed into the browser history or the current url in the browser history is replaced: The new url generates via on_url_change a message which is fed into the update function.
  5. The update function changes the state such that it represents the new url

Using the Browser History

A switch to a different internal page of the application creates a new entry into the browser history. The user might press the back button (maybe several times) or the forward button. As long as the url in the corresponding entry of the browser history belongs to the single page application, the browser does not trigger a page load. It just creates an event which is fed into the on_url_change function which creates a message which is then fed into the update function.

I.e. moving through the browser history of the single page application by pressing the forward or backward button of the browser is a one stage process. The button is clicked, the new url is fed into the on_url_change function to generate a message which is sent to the update function. The update function switches to the new internal page.

The user can move through the browser history by pressing the back or forward button of the browser. Alternatively moving through the browser history can be triggered by the commands Fmlib_browser.Command.back and Fmlib_browser.Command.forward.

It is an important decision whether to switch to a new virtual page via the push_url or the replace_url command. The push_url command enables the user to switch arbitrarily between the virtual pages of the single page application by pressing the back and forward button of the browser. If the replace_url command is used, no new entry is entered into the browser history. Therefore the user can use only the interfaces of the single page application to switch between the virtual pages. Usually the push_url commands is used to switch between relatively independent virtual pages of the single page application. If the virtual pages are tightly coupled like e.g. in a banking application the command replace_url might be the better choice.

Summary:

  1. The user presses the back or forward button or the command Command.back or Command.forward is issued.
  2. The new url generates via the on_url_change a message which is fed into the update function.
  3. The update function switches to a state representing the new url.

Example

Counter and Digital Clock

In order to demonstrate the implementation of a single page application we merge the counter and the digital clock applications shown in the chapter Getting Started into a single page application.

A single page application with more subapplications can be found at https://github.com/hbr/fmlib/tree/master/src/examples/browser with the files single_page.ml, single_page_counter.ml, ...

The counter page and the digital clock page are completely independent, therefore we use the command push_url to switch between the virtual pages and the user can arbitrarily push the back and forward button of the browser.

We assume that the single page application is on a webserver at

    /fmlib/webapp

i.e. on the webserver there are the files

    /fmlib/webapp/single_page.html
    /fmlib/webapp/single_page.js

i.e. the single page application is loaded by letting the browser load the url

    https:/hbr.github.io/fmlib/webapp/single_page.html

with a single load (i.e. it loads the html file and the javascript file).

Defining the pages

Our example has 3 virtual pages:

  1. A home page offering a menu to switch between the pages.
  2. A page representing the counter.
  3. A page representing the digital clock.

In this simple example it makes sense to run the different pages in parallel i.e. the clock application maintains its state and gets each second an update of the current time even if it is not the current page and the counter page maintains its state even if it is not the current page.

In a more complex single page application the structure might be completely different.

It is convenient to put the logic of each page into an ocaml module. Each module in our example satisfies the signature

    module type PAGE =
    sig
        type msg
        type state
        val init:   state * msg Command.t
        val view:   state -> msg Html.t * string (* Virtual dom and
                                                    a title string *)
        val update: state -> msg -> state * msg Command.t
        val subscriptions: state -> msg Subscription.t
    end

First we define the home page which has no internal logic and just displays a menu to switch the counter or the digital clock.

    module Home_page =
    struct
        type msg = |        (* The home page does not receive messages. *)
        type state = unit   (* No interesting state. *)
        let init: state * msg Command.t = (), Command.none
        let view (_: state): 'a Html.t =
            (* There are no messages, therefore ['a Html.t] is possible. *)
            let open Html in
            let open Attribute in
            ( ul [] [
                    li [] [href "counter"];
                    li [] [href "digital clock"];
              ]
            )
        let update (): msg -> state * msg Command.t =
            function
            | _ -> .       (* No messages, therefore no call to the update
                              function possible. *)
        let subscriptions (_: state): msg Subscription.t =
            assert false (* Illegal call *)
    end

The modules Counter_page and Digital_clock_page can be defined easily by using the implementations shown in the chapter Getting Started.

    module Counter_page =
    struct
        type msg = Increment | Decrement
        type state = {counter: int}
        let init: state * msg Command.t = {counter = 0}, Command.none
        let view (s: state): msg Html.t  =
            ...
        let update = ...
        let subscriptions = fun _ _ = assert false (* Illegal call *)
    end

    module Clock_page =
    struct
        type msg = | Got_time of Time.t | Got_time_zone of Time.Zone.t
        type state = {time: Time.t; zone: Time.Zone.t}

        let init = ...
        let view = ...
        let subscription = ...
    end

Messages and State

In the single page application we need global messages and a global state and an indicator for the current page.

    type msg =
        | Clicked_link of Navigation.url_request
        | Changed_url of Url.t
        | Got_counter_msg of Counter_page.msg
        | Got_clock_msg   of Clock_page.msg

    type page = Home | Counter | Clock (* The current page *)

    type state = {
        key: msg Navigation.key; (* The navigation key is needed
                                    for [push_url] *)
        counter: Counter_page.state;
        clock:   Clock_page.state;
        page:    page;
    }

    (* Ocaml does not allow to use constructors as functions, therefore the
       following functions are convenient. *)

    let counter_msg m = Got_counter_msg m
    let clock_msg m   = Got_clock_msg   m

Note that the states of the counter page and the clock page are present in the global state even if they are not the current pages. No state of the home page is needed in this simple example, because the state of the home page is trivial.

View

We present all virtual pages in a common format

    Home button                             link to documentation

    Headline for the virtual page

    Virtual page

The function view_frame displays a specific page in a common format.

    let view_page
        (page: msg Html.t) (headline: string)
        : msg Html.t * string
        =
        let open Html in
        let open Attribute in
        div [] [
            nav [margin "20px"] [
                a [href "single_page.html"] [text "Home"];
                a [href "... link to documentation ...";
                   style "float" "right"]
                  [text "documentation"]
            ];
            div [margin "20px";
                 border_style "solid";
                 padding "0px 20px 20px 20px"]
                [ h2 [] [text headline];
                  page
                ]
        ]

We get the virtual dom of the pages by calling the view function of the corresponding page. In order to get a virtual dom for the single page app the virtual dom of the specific page (except the home pages which has no messages) has to be mapped to a part of the virtual dom of the whole app.

    let page_html (state: state): msg Html.t * string =
        match state.page with
        | Home ->
            Home_page.view (), (* trivial state, no mapping needed *)
            "Home"
        | Counter ->
            Counter_page.view state.counter |> Html.map counter_msg,
            "Counter"
        | Clock ->
            Clock_page.view state.clock |> Html.map clock_msg,
            "Clock"

With these functions it is easy to write the view function for the single page application.

    let view (state: state): msg Html.t * string =
            let page, title = page_html state
            in
            view_page page title, title

Update

In order to route to the correct virtual page we need a function which parses the internal url and returns the correct page.


    let route (url: Url.t): page =
        (* Find the new page by parsing the internal url. *)
        let open Url.Parser in
        match
            parse
                ( s "fmlib" </>
                  s "webapp" </>
                  one_of [
                    map Home    (s "single_page.html")
                    map Counter (s "counter");
                    map Clock   (s "clock");
                  ])
                url
        with
        | None ->
            assert false (* Cannot happen, one path must match. *)
        | Some page ->
            page

route uses the functions one_of, map and s from the Url.Parser module. Each of these functions returns a path parser (for parsing the path of a Url.t):

  • s "fmlib" tries to consume the path segment "fmlib"
  • s "webapp" tries to consume the path segment "webapp"
  • s "counter" tries to consume the path segment "counter"
  • map Counter p produces the value Counter of type page if the parser p is successful
  • one_of combines multiple path parsers and yields the result of the first successful one

This kind of parsing is based on monadic parser combinators and might seem very unfamiliar to developers who are new to functional programming. For now it is sufficient to understand, that it allows us to combine several "sub-parsers", each parsing a specific route, into a bigger parser. This parser is then passed to Url.Parser.parse, which either returns Some page or None.

With the help of the route function it is easy to write the update function. Note that an url request is external only if it has a different origin than the web application, but a url on the same origin might point to a different application. Therefore we have to analyze an internal url as well.

A message arriving for a specific page is forwarded to the correct module with the appropriate mapping of the messages.

    let update (state: state): msg -> state * msg Command.t =
        function
        | Clicked_link (External e) ->
            state, Command.load e

        | Clicked_link (Internal i) ->
            let url = Url.to_string i
            in
            state,
            if String.starts_with ~prefix:"/fmlib_webapp" i.path then
                Command.push_url state.key url
            else
                (* Link to the documentation might be on the same server *)
                Command.load url *)

        | Changed_url url ->
            {state with page = route url},
            Command.none

        | Got_counter_msg msg ->
            let counter, cmd = Counter_page.update state.counter msg in
            {state with counter}, Command.map counter_msg cmd

        | Got_clock_msg msg ->
            let counter, cmd = Clock_page.update state.clock msg in
            {state with clock}, Command.map clock_msg cmd

Subscriptions

Only the digital clock page has subscriptions to timer events.

    let subscriptions (state: state): msg Command.t =
        Single_page.subscriptions state.clock
        |> Command.map clock_msg

Glueing it all together

The goal is to call application which produces the most fully-featured kind of application of this library:

    let _ =
        application
            "single_page_app"
            init
            view
            subscriptions
            update
            (fun req -> Clicked_link req) (* on_url_request *)
            (fun url -> Changed_url url) (* on_url_change *)

The first argument is an application identifier which allows referencing the application from Javascript. The last two arguments are for URL management:

  • on_url_request is called when the user clicks a link. It takes a url_request and produces a message called Clicked_link
  • on_url_change is called when the URL in the browser's address bar has actually changed. It takes a Url.t and produces a message called Changed_url

See module Navigation for more information about URL management.

Instantiating the application from HTML

Compilation works the same as in the previous example but the instantiation from HTML works slightly differently:

    <!DOCTYPE html>
    <html>
        <head>
            <meta name="viewport" content="width=device-width, initial-scale=1">
            <script type="text/javascript" src="main.js">
            </script>
            <script>
                single_page_app.init ({
                    data: null,
                    onMessage: () => {},
                })
            </script>
        </head>
        <body>
        </body>
    </html>

We call init on the single_page_app subject. This function takes a dictionary with two fields:

  • data allows allows passing data to our application from Javascript
  • onMessage allows sending messages from our application to Javascript

Here, we simply pass null and an empty function.

Note, that to run this example, we need a webserver, that serves this HTML document. Simply storing it into an index.html file and open it in the browser will not work, because that gives us a file URL, but only http or https URLs are supported by this library.

We can run python3 -m http.server or write a webserver in OCaml (see examples/browser/single_page_backend.ml for an example).