Building reusable abstractions for browser-based frontends is a widely addressed, but only partially solved problem. Libraries such as jQuery UI provide developers already with a useful collection of commonly used UI elements. This frees developers from having to implement, for example, interactive date picker widgets and allows them to focus more on the actual business logic. However, such libraries come at a price: as soon as something is required, which is not provided by the library, things get messy.
The new requirement can be fulfilled by extending the library, which requires knowledge of the library's inner workings. The benefit of this approach is consistency. The newly developed widget ideally behaves like the rest of the widgets provided by the library.
Alternatively, the requirement can be fulfilled by using another library or creating a custom implementation. This approach can quickly lead to an inconsistent codebase but usually provides a faster way to a first implementation (based on the assumption that writing code is easier than reading it).
I propose a set of rules, which are not tied to any framework or library, with the aim of facilitating the creation of reusable UI components. The secondary goal of these rules is to do this without breaking progressive enhancement.
Only one piece of code is allowed to mutate a given subtree of the DOM. This is called owning a subtree.
Rationale: the DOM is global state and accessible to every piece of code. As such, the usual principles for handling global variables should be applied to prevent the problems associated with global state.
All meaningful state is stored in the DOM. Meaningful state is state that carries a meaning for the application, e.g. for a blogging application, state related to articles is most likely considered meaningful.
Rationale: Putting all meaningful state into the DOM enables the server to generate the state, and the client to work with this state. It eliminates the need to serialize and deserialize data (e.g. using JSON or XML). Also, the user agent does not need any capabilities beyond parsing HTML.
Pieces of code communicate through events, using the DOM as the event bus. Every public function should trigger events in order to notify other parts of the application about the function call. Public functions should be invokable by triggering the appropriate event on the element owned by this piece of code.
Rationale: Triggering events for every function call on the DOM allows for easy delegation using event bubbling. By making every public function available through the DOM, the DOM becomes the single interface to interact with the application.
This is an example application built adhering to the rules described above. It is a simple todo list application, allowing the creation and removal of todo items as well as marking them as "done". The count of items marked as "done" and the count of all items is display in the headline, next to the word "Todos".
Note: The implementation of the example presented above follows progressive enhancement only partially: the creation of new todo items is possible without JavaScript (given a matching server backend), the modification and deletion of items is not.
The example application builds
on jQuery for DOM manipulation and
event handling. Components are defined using the
function Component.create(definition)
, which returns a
constructor for a new UI component based on definition
.
Here are parts of the definition of a single todo item:
{
_namespace: "todo-list",
_references: {
"done": ".todo-item-done"
},
_events: {
"change .todo-item-done": "toggle"
},
toggle: function(data, event) {
if (this.$done.is(":checked")) {
this.markDone();
} else {
this.markNotDone();
}
},
markNotDone: function(data, event) {
this.$root.removeClass("is-done").addClass("is-not-done");
},
markDone: function(data, event) {
this.$root.addClass("is-done").removeClass("is-not-done");
},
remove: function(data, event) {
this.$root.remove();
}
}
Component.create
treats some of these keys specially:
_namespace
_references
this.$done
is defined in the example
definition._events
(event, selector)
-tuples to method
names. This map is used for setting up event handlers during
initialization. In the example, the toggle
method
is invoked when the change
event is triggered by an
element in the todo item matching .todo-item-done
.All other keys starting with a lowercase letter and mapping to a
function are recognized as method definitions. The method
definitions are wrapped to trigger events on the root DOM element of
the component instance whenever the method is called: one before the
method call (before-$METHOD_NAME
) and one after the
method call (after-$METHOD_NAME
). Additionally, the
methods can be invoked by triggering a do-$METHOD_NAME
event on said DOM element.