Building a JSX + DOM library Part 1
Vesa Piittinen
Posted on May 10, 2019
When one is building something new it is always a good idea to take a little break after a while. While it is great to get yourself into the problems by iterating with stuff in a quick cycle it is just as important to halt, take some distance, study more, and lookup similar solutions.
A while ago I started working again on a project that had been untouched for four years. I started applying new ideas fast and found a few nice solutions, but some other things didn't feel that good and a lot of it had to do with the fact the code was old and was written with extremely broad browser support.
And then it hit me: does it make sense to aim for a large legacy browser support with a fresh new library when the world is filling up with evergreen browsers? Wouldn't I be simply limiting myself by looking too much into the past? Five years ago this still made sense. But now the web is quite different: IE11 is getting close to the end of it's lifespan. And it is very much the only non-evergreen browser we still have around. As far as browser engines go we only really have Firefox, Chromium and Safari.
Getting started
In this article series, which I hopefully am able to complete some day, I'm rebuilding what I have done with NomJS from scratch. The intention is to tackle a challenge: can you make a DOM library that uses JSX syntax and has React-like components with lifecycles and sensible state management? This means virtual DOM is banned!
This should give us a performance edge over React - as long as the developer experience for the possible future user of the library does not encourage bad performance killing habits too much.
First if you need to introduce yourself to JSX, how to use a custom pragma or how to get things set up you can read this little tutorial by Aleks@ITNEXT. Those basics are quite essential to be read, and it does also introduce the code problem: creating something where you can keep continuously render updated state is not trivial!
From this point on you need to have a dev environment with Babel where you can use /** @jsx dom */
(for example CodePen works fine).
Knowing your limitations
Our first function is dom()
. This has the same syntax as React.createElement
so that we can use JSX syntax for all the easy-to-read HTML-like goodness it provides.
This function has one clear rule: it must output native DOM nodes. Outputting anything else is forbidden. Whatever comes out must be valid input for appendChild
and the like.
Implementing first naive version is simple enough:
/** @jsx dom */
function dom(component, props, ...children) {
// make sure props is an object
props = { ...props }
// make DOM element
component = document.createElement(component)
// apply props as attributes
Object.assign(component, props)
// add children
return children.reduce(function(el, child) {
// in both cases make sure we output a valid DOM node
if (child instanceof Node) el.appendChild(child)
else el.appendChild(document.createTextNode(String(child)))
return el
}, component)
}
// to make sure it works...
document.body.appendChild(
<div style="background: gray; padding: 5px;">
<h1>Hello world!</h1>
<p>This is a test</p>
</div>
)
While this works for many simple, static cases, it doesn't work with a lot of other things we want to do. It only outputs new DOM nodes and that is all it can do.
What if we want to render something different? Can we change children? Can we change attributes / props?
The simplest way is to resort to native DOM methods: just use appendChild
and removeChild
and set attributes directly just "the good old way". This however doesn't bring the goodies that React provides when it controls what you can do and when you can do it. We want to do better.
Changing the props
So, we want to update the props. At simplest we could abstract this into something like the following:
// --- Library ---
const propsStore = new WeakMap()
function render(element, nextProps) {
if (!propsStore.has(element)) return
const props = Object.assign(propsStore.get(element), nextProps)
Object.assign(element, props)
return element
}
function dom(component, props, ...children) {
props = { ...props }
const element = document.createElement(component)
// remember a reference to our props
propsStore.set(element, props)
Object.assign(element, props)
return children.reduce(function(el, child) {
if (child instanceof Node) el.appendChild(child)
else el.appendChild(document.createTextNode(String(child)))
return el
}, element)
}
// --- Application ---
const App = (
<div style="background: gray; padding: 5px;">
<h1>Hello world!</h1>
<p>This is a test</p>
</div>
)
document.body.appendChild(App)
render(
App,
{ style: 'background: red; padding: 5px;' }
)
Above we added a render
method which allows to change props. If our sample had more props it would now update all the other given props and not only style
. However that would be about the only pro we have: we still can't update props of the inner components. Or well, we can:
render(
App.querySelector('h1'),
{ style: 'color: white; font-family: sans-serif;' }
)
But this doesn't really lead into maintainable code. And this is very verbose, too, it is almost the same if we just called App.querySelector('h1').style = 'color: white; font-family: sans-serif;'
. We are missing something!
Supporting components
This far we've only supported string elements. Meaning, you can only create div
s, br
s and all the other native DOM elements. This is nice for simple cases, but we are quite limited at the moment as we can see from the previous code sample. We can't hold state anywhere!
To solve this problem we can use a simple native JavaScript mechanism: a function! Within function we can hold some state in it's local variables, or outside variables too, although that is generally a bad idea.
Let's extend our dom
method to support function components!
function dom(component, props, ...children) {
props = { ...props }
const element = typeof component === 'function'
? component(props)
: document.createElement(component)
propsStore.set(element, props)
Object.assign(element, props)
return children.reduce(function(el, child) {
if (child instanceof Node) el.appendChild(child)
else el.appendChild(document.createTextNode(String(child)))
return el
}, element)
}
It must be noted that we don't have error checks and assume the function returns a native DOM element. The code above works however and you are now able to do the following!
// --- Application ---
function Component(props) {
function changeColor() {
render(ref, { style: 'background: red; padding: 5px;' })
}
const ref = (
<div style={props.style}>
<h1>Hello world!</h1>
<button onclick={changeColor}>Change color</button>
</div>
)
return ref
}
const App = <Component style="background: gray; padding: 5px;" />
document.body.appendChild(App)
The good thing is that we have now contained all our related code within a single scope. It is within a component. This gives us something that begins to actually resemble a React component, but there are quite a bit of downsides: for example, mutations are still quite direct as we haven't fixed render
.
It is starting to look like a bad idea to throw nextProps
to render. We have to control state in a different way, but how do we do that? We can't run <Component />
again as that gives us an entirely new instance! We are passing props.style
to the root div
, but that line is only executed once, ever, so even if props.style
changes we are not going to get an update to it.
In the next part we start managing props updates. I'm releasing these articles as they get written so it might take a while - why not attempt to figure out a solution in the meanwhile? :)
- Make the component
div
toggle betweengray
andred
backgrounds - Can you make the component feel more like React?
- Can you avoid making a local
ref
reference?
Posted on May 10, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
December 30, 2021