Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better benchmark #3

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# sig-html
sig-html is a micro framework for web apps based on plain or computed signals + lit-html template syntax.

How micro? **943** bytes, when maximally Brotli-compressed!
How micro? [**965**](https://raw.githubusercontent.com/cloudspeech/sig-html/main/dist/index.min.js) bytes, when maximally Brotli-compressed!

Similarly, the `repeat` directive is only [__277__](https://raw.githubusercontent.com/cloudspeech/sig-html/main/dist/directives/repeat.min.js) bytes, when maximally Brotli-compressed.

## Status
This is alpha-quality software and as such not ready for production.
Expand Down Expand Up @@ -50,4 +52,4 @@ This is a subset of the [full lit-html syntax](https://lit.dev/docs/templates/ex
1. Install [bun](https://bun.sh)
2. bun install
3. bun start
4. Point your browser to http://localhost:8080/index.html **or** http://localhost:8080/counter.html **or** http://localhost:8080/spreadsheet.html **or** http://localhost:8080/eventlog.html
4. Point your browser to http://localhost:8080/index.html **or** http://localhost:8080/counter.html **or** http://localhost:8080/spreadsheet.html **or** http://localhost:8080/eventlog.html **or** http://localhost:8080/benchmark.html
35 changes: 35 additions & 0 deletions benchmark.css

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions benchmark.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!DOCTYPE html>
<meta charset="utf-8" />
<title>sig-html</title>
<link href="/benchmark.css" rel="stylesheet" />
<div id="container"></div>
<script type="module" src='/benchmark.js'></script>
162 changes: 162 additions & 0 deletions benchmark.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { html, render, option, Signal } from './index.js';
import { repeat } from './directives/repeat.js';

const adjectives = [
'pretty', 'large', 'big', 'small', 'tall', 'short', 'long', 'handsome', 'plain', 'quaint', 'clean', 'elegant', 'easy', 'angry', 'crazy', 'helpful', 'mushy', 'odd', 'unsightly', 'adorable', 'important', 'inexpensive', 'cheap', 'expensive', 'fancy'];
const colours = ['red', 'yellow', 'blue', 'green', 'pink', 'brown', 'purple', 'brown', 'white', 'black', 'orange'];
const nouns = ['table', 'chair', 'house', 'bbq', 'desk', 'car', 'pony', 'cookie', 'sandwich', 'burger', 'pizza', 'mouse', 'keyboard'];

let data = [];
let did = 1;
let selected = -1;

const container = document.getElementById('container');

let tableRef = repeat();

const add = () => {
const skip = data.length;
data = data.concat(buildData(1000));
_render(skip);
};

const run = () => {
let n = 100;
let incrementalRender = () => {
if (data.length >= 10*n) return;
data = data.concat(buildData(n));
_render(data.length - n);
requestAnimationFrame(incrementalRender);
};
incrementalRender();
};

const runLots = () => {
let n = 1000;
let incrementalRender = () => {
if (data.length >= 10*n) return;
data = data.concat(buildData(n));
_render(data.length - n);
requestAnimationFrame(incrementalRender);
};
incrementalRender();
};

const clear = () => {
data = [];
_render();
};

const interact = e => {
const td = e.target.closest('td');
const interaction = td.getAttribute('data-interaction');
const id = parseInt(td.parentNode.id);
if (interaction === 'delete') {
del(id);
} else {
select(id);
}
};

const del = id => {
const idx = data.findIndex(d => d.id === id);
if (selected > -1 && idx < selected) selected--;
data.splice(idx, 1);
_render(0, id);
};

const select = id => {
if (selected > -1) {
data[selected].selected = false;
_render(0, 0, selected + 1);
}
selected = data.findIndex(d => d.id === id);
data[selected].selected = true;
_render(0, 0, selected + 1);
};

/*
data id => data id

1 | 2 | 1 | 999 |
| ... | | ... |
998 | 999 | 998 | 2 |
*/

const swapRows = () => {
if (data.length > 998) {
const tmp = data[1];
data[1] = data[998];
data[998] = tmp;
_render(0, data[1].id, data[998].id);
}
};

const update = () => {
for (let i = 0; i < data.length; i += 10) {
const item = data[i];
data[i].label += ' !!!';
_render(0, 0, i+1);
}
};

const buildData = count => {
const data = [];
for (let i = 0; i < count; i++) {
data.push({
id: did++,
label: `${adjectives[_random(adjectives.length)]} ${colours[_random(colours.length)]} ${nouns[_random(nouns.length)]}`,
selected: false,
});
}
return data;
};

const _random = max => {
return Math.round(Math.random() * 1000) % max;
};

const _render = (skip = 0, del = 0, mut = 0) => repeat(tableRef, del || (skip ? data.slice(skip) : data), ({id,selected,label}) => `
<tr id="${id}" class="${selected ? 'danger' : ''}">
<td class="col-md-1">${id}</td>
<td class="col-md-4"><a>${label}</a></td>
<td data-interaction="delete" class="col-md-1"><a><span class="glyphicon glyphicon-remove" aria-hidden="true"></span></a></td>
<td class="col-md-6"></td>
</tr>`, !skip && !del && !mut, mut);

const template = () => html`
<div class="container">
<div class="jumbotron">
<div class="row">
<div class="col-md-6">
<h1>sig-html</h1>
</div>
<div class="col-md-6">
<div class="row">
<div class="col-sm-6 smallpad">
<button type="button" class="btn btn-primary btn-block" id="run" @click="${run}">Create 1,000 rows</button>
</div>
<div class="col-sm-6 smallpad">
<button type="button" class="btn btn-primary btn-block" id="runlots" @click="${runLots}">Create 10,000 rows</button>
</div>
<div class="col-sm-6 smallpad">
<button type="button" class="btn btn-primary btn-block" id="add" @click="${add}">Append 1,000 rows</button>
</div>
<div class="col-sm-6 smallpad">
<button type="button" class="btn btn-primary btn-block" id="update" @click="${update}">Update every 10th row</button>
</div>
<div class="col-sm-6 smallpad">
<button type="button" class="btn btn-primary btn-block" id="clear" @click="${clear}">Clear</button>
</div>
<div class="col-sm-6 smallpad">
<button type="button" class="btn btn-primary btn-block" id="swaprows" @click="${swapRows}">Swap Rows</button>
</div>
</div>
</div>
</div>
</div>
<table @click="${interact}" class="table table-hover table-striped test-data"><tbody><!--${tableRef}--></tbody></table>
<span class="preloadicon glyphicon glyphicon-remove" aria-hidden="true"></span>
</div>`;

render(container, template());
28 changes: 24 additions & 4 deletions compress.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,34 @@
import { compress } from 'brotli';
import { readFileSync } from 'fs'
import { readFileSync, writeFileSync } from 'fs'

const minified = readFileSync('dist/index.min.js');
console.log('index.js:\n');

let minified = readFileSync('dist/index.min.js');

console.log('Uncompressed, minified:', minified.length, 'Bytes');

const brotliCompressed = compress(minified);
let brotliCompressed = compress(minified);

console.log('Brotli -11:', brotliCompressed.length, 'Bytes');

const gzipCompressed = Bun.gzipSync(minified, {level: 9});
let gzipCompressed = Bun.gzipSync(minified, {level: 9});

console.log('Gzip -9:', gzipCompressed.length, 'Bytes');

writeFileSync('README.md', readFileSync('README.md', 'utf-8').replace(/\*\*\d+\*\*/, `**${brotliCompressed.length}**`));

console.log('\ndirectives/repeat.js:\n');

minified = readFileSync('dist/directives/repeat.min.js');

console.log('Uncompressed, minified:', minified.length, 'Bytes');

brotliCompressed = compress(minified);

console.log('Brotli -11:', brotliCompressed.length, 'Bytes');

gzipCompressed = Bun.gzipSync(minified, {level: 9});

console.log('Gzip -9:', gzipCompressed.length, 'Bytes');

writeFileSync('README.md', readFileSync('README.md', 'utf-8').replace(/__\d+__/, `__${brotliCompressed.length}__`));
40 changes: 40 additions & 0 deletions directives/repeat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {Signal, render} from '../index.js';

// provide a simple repeat directive
export let repeat = (signal, data, itemTemplate, clear = 1, index = 0) => {
// initializer requested?
if (!signal) {
// create a ref(erence) to a document fragment
return new Signal(/* document fragment */ render(null), /* just return a ref, not a full signal */ 1);
}
let fragment = signal.value;

let q = id => fragment.querySelector(`[id="${id}"]`);

if (typeof data === 'number') {

let el1 = q(data);

if (index) {
// swap!
let el2 = q(index);
let p2 = el2.parentNode, n2 = el2.nextElementSibling;
if (n2 === el1) return p2.insertBefore(el1, el2);
el1.parentNode.insertBefore(el2, el1);
return p2.insertBefore(el1, n2);
}
// delete
return el1.remove();
}
// should we clear the document fragment initially?
if (clear) {
// yes, do it in a fast way
fragment.textContent = "";
}
// append the data-instantiated sequence of items to the document fragment
render(signal, (index ? [data[index - 1]] : data).map(item => itemTemplate(item)).join(""));
if (index) {
// mutate
q(data[index - 1].id).replaceWith(fragment.lastElementChild);
}
};
1 change: 1 addition & 0 deletions dist/directives/repeat.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added fonts/glyphicons-halflings-regular.woff2
Binary file not shown.
34 changes: 19 additions & 15 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,18 @@ let LIT_HTML_SPECIALS = ".?@";
// module globals
let plugs2Values = {};
let plugCounter = 0;
let parser = new DOMParser();
let d = document;
let options = {};

// helper functions
let parse = htmlText => {
return parser.parseFromString(htmlText, "text/html"); // returns document tree corresponding to <html><head></head><body>child nodes</body></html>
let parse = (htmlText) => {
let template = d.createElement("template");
template.innerHTML = htmlText;
return template.content.cloneNode(true);
};

let extractNodes = dom => dom?.all[2].childNodes; // extract the <html>, then the <body>, then the children under it

let isText = domNode => domNode.nodeType === 3; // is it a Text node?
let isComment = domNode => domNode.nodeType === 8; // is it a Comment node?

let isFunction = f => typeof f === "function";

Expand Down Expand Up @@ -124,7 +125,7 @@ let handleSignal = (domNode, value, attribute) => {

let handleVariables = domNode => {
// Text node?
if (isText(domNode)) {
if (isText(domNode) || isComment(domNode)) {
// yes, try to see whether it contains a plug
let value = plugs2Values[domNode[TEXTCONTENT]];
// it does?
Expand Down Expand Up @@ -161,6 +162,8 @@ let handleVariables = domNode => {

// API

export let option = (key, value) => value ? (options[key] = value) : options[key];

// replace the string-template variables with unique 'plug' strings s.t. the resulting fully instantiated string can be DOM-parsed.
// Along the way, remember the association of plugs with their corresponding variable values
export let html = (fragments, ...values) => {
Expand All @@ -172,7 +175,7 @@ export let html = (fragments, ...values) => {
// for all string fragments, in order:
for (let i = 0, plug; i < n; i++) {
// calculate unique string-valued variable filler a.k.a. 'plug' that's unlikely to be confused with a real attribute value or text content
plug = i < m ? `_${++plugCounter}${Math.random() * 1e18}_` : "";
plug = i < m ? (options.direct ? values[i]: `_${++plugCounter}${Math.random() * 1e18}_`) : "";
// the fragment with right-adjacent variable is replaced by the fragment with right-adjacent 'plug'
strings[i] = fragments[i] + plug;
// and we remember the association between 'plug' and original variable value
Expand All @@ -184,31 +187,31 @@ export let html = (fragments, ...values) => {
// render plugged string-template HTML under a root node
export let render = (rootNode, pluggedHTML = "", domTree) => {
let aSignal = isSignal(rootNode);
domTree = domTree ?? aSignal ? rootNode.value : d.createDocumentFragment();
domTree = domTree ?? aSignal ? rootNode.value : new DocumentFragment();
// extract a list of child nodes from parsed, string-template-variables-plugged HTML text
let domHTML = extractNodes(parse(pluggedHTML));
let domHTML = parse(pluggedHTML);
// create a corresponding DOM tree, reparenting the child nodes under a new document fragment
for (let domNode of domHTML) {
domTree[APPENDCHILD](domNode);
}
domTree[APPENDCHILD](domHTML);
// walk the entire DOM tree under the document fragment...
for (let treeWalker = d.createTreeWalker(domTree, 5); treeWalker.nextNode(); ) {
for (let treeWalker = d.createTreeWalker(domTree, 133); treeWalker.nextNode(); ) {
// ... handling its string template variables-turned-unique-plugs
handleVariables(treeWalker.currentNode);
}
// finally, append the DOM tree under the root node provided
return aSignal ? domTree : rootNode ? rootNode[APPENDCHILD](domTree) : domTree;
return aSignal ? rootNode : rootNode ? rootNode[APPENDCHILD](domTree) : domTree;
};

// provide plain and computed signals
export class Signal {
// private instance variables
#value;
#isRef;
#subscribers;
#effects;

constructor(initialValue) {
constructor(initialValue, isRef) {
this.#value = initialValue;
this.#isRef = isRef;
this.#subscribers = new Set();
this.#effects = new WeakMap();
}
Expand All @@ -229,6 +232,7 @@ export class Signal {

set value(newValue) {
this.#value = newValue;
if (this.#isRef) return;
for (let domNode of this.#subscribers) {
if (isSignal(domNode)) {
domNode.value = undefined;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"format": "bun run prettier --write ./index.js",
"build": "bun run terser ./index.js --module --compress ecma=2022,passes=2 --mangle > dist/index.min.js && bun run compress.js",
"build": "bun run terser ./index.js --module --compress ecma=2022,passes=2 --mangle > dist/index.min.js && bun run terser ./directives/repeat.js --module --compress ecma=2022,passes=2 --mangle > dist/directives/repeat.min.js && bun run compress.js",
"start": "bun --hot run ./server.js"
},
"keywords": [
Expand Down