Lately I've been experimenting with js_of_ocaml
(from Ocsigen)
and I have developed
a small library to help me write client-side browser applications.
The library is called Funweb (for Functional Web) because imagination
is hard. It is available on GitHub.
Browser applications have two sides: the description of the user interface, which is a DOM tree, and the code which actually does stuff. Graphical User Interface (GUI) programming can be messy when the two are interleaved, especially if the user interface itself can be the result of computations.
See some examples here:
The code can be found in the repository.
The first goal of Funweb is that the code which produces the DOM should look like HTML. For instance, here is a login form:
form [
div [
text "Username: ";
input_text username;
];
div [
text "Password: ";
input_password password;
input_checkbox show_password;
text " Show password";
];
button [ text "Login" ];
]
(Ignore variables username
, password
and show_password
for now.)
Here is the equivalent HTML:
<form>
<div>
Username:
<input type="text">
</div>
<div>
Password:
<input type="password">
<input type="checkbox">
Show password
</div>
<button>Login</button>
</form>
As you can see, this is pretty close to the OCaml code.
Interactive nodes, such as the username field in our example, are not very useful if we can't refer to them to, say, read their contents. Imagine our username field actually has a few more attributes:
<form>
<div>
Username:
<input name="username" class="username" type="text" disabled size="32">
</div>
...
</form>
Giving a name to this button is easy in HTML; you just use the name
attribute. The OCaml equivalent would be to bind this node as follows:
let username = input_text ~c: "username" ~disabled: true ~size: 32 () in
form [
div [
text "Username: ";
username;
];
...
]
We have to move the definition of the node outside of the div and of the
form, otherwise the scope of the username
variable would be too limited
to be useful.
The DOM tree is scattered, making it harder to maintain.
It looks less like HTML, and more importantly
the description of the user interface is starting to get mixed with the
code.
Instead of this approach, Funweb provides properties. They basically are binders for nodes. They are declared before the DOM description, but this declaration does not contain DOM information such as: this is an input node of size 32. This information is put in the DOM description. The above example becomes:
let username = Property.(single Volatile string "") in
form [
div [
text "Username: ";
input_text ~c: "username" ~disabled: true ~size: 32 username;
];
...
]
This is more like HTML, except that names have to be declared as
properties first. We will explain Property.single
and its
arguments later in this article.
During my experimentations without Funweb I found that I would bind nodes to:
We'll see later that with Funweb, replacing nodes can be done without binding them.
If the only operation we perform using a property is to access the value of an input node, properties can be seen as references on those values. It is more than a reference because when the property is set, the node value must be updated. Conversely, when the user modifies the input (by typing text or clicking a checkbox) the property must be updated. This is done automatically by Funweb.
The name "property", in fact, comes from the language construction from Delphi, where properties are pairs of a setter and a getter which can be accessed using the syntax for regular fields. This is a rather convenient feature which allows to abstract fields without changing how they are accessed.
So, Funweb properties are used to access the value of input nodes.
One can use Property.get p
(or !!p
) to read, and
Property.set p x
(or p <-- x
) to write.
Without Funweb, one may wonder whether setting the value of an input
field from its on_change
event may cause the on_change
event to be
triggered again, resulting in an infinite loop. Using Funweb, one no
longer has to care.
Since properties may contain inputs from the user, it makes sense to want to save them for future sessions. There are several ways to save information in a browser.
The first method is to use cookies. Cookies are saved by the browser and can be read when the application is reloaded. It is worth noting, though, that cookies are also transferred to the HTTP server, which may or may not be what we want.
Another method is to store data in the fragment, or hash, of the URL.
This is the part which is after the #
character.
It can be read and set using Javascript, and one can react to changes
by listening to the onhashchange
event.
If we store the state of the application in the URL hash,
it means that the user can bookmark the state to restore it later.
It also means that he can share the state with other users, just by
sending them the URL, including the hash.
Finally, it means that the Back and Forward buttons of the browser
will allow the user to restore previous states.
Each method has its use case, and both can be used at the same time. The URL hash is usually used to identify the resource which is being viewed. After all, URL stands for Uniform Resource Locator. For instance, it can be used to store the contents of a search field, a position in a media, or the name of a Wiki page. Sensitive information, however, should not be stored in the URL hash, as users may easily share URLs.
When one defines a property, one chooses a save method. There are three:
Volatile
, which means that the property is not saved;Cookie
, which means that the property is saved using a cookie;URL
, which means that the property is saved in the URL hash.Properties are then loaded and saved automatically at appropriate times. Values are base64-encoded using characters which do not interfere with the syntax of URLs and cookies.
Here is how we could define the properties for our Login form:
let username = Property.(single (cookie "username") string "")
let password = Property.(single Volatile string "")
let show_password = Property.(single URL bool false)
The last argument is the default value, which is used if no save exists.
There is also a function named Property.reset_all
which resets all
properties to their default values. Implementing a reset button is
trivial:
button ~on_click: Property.reset_all [ text "Reset" ]
Radio inputs are checkboxes which can be grouped together, and only
one radio input of a given group can be checked at a given time.
We don't want to define a bool
property for each radio input.
Instead, we want to have one property for each group, whose value
reflects the radio input which is currently checked.
However, properties can only be used by a single node at a time
(more on that later). So Funweb also implements group properties.
Regular properties are called single properties.
To declare a group property, one just uses Property.group
instead
of Property.single
. The result
of Property.single
has type ('a, single) Property.t
while the result of Property.group
has type ('a, group) Property.t
.
Both types can be accessed using Property.get
and Property.set
,
but radio inputs expect group
properties while other nodes
expect single
properties.
When defining a radio input, one specifies the group property as well as the value for this property which the radio input represents. For instance, here is how to provide a choice between 1, 2 and 3:
let choice = Property.(group "choice" Volatile int 1)
...
div [
input_radio choice 1; text "1";
input_radio choice 2; text "2";
input_radio choice 3; text "3";
]
The first argument of Property.group
(here "choice"
) is the name
attribute given to the radio input nodes. This ensures that only
one of them is active at a time. Each group must have a unique name.
So far we only saw how to describe a DOM tree and how to save input nodes. We don't have an application if we do nothing with those inputs. This is where events and dynamic nodes come into play.
Event handlers can be attached to nodes to react to user interactions.
For instance, one can associate an on_click
event to a button.
Here is how we can implement a unit converter from kilometers to miles:
open Funweb
open Property.Symbols (* to use !! and <-- *)
open Html
let input = Property.(single URL string "0")
let output = Property.(single URL string "0")
let convert () =
output <-- string_of_float (float_of_string !!input /. 1.609344)
let () =
run @@ fun () ->
div [
input_text input;
text "km = ";
input_text output;
text "miles ";
button ~on_click: convert [ text "Convert" ];
]
However the user has to click on the Convert button to actually perform the conversion. Another issue is that the user may edit the output field and expect the input field to be updated when he clicks Convert. Here is an implementation using a dynamic node which solves both of these issues:
open Funweb
open Property.Symbols (* to use !! and <-- *)
open Html
let input = Property.(single URL string "0")
let () =
run @@ fun () ->
div [
input_text input;
text "km = ";
(
dynamic @@ fun () ->
text (string_of_float (float_of_string !!input /. 1.609344))
);
text " miles";
]
dynamic
takes a function which builds a node. The result of
dynamic
is the node that this function builds.
However, each time a property is modified, either by the user or
using Property.set
, the function is called to rebuild the node.
In this example, each time the user types a character in the
input field, the output is updated.
By default, dynamic nodes are rebuilt when any property is modified,
but one can restrict this behavior to a subset of the properties
using optional argument deps
(which stands for "dependencies").
Rebuilding whole parts of the DOM tree may sound scary. In practice, it is rather fast, at least for small trees. You don't have to rebuild the whole tree anyway; only the parts which depends on user inputs.
Funweb ensures that when you rebuild an input node, the previous existing node is reused if its type is the same so that its contents and the cursor position is not lost. This is why you cannot attach a single property to more than one node: properties can still be viewed as node binders, not just references.
Focus is also restored to the node which had it before rebuilding if this node still exists. Otherwise, the user would have to re-select input fields after each character typed.
Note that properties do not have to be attached to a node. This can be useful to have invisible data be automatically saved, or to have a dynamic node be rebuilt when invisible data gets modified.
You can also attach a property to a node sometimes. For instance, there may be an option to hide advanced parts of the UI. Let's say that they contain a checkbox. When this checkbox is visible, it is attached to a property. When this checkbox is hidden, the property still exists but it is not necessarily attached to a node.
The underlying ideas are definitely not new but they work well in practice. The most important features are properties, simple DOM node constructors, and dynamic nodes. Their implementation is rather simple and lightweight. Although Funweb is only a prototype, I'll be happy to use it for my own projects.