loading...

Building a JSX + DOM library Part 4

merri profile image Vesa Piittinen Updated on ・5 min read

In the third part we fixed a lot of component behavior. While still not perfect we can finally get into making a dream come true that was introduced in the second part: a component without a manual keeping of ref and calling render!

This is now our target application code:

function HelloWorld(props) {
    return (
        <h1 style={() => `color: ${props.dark ? 'white' : '#333'};`}>
            Hello world!
        </h1>
    )
}

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

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

document.body.appendChild(App)

So the cool parts:

  1. changeColor does not call render! It is now one line arrow function!
  2. No local copy of ref!

The Plan

We've entered to a classic problem in state management: when to update? When looking into other solutions we can see that in classical React we were directed to use this.setState. This allowed authors of React to optimize renders so that the entire tree didn't need to change, only the current branch. Unfortunatenaly this also added some extra boilerplate, for example you had to manage this.

In the other hand this state change optimization could also be broken in React! For example in pre-hooks Redux each component that is connected will be called each time state store is changed: despite added diff checks blocking actual renders this is still extra work. Others have solved this issue in their own state solutions such as Storeon that allow for targeted re-renders.


But... if we look at what our app looks like, there is nothing! The only thing that deals with state is props. We're quite evil, too, because we're mutating it. In React, Redux and Storeon, you're encouraged to deal with state as if it is immutable. And here we are, not doing it!

However, if we think about the actual problem, we're not rendering like React. There the virtual DOM tree is built upon each render call and any state held by the render function is lost when the next render occurs. We don't have virtual DOM, instead the function remains in use and can be a source of state, allowing us to use props.

This is now leading to what can be a performance edge against React. Instead of a single large render function we target single attributes and render those with the help of many tiny render functions. And those functions don't waste their time dealing with virtual DOM: they cause direct mutations.

This means that even if we implemented the least optimal render strategy, to render the whole tree each time, we're likely to do less work than a similar React app would - especially if the app is large.

So it seems it might be plausible to go ahead and write a simple update strategy!

The Execution

With the actual code we can implement a simple render queue: call requestAnimationFrame for a re-render from each change and only ever keep one upcoming render in the queue, ignoring any further requests for rendering again until render has been done.

We're also taking a very naive route: simply capture all DOM1 event handlers (onclick etc.) and add a call to queue a render to the very root of our app. The only special case to be aware of is that we may have multiple apps running at the same time, so we need allow to queue one render for each app that we have.

const queuedRenders = new Map()

function queueRender(element) {
    if (!propsStore.has(element)) return
    // find the top-most element in the tree
    while (element.parentNode && propsStore.has(element.parentNode)) {
        element = element.parentNode
    }
    // find component, and if element is not in component then use that
    const root = parentComponents.get(element) || element
    if (queuedRenders.has(root)) return
    queuedRenders.set(root, requestAnimationFrame(function() {
        // allow for new render calls
        queuedRenders.delete(root)
        // if equal then not wrapped inside a component
        if (root === element) {
            if (document.documentElement.contains(root)) {
                render(root)
            }
        } else {
            // find all siblings that are owned by the same component and render
            for (let child of element.parentNode.childNodes) {
                if (root === parentComponents.get(child)) render(child)
            }
        }
    }))
}

There are some things to note:

  1. Fragment components do not currently have a perfect record of their children, it is only the other way around, so we have to loop and check if element's parent is the same component. A bit ugly, but good enough.
  2. And yes, we even allow re-renders without wrapping to a component! Or, we would but there is an issue to resolve. We'll get to that a bit later!

Now that we can queue renders we should then make use of the queue, too! Let's update a part of updateProps...

const queueFunctions = new WeakMap()

function updateProps(element, componentProps) {
    const props = propsStore.get(element)
    Object.entries(props).forEach(([key, value]) => {
        if (typeof value === 'function') {
            if (key.slice(0, 2) === 'on') {
                // restore cached version
                if (queueFunctions.has(value)) {
                    const onFn = queueFunctions.get(value)
                    if (element[key] !== onFn) {
                        element[key] = onFn
                    }
                } else {
                    // wrap to a function that handles queuein
                    const newOnFn = (...attr) => {
                        value.call(element, ...attr)
                        queueRender(element)
                    }
                    // cache it
                    queueFunctions.set(value, newOnFn)
                    element[key] = newOnFn
                }
                return
            }
            value = value.call(element, componentProps)
        }
        if (element[key] !== value) {
            element[key] = value
        }
    })
}

Now when pushing a button the App updates! However, I did mention about an issue...

Refactoring mistakes

First of all, here is the shortest readable Counter sample you can probably find anywhere:

let count = 0
document.body.appendChild(
    <p title={() => count}>
        <button onclick={() => count++}>+</button>
        <button onclick={() => count--}>-</button>
    </p>
)

It uses title attribute because we don't manage dynamic children yet. Anyway, it is short! And we want to make it work - and actually, we did make it work when updateProps had it's checks for componentProps removed.

Hitting this issue got me into looking at how setting parents was done, and I noticed I had been a bit silly in how it was made with looping children. Instead, a simple stack that knows the parent component at each times makes parent management much easier.

So, we throw setParentComponent away entirely. Then we update dom as follows:

const parentStack = []

export function dom(component, props, ...children) {
    props = { ...props }
    const isComponent = typeof component === 'function'
    const element = isComponent
        ? document.createDocumentFragment()
        : document.createElement(component)
    // if no parent component then element is parent of itself
    const parent = parentStack[0] || { component: element, props: {} }
    parentComponents.set(element, parent.component)
    if (isComponent) {
        componentPropsStore.set(element, props)
        // fixed a bug here where initial props was unset
        const exposedProps = updateComponentProps({ ...props }, props)
        propsStore.set(element, exposedProps)
        // increase stack before calling the component
        parentStack.unshift({ component: element, props: exposedProps })
        // the following will cause further calls to dom
        element.appendChild(component(exposedProps))
        // work is done, decrease stack
        parentStack.shift()
    } else {
        // is element independent of a component?
        if (parent.component === element) {
            componentPropsStore.set(element, parent.props)
        }
        propsStore.set(element, props)
        updateProps(element, parent.props)
    }
    return children.reduce(function(el, child) {
        if (child instanceof Node) el.appendChild(child)
        else el.appendChild(document.createTextNode(String(child)))
        return el
    }, element)
}

As a result we reduced a bit of code! And we now have a bit clearer management of state where componentProps is always available, thus avoiding "no initial state" issue with elements that aren't within a component.

Here, have a look at the current app - including the super short counter example!

The counter sample shows that we have not taken proper care of our children. While there are other problems remaining, for example management of element attributes could be improved a great deal, it might be for the best to push forward with taking our children seriously. So that'll be our next topic!


Other parts: 1, 2, 3

Posted on by:

merri profile

Vesa Piittinen

@merri

Web Front End Specialist who doesn't want to identify as Full Stack, but knows how to get stuff done. Loves perf and minimalism. HTML + CSS + Web Standards over JS. UX over DX. Hates div disease.

Discussion

markdown guide