Part 2 - rendering

This tutorial aims to explain how modern JS frameworks perform rendering via virtual dom.

First of all, why do JS frameworks use "Virtual DOM"? Here's an example of how VueJS does it's rendering:
const { h, createApp } = Vue;

const App = {
    render() {
        return h("div", {
            id: "hello",
        }, [
            h("span", "world")
        ])
    }
}
will create
<div id="hello">
    <span>world</span>
</div>

Now that we know how Vue does it, let's try to split the problem into smaller ones.

Let's start with h function.

function h(tag, props, children) {
    return {
        tag,
        props,
        children
    };
};
That was easy. Since VNode is only an internal object it can look however we want :).

Next is mount function:

function mount(vnode, container) {
    // create the DOM element
    const el = vnode.el = document.createElement(vnode.tag);

    // assign all the props
    if (vnode.props) {
        for (const key in vnode.props) {
            const value = vnode.props[key];

            if (key.startsWith("on")) {
                // event handler
                el.addEventListener(key.slice(2).toLowerCase(), value);
            } else {
                // just a normal prop
                el.setAttribute(key, value);
            }
        }
    }

    // create it's children
    if (vnode.children) {
        if (typeof vnode.children === "string") {
            // just text
            el.textContent = vnode.children;
        } else if (vnode.children instanceof Array) {
            // For simplification let's assume that if we have an array
            // it's only VNodes (without strings mixed in).
            vnode.children.forEach(child => {
                mount(child, el);
            });
        } else {
            console.error("Invalid typeof VNode children", vnode.children);
        }
    }

    container.appendChild(el);
}

Now we can render our virtual DOM, but to make it update we need a patch function.

function patch(dom1, dom2) {
    // if the tags don't match we need a full replace
    if (dom1.tag !== dom2.tag) {
        // replace
        return;
    }

    const el = dom1.el = dom2.el;

    // props
    const oldProps = dom1.props || {};
    const newProps = dom2.props || {};

    // add or update props
    for(const key in newProps) {
        const oldValue = oldProps[key];
        const newValue = newProps[key];

        if (newValue != oldValue) {
            el.setAttribute(key, newValue);
        }
    }

    // remove if prop removed
    for (const key in oldProps) {
        if(!(key in newProps)) {
            el.removeAttribute(key);
        }
    }

    // children
    const oldChildren = dom1.children;
    const newChildren = dom2.children;

    if (typeof newChildren === "string") {
        el.textContent = newChildren;
    } else {
        // find common length
        const commonLength = Math.min(oldChildren.length, newChildren.length);
            
        // patch each individually - this is a very naive approach, because inserting
        // a new child at the beginning forces every single child to re-render
        for (let i = 0; i < commonLength; i++) {
            patch(oldChildren[i], newChildren[i]);
        }

        if (newChildren.length > oldChildren.length) {
            // we need to insert new elements
            newChildren.slice(oldChildren.length).forEach(child => {
                mount(child, el);
            });
        } else if (newChildren.length < oldChildren.length) {
            // we need to remove elements
            oldChildren.slice(newChildren.length).forEach(child => {
                el.removeChild(child.el);
            });
        };
    }
}

You probably also want a small utility to mount your app.

function mountApp(component, container) {
    let mounted = false;
    let prevVdom = null;

    useEffect(() => {
        if (!mounted) {
            prevVdom = component.render();
            mount(prevVdom, container);
            mounted = true;
        } else {
            const newVdom = component.render();
            patch(prevVdom, newVdom);
            prevVdom = newVdom;
        }
    });
};