DEV Community

Vesa Piittinen
Vesa Piittinen

Posted on • Edited on

Building a JSX + DOM library Part 2

We have now reached a point where complexity will increase a great deal compared to the simplicity of the first part. This complexity is caused by two things:

  1. We want to be React-like in making changes to the DOM tree via single JSX representation.
  2. dom() must output DOM nodes only

Setting a target

In the first part we ended up with this application code:

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)
Enter fullscreen mode Exit fullscreen mode

We want to get rid of some issues here:

  1. There should be no need to capture a local ref
  2. Our component props shouldn't be direct DOM element attributes
  3. changeColor shouldn't need to know about render

In short we want to transition from pure DOM mutation into state mutation where the developer using the library can focus on what he is doing and not care too much about the library. Or put other way: use components to describe what things should be like instead of manually writing DOM manipulation code.

How could we mangle the JSX so that we could as library authors get something to work with? If we look at React, it renders component render methods all the time. As such we do not have a render method at the moment. We need to add function somewhere. So how about...

function Component(props) {
    function changeColor() {
        props.dark = !props.dark
    }

    return (
        <div style={() => `background-color: ${props.dark ? 'red' : 'wheat'}; padding: 5px;`}>
            <h1>Hello world!</h1>
            <button onclick={changeColor}>Change color</button>
        </div>
    )
}

const App = <Component dark={false} />

document.body.appendChild(App)
Enter fullscreen mode Exit fullscreen mode

Doesn't this look good? We now have a function in style attribute which we can call. We also have local state with the component which we can mutate because it is something we own. And best of all the syntax is quite readable, easy to reason about and there are no signs of library.

This does give challenges and questions: shouldn't we distinguish between functions like onclick and style? How do we render again after changes to state?

Dealing with the functions

From now on there is quite a lot of code to work with, so to ease following here is the complete code from part 1:

From here let's adjust the application code to add features step-by-step. Our initial step is to introduce functions!

// --- Application ---

function Component(props) {
    function changeColor() {
        props.dark = !props.dark
        render(ref)
    }

    const ref = (
        <div style={() => `background-color: ${props.dark ? 'red' : 'wheat'}; padding: 5px;`}>
            <h1>Hello world!</h1>
            <button onclick={changeColor}>Change color</button>
        </div>
    )

    return ref
}

const App = <Component dark={false} />

document.body.appendChild(App)
Enter fullscreen mode Exit fullscreen mode

We got pretty close to what we want! Now the only bad thing is that we have render and that we need to manually track ref. We'll deal with these issues later on.

As such the application is now "broken", because style is clearly not working. We need to start managing our props, our one-liner Object.assign(element, props) is no longer fit for our needs.

We have two pieces of code that use this call. This means we need to build a new function that manages this specific task! We shall call this method updateProps. Before we write that we can update the calling methods and as we go there is no longer need to pass nextProps to render:

// --- Library ---

const propsStore = new WeakMap()

function updateProps(element) {
    const props = propsStore.get(element)
}

function render(element) {
    if (!propsStore.has(element)) return
    updateProps(element)
}

function dom(component, props, ...children) {
    props = { ...props }
    const element = typeof component === 'function'
        ? component(props)
        : document.createElement(component)
    propsStore.set(element, props)
    updateProps(element)
    return children.reduce(function(el, child) {
        if (child instanceof Node) el.appendChild(child)
        else el.appendChild(document.createTextNode(String(child)))
        return el
    }, element)
}
Enter fullscreen mode Exit fullscreen mode

updateProps only needs to take element in as we can simply get reference to props. There is no reason to do this when calling it.

render will be a public method, while updateProps is intended to be internal to the library. This is why render does a check for existance of the element in the propsStore.

It is time to write some logic to handle the functions!

function updateProps(element) {
    const props = propsStore.get(element)
    Object.entries(props).forEach(([key, value]) => {
        if (typeof value === 'function') {
            // use event handlers as they are
            if (key.slice(0, 2) === 'on') {
                if (element[key] !== value) element[key] = value
                return
            }
            // call the function: use element as this and props as first parameter
            value = value.call(element, props)
        }
        // naively update value if different
        if (element[key] !== value) {
            element[key] = value
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

And now when we run the app we should have a wheat colored background. Do we?

Success! However... why doesn't the button work? We have to debug. So, good old console logging: console.log('updateProps', element, props) before Object.entries should show us what is wrong.

And the result:

"<div style='background-color: wheat; padding: 5px;'>...</div>" Object {
  dark: true
}
Enter fullscreen mode Exit fullscreen mode

Well darn! We no longer get style props here, instead we get the component's props! We do need component's props to pass them as first parameter to the function as that will be useful for currently unrelated reasons, but we also need to distinguish between component and element.

Our line to blame is in dom method: there we set propsStore without checking if we already have a reference. This gets called twice: first when dom creates div element and a second time for the same div when Component is called.

A simple solution to this is to ignore components:

function dom(component, props, ...children) {
    props = { ...props }
    const isFn = typeof component === 'function'
    const element = isFn ? component(props) : document.createElement(component)
    if (!isFn) propsStore.set(element, props)
    updateProps(element)
    return children.reduce(function(el, child) {
        if (child instanceof Node) el.appendChild(child)
        else el.appendChild(document.createTextNode(String(child)))
        return el
    }, element)
}
Enter fullscreen mode Exit fullscreen mode

And does our code work?

It does! The button now correctly swaps between two colors. This brings us to the end of the second part.

There are further challenges to solve:

  1. Component props would be nice to pass to the attribute prop functions.
  2. We still need to call render manually and keep ref.
  3. If we move style to h1 element then our click no longer works :(

The first and second are challenging; the third should be an easier one to solve. Can you solve it before the next part is out?


Other parts: 1, 3, 4

Top comments (0)