create-bablojs
Version:
CLI tool to quickly scaffold a new BABLOJS project. BABLOJS is a lightweight, fast, and scalable Single Page Application framework built with vanilla JavaScript, providing React-like features including Virtual DOM, hooks, routing, and component-based arch
602 lines (524 loc) • 18.1 kB
JavaScript
// bablo.js - Ultra-optimized VDOM (Bug-Free, cleaned & safe)
// Improvements: pooling optional, proper cleanup (listeners & vnode), robust keyed reconciliation,
// reorder checks use isSameNode, style handling fixed, no unnecessary insertBefore churn.
import { babloApp } from "./BabloApp.js";
import { resetStateCursor, runEffects } from "./hooks.js";
/* ---------- Config Flags ---------- */
const ENABLE_POOL = false; // toggle pooling for safety during dev
const MAX_POOL_SIZE = 1000;
/* ---------- Constants ---------- */
const EMPTY_OBJ = Object.freeze({});
const EMPTY_ARR = Object.freeze([]);
const TEXT_NODE = 3;
const ELEMENT_NODE = 1;
/* ---------- VNode Pooling (optional) ---------- */
const vnodePool = [];
function getPooledVNode() {
if (!ENABLE_POOL) return {};
return vnodePool.pop() || {};
}
function releaseVNode(vnode) {
if (!ENABLE_POOL || !vnode) return;
// clear references conservatively
vnode.type = null;
vnode.key = null;
vnode.props = null;
if (vnodePool.length < MAX_POOL_SIZE) vnodePool.push(vnode);
}
/* ---------- Utility Helpers ---------- */
function isPrimitive(v) {
return v == null || typeof v === "string" || typeof v === "number" || typeof v === "boolean";
}
function flattenChildren(children) {
// non-recursive flatten optimized
const out = [];
for (let i = 0; i < children.length; i++) {
const c = children[i];
if (c == null || c === false || c === true) continue;
if (Array.isArray(c)) {
for (let j = 0; j < c.length; j++) {
const n = c[j];
if (n == null || n === false || n === true) continue;
out.push(n);
}
} else {
out.push(c);
}
}
return out.length ? out : EMPTY_ARR;
}
/* ---------- createElement (VDOM) ---------- */
export function createElement(type, props, ...children) {
const vnode = getPooledVNode();
vnode.type = type;
vnode.key = props?.key ?? null;
if (props) {
const p = {};
for (const k in props) {
if (k !== "key") p[k] = props[k];
}
p.children = children.length ? flattenChildren(children) : EMPTY_ARR;
vnode.props = p;
} else {
vnode.props = children.length ? { children: flattenChildren(children) } : EMPTY_OBJ;
}
return vnode;
}
/* ---------- DOM creation & vnode cache ---------- */
function createDOMElement(vNode) {
if (vNode == null || vNode === false || vNode === true) return document.createTextNode("");
if (typeof vNode !== "object") return document.createTextNode(String(vNode));
const { type, props = EMPTY_OBJ } = vNode;
if (typeof type !== "string") return document.createTextNode(String(type));
const el = document.createElement(type);
// initialize listeners tracker
el.__listeners = el.__listeners || new Map();
// apply props then children (props first improves layout in some cases)
applyPropsOptimized(el, EMPTY_OBJ, props);
const children = props.children || EMPTY_ARR;
if (children !== EMPTY_ARR && children.length) {
const frag = document.createDocumentFragment();
for (let i = 0; i < children.length; i++) {
const c = children[i];
if (c == null || c === false || c === true) continue;
frag.appendChild(createDOMElement(c));
}
el.appendChild(frag);
}
// cache vnode on DOM for keyed lookup and later patching
try { el.__vnode = vNode; } catch (e) { /* ignore readonly edge */ }
return el;
}
/* ---------- Props application with listener tracking ---------- */
const eventCache = new Map();
function getEventName(key) {
if (typeof key !== "string") return "";
let cached = eventCache.get(key);
if (!cached) {
cached = key.slice(2).toLowerCase();
eventCache.set(key, cached);
}
return cached;
}
function applyPropsOptimized(el, oldProps = EMPTY_OBJ, newProps = EMPTY_OBJ) {
// ensure listener map exists
el.__listeners = el.__listeners || new Map();
// fast add/update path if oldProps is EMPTY_OBJ
if (oldProps === EMPTY_OBJ) {
for (const k in newProps) {
if (k === "children") continue;
setProp(el, k, newProps[k], undefined);
}
return;
}
// remove old props not present anymore
for (const k in oldProps) {
if (k === "children") continue;
if (!(k in newProps)) removeProp(el, k, oldProps[k]);
}
// set/update new props
for (const k in newProps) {
if (k === "children") continue;
const oldVal = oldProps[k];
const newVal = newProps[k];
if (oldVal !== newVal) {
setProp(el, k, newVal, oldVal);
}
}
}
function setProp(el, key, value, oldValue) {
if (typeof key === "string" && key.startsWith("on")) {
const ev = getEventName(key);
// remove old listener if function
try {
const prev = el.__listeners?.get(ev);
if (typeof prev === "function") el.removeEventListener(ev, prev);
} catch (e) { }
el.__listeners = el.__listeners || new Map();
if (typeof value === "function") {
try { el.addEventListener(ev, value); } catch (e) { }
el.__listeners.set(ev, value);
} else {
el.__listeners.delete(ev);
}
return;
}
if (key === "style") {
// accept string or object
const style = el.style;
if (typeof oldValue === "object" && typeof value === "object") {
// clear removed keys
for (const p in oldValue) {
if (!(p in value)) style[p] = "";
}
for (const p in value) style[p] = value[p];
} else if (typeof value === "object") {
// replace entirely if old was string or undefined
try { el.removeAttribute("style"); } catch (e) { }
for (const p in value) style[p] = value[p];
} else {
// primitive style string
el.setAttribute("style", value == null ? "" : String(value));
}
return;
}
if (key === "dangerouslySetInnerHTML") {
el.innerHTML = value?.__html || "";
return;
}
if (key === "className") {
el.className = value || "";
return;
}
// boolean attr
if (typeof value === "boolean") {
if (value) el.setAttribute(key, "");
else el.removeAttribute(key);
return;
}
if (value == null) {
try { el.removeAttribute(key); } catch (e) { }
} else {
try { el.setAttribute(key, String(value)); } catch (e) { }
}
}
function removeProp(el, key, oldValue) {
if (!el) return;
if (typeof key === "string" && key.startsWith("on")) {
const ev = getEventName(key);
try {
const fn = el.__listeners?.get(ev);
if (typeof fn === "function") el.removeEventListener(ev, fn);
} catch (e) { }
el.__listeners?.delete(ev);
return;
}
if (key === "className") {
el.className = "";
return;
}
if (key === "style") {
try { el.removeAttribute("style"); } catch (e) { }
return;
}
if (key === "dangerouslySetInnerHTML") {
el.innerHTML = "";
return;
}
try { el.removeAttribute(key); } catch (e) { }
}
/* ---------- Node cleanup (listeners & vnode) ---------- */
function cleanupDomNode(node) {
if (!node) return;
// remove listeners tracked
const listeners = node.__listeners;
if (listeners instanceof Map) {
for (const [ev, fn] of listeners) {
try { node.removeEventListener(ev, fn); } catch (e) { }
}
listeners.clear();
}
// recursively cleanup children (iterate backwards for safety)
const childNodes = node.childNodes || [];
for (let i = childNodes.length - 1; i >= 0; i--) {
cleanupDomNode(childNodes[i]);
}
// remove vnode cache
if (node.__vnode) {
releaseVNode(node.__vnode);
node.__vnode = null;
}
}
/* ---------- changed comparator ---------- */
function changed(a, b) {
if (a === b) return false;
if (typeof a !== typeof b) return true;
if (typeof a !== "object") return a !== b;
if (!a || !b) return true;
if (a.type !== b.type) return true;
if ((a.key ?? null) !== (b.key ?? null)) return true;
return false;
}
/* ---------- reconcileChildren (robust keyed & non-keyed) ---------- */
function reconcileChildren(parentEl, oldVNode, newVNode) {
const oldChildren = (oldVNode?.props?.children) || EMPTY_ARR;
const newChildren = (newVNode?.props?.children) || EMPTY_ARR;
const oldLen = oldChildren.length;
const newLen = newChildren.length;
if (newLen === 0) {
// remove everything safely
while (parentEl.firstChild) {
cleanupDomNode(parentEl.firstChild);
parentEl.removeChild(parentEl.firstChild);
}
return;
}
if (oldLen === 0) {
const frag = document.createDocumentFragment();
for (let i = 0; i < newLen; i++) {
const c = newChildren[i];
if (c == null || c === false || c === true) continue;
frag.appendChild(createDOMElement(c));
}
parentEl.appendChild(frag);
return;
}
// check keyed presence
let hasKeys = false;
for (let i = 0; i < newLen; i++) {
if (newChildren[i]?.key != null) { hasKeys = true; break; }
}
if (!hasKeys) {
const min = Math.min(oldLen, newLen);
// patch existing in-place
for (let i = 0; i < min; i++) {
patchElement(parentEl.childNodes[i], parentEl, oldChildren[i], newChildren[i]);
}
// append new
if (newLen > oldLen) {
const frag = document.createDocumentFragment();
for (let i = oldLen; i < newLen; i++) {
const c = newChildren[i];
if (c == null || c === false || c === true) continue;
frag.appendChild(createDOMElement(c));
}
parentEl.appendChild(frag);
} else if (oldLen > newLen) {
for (let i = oldLen - 1; i >= newLen; i--) {
const child = parentEl.childNodes[i];
if (child) {
cleanupDomNode(child);
parentEl.removeChild(child);
}
}
}
return;
}
// Keyed algorithm
const oldKeyed = new Map();
for (let i = 0; i < oldLen; i++) {
const ch = oldChildren[i];
const dom = parentEl.childNodes[i];
if (ch?.key != null) oldKeyed.set(ch.key, { vnode: ch, dom, index: i });
}
const usedKeys = new Set();
const newDomNodes = new Array(newLen);
for (let i = 0; i < newLen; i++) {
const newC = newChildren[i];
if (newC == null || newC === false || newC === true) {
newDomNodes[i] = document.createTextNode("");
continue;
}
if (newC.key != null) {
const match = oldKeyed.get(newC.key);
if (match) {
usedKeys.add(newC.key);
patchElement(match.dom, parentEl, match.vnode, newC);
newDomNodes[i] = match.dom;
} else {
// new keyed node
newDomNodes[i] = createDOMElement(newC);
}
} else {
// non-keyed in keyed context -> create new
newDomNodes[i] = createDOMElement(newC);
}
}
// remove old keyed nodes that weren't reused
for (let i = oldLen - 1; i >= 0; i--) {
const och = oldChildren[i];
if (och?.key != null && !usedKeys.has(och.key)) {
const domn = parentEl.childNodes[i];
if (domn) {
cleanupDomNode(domn);
parentEl.removeChild(domn);
}
}
}
// reorder/insert nodes to match newDomNodes (only when necessary)
for (let i = 0; i < newLen; i++) {
const desired = newDomNodes[i];
const current = parentEl.childNodes[i];
if (!desired) continue;
// if identical node already in place, skip
if (current === desired || (current && desired && current.isSameNode && desired.isSameNode && current.isSameNode(desired))) {
continue;
}
parentEl.insertBefore(desired, current || null);
}
// remove excess DOM nodes
while (parentEl.childNodes.length > newLen) {
const last = parentEl.lastChild;
cleanupDomNode(last);
parentEl.removeChild(last);
}
}
/* ---------- patchElement (single node) ---------- */
function patchElement(domNode, parent, oldVNode, newVNode) {
// newVNode primitive / null handling
if (newVNode == null || newVNode === false || newVNode === true) {
if (domNode) {
cleanupDomNode(domNode);
parent.removeChild(domNode);
}
// release old vnode
releaseVNode(oldVNode);
return;
}
// mount if no oldVNode
if (!oldVNode) {
const newDom = createDOMElement(newVNode);
if (domNode) parent.replaceChild(newDom, domNode);
else parent.appendChild(newDom);
return;
}
// type/key changed -> replace
if (changed(oldVNode, newVNode)) {
const newDom = createDOMElement(newVNode);
if (domNode) {
cleanupDomNode(domNode);
parent.replaceChild(newDom, domNode);
} else {
parent.appendChild(newDom);
}
// release old vnode
releaseVNode(oldVNode);
return;
}
// both primitives (text) and equal type -> update text
if (typeof newVNode !== "object") {
if (domNode && domNode.nodeType === TEXT_NODE) {
const txt = String(newVNode);
if (domNode.nodeValue !== txt) domNode.nodeValue = txt;
} else {
const textNode = document.createTextNode(String(newVNode));
if (domNode) {
cleanupDomNode(domNode);
parent.replaceChild(textNode, domNode);
} else parent.appendChild(textNode);
}
releaseVNode(oldVNode);
return;
}
// ensure domNode is element
if (!domNode || domNode.nodeType !== ELEMENT_NODE) {
const newDom = createDOMElement(newVNode);
if (domNode) {
cleanupDomNode(domNode);
parent.replaceChild(newDom, domNode);
} else parent.appendChild(newDom);
releaseVNode(oldVNode);
return;
}
// patch props
applyPropsOptimized(domNode, oldVNode.props || EMPTY_OBJ, newVNode.props || EMPTY_OBJ);
// update vnode cache
domNode.__vnode = newVNode;
// reconcile children
reconcileChildren(domNode, oldVNode, newVNode);
// release old vnode after patch (if pooling enabled)
releaseVNode(oldVNode);
}
/* ---------- Batched rendering ---------- */
const renderQueue = new Map();
let scheduled = false;
let rafId = null;
// Utility: unique component key per container + renderFn
function getComponentKey(container, renderFn) {
if (!container.__componentKey) container.__componentKey = new Map();
if (!container.__componentKey.has(renderFn)) container.__componentKey.set(renderFn, Symbol());
return container.__componentKey.get(renderFn);
}
// Flush the render queue
function flushRender() {
scheduled = false;
rafId = null;
const entries = Array.from(renderQueue.entries());
renderQueue.clear();
for (let [key, { container, renderFn }] of entries) {
try {
// 1️⃣ Set component-index BEFORE hooks
const compId = container.__componentKey.get(renderFn).toString();
babloApp.appState.set("render-component-index", compId);
resetStateCursor();
if (!container) continue;
const oldVNode = container.__vnode || null;
const newVNode = renderFn();
if (!oldVNode) {
while (container.firstChild) cleanupDomNode(container.firstChild), container.removeChild(container.firstChild);
container.appendChild(createDOMElement(newVNode));
} else if (changed(oldVNode, newVNode)) {
while (container.firstChild) cleanupDomNode(container.firstChild), container.removeChild(container.firstChild);
container.appendChild(createDOMElement(newVNode));
releaseVNode(oldVNode);
} else {
patchElement(container.firstChild, container, oldVNode, newVNode);
}
container.__vnode = newVNode;
// Store component state BEFORE running effects (in case effects trigger more renders)
babloApp.componentState.set("renderd-state", renderFn);
// Run effects after render
runEffects();
} catch (err) {
console.error("Render Error:", err, container);
}
}
}
// Normal batched render
export function render(renderFn, container) {
const key = getComponentKey(container, renderFn);
renderQueue.set(key, { container, renderFn });
if (!scheduled) {
scheduled = true;
rafId = requestAnimationFrame(flushRender);
}
}
// Immediate synchronous render
export function renderSync(renderFn, container) {
const key = getComponentKey(container, renderFn);
renderQueue.set(key, { container, renderFn });
if (scheduled && rafId) {
cancelAnimationFrame(rafId);
scheduled = false;
rafId = null;
}
flushRender();
}
/* ---------- Helpers to remove/replace external nodes ---------- */
export function removeElement(selector) {
const el = typeof selector === "string" ? document.querySelector(selector) : selector;
if (el?.parentNode) {
cleanupDomNode(el);
el.parentNode.removeChild(el);
}
}
export function updateElement(selector, newVNode) {
const el = typeof selector === "string" ? document.querySelector(selector) : selector;
if (!el || !el.parentNode) return;
const newEl = createDOMElement(newVNode);
cleanupDomNode(el);
el.parentNode.replaceChild(newEl, el);
}
/* ---------- Perf (dev) ---------- */
export const perf = {
renderCount: 0,
totalRenderTime: 0,
avgRenderTime: 0,
reset() {
this.renderCount = 0; this.totalRenderTime = 0; this.avgRenderTime = 0;
},
track(time) {
this.renderCount++; this.totalRenderTime += time; this.avgRenderTime = this.totalRenderTime / this.renderCount;
},
log() { console.log(`Renders: ${this.renderCount}, Avg: ${this.avgRenderTime.toFixed(2)}ms`); }
};
// optional dev wrapper for perf - safe approach:
if (typeof process !== "undefined" && process.env?.NODE_ENV === "development") {
const originalFlush = flushRender;
flushRender = function () {
const start = performance.now();
originalFlush();
const t = performance.now() - start;
perf.track(t);
};
}