skip to content
Avatar Logo
VirtualDOM rendering

VirtualDOM - Optimize rendering process

/ 9 min read

Hi folks.

Today we are going to improve and optimize rendering process for our custom VirtualDOM.

Introduction

Re-rendering a specific node in the DOM when the state changes can be tricky, but it’s not too hard once you understand how it works. And I’m here to walk you through the process, we’ll break down the implementation step by step.

Our Structure

We’ll build on the structure from our last post. Feel free to fork it. Your final result should look like this:

  • Directorysrc
    • Directoryutils
      • jsx-runtime.mjs
      • render-jsx.mjs
      • render.mjs
      • signal.mjs
    • App.mjs
    • index.html
    • index.mjs
  • .babelrc
  • package.json

Refactoring the Code

Currently, every time the state changes, the entire page is re-rendered, which affects performance, especially in larger projects. To improve this, we need a solution that compares nodes to find differences between the old and new ones, and updates only the changed nodes.

Let’s get started by adding a new function, renderVNode, to the /src/utils/render-jsx.mjs file. We’ll move all the logic from renderJSX into renderVNode. For now, renderJSX will be empty, but don’t worry, we’ll address that soon.

Your final result should look like this:

/src/utils/render-jsx.mjs
import { Fragment } from "./jsx-runtime.mjs"
export function renderVNode(element, parent) {
const stack = [{ element, parent }]
while (stack.length > 0) {
const { element, parent } = stack.pop()
if (typeof element === "string" || typeof element === "number") {
parent.appendChild(document.createTextNode(element))
continue
}
const { type, props = {} } = element
const domElement =
type === Fragment
? document.createDocumentFragment()
: document.createElement(type)
Object.keys(props).forEach((name) => {
if (name === "children") return
if (name.startsWith("on") && typeof props[name] === "function")
domElement.addEventListener(
name.substring(2).toLowerCase(),
props[name]
)
if (typeof props[name] !== "undefined")
domElement.setAttribute(name, props[name])
})
if (props.children) {
for (let i = props.children.length - 1; i >= 0; i--) {
stack.push({ element: props.children[i], parent: domElement })
}
}
parent.appendChild(domElement)
}
}
export function renderJSX(newVNode, container) {
// all the logic moved to renderVNode
}

Now we need to update renderJSX function to render our virtual DOM nodes.

/src/utils/render-jsx.mjs
...
export function renderJSX(newVNode, container) {
renderVNode(newVNode, container)
}

So, basically, we haven’t changed any behavior yet. This step is just setting us up for future improvements.

Next, we need to update the render function in the /src/utils/render.mjs file.

/src/utils/render.mjsx
import { renderJSX } from "./render-jsx.mjs"
import { subscribeGlobal } from "./signal.mjs"
function renderApp(App, container) {
container.innerHTML = ""
renderJSX(App(), container)
}
function render(App, container) {
renderApp(App, container)
container.innerHTML = ""
renderJSX(App(), container)
subscribeGlobal(() => {
renderApp(App, container)
renderJSX(App(), container)
})
}
export { render }

We’ve removed the renderApp function because it caused the entire page to be re-rendered every time the state changed. This function cleared the page and rebuilt it from them scratch each time.

VirtualDOM Diff

Now, let’s dive into the main part of this update. We’ll create a new diff.mjs file and place it in the /src/utils/ folder.

  • Directorysrc
    • Directoryutils
      • diff.mjs // new file
      • jsx-runtime.mjs
      • render-jsx.mjs
      • render.mjs
      • signal.mjs
    • App.mjs
    • index.html
    • index.mjs
  • .babelrc
  • package.json

We previously added the renderVNode function in the /src/utils/render-jsx.mjs file. Now it’s time to import this function along with any other predefined functions into our new file. It should look like this:

/src/utils/diff.mjs
import { renderVNode } from "./render-jsx.mjs"
function diffProps(oldProps, newProps) {}
function diffChildren(oldChildren, newChildren) {}
export function diff(oldVNode, newVNode) {}

diffProps function

diffProps function is essential in a virtual DOM implementation to efficiently update the actual DOM when a components props change.

/src/utils/diff.mjs
function diffProps(oldProps, newProps) {
// Patches is an array that will store
// all the changes needed to update the DOM.
//
// Each patch is a function that, when executed
// will apply a specific change to a DOM element.
const patches = []
// Helper function to handle adding/removing event listeners
const handleEventListeners = (name, oldValue, newValue) => {
// Extract event type "onClick" -> "click".
const eventType = name.substring(2).toLowerCase()
return (dom) => {
if (oldValue) {
// The old event listener (if any) is removed to avoid multiple bindings.
dom.removeEventListener(eventType, oldValue)
}
if (newValue) {
// The new event listener is added if it exists.
dom.addEventListener(eventType, newValue)
}
}
}
// Function to set or update a prop.
const setOrUpdateProp = (name, value) => {
if (typeof value === "function" && name.startsWith("on")) {
// If the prop is an event listener, handle it with the helper function.
patches.push(handleEventListeners(name, oldProps[name], value))
} else {
// Otherwise, add a patch to set the attribute on the DOM element.
patches.push((dom) => dom.setAttribute(name, value))
}
}
// Function to remove a prop that no longer exists in newProps.
const removeProp = (name) => {
if (typeof oldProps[name] === "function" && name.startsWith("on")) {
// If the old prop is an event listener, remove it.
patches.push(handleEventListeners(name, oldProps[name], null))
} else {
// Otherwise, add a patch to remove the attribute from the DOM element.
patches.push((dom) => dom.removeAttribute(name))
}
}
// It loops through newProps and adds
// patches for any new or changed properties.
Object.entries(newProps).forEach(([name, value]) => {
if (oldProps[name] !== value) setOrUpdateProp(name, value)
})
// It loops through oldProps and adds patches
// to remove any properties that no longer exist in newProps.
Object.keys(oldProps).forEach((name) => {
if (!(name in newProps)) removeProp(name)
})
// Return a function that applies
// all the patches to the given DOM element.
return (dom) => {
patches.forEach((patch) => patch(dom))
// Return the DOM element after all patches have been applied
return dom
}
}

Directly manipulating the DOM is slow and can cause performance issues, especially in complex applications. The diffProps function helps avoid unnecessary updates by only modifying the parts of the DOM that have actually changed.

Event listeners need special handling because they are not regular attributes but functions that should be added or removed correctly to ensure that events (like clicks) work as expected.

diffChildren function

/src/utils/diff.mjs
function diffChildren(oldChildren, newChildren) {
// This array stores the patches (functions)
// that will be used to update the existing child nodes.
const childPatches = []
// This array stores the patches (functions)
// that will be used to add any new child nodes
// that exist in newChildren but not in oldChildren.
const additionalPatches = []
// For each oldChild, the function compares it
// with the corresponding newChild at the same index
// using the diff function.
oldChildren.forEach((oldChild, i) => {
childPatches.push(diff(oldChild, newChildren[i]))
})
// Handle any extra new children that didn't exist in oldChildren
for (const additionalChild of newChildren.slice(oldChildren.length)) {
// Create a patch for each additional child to append it to the DOM
additionalPatches.push((dom) =>
dom.appendChild(renderVNode(additionalChild, dom))
)
}
// This function takes a DOM element as an argument
// and applies the previously calculated patches to update its child nodes.
return (dom) => {
// This loop applies the patches
// from childPatches to update existing child nodes.
dom.childNodes.forEach((child, i) => {
if (childPatches[i]) childPatches[i](child)
})
// This loop appends any additional child nodes to the DOM element.
additionalPatches.forEach((patch) => patch(dom))
// Return the updated DOM element
return dom
}
}

The diffChildren function is responsible for comparing the old children of a DOM element with the new children and generating the necessary changes (patches) to update the DOM element.

It manages both updating or removing existing children and adding new ones as needed.

diff function

/src/utils/diff.mjs
export function diff(oldVNode, newVNode) {
// If newVNode is null or undefined,
// it means the node should be removed.
if (!newVNode) return (dom) => dom.remove()
if (typeof oldVNode === "string" || typeof oldVNode === "number") {
if (oldVNode !== newVNode)
return (dom) => dom.replaceWith(document.createTextNode(newVNode))
return (dom) => dom
}
// If the type of oldVNode and newVNode differs (from <div> to <span>),
// the function returns a patch that replaces the entire DOM node
// with a new one created from newVNode.
if (oldVNode.type !== newVNode.type)
return (dom) => dom.replaceWith(renderVNode(newVNode, dom.parentNode))
// Extract children and other properties from the VNodes.
const { children: oldChild, ...oldNodeRest } = oldVNode.props
const { children: newChild, ...newNodeRest } = newVNode.props
// Create patches for the properties and children.
const patchProps = diffProps(oldNodeRest, newNodeRest)
const patchChildren = diffChildren(oldChild, newChild)
// The function returns a patch that, when applied
// will update the DOM node by applying
// both patchProps and patchChildren.
return (dom) => {
patchProps(dom)
patchChildren(dom)
// Return the updated DOM node.
return dom
}
}

The diff function is the core part of the virtual DOM diffing algorithm. Its role is to compare two virtual DOM nodes (oldVNode and newVNode) and generate the minimal set of changes (patches) needed to update the real DOM to match the new virtual DOM structure.

It handles the removal, replacement, updating of text nodes, as well as the diffing of properties and children.

renderJSX with diff

And the final step is to apply diff function in renderJSX function.

/src/utils/render-jsx.mjs
...
// oldVNode is initialized as null globally.
// This stores the previous virtual DOM node
// for comparison with the new VNode in subsequent renders.
let oldVNode = null
export function renderJSX(newVNode, container) {
renderVNode(newVNode, container)
// Check if the container is empty (no child elements).
if (!container.firstChild) {
// Initial render
renderVNode(newVNode, container)
} else {
// If there are existing children, calculate
// the differences (patches) between
// the old and new VNode.
const patches = diff(oldVNode, newVNode)
// If there are any patches,
// apply them to the first child of the container.
if (patches) patches(container.firstChild)
}
// After rendering or applying patches,
// oldVNode is updated to reference newVNode.
//
// This means that on the next render, the new VNode
// becomes the old one, allowing
// for efficient comparison and updates.
oldVNode = newVNode
}

The renderJSX function is responsible for managing the rendering and updating process of a component represented by a virtual DOM.

Conclusion

We’ve built a VirtualDOM rendering process from the scratch. This approach improves performance by avoiding unnecessary full re-renders, making the rendering process more efficient.

Thank you for reading 😊

References