// Rendering benchmarks: @choojs/html vs nanohtml v1 vs µhtml. // // npm run bench // // Two environments: // - server: pure string rendering in Node (no DOM at all) // - browser-path: DOM construction in happy-dom. Caveat: happy-dom is a // JS DOM, so absolute numbers are not real-browser numbers — but all // contenders pay the same DOM tax, so the relative ordering is // indicative of template-engine overhead. // // Scenarios: "create" builds a fresh 100-row table per iteration; // "update" re-renders the same live tree with new data each iteration // (choo-style engines build fresh + morph, µhtml updates holes in place). import { createRequire } from 'node:module' const require = createRequire(import.meta.url) const ROWS = 100 const DURATION_MS = 500 const WARMUP_MS = 100 function data (tick) { const rows = new Array(ROWS) for (let i = 0; i < ROWS; i++) { rows[i] = { id: i, label: 'row ' + i + ' rev ' + tick, selected: i === tick % ROWS } } return rows } function bench (name, fn) { let t = performance.now() const warmupEnd = t + WARMUP_MS let tick = 0 while (performance.now() < warmupEnd) fn(tick++) let ops = 0 const start = performance.now() const end = start + DURATION_MS while (performance.now() < end) { fn(tick++) ops++ } const elapsed = performance.now() - start const opsSec = Math.round((ops / elapsed) * 1000) console.log(' ' + name.padEnd(34) + String(opsSec).padStart(8) + ' ops/s') return opsSec } // ---------- server (string) rendering ---------- console.log('\nserver rendering — %d-row table to string:', ROWS) { const { default: ourHtml } = await import('@choojs/html/server') const nano = require('nanohtml') // main entry is the server renderer const table = (html, rows) => html`${rows.map((r) => html``)}
${r.id}${r.label}
` bench('@choojs/html (server)', (t) => String(table(ourHtml, data(t)))) bench('nanohtml v1 (server)', (t) => String(table(nano, data(t)))) } // ---------- browser-path rendering (happy-dom) ---------- const { Window } = await import('happy-dom') const win = new Window() globalThis.window = win globalThis.document = win.document // µhtml touches DOM constructors as ambient globals at import time for (const name of [ 'DocumentFragment', 'HTMLElement', 'SVGElement', 'Element', 'Node', 'Text', 'Comment', 'Range', 'MutationObserver', 'customElements', 'requestAnimationFrame', 'cancelAnimationFrame' ]) { if (!(name in globalThis) && name in win) globalThis[name] = win[name] } const { default: ourBrowserHtml } = await import('@choojs/html/browser') const { default: morph } = await import('@choojs/html/morph') const nanoBrowser = require('nanohtml/lib/browser.js') const { html: uhtml, render: urender } = await import('uhtml') const table = (html, rows) => html`${rows.map((r) => html``)}
${r.id}${r.label}
` console.log('\nbrowser-path create — fresh %d-row table per iteration (happy-dom):', ROWS) bench('@choojs/html (cached templates)', (t) => table(ourBrowserHtml, data(t))) bench('nanohtml v1 (hyperx runtime)', (t) => table(nanoBrowser, data(t))) bench('uhtml v5 (fresh container)', (t) => { const c = document.createElement('div') urender(c, uhtml`${data(t).map((r) => uhtml``)}
${r.id}${r.label}
`) }) console.log('\nbrowser-path update — re-render same live tree (happy-dom):') { const live = table(ourBrowserHtml, data(0)) document.body.appendChild(live) bench('@choojs/html + nanomorph', (t) => morph(live, table(ourBrowserHtml, data(t)))) document.body.removeChild(live) } { const live = table(nanoBrowser, data(0)) document.body.appendChild(live) bench('nanohtml v1 + nanomorph', (t) => morph(live, table(nanoBrowser, data(t)))) document.body.removeChild(live) } { const c = document.createElement('div') document.body.appendChild(c) const view = (t) => uhtml`${data(t).map((r) => uhtml``)}
${r.id}${r.label}
` bench('uhtml v5 (keyed holes, in place)', (t) => urender(c, view(t))) document.body.removeChild(c) } console.log('\ncaveat: happy-dom numbers are indicative (same DOM tax for all), not real-browser numbers.\n')