Skip to content

Instantly share code, notes, and snippets.

@jaeschrich
Last active May 27, 2022 04:43
Show Gist options
  • Save jaeschrich/45b5f1e672e8ec8a7071cbe99b9cb0bf to your computer and use it in GitHub Desktop.
Save jaeschrich/45b5f1e672e8ec8a7071cbe99b9cb0bf to your computer and use it in GitHub Desktop.

ListView.js

Introduction

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.

  1. Updating DOM nodes in place--for example, changing the color of a button, adding or removing an "Error: Invalid Password!" message, et cetera.
  2. 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).
  3. 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!

Demo

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 = '';
});

Documentation

class ListView

new ListView(container, make, keyName = "key")

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 as).

ListView.update(newList)

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.

ListView.listen(listener)

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.

ListView.state

Gets the current state.

function updateList

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!

License

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.

<!doctype html>
<meta charset="utf-8">
<title>ListView Demo</title>
<ul id="todos"></ul>
<form id="todo-form">
<input type="text">
<button>Add Todo</button>
</form>
<script src="./ListView.js"></script>
<script>
let $ = (x) => document.querySelector(x);
function $make(element, attributes = {}, parent = null) {
let node = document.createElement(element);
for (let key of Object.keys(attributes)) {
node[key] = attributes[key];
}
return node;
}
const $append = (parent, element, attributes = {}) => {
let node = $make(element, attributes);
parent.appendChild(node);
return node;
};
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 = '';
});
</script>
function updateList(container, make, newList, prev = [], keyName = "key") {
function insertNode(node, pos) {
if (pos >= container.children.length) {
container.appendChild(node);
prev.push(node.dataset[keyName]);
} else {
container.insertBefore(node, container.children[pos]);
prev.splice(pos, 0, node.dataset[keyName]);
}
}
let i = 0;
for (; i < newList.length; i++) {
let item = newList[i];
let oldPos = prev.indexOf(item.key);
if (oldPos == -1) {
let node = make(item);
node.dataset[keyName] = item.key;
insertNode(node, i);
} else {
if (i == oldPos) continue;
let node = container.children[oldPos];
container.removeChild(node);
prev.splice(oldPos, 1);
if (i < oldPos) insertNode(node, i);
else insertNode(node, i - 1);
}
}
while (i < prev.length) {
container.removeChild(container.lastChild);
i++;
}
return newList.map(x => x.key);
}
class ListView {
constructor(container, make, keyName = "key") {
this.container = container;
this.make = make;
this._prev = [];
this._listeners = [];
this._state = [];
this.keyName = "key";
}
update(newList) {
this._prev = updateList(this.container, this.make, newList, this._prev, this.keyName);
this._listeners.forEach(f => f(newList, this._state));
this._state = newList;
}
get state() {
return Array.from(this._state);
}
listen(f) {
this._listeners.push(f);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment