Introduction
I’ve recently been reworking zhongmu’s UI, which so far managed not to use any framework. But, some aspects of the code were a bit clumsy, so I decided to see if I couldn’t use a variant of a previous framework I wrote to make it nicer. It did bring some improvements, but there were still a few subtle bits.
It turns out, after reading a few React tutorials, that there’s an even simpler way to write non-trivial UIs without relying on any framework:
Note: I’m not merely relying on document.createElement
and setting up handlers by hand: I do that (what else…) but in a
subtle way, so you might want to keep your eyes opened. We could say that
the point of this approach is to cleverly pack your document.createElement
& cie to get components (but without still relying even on
Web Components).
I’ll start here by showing you an example and how this approach compares to React, briefly talk about Vue.js, then present the main conventions used to make this work, before leaving you with some additional “historical” context.
Zhongmu’s code is available on github, if you want to get an idea on how this scale in practice; the previously displayed UI element is a part of it.
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:
- 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;
- 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, if any, 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.
“Frameworkless” JS
Here are the general conventions:
- node types/components are functions (e.g.
mkmodal(...)
, which returns aHTMLElement
of “type” “modal” / a “modal” “component”); - such functions typically take two arguments:
- a state (an
Object
) that is to be shared (e.g. with DOM children / parent); - eventually, some options to configure the node (e.g. a class
name, some subnode
.innerText
); this can be split in multiple arguments if needed.
- a state (an
The returned node may be augmented by some extra functions, allowing to act on the node from the outside, for example:
- 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) - 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 aget()
function. - etc.
Note: There are some discussions regarding whether storing data in DOM nodes is kosher. But the arguments against are more conceptual than pragmatic, around the lines of « someone might change those bits under your nose, » but that someone is usually you. No amount of extra-code will ever compensate for a lack of discipline and rigor.
Again, don’t hesitate to refer to zhongmu’s code if you want to see a “real life” example.
Historical context
A few years ago, I had to rework a Web UI. As the JS ecosystem has a reputation for its instability (I think the current major trends are React or Back to SSR: 2), 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:
- compilation of an abstract tree to the DOM; each node of the
tree has some random attributes, among which a type; to each
type was associated a set of functions:
mk()
to build the DOM node corresponding to the asbtract node;setup()
to register handlers & cie;build()
, in retrospect, very similar to React’s “render()”;- anything else you might need that isn’t covered by the three previous functions;
- the ability to perform “deep” recursive operation on the DOM, such
as recursively calling the
build()
functions of child nodes, when available.
It worked nicely, could be used locally/globally (SPA implementation is trivial), but there was a few caveats/subtleties.
The current solution improves on this, and brings the line count to, well, zero.
-
Spending time and effort to remove features, usually in the name of safety, rarely leaves me indifferent! ↩︎
-
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. ↩︎ -
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: