The web has come a long way since its inception, and with the advancements in technology, web applications have become more complex, making it challenging to create responsive, interactive, and fast web applications. This is where the virtual DOM comes in.
The virtual DOM is an essential concept in modern web development, which provides a way to create dynamic, performant, and scalable web applications. It is a lightweight copy of the actual DOM, which is a hierarchical representation of the HTML document. By creating a virtual DOM, we can manipulate the content of our web application without interacting with the actual DOM, making it faster and more efficient.
In this blog post, we will be building a custom virtual DOM implementation in JavaScript. We will cover the basic concepts of the virtual DOM, including how it works and its benefits. We will then walk you through each step of building a custom virtual DOM implementation, including creating the virtual DOM, rendering it, and updating it. By the end of this blog post, you will have a solid understanding of the virtual DOM and how it works. Let’s get started!
Setting up the project
Before we start building our custom virtual DOM, we need to set up our project. Here are the steps to set up our project:
- Create a new directory for your project.
- Open the directory in your code editor.
- Create a new HTML file (index.html) and a new JavaScript file (index.js) in the directory.
- Link the JavaScript file to the HTML file using a script tag.
<!DOCTYPE html> <html> <head> <title>Custom Virtual DOM</title> </head> <body> <div id="app"></div> <script src="index.js"></script> </body> </html>
- Create a simple HTML structure inside the
div
element for testing purposes.
<div id="app"> <h1>Hello, world!</h1> <p>This is a simple test.</p> </div>
- Open the JavaScript file and add a console log statement to verify that the file is linked to the HTML file properly.
console.log("Hello from index.js!");
- Open the HTML file in your web browser and verify that the console log statement is displayed in the browser console.
Now that we have set up our project, we can move on to building our custom virtual DOM.
Creating the virtual DOM
The first step in building a custom virtual DOM is to create the virtual DOM itself. In this step, we will define the functions that will create the virtual nodes, which we will use to build our virtual DOM. Here are the steps to create the virtual DOM:
- Create a function called
createElement
that will create a virtual node for an HTML element. This function will take in two arguments: the type of the HTML element and an object containing its attributes.
function createElement(type, props) { return { type, props, }; }
- Create a function called
createTextElement
that will create a virtual node for a text element. This function will take in a string as its only argument.
function createTextElement(text) { return { type: "TEXT_ELEMENT", props: { nodeValue: text, children: [], }, }; }
- Create a function called
createVirtualNode
that will create a virtual node based on the type of the element passed to it. This function will call eithercreateElement
orcreateTextElement
depending on the type of the element.
function createVirtualNode(type, props, ...children) { if (typeof type === "function") { return type(props); } else if (typeof type === "string") { return createElement(type, props, ...children); } else { throw new Error(`Invalid virtual node type: ${type}.`); } }
- Create a function called
createVirtualDOM
that will create a virtual DOM tree based on the elements passed to it. This function will take in an array of elements and return a virtual DOM tree.
function createVirtualDOM(elements) { const virtualNodes = elements.map((element) => { return createVirtualNode( element.type, element.props, ...(element.children || []).map(createVirtualNode) ); }); return { type: "ROOT", props: { children: virtualNodes, }, }; }
Now we have created the functions necessary to create a virtual DOM. In the next step, we will render the virtual DOM to the actual DOM.
Rendering the virtual DOM
Now that we have created the virtual DOM, the next step is to render it to the actual DOM. In this step, we will define the function that will take the virtual DOM tree and render it to the actual DOM. Here are the steps to render the virtual DOM:
- Create a function called
render
that will take in a virtual node and a container element and render the virtual node to the container element. This function will create a DOM node based on the type of the virtual node and its attributes, and then append the children of the virtual node to it.
function render(virtualNode, container) { const element = virtualNode.type === "TEXT_ELEMENT" ? document.createTextNode("") : document.createElement(virtualNode.type); updateAttributes(element, virtualNode.props); virtualNode.props.children.forEach(child => render(child, element)); container.appendChild(element); }
- Create a function called
updateAttributes
that will take in a DOM element and an object containing the attributes of the virtual node, and update the attributes of the DOM element accordingly.
function updateAttributes(element, attributes) { Object.keys(attributes).forEach(name => { if (name === "children") { return; } if (name.startsWith("on")) { const eventName = name.slice(2).toLowerCase(); element.addEventListener(eventName, attributes[name]); } else { element.setAttribute(name, attributes[name]); } }); }
- Update the
createTextElement
function to use the text node value instead of thenodeValue
property of the props object. This will make it consistent with the way we create text nodes in therender
function.
function createTextElement(text) { return { type: "TEXT_ELEMENT", props: { children: [], text, }, }; }
Now that we have defined the render
function, we can use it to render our virtual DOM tree to the actual DOM. In the next step, we will update the virtual DOM based on changes to the data.
Updating the virtual DOM
The final step in building a custom virtual DOM is to update the virtual DOM based on changes to the data. In this step, we will define the function that will take in the new data and update the virtual DOM accordingly. Here are the steps to update the virtual DOM:
- Create a function called
updateVirtualNode
that will update a virtual node based on its type and attributes. This function will take in a virtual node and an object containing the new attributes, and update the attributes of the virtual node accordingly.
function updateVirtualNode(virtualNode, newProps) { virtualNode.props = { ...virtualNode.props, ...newProps, }; return virtualNode; }
- Create a function called
updateVirtualDOM
that will update a virtual DOM tree based on the changes to the data. This function will take in the new data and the old virtual DOM tree, and return a new virtual DOM tree with the changes applied.
function updateVirtualDOM(data, virtualDOM) { const newVirtualNodes = data.map((item) => { return createVirtualNode(item.type, item.props, ...item.children); }); const patches = diff(virtualDOM, newVirtualNodes); patches.forEach((patch) => { patch.domNode = patch.parent.domNode.childNodes[patch.index]; }); patches.forEach((patch) => { switch (patch.type) { case PATCH_TYPES.REPLACE: patch.parent.domNode.replaceChild(render(patch.newNode), patch.domNode); break; case PATCH_TYPES.REORDER: patch.parent.domNode.insertBefore(patch.domNode, patch.domNode2); break; case PATCH_TYPES.PROPS: updateAttributes(patch.domNode, patch.props); break; case PATCH_TYPES.TEXT: patch.domNode.textContent = patch.newValue; break; default: break; } }); return newVirtualNodes; }
- Create an enumeration called
PATCH_TYPES
that will contain the different types of patches that can be applied to a virtual node.
const PATCH_TYPES = { REPLACE: "REPLACE", REORDER: "REORDER", PROPS: "PROPS", TEXT: "TEXT", };
- Create a function called
diff
that will compare two virtual DOM trees and return an array of patches that can be applied to the old virtual DOM tree to update it to the new virtual DOM tree. This function will use a diffing algorithm to determine which patches to apply.
function diff(oldVirtualNode, newVirtualNode) { const patches = []; const index = { value: 0 }; diffVirtualNodes(oldVirtualNode, newVirtualNode, patches, index); return patches; }
- Create a function called
diffVirtualNodes
that will compare two virtual nodes and generate patches based on the differences between them. This function will take in two virtual nodes, an array of patches, and an index that represents the current index of the virtual nodes in the DOM tree.
function diffVirtualNodes(oldVirtualNode, newVirtualNode, patches, index) { if (oldVirtualNode === newVirtualNode) { return; } if (!newVirtualNode) { patches.push({ type: PATCH_TYPES.REPLACE, parentNode: getParent(oldVirtualNode), index: index.value, newNode: null, }); return; } if (oldVirtualNode.type !== newVirtualNode.type) { patches.push({ type: PATCH_TYPES.REPLACE, parentNode: getParent(oldVirtualNode), index: index.value, newNode: newVirtualNode, }); return; } if (typeof oldVirtualNode.type === 'string') { diffAttributes(oldVirtualNode.props, newVirtualNode.props, patches, oldVirtualNode.domNode); diffChildren(oldVirtualNode.children, newVirtualNode.children, patches, index); } else if (typeof oldVirtualNode.type === 'function') { const oldState = oldVirtualNode.instance.state; const oldProps = oldVirtualNode.instance.props; const newState = { ...oldState, ...newVirtualNode.props.state }; const newProps = { ...oldProps, ...newVirtualNode.props }; const shouldUpdate = oldVirtualNode.instance.shouldComponentUpdate(newProps, newState); if (shouldUpdate) { const prevInstance = oldVirtualNode.instance; const nextInstance = new oldVirtualNode.type(newProps, newState); const childElement = nextInstance.render(); const domNode = prevInstance.domNode; const parentNode = getParent(oldVirtualNode); patches.push({ type: PATCH_TYPES.REPLACE, parentNode, index: index.value, newNode: render(childElement), }); } else { newVirtualNode.instance = oldVirtualNode.instance; newVirtualNode.domNode = oldVirtualNode.domNode; newVirtualNode.instance.props = newProps; newVirtualNode.instance.state = newState; diffChildren(oldVirtualNode.children, newVirtualNode.instance.render().children, patches, index); } } else { newVirtualNode.instance = oldVirtualNode.instance; newVirtualNode.domNode = oldVirtualNode.domNode; diffChildren(oldVirtualNode.children, newVirtualNode.instance.render().children, patches, index); } index.value++; }
- Create a function called `diffAttributes` that will compare the attributes of two virtual nodes and generate patches based on the differences between them. This function will take in the old attributes, new attributes, an array of patches, and the DOM node that the virtual nodes represent.
function diffAttributes(oldAttributes, newAttributes, patches, domNode) { const attributes = { ...oldAttributes, ...newAttributes }; for (const [key, value] of Object.entries(attributes)) { if (oldAttributes[key] === newAttributes[key]) { continue; } patches.push({ type: PATCH_TYPES.PROPS, domNode, props: { [key]: value, }, }); } }
- Create a function called
diffChildren
that will compare the children of two virtual nodes and generate patches based on the differences between them. This function will take in the old children, new children, an array of patches, and the index that represents the current index of the virtual nodes in the DOM tree.
function diffChildren(oldChildren, newChildren, patches, index) { const oldChildNodes = oldChildren.map((child) => child.domNode); newChildren.forEach((newChild, i) => { const oldChild = oldChildren[i]; if (oldChild && newChild) { diffVirtualNodes(oldChild, newChild, patches, index); } else if (newChild) { patches.push({ type: PATCH_TYPES.REORDER, parentNode: getParent(oldChild), domNode: render(newChild), index: index.value, }); } }); oldChildren.forEach((oldChild, i) => { if (!newChildren[i]) { patches.push({ type: PATCH_TYPES.REORDER, parentNode: getParent(oldChild), index: index.value, domNode2: oldChild.domNode, }); } }); }
- Finally, create a function called `patch` that will take in the patches generated by the `diffVirtualNodes` function and apply them to the actual DOM tree.
function patch(parentNode, patches) { patches.forEach((patch) => { switch (patch.type) { case PATCH_TYPES.PROPS: setAttributes(patch.domNode, patch.props); break; case PATCH_TYPES.REPLACE: replaceNode(patch.parentNode, patch.newNode, patch.index); break; case PATCH_TYPES.REORDER: reorderChildren(patch.parentNode, patch.domNode, patch.index, patch.domNode2); break; } }); } function setAttributes(domNode, props) { for (const [key, value] of Object.entries(props)) { if (value === false) { domNode.removeAttribute(key); } else { domNode.setAttribute(key, value); } } } function replaceNode(parentNode, newNode, index) { parentNode.replaceChild(newNode, parentNode.childNodes[index]); } function reorderChildren(parentNode, domNode, index, domNode2) { if (domNode2) { parentNode.insertBefore(domNode, domNode2); } else { parentNode.appendChild(domNode); } }
With these functions, we have a functioning custom virtual DOM implementation in JavaScript!
Here is the full JavaScript code for a custom virtual DOM implementation that we have covered in this blog post:
const VIRTUAL_NODE_TYPES = { ELEMENT: "ELEMENT", TEXT: "TEXT", }; const PATCH_TYPES = { PROPS: "PROPS", REPLACE: "REPLACE", REORDER: "REORDER", }; let currentNodeIndex = 0; function createElement(type, props, children) { return { type, props: props || {}, children: children || [], }; } function createTextElement(text) { return { type: VIRTUAL_NODE_TYPES.TEXT, text, }; } function createVirtualNode(node) { if (typeof node === "string") { return createTextElement(node); } return createElement(node.type, node.props, node.children.map(createVirtualNode)); } function diffProps(oldVirtualNode, newVirtualNode) { const patches = []; const allProps = { ...oldVirtualNode.props, ...newVirtualNode.props }; for (const [key, value] of Object.entries(allProps)) { if (oldVirtualNode.props[key] !== newVirtualNode.props[key]) { patches.push({ type: PATCH_TYPES.PROPS, domNode: oldVirtualNode.domNode, props: newVirtualNode.props }); break; } } return patches; } function diffChildren(oldVirtualNode, newVirtualNode) { const patches = []; const oldChildren = oldVirtualNode.children; const newChildren = newVirtualNode.children; const minLength = Math.min(oldChildren.length, newChildren.length); for (let i = 0; i < minLength; i++) { patches.push(...diffVirtualNodes(oldChildren[i], newChildren[i])); } if (oldChildren.length > newChildren.length) { patches.push({ type: PATCH_TYPES.REORDER, parentNode: oldVirtualNode.domNode, domNode: oldChildren[minLength].domNode, index: minLength }); } else if (oldChildren.length < newChildren.length) { for (let i = minLength; i < newChildren.length; i++) { const newChildNode = newChildren[i]; const newNode = createVirtualNode(newChildNode); patches.push({ type: PATCH_TYPES.REORDER, parentNode: newVirtualNode.domNode, domNode: newNode.domNode }); } } return patches; } function diffVirtualNodes(oldVirtualNode, newVirtualNode) { const patches = []; if (oldVirtualNode === undefined) { patches.push({ type: PATCH_TYPES.REORDER, parentNode: undefined, domNode: createVirtualNode(newVirtualNode).domNode }); } else if (newVirtualNode === undefined) { patches.push({ type: PATCH_TYPES.REPLACE, parentNode: oldVirtualNode.parentNode, newNode: undefined, index: currentNodeIndex }); } else if (oldVirtualNode.type !== newVirtualNode.type) { patches.push({ type: PATCH_TYPES.REPLACE, parentNode: oldVirtualNode.parentNode, newNode: createVirtualNode(newVirtualNode).domNode, index: currentNodeIndex }); } else if (oldVirtualNode.type === VIRTUAL_NODE_TYPES.TEXT && newVirtualNode.type === VIRTUAL_NODE_TYPES.TEXT && oldVirtualNode.text !== newVirtualNode.text) { patches.push({ type: PATCH_TYPES.REPLACE, parentNode: oldVirtualNode.parentNode, newNode: createVirtualNode(newVirtualNode).domNode, index: currentNodeIndex }); } else { patches.push(...diffProps(oldVirtualNode, newVirtualNode)); patches.push(...diffChildren(oldVirtualNode, newVirtualNode)); } return patches; } function renderVirtualNode(virtualNode) { if (virtualNode === undefined) { return; } if (virtualNode.type === VIRTUAL_NODE_TYPES.ELEMENT) { const domNode = document.createElement(virtualNode.type); virtualNode.domNode = domNode; for (const [prop, value] of Object.entries(virtualNode.props)) { if (prop === "className") { domNode.className = value; } else if (prop === "style") { for (const [styleProp, styleValue] of Object.entries(value)) { domNode.style[styleProp] = styleValue; } } else if (prop.startsWith("on")) { const eventName = prop.substring(2).toLowerCase(); domNode.addEventListener(eventName, value); } else { domNode.setAttribute(prop, value); } } for (const childNode of virtualNode.children) { const childDomNode = renderVirtualNode(childNode); domNode.appendChild(childDomNode); } return domNode; } else { const domNode = document.createTextNode(virtualNode.text); virtualNode.domNode = domNode; return domNode; } }
Conclusion
In this blog post, we have covered the steps for building a custom virtual DOM implementation in JavaScript. We started by setting up the project and creating the virtual DOM data structure. Then, we learned how to render the virtual DOM to the actual DOM tree, and how to update the virtual DOM efficiently by creating a diffing algorithm. We also went through the steps for creating new virtual nodes, and finally, we created functions to apply the patches generated by the diffing algorithm to the actual DOM tree.
Building a custom virtual DOM implementation can be a challenging task, but it is a great way to understand how popular frameworks like React work under the hood. By building a virtual DOM implementation from scratch, we gain a deeper understanding of the underlying concepts and are better equipped to solve performance issues in our own applications.
Hopefully, this blog post has provided you with a good starting point for building your own custom virtual DOM implementation. Happy coding!
No Comments
Leave a comment Cancel