You may want to inform yourself about human rights in China.

On Frameworkless JavaScript

tags: computing web
date: 2023-03-15
update: 2023-12-18

Context

A few years ago, I had to rework a Web UI. However, the JS ecosystem has a reputation for its instability (I think the current major trends are React or back to SSR 2). Hence I decided to stick to Vanilla JS as much as possible.

At the time, I managed, while progressively refactoring the old UI, to come around a relatively simple (~200 SLOC) “framework” built around the following ideas:

It worked nicely, could be used locally/globally (SPA implementation is trivial), but there was a few caveats/subtleties.

L’Enfant et la Fortune (The Child and Fortune), oil on canvas, 1801, 138.5×171cm

L’Enfant et la Fortune (The Child and Fortune), oil on canvas, 1801, 138.5×171cm by Pierre Bouillon through wikimedia.orgPublic domain

I’ve recently been reworking zhongmu’s UI, which so far managed not to use any framework (not even the one I’ve just described). But, some aspects of the code were a bit clumsy, so I decided to see if I couldn’t use a variant of the previous framework to make it nicer. It did bring some improvements, but there were still a few subtle bits.

It turns out, after reading a few basic React tutorials, that there’s an even simpler, dependency-free approach, that still allows you to write non-trivial UIs.

Loading...

Frameworkless “JS”

This can be understood as a refinement over the previous approach, hence why I’ve presented it first:

Such functions typically take two arguments:

There are no real restrictions here: the key point the ability to pass around a pointer to a shared state.

The returned node may be augmented by some extra functions, allowing to act on the node from the outside, for example:

  1. with a build() function, triggering a rendering on a node “from the outside”, meaning, not within the scope of the function instantiating the node (in particular, not within the event handlers which are registered in this instantiating function)
  2. with a get() function, retrieve the value of a generic field; note that fields can then be identified systematically in the DOM, by the existence of such a get() function.

Note: There are some discussions regarding whether storing data in DOM nodes is kosher. But the arguments against are more conceptual than pragmatic.

I understand this can be a little foggy, so here’s an example to hopefully make things clearer.

Comparison with React

The following is from a React (official) tutorial, aiming at building a tic-tac-toe, adjusted to minor points. I’ve kept a similar behavior, although I could have changed a thing or two to make my life easier (for instance, the grid could be implemented as a list in the DOM, and rendered as a grid via CSS).

'use strict';

function calculateWinner(squares) {
	const lines = [[0, 1, 2],
		[3, 4, 5],
		[6, 7, 8],
		[0, 3, 6],
		[1, 4, 7],
		[2, 5, 8],
		[0, 4, 8],
		[2, 4, 6],
	];

	for (let i = 0; i < lines.length; i++) {
		const [a, b, c] = lines[i];
		if (squares[a] && squares[a] === squares[b] &&
				squares[a] === squares[c])
			return squares[a];
	}

	return null;
}

const Square = ({value, onClick}) => {
	return <button className="square" onClick={onClick}>{value}</button>;
}

const Board = ({ xIsNext, squares, onPlay }) => {
	function handler(i) {
		if (squares[i] || calculateWinner(squares))
			return;

		const nextSquares = squares.slice();
		nextSquares[i] = xIsNext ? "X" : "O";

		return onPlay(nextSquares);
	}

	const winner = calculateWinner(squares);
	let status = winner ? "Winner: "+winner
		: "Next player: " + (xIsNext ? "X" : "O");

	return (
		<div>
			<div className="status">{status}</div>
			<div className="board-row">
				<Square value={squares[0]} onClick={() => handler(0)} />
				<Square value={squares[1]} onClick={() => handler(1)} />
				<Square value={squares[2]} onClick={() => handler(2)} />
			</div>
			<div className="board-row">
				<Square value={squares[3]} onClick={() => handler(3)} />
				<Square value={squares[4]} onClick={() => handler(4)} />
				<Square value={squares[5]} onClick={() => handler(5)} />
			</div>
			<div className="board-row">
				<Square value={squares[6]} onClick={() => handler(6)} />
				<Square value={squares[7]} onClick={() => handler(7)} />
				<Square value={squares[8]} onClick={() => handler(8)} />
			</div>
		</div>
	);
}

const Game = () => {
	const [history, setHistory] = React.useState([Array(9).fill(null)]);
	const [currentMove, setCurrentMove] = React.useState(0);
	const currentSquares = history[currentMove];
	var xIsNext = currentMove % 2 == 0;

	function handlePlay(nextSquares) {
		const nextHistory = [...history.slice(0, currentMove+1),
			nextSquares];

		setHistory(nextHistory);
		setCurrentMove(nextHistory.length-1);
	}

	function jumpTo(nextMove) { setCurrentMove(nextMove); }

	const moves = history.map((squares, move) => {
		let descr = move > 0 ? "Go to move #"+move
			: "Go to game start";
		return (
			<li key={move}>
				<button onClick={() => jumpTo(move)}>{descr}</button>
			</li>
		);
	});

	return (
		<div className="game">
			<div className="game-board">
				<Board xIsNext={xIsNext} squares={currentSquares}
					onPlay={handlePlay} />
			</div>
			<div className="game-info">
				<ol>{moves}</ol>
			</div>
		</div>
	)
}

const root = ReactDOM.createRoot(document.getElementById("react-root"));
root.render(
  <React.StrictMode>
    <Game />
  </React.StrictMode>
);
function calculateWinner(squares) {
	const lines = [
		[0, 1, 2],
		[3, 4, 5],
		[6, 7, 8],
		[0, 3, 6],
		[1, 4, 7],
		[2, 5, 8],
		[0, 4, 8],
		[2, 4, 6],
	];

	for (let i = 0; i < lines.length; i++) {
		const [a, b, c] = lines[i];
		if (squares[a] && squares[a] === squares[b] &&
				squares[a] === squares[c])
			return squares[a];
	}

	return null;
}

function mksquare(S, i, onclick) {
	let p = document.createElement("button");
	p.className = "square";

	p.build = () => { p.innerText = S.squares()[i]; }

	p.addEventListener("click", () => { onclick(i); p.build(); });

	return p;
}

function mkstatus(S) {
	let p = document.createElement("div");
	p.className = "status";

	(p.build = function() {
		let w = calculateWinner(S.squares());
		p.innerText = w ? "Winner: "+w
			: "Next player: "+(S.xnext() ? "X" : "O");
	})();

	return p;
}

function mkboard(S, onplay) {
	let p = document.createElement("div");
	p.className = "game-board";

	let pstatus = mkstatus(S);
	p.appendChild(pstatus);

	function handleclick(i) {
		if (S.squares()[i] || calculateWinner(S.squares()))
			return;

		let next = S.squares().slice();
		next[i]  = S.xnext() ? "X" : "O";

		onplay(next); pstatus.build();
	}

	var rs = [];

	for (let i = 0; i < 3; i++) {
		let q = document.createElement("div");
		q.className = "board-row";

		for (let j = 0; j < 3; j++) {
			var r = mksquare(S, (i*3) + j, handleclick);
			q.appendChild(r); rs.push(r);
		}

		p.appendChild(q);
	}

	p.build = () => { pstatus.build(); rs.forEach(r => r.build()); }

	return p;
}

function mkinfo(S, jumpto) {
	let p = document.createElement("div");
	p.className = "game-info";

	let ol = document.createElement("ol");

	p.appendChild(ol);

	(p.build = function() {
		ol.innerHTML = "";
		for (let i = 0; i < S.history.length; i++) {
			let li = document.createElement("li");
			let b  = document.createElement("button");
			b.innerText = i > 0	? "Go to move #"+i : "Go to game start";
			b.addEventListener("click", () => jumpto(i));
			li.appendChild(b);
			ol.appendChild(li);
		}
	})();

	return p;
}

function mkgame(S) {
	let p = document.createElement("div"); p.className = "game";

	S.history = [Array(9).fill(null)];
	S.move    = 0;

	// wrap access to current board / player
	S.squares = () =>  S.history[S.move];
	S.xnext   = () =>  S.move % 2 == 0;

	function handleplay(next) {
		S.history = [...S.history.slice(0, S.move+1), next];
		S.move = S.history.length-1;
		pinfo.build();
	}

	let pboard = mkboard(S, handleplay);

	function jumpto(m) { S.move = m; pboard.build(); pinfo.build(); }

	let pinfo  = mkinfo(S, jumpto);

	p.append(pboard, pinfo);

	return p;
}

window.addEventListener('load', function() {
	document.getElementById("bare-root").replaceWith(mkgame({}));
});

Note that the Vanilla JS version, strictly speaking, doesn’t work exactly as the React one, on two aspects:

  1. While the state is still shared by convention, the sharing isn’t enforced (it could have been, at least to a considerably greater extent)1;
  2. Some DOM elements likely will be rebuilt more than they need, for instance, when reloading a board from the history, all squares gets “redrawn” (again, this could have been “optimized”).

Those two points aside, React+JSX2 essentially provide syntactic sugar over the Vanilla JS version. I think the main pros of the latter is that it’s conceptually simpler: every state/DOM update is written in plain sight, making it easy to reason about.

The performance advantages of React likely may not matter3 and in the end, you should be able to optimize the Vanilla JS locally in case of measured issues anyway.

Note also the possibility of reducing some of the typical Vanilla JS boilerplate, e.g with the help of small utilities, e.g. things like

// <{{ x }} class="{{ c }}">{{ t }}</{{ x }}>
function mk(x, t, c) {
	var p = document.createElement(x);
	p.innerText = t;
	p.className = c;
	return p;
}

You could find similar ways to automatically register handlers too, but this doesn’t really add much to the conversation

Finally, a main advantage over this Vanilla approach versus what typically happens with Vanilla JS, is that you can avoid to a great extent brittle DOM queries (querySelector() & cie). Instead of keeping track of IDs or specific classes, you just have to keep pointers to the DOM to the elements you care about, in the functions scope. Almost always, those are descendants.

To send data higher-up, either use bubbling events or callbacks, as demonstrated.

And you can combine pointers to descendants and bubbling events/callbacks to address any node of the DOM, in a rather systematic/predictable fashion.

Vue.js

I’ve also spent a few moments on the basic Vue.js tutorial. As far as I can see, it’s essentially a mix between React and JSX (I do think separating both is conceptually a better option).

That’s to say, in essence, the comparison between the Vanilla JS approach and Vue is, as far as the tutorials’ scope permit to perceive, fairly similar.


  1. Spending time and effort to remove features, usually in the name of safety, rarely leaves me indifferent! ↩︎

  2. If you’re not familiar with “JSX”, that’s what allows you to write this soup of HTML+JavaScript. Now you may wonder, how does this even compile? You need to use a transpiler, such as Babel. This can be performed “in-the-browser”, with some special attributes to your <script></script> tags. It’s common to use both React and JSX, albeit not mandatory. Here’s the related React documentation↩︎

  3. I guess it’s not easy to use performance as a honest selling argument for a wide-scope tool (think, “my Linux has better performances than your windows”): there are many contributing factors, and for a complex enough application, React or not, such factors may still exist. ↩︎


Comments

By email, at mathieu.bivert chez:

email