Let's make the worst React ever!
Turns out we got a bank holiday here in Germany, and it freed up some unexpected time and suddenly my thoughts broke loose. I love React. Yet, I never use it. I normally end up with smaller alternatives like Preact, superfine, hyperapp or Mithril. I choose those, because I like that satisfying feeling, when you skim through the source code and understand how things are implemented. Also, I must inform you upfront, that I am not a frontend developer at all, so please take my following words with a grain of salt.
Anyway, this morning I started to wonder. What does it take to build a silly React clone? The one that would be terribly slow, insanely buggy, barely usable apart from “Hello world” example, but built with my own hands.
Shall we start?
Hyperscript
React-like frameworks use JSX to define a layout. But JSX is just an syntactic extension to JavaScript that never reaches your production code. It gets “transpiled” into regular JavaScript. Many of you know, that internally JSX gets represented by many nested createElement()
calls. Each function call declares a single DOM node or component, with the certain tag name, set of properties and a list of child nodes, represented with the similar function calls. The following two layouts are identical:
// Using JSX
<div onClick={handleClick}>
<h1 className="header">Hello</h1>
</div>
// Using createElement()
createElement('div', {onClick: handleClick},
createElement('h1', {className: 'header'}, 'Hello'));
In fact, the latter syntax has been known even before React became famous, and it was called hyperscript. It is identical to createElement()
, but uses a shorter h(tag, props, ...children)
function to define the layout.
In our ugly React clone we will have the same h()
function that would do nothing, but put arguments into an object for further processing during the render phase:
// Our tiny Hyperscript function.
// 'el' is element name (tag or component)
// 'props' is properties map
// 'children' is child elements array
const h = (el, props, ...children) => ({el, props, children});
Now, let’s see how we can render the hyperscript layout.
Rendering
In general, we need a render(virtNodes, domNode)
function that would render a list of virtual nodes as child elements of some existing real DOM node.
Often, only one virtual node will be passed as a parameter, but sometimes it would be a list. So we use [].concat()
trick to handle both cases (single element will be converted into an array, while the arrays will be flattened).
Then, we loop over each and every virtual node. In our dumb React clone, a node can be an object (a result of hyperscript calls), a string (plain text in between the DOM nodes) or a function (a functional component that returns a hyperscript structure when evaluated).
We evaluate functional components by calling the functions with the assigned properties map, children list and a special forceUpdate
function parameter that would re-render the whole component once called. We will need it later to add dynamic behavior to our stateful components.
Then, we create a “builder” function that would build a new DOM element or text node, depending on the virtual node type. We don’t call it yet, until we check how different is our virtual node from the real DOM element we encountered.
If the real DOM element is missing, or the tag is different - we call our builder and insert the newly built DOM element instead.
Then we store all virtual node properties into the real DOM node. They will be used to compare virtual and real nodes in the next rending cycle. Then we re-assign node properties, if they got different from the ones stored in the real DOM node.
At this point a DOM node is identical to our virtual node, and we call rendering function recursively for node children.
Finally, after all virtual nodes have been processed and mirrored in the real DOM, there might be some leftovers from the previous renders in the real DOM tree. So we remove all remaining DOM elements that come after the last rendered virtual node.
const h = (el, props, ...children) => ({el, props, children});
const render = (vnodes, dom) => {
vnodes = [].concat(vnodes);
const forceUpdate = () => render(vnodes, dom);
vnodes.forEach((v, i) => {
while (typeof v.el === 'function') {
v = v.el(v.props, v.children, forceUpdate);
}
const newNode = () => v.el ? document.createElement(v.el) : document.createTextNode(v);
let node = dom.childNodes[i];
if (!node || (node.el !== v.el && node.data !== v)) {
node = dom.insertBefore(newNode(), node);
}
if (v.el) {
node.el = v.el;
for (let propName in v.props) {
if (node[propName] !== v.props[propName]) {
node[propName] = v.props[propName];
}
}
render(v.children, node);
} else {
node.data = v;
}
});
for (let c; (c = dom.childNodes[vnodes.length]); ) {
dom.removeChild(c);
}
};
// Example
const Header = (props, children) => (
h('h1', {style: "color: red"}, ...children)
);
render(h(Header, {}, 'Hello', 'World'), document.body);
The above renders a nice red “Hello World” text.
Stateful components
A proper React clone would use keys to smartly patch the DOM tree, and it would also use keys to link component state if the components gets moved during the render. It would also use hooks, connected to the components that would allow to manage component state smartly.
I decided not to spend much time on this now. Instead, each component gets a forceUpdate
callback, that can be called from any event listener to forcefully re-render the whole component. Store your state as a global variable, manage it like the world would end today, go wild!
let n = 0;
const Counter = (props, children, forceUpdate) => {
const handleClick = () => {
n++;
forceUpdate();
};
return x`
<div>
<div className="count">Count: ${n}</div>
<button onclick=${handleClick}>Add</button>
</div>
`;
};
What interested me, is how can I get closer to JSX without using any of the transpiler nonsense.
Tagged template literals
You are most likely familiar with ES6 template literals (the strings in backtick quotes). However, all modern browsers now support so called tagged literals, when a string is prefixed by a certain word, which is a function that processes the template string. The function takes an array of strings in between the placeholders and the actual placeholders as the rest of the arguments:
const x = (strings, ...fields) => {...};
x`Hello, ${user}!`
// strings: ['Hello ', '!'];
// fields: [user]
Now we make our hands dirty by writing a tiny parser for HTML-like language that would return a hyperscript hierarchy of nodes based on the given string literal.
I thought of a grammar I want to support. Regular tags, like <{tagName} attr={value} ...>
, self-closing tags ending with />
, closing tags starting with </
and raw text between the tags. I decided the attribute values must be quoted, unless they are placeholders. That’s it. No HTML comments, white-space optimizations etc.
Now if we think about the state machine we need, it would have only 3 states:
- “text”, when we are looking for
<
or</
. - “open”, when we are inside the open tag and are looking for attributes until the end of tag.
- “close”, when we are inside the closing tag and are only looking for
>
.
Initial state is “text”. Possible positions for the placeholders would be tag name, attribute value or raw text. This means, that if our literal string is empty by that point - we use the placeholder field. Otherwise we continue reading the string literal as long as it fits.
Here’s how the resulting parser might look like:
export const x = (strings, ...fields) => {
const stack = [{children: []}];
const find = (s, re, arg) => {
if (!s) {
return [s, arg];
}
let m = s.match(re);
return [s.substring(m[0].length), m[1]];
};
const MODE_TEXT = 0;
const MODE_OPEN = 1;
const MODE_CLOSE = 2;
let mode = MODE_TEXT;
strings.forEach((s, i) => {
while (s) {
let val;
s = s.trimLeft();
switch (mode) {
case MODE_TEXT:
if (s[0] === '<') {
if (s[1] === '/') {
[s, val] = find(s.substring(2), /^([a-zA-Z]+)/, fields[i]);
mode = MODE_CLOSE;
} else {
[s, val] = find(s.substring(1), /^([a-zA-Z]+)/, fields[i]);
mode = MODE_OPEN;
stack.push(h(val, {}, []));
}
} else {
[s, val] = find(s, /^([^<]+)/, '');
stack[stack.length - 1].children.push(val);
}
break;
case MODE_OPEN:
if (s[0] === '/' && s[1] === '>') {
s = s.substring(2);
stack[stack.length - 2].children.push(stack.pop());
mode = MODE_TEXT;
} else if (s[0] === '>') {
s = s.substring(1);
mode = MODE_TEXT;
} else {
let m = s.match(/^([a-zA-Z0-9]+)=/);
console.assert(m);
s = s.substring(m[0].length);
let propName = m[1];
[s, val] = find(s, /^"([^"]*)"/, fields[i]);
stack[stack.length - 1].props[propName] = val;
}
break;
case MODE_CLOSE:
console.assert(s[0] === '>');
stack[stack.length - 2].children.push(stack.pop());
s = s.substring(1);
mode = MODE_TEXT;
break;
}
}
if (mode === MODE_TEXT) {
stack[stack.length - 1].children.push(fields[i]);
}
});
return stack[0].children[0];
};
It is most likely enormously slow and clumsy, but it seems to work:
const Hello = ({onClick}, children) => x`
<div className="foo" onclick=${onClick}>
${children}
</div>
`;
render(h(Hello, {onClick: () => {}}, 'Hello world'), document.body);
What’s next
At this point we got a quick-and-dirty parody on React. But I decided to go a little bit further and actually put it on Github. There you can see that rendering algorithm has slightly changed to support keys. Also, some tests are written to give more credibility to this joke. I desperately wanted to make hooks working, and seems like I did.
The library is called “O!”. It’s a sound of realisation, once you understood how simple it is. Or despair, if you caught a fatal bug after you decided to use this in production. It also resembles zero, which is a metaphor for both, library footprint and usefulness (did I mention that it seems to be less than 1KB once minified and compressed? Yes, with “JSX”, hooks and all that jazz!).
Github project is here: https://github.com/zserge/o. Please, don’t expect any realistic support, but I would be more than happy to hear any feedback from you with eventual issue reports and PRs!
Anyway, it’s been a nice morning. Thanks for reading, hope we all learned something!
I hope you’ve enjoyed this article. You can follow – and contribute to – on Github, Mastodon, Twitter or subscribe via rss.
Nov 01, 2019
See also: My experience with asm.js and more.