UNPKG

eleva

Version:

A minimalist and lightweight, pure vanilla JavaScript frontend runtime framework.

264 lines (237 loc) 8.06 kB
"use strict"; /** * @class 🎨 Renderer * @classdesc A high-performance DOM renderer that implements an optimized direct DOM diffing algorithm. * * Key features: * - Single-pass diffing algorithm for efficient DOM updates * - Key-based node reconciliation for optimal performance * - Intelligent attribute handling for ARIA, data attributes, and boolean properties * - Preservation of special Eleva-managed instances and style elements * - Memory-efficient with reusable temporary containers * * The renderer is designed to minimize DOM operations while maintaining * exact attribute synchronization and proper node identity preservation. * It's particularly optimized for frequent updates and complex DOM structures. * * @example * const renderer = new Renderer(); * const container = document.getElementById("app"); * const newHtml = "<div>Updated content</div>"; * renderer.patchDOM(container, newHtml); */ export class Renderer { /** * Creates a new Renderer instance. * @public */ constructor() { /** * A temporary container to hold the new HTML content while diffing. * @private * @type {HTMLElement} */ this._tempContainer = document.createElement("div"); } /** * Patches the DOM of the given container with the provided HTML string. * * @public * @param {HTMLElement} container - The container element to patch. * @param {string} newHtml - The new HTML string. * @returns {void} * @throws {TypeError} If container is not an HTMLElement or newHtml is not a string. * @throws {Error} If DOM patching fails. */ patchDOM(container, newHtml) { if (!(container instanceof HTMLElement)) { throw new TypeError("Container must be an HTMLElement"); } if (typeof newHtml !== "string") { throw new TypeError("newHtml must be a string"); } try { this._tempContainer.innerHTML = newHtml; this._diff(container, this._tempContainer); } catch (error) { throw new Error(`Failed to patch DOM: ${error.message}`); } } /** * Performs a diff between two DOM nodes and patches the old node to match the new node. * * @private * @param {HTMLElement} oldParent - The original DOM element. * @param {HTMLElement} newParent - The new DOM element. * @returns {void} */ _diff(oldParent, newParent) { if (oldParent === newParent || oldParent.isEqualNode?.(newParent)) return; const oldChildren = Array.from(oldParent.childNodes); const newChildren = Array.from(newParent.childNodes); let oldStartIdx = 0, newStartIdx = 0; let oldEndIdx = oldChildren.length - 1; let newEndIdx = newChildren.length - 1; let oldKeyMap = null; while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { let oldStartNode = oldChildren[oldStartIdx]; let newStartNode = newChildren[newStartIdx]; if (!oldStartNode) { oldStartNode = oldChildren[++oldStartIdx]; } else if (this._isSameNode(oldStartNode, newStartNode)) { this._patchNode(oldStartNode, newStartNode); oldStartIdx++; newStartIdx++; } else { if (!oldKeyMap) { oldKeyMap = this._createKeyMap(oldChildren, oldStartIdx, oldEndIdx); } const key = this._getNodeKey(newStartNode); const oldNodeToMove = key ? oldKeyMap.get(key) : null; if (oldNodeToMove) { this._patchNode(oldNodeToMove, newStartNode); oldParent.insertBefore(oldNodeToMove, oldStartNode); oldChildren[oldChildren.indexOf(oldNodeToMove)] = null; } else { oldParent.insertBefore(newStartNode.cloneNode(true), oldStartNode); } newStartIdx++; } } if (oldStartIdx > oldEndIdx) { const refNode = newChildren[newEndIdx + 1] ? oldChildren[oldStartIdx] : null; for (let i = newStartIdx; i <= newEndIdx; i++) { if (newChildren[i]) oldParent.insertBefore(newChildren[i].cloneNode(true), refNode); } } else if (newStartIdx > newEndIdx) { for (let i = oldStartIdx; i <= oldEndIdx; i++) { if (oldChildren[i]) this._removeNode(oldParent, oldChildren[i]); } } } /** * Patches a single node. * * @private * @param {Node} oldNode - The original DOM node. * @param {Node} newNode - The new DOM node. * @returns {void} */ _patchNode(oldNode, newNode) { if (oldNode?._eleva_instance) return; if (!this._isSameNode(oldNode, newNode)) { oldNode.replaceWith(newNode.cloneNode(true)); return; } if (oldNode.nodeType === Node.ELEMENT_NODE) { this._updateAttributes(oldNode, newNode); this._diff(oldNode, newNode); } else if ( oldNode.nodeType === Node.TEXT_NODE && oldNode.nodeValue !== newNode.nodeValue ) { oldNode.nodeValue = newNode.nodeValue; } } /** * Removes a node from its parent. * * @private * @param {HTMLElement} parent - The parent element containing the node to remove. * @param {Node} node - The node to remove. * @returns {void} */ _removeNode(parent, node) { if (node.nodeName === "STYLE" && node.hasAttribute("data-e-style")) return; parent.removeChild(node); } /** * Updates the attributes of an element to match a new element's attributes. * * @private * @param {HTMLElement} oldEl - The original element to update. * @param {HTMLElement} newEl - The new element to update. * @returns {void} */ _updateAttributes(oldEl, newEl) { const oldAttrs = oldEl.attributes; const newAttrs = newEl.attributes; // Process new attributes for (let i = 0; i < newAttrs.length; i++) { const { name, value } = newAttrs[i]; // Skip event attributes (handled by event system) if (name.startsWith("@")) continue; // Skip if attribute hasn't changed if (oldEl.getAttribute(name) === value) continue; // Basic attribute setting oldEl.setAttribute(name, value); } // Remove old attributes that are no longer present for (let i = oldAttrs.length - 1; i >= 0; i--) { const name = oldAttrs[i].name; if (!newEl.hasAttribute(name)) { oldEl.removeAttribute(name); } } } /** * Determines if two nodes are the same based on their type, name, and key attributes. * * @private * @param {Node} oldNode - The first node to compare. * @param {Node} newNode - The second node to compare. * @returns {boolean} True if the nodes are considered the same, false otherwise. */ _isSameNode(oldNode, newNode) { if (!oldNode || !newNode) return false; const oldKey = oldNode.nodeType === Node.ELEMENT_NODE ? oldNode.getAttribute("key") : null; const newKey = newNode.nodeType === Node.ELEMENT_NODE ? newNode.getAttribute("key") : null; if (oldKey && newKey) return oldKey === newKey; return ( !oldKey && !newKey && oldNode.nodeType === newNode.nodeType && oldNode.nodeName === newNode.nodeName ); } /** * Creates a key map for the children of a parent node. * * @private * @param {Array<Node>} children - The children of the parent node. * @param {number} start - The start index of the children. * @param {number} end - The end index of the children. * @returns {Map<string, Node>} A key map for the children. */ _createKeyMap(children, start, end) { const map = new Map(); for (let i = start; i <= end; i++) { const child = children[i]; const key = this._getNodeKey(child); if (key) map.set(key, child); } return map; } /** * Extracts the key attribute from a node if it exists. * * @private * @param {Node} node - The node to extract the key from. * @returns {string|null} The key attribute value or null if not found. */ _getNodeKey(node) { return node?.nodeType === Node.ELEMENT_NODE ? node.getAttribute("key") : null; } }