The idea of a virtual DOM is really great. But one thing I've realized is that, often, the work a virtual DOM algorithm is doing falls into one of three cases.
- Updating DOM nodes in place--for example, changing the color of a button, adding or removing an "Error: Invalid Password!" message, et cetera.
- Completely re-rendering the screen, or a section of it--for example, rendering a client-side route, or loading dynamic content into a side panel (like emails in gmail).
- Handling a dynamic list--e.g. the classic todo list example, or sorting the rows of a table.
For (1), the virtual DOM seems unnecessary--you can just modify the DOM node directly from an internal representation of the state. In other words, the function from state to DOM operations is trivial.
For (2), the virtual DOM seems like overkill--if you're just replacing everything, why not use element.innerHTML = "..."
?
I suspect the performance difference, when it's a total redraw, is minimal. Again, the function from internal state to DOM operations is trivial.
This just leaves (3). Here, the function from state to DOM operations is non-trivial, and thus is a place where the virtual DOM technique is genuinely useful.
But notice! React, Vue, etc. all require a "key" attribute in case (3), to make comparisons. These frameworks set a "data-key" attribute, or something, on the DOM nodes to keep track of the appropriate key. And, in case (3), the DOM tree is basically "flat"--you don't have to recurse into the child elements at all, you're really just comparing by keys.
So even in case (3), while the function from state to DOM operations is non-trivial, the entire functionality of a virtual DOM framework is still unnecessary.
So I thought why not write the non-trivial bit of logic, in it's minimal form, for future reference?
It is mildly configurable, and comes in functional and Object-Oriented flavors.
I hope it's useful, or at least interesting!
Check out demo.html
for an example. It's a localStorage-backed todo list! The relevant JS part looks like this (modulo some helper functions)
let items = JSON.parse( localStorage.getItem('todos') || '[]' );
let text = $("#todo-form input");
let button = $("#todo-form button");
let todos = new ListView($("#todos"), (todo) => {
let li = $make("li");
$append(li, "span", { innerText: todo.value });
$append(li, "button", { innerText: " 🗑 " , className: 'delete-button',
onclick: (ev) => {
let state = todos.state;
state.splice(state.indexOf(todo), 1);
todos.update(state);
}
});
return li;
});
todos.update(items); // initial render
todos.listen((state) => localStorage.setItem('todos', JSON.stringify(state)));
button.addEventListener('click', (ev) => {
ev.preventDefault();
todos.update(todos.state.concat([{ value: text.value, key: text.value }]));
text.value = '';
});
ListView
binds to a container (any DOM element), and takes a make
function which is used to construct todo items from the raw state that ListView
wraps over.
Basically, make
is like a DIY React component function--it takes in one element from a list of state items, and returns a DOM node ready to be inserted.
The keyName
attribute is optional. But, if you're already using the data-key
attribute, you can pass in another string, like "rutabaga"
, and and ListView will use data-rutabaga
instead.
(Side-note: rutabaga has far too many a
s).
Pass in a new list. This will update ListView's internal .state
attribute, and call out to updateList
in order to update the DOM accordingly.
.update
will also notify any listeners of the state change, and pass in the new list.
Pass in a function, that accepts one or two arguments: the new state, and (optionally) the old state. This function will get called after every update.
Gets the current state.
Is ListView
too much framework for you? Then feel free to use the raw updateList
function instead!
Just pick a parent container for updateList
to work within.
Then, pick a make
function that constructs DOM nodes for elements that are being inserted into the DOM.
Then, whenever you need to update, pass in the container, the make function, the result of the last updateList
call, and an optional keyName.
Save the result of the call for the next update (it's just a list of keys in order).
That's it!
Just in case :)
The MIT License (MIT)
Copyright (c) 2022 James A. Eschrich
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.