docs(demo): add the library-comparison counter demo

This commit is contained in:
ayo 2026-07-22 23:41:52 +02:00 committed by GitHub
parent 9d8c8879f8
commit 005db549c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 728 additions and 189 deletions

View file

@ -0,0 +1,29 @@
# Library comparison
The counter component from the [comparison guide](https://webcomponent.io/comparison/),
implemented in each library it's measured against, plus the reproducible size
benchmark behind the guide's numbers.
- `counters/*.mjs` — the same minimal counter (one reactive `count` prop, a
click handler, a re-render on change) in `web-component-base`, Elena, Lit,
FAST, and vanilla `HTMLElement`. `index.html` runs all five live.
- `measure.mjs` — bundles each counter _with_ its library runtime (esbuild,
`--bundle --minify --format=esm`) and compresses the result with gzip (9) and
brotli (q11) via Node's `zlib`. That's the real "cost of your first
component": library runtime + component code, everything the browser
downloads.
## Run the benchmark
From this folder:
```sh
node measure.mjs # human-readable table
node measure.mjs --md # Markdown table (what the guide embeds)
node measure.mjs --json # raw numbers
```
The external libraries are pinned dev dependencies of the `demo` workspace, and
`web-component-base` is bundled from its published `dist/` (run `pnpm build` at
the repo root first), so the numbers are deterministic. Bump a pinned version in
`demo/package.json`, re-run, and the table moves with it.

View file

@ -0,0 +1,24 @@
// The same reactive counter, written in Elena (@elenajs/core).
// Elena's `html` has no inline event syntax, so the click handler is attached
// in `firstUpdated` (after the first render) rather than in the template.
// `count` is a non-reflected reactive prop.
import { Elena, html } from '@elenajs/core'
export class ElenaCounter extends Elena(HTMLElement) {
static tagName = 'elena-counter'
static props = [{ name: 'count', reflect: false }]
count = 0
render() {
return html`<button type="button">${this.count}</button>`
}
firstUpdated() {
this.element.addEventListener('click', () => {
this.count++
})
}
}
ElenaCounter.define()

View file

@ -0,0 +1,25 @@
// The same reactive counter, written in FAST (@microsoft/fast-element).
// The buildless, non-decorator form: the template binds against the element
// instance (`x`), and `count` is declared as a number attribute.
import {
FASTElement,
html,
nullableNumberConverter,
} from '@microsoft/fast-element'
const template = html`
<button @click=${(x) => x.count++}>${(x) => x.count}</button>
`
export class FastCounter extends FASTElement {
constructor() {
super()
this.count = 0
}
}
FastCounter.define({
name: 'fast-counter',
template,
attributes: [{ property: 'count', converter: nullableNumberConverter }],
})

View file

@ -0,0 +1,17 @@
// The same reactive counter, written in Lit.
import { LitElement, html } from 'lit'
export class LitCounter extends LitElement {
static properties = { count: { type: Number } }
constructor() {
super()
this.count = 0
}
render() {
return html`<button @click=${() => this.count++}>${this.count}</button>`
}
}
customElements.define('lit-counter', LitCounter)

View file

@ -0,0 +1,33 @@
// The same reactive counter, written from scratch on top of HTMLElement — the
// baseline every library above is measured against. No library runtime: the
// reactivity, the attribute reflection, and the re-render are all hand-rolled.
export class VanillaCounter extends HTMLElement {
static observedAttributes = ['count']
connectedCallback() {
if (!this.hasAttribute('count')) this.setAttribute('count', '0')
this.render()
this.addEventListener('click', () => {
this.setAttribute('count', String(this.count + 1))
})
}
attributeChangedCallback() {
this.render()
}
get count() {
return Number(this.getAttribute('count')) || 0
}
render() {
let button = this.querySelector('button')
if (!button) {
button = document.createElement('button')
this.append(button)
}
button.textContent = String(this.count)
}
}
customElements.define('vanilla-counter', VanillaCounter)

View file

@ -0,0 +1,15 @@
// The same reactive counter, written in web-component-base.
// One reactive `count` prop, a click handler, a re-render on change.
import { WebComponent, html } from 'web-component-base'
export class WcbCounter extends WebComponent {
static props = { count: 0 }
get template() {
return html`
<button onClick=${() => ++this.props.count}>${this.props.count}</button>
`
}
}
customElements.define('wcb-counter', WcbCounter)

View file

@ -0,0 +1,231 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Library comparison — the same counter, measured</title>
<script type="module" src="./counters/vanilla.mjs"></script>
<script type="module" src="./counters/wcb.mjs"></script>
<script type="module" src="./counters/elena.mjs"></script>
<script type="module" src="./counters/lit.mjs"></script>
<script type="module" src="./counters/fast.mjs"></script>
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
<link rel="stylesheet" href="../../shell.css" />
<script type="module" src="../../shell.js"></script>
<style>
.counters {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1em;
margin: 1.5em 0;
}
.counter-card {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
padding: 1em;
display: flex;
flex-direction: column;
gap: 0.6em;
}
.counter-card .lib {
font-weight: 600;
}
.counter-card .size {
font-size: 0.85em;
color: var(--muted);
}
.counter-card .size strong {
color: var(--accent);
}
.counter-card button {
font-size: 1.4em;
padding: 0.3em 0.8em;
min-width: 3em;
}
.win {
color: var(--accent);
font-weight: 600;
}
figcaption {
color: var(--muted);
font-size: 0.9em;
margin-top: 0.4em;
}
table.sizes {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
table.sizes th,
table.sizes td {
border: 1px solid var(--border);
padding: 0.5em 0.75em;
text-align: right;
}
table.sizes th:first-child,
table.sizes td:first-child {
text-align: left;
}
table.sizes thead th {
background: var(--tag);
}
table.sizes tbody tr:first-child {
font-weight: 600;
}
.repro {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
align-items: baseline;
}
</style>
</head>
<body>
<h1>The same counter, in each library</h1>
<p>
This is the component behind the
<a href="https://webcomponent.io/comparison/">comparison guide</a>: one
minimal counter — a single reactive <code>count</code> prop, a click
handler, a re-render on change — implemented in
<code>web-component-base</code> and in each library it's measured against.
Every counter below is live; click it. The source of each is listed at
the bottom of the page, and the
<a href="#methodology">measurement that produces the sizes</a> is
<code>measure.mjs</code>, runnable from this folder.
</p>
<div class="counters">
<figure class="counter-card">
<span class="lib">web-component-base</span>
<span class="size"><strong>2.6 kB</strong> brotli</span>
<wcb-counter></wcb-counter>
<figcaption><code>static props</code> + <code>html</code></figcaption>
</figure>
<figure class="counter-card">
<span class="lib">Elena</span>
<span class="size"><strong>3.4 kB</strong> brotli</span>
<elena-counter></elena-counter>
<figcaption>click handler wired in <code>firstUpdated</code></figcaption>
</figure>
<figure class="counter-card">
<span class="lib">Lit</span>
<span class="size"><strong>5.3 kB</strong> brotli</span>
<lit-counter></lit-counter>
<figcaption><code>static properties</code>, shadow DOM</figcaption>
</figure>
<figure class="counter-card">
<span class="lib">FAST</span>
<span class="size"><strong>12.2 kB</strong> brotli</span>
<fast-counter></fast-counter>
<figcaption>binding directives, shadow DOM</figcaption>
</figure>
<figure class="counter-card">
<span class="lib">vanilla <code>HTMLElement</code></span>
<span class="size"><strong>~0.2 kB</strong> brotli</span>
<vanilla-counter></vanilla-counter>
<figcaption>no library — the hand-rolled baseline</figcaption>
</figure>
</div>
<h2 id="methodology">How it's measured</h2>
<p>
The number that matters for a first component is not the library's
advertised size — it's <strong>library runtime + your component code,
bundled and compressed</strong>, because that's everything the browser
actually downloads. So <code>measure.mjs</code>, for each library:
</p>
<ol>
<li>
bundles the counter <em>with</em> its library runtime using
<code>esbuild</code> (<code>bundle: true</code>,
<code>minify: true</code>, <code>format: 'esm'</code>) — tree-shaking
away whatever that component doesn't touch;
</li>
<li>
compresses the bundle with <strong>gzip</strong> (level 9) and
<strong>brotli</strong> (quality 11) via Node's built-in
<code>zlib</code> — the same codecs a CDN or server serves with.
</li>
</ol>
<p>
The counters are byte-for-byte the same components running above and
listed under Implementation below. The external libraries are pinned dev
dependencies of this demo workspace, and <code>web-component-base</code>
is bundled from its published <code>dist/</code>, so the run is
deterministic.
</p>
<h2>Output</h2>
<p>
Measured at the pinned versions below (WCB from this repo's build,
esbuild <code>0.27</code>, Node <code>zlib</code>):
</p>
<table class="sizes">
<thead>
<tr>
<th>Library</th>
<th>Version</th>
<th>Minified</th>
<th>Gzip</th>
<th>Brotli</th>
</tr>
</thead>
<tbody>
<tr>
<td>web-component-base</td>
<td>6.1.1</td>
<td>6.7 kB</td>
<td>2.9 kB</td>
<td>2.6 kB</td>
</tr>
<tr>
<td>@elenajs/core</td>
<td>1.0.0</td>
<td>9.1 kB</td>
<td>3.7 kB</td>
<td>3.4 kB</td>
</tr>
<tr>
<td>lit</td>
<td>3.3.3</td>
<td>15.3 kB</td>
<td>5.9 kB</td>
<td>5.3 kB</td>
</tr>
<tr>
<td>@microsoft/fast-element</td>
<td>3.0.1</td>
<td>44.8 kB</td>
<td>13.6 kB</td>
<td>12.2 kB</td>
</tr>
<tr>
<td>vanilla <code>HTMLElement</code></td>
<td></td>
<td>0.6 kB</td>
<td>0.3 kB</td>
<td>0.2 kB</td>
</tr>
</tbody>
</table>
<p>
The WCB counter is the smallest real component here — about
<strong>21% under Elena, 51% under Lit, and 79% under FAST</strong> once
compressed.
</p>
<h2>Reproduce it</h2>
<p class="repro">
From <code>demo/examples/library-comparison/</code>:
<code>node measure.mjs</code> for the table,
<code>node measure.mjs --md</code> for Markdown, or
<code>node measure.mjs --json</code> for raw numbers.
</p>
<p>
Bump a pinned version in <code>demo/package.json</code>, re-run, and the
numbers move with it — the benchmark is the source of truth, not this
page.
</p>
</body>
</html>

View file

@ -0,0 +1,125 @@
// Reproducible size benchmark behind the comparison guide's numbers.
//
// For each library it bundles the *same* counter component
// (`counters/<lib>.mjs`) together with that library's runtime, minifies with
// esbuild, and compresses the result with gzip (level 9) and brotli (quality
// 11) using Node's built-in zlib. That is the real "cost of your first
// component": everything the browser downloads for one reactive element.
//
// node measure.mjs # human-readable table
// node measure.mjs --md # a Markdown table (what the guide embeds)
// node measure.mjs --json # machine-readable JSON
//
// The external libraries are pinned dev dependencies of this demo workspace, so
// the run is deterministic; `web-component-base` is bundled from its published
// build output (`dist/`) rather than the workspace source, to measure exactly
// what consumers install.
import { build } from 'esbuild'
import { gzipSync, brotliCompressSync, constants } from 'node:zlib'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { readFileSync } from 'node:fs'
import process from 'node:process'
const here = dirname(fileURLToPath(import.meta.url))
const demoRoot = resolve(here, '../..')
// Resolve `web-component-base` to its built entry (the published artifact), not
// the workspace `src/` the Vite dev server aliases it to.
const wcbDist = resolve(here, '../../../dist/index.js')
const wcbAlias = {
name: 'wcb-dist',
setup(b) {
b.onResolve({ filter: /^web-component-base$/ }, () => ({ path: wcbDist }))
},
}
// Not every library exposes `./package.json` in its `exports` map, so read the
// installed manifest straight from the demo's `node_modules`.
const version = (pkg) =>
JSON.parse(
readFileSync(resolve(demoRoot, 'node_modules', pkg, 'package.json'), 'utf8')
).version
const wcbVersion = JSON.parse(
readFileSync(resolve(here, '../../../package.json'), 'utf8')
).version
const targets = [
{ name: 'web-component-base', file: 'wcb.mjs', version: wcbVersion },
{
name: '@elenajs/core',
file: 'elena.mjs',
version: version('@elenajs/core'),
},
{ name: 'lit', file: 'lit.mjs', version: version('lit') },
{
name: '@microsoft/fast-element',
file: 'fast.mjs',
version: version('@microsoft/fast-element'),
},
{ name: 'vanilla HTMLElement', file: 'vanilla.mjs', version: '-' },
]
const gzip = (buf) => gzipSync(buf, { level: 9 }).length
const brotli = (buf) =>
brotliCompressSync(buf, {
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
}).length
const kb = (n) => (n / 1000).toFixed(1) + ' kB'
const rows = []
for (const t of targets) {
const result = await build({
entryPoints: [resolve(here, 'counters', t.file)],
bundle: true,
minify: true,
format: 'esm',
write: false,
plugins: [wcbAlias],
logLevel: 'silent',
})
const code = result.outputFiles[0].contents
rows.push({
name: t.name,
version: t.version,
minified: code.length,
gzip: gzip(code),
brotli: brotli(code),
})
}
const arg = process.argv[2]
if (arg === '--json') {
console.log(JSON.stringify(rows, null, 2))
} else if (arg === '--md') {
console.log('| Library | Version | Minified | Gzip | Brotli |')
console.log('| ------- | ------- | -------- | ---- | ------ |')
for (const r of rows) {
console.log(
`| ${r.name} | ${r.version} | ${kb(r.minified)} | ${kb(r.gzip)} | ${kb(r.brotli)} |`
)
}
} else {
const pad = (s, n) => String(s).padEnd(n)
const lpad = (s, n) => String(s).padStart(n)
console.log(
pad('Library', 26),
pad('Version', 9),
lpad('Minified', 10),
lpad('Gzip', 8),
lpad('Brotli', 8)
)
console.log('-'.repeat(63))
for (const r of rows) {
console.log(
pad(r.name, 26),
pad(r.version, 9),
lpad(kb(r.minified), 10),
lpad(kb(r.gzip), 8),
lpad(kb(r.brotli), 8)
)
}
}

View file

@ -25,6 +25,20 @@
</p>
</header>
<h2 class="section-label">Library comparison</h2>
<div class="grid">
<a class="card" href="./examples/library-comparison/index.html">
<div class="name">
The same counter, measured<span class="tag">sizes</span>
</div>
<div class="desc">
One counter in WCB, Elena, Lit, FAST, and vanilla — live side by side,
with the reproducible <code>measure.mjs</code> behind the comparison
guide's numbers.
</div>
</a>
</div>
<h2 class="section-label">v6 behavior demos</h2>
<div class="grid">
<a class="card" href="./examples/boolean-props/index.html">

View file

@ -14,6 +14,10 @@
"web-component-base": "workspace:*"
},
"devDependencies": {
"@elenajs/core": "1.0.0",
"@microsoft/fast-element": "3.0.1",
"esbuild": "^0.27.2",
"lit": "3.3.3",
"vite": "^7.3.1"
}
}

View file

@ -11,6 +11,8 @@ This page puts these benefits and their cost in context: how much does WCB weigh
Numbers below are **measured** from the same minimal counter component (one reactive `count` prop, a click handler, a re-render on change) written in each library, bundled with `esbuild --bundle --minify --format=esm`, and compressed with gzip (level 9) and brotli (quality 11). This is the real "cost of your first component": library runtime + component code, everything the browser downloads.
See it live: [Library comparison demo ↗](https://demo.webcomponent.io/examples/library-comparison/) — every counter running side by side, the source of each, and the `measure.mjs` script that produces the table below.
| Library | Version | Minified | Gzip | Brotli |
| ------------------------- | ------- | -------- | ------- | ---------- |
| **web-component-base** | 6.1.0 | 6.7 kB | 2.9 kB | **2.6 kB** |
@ -35,18 +37,18 @@ What each library gives you beyond extending directly from `HTMLElement`, the bo
| Keyed list reconciliation | ⚠️ positional | ✅ `repeat` directive | ❌ | ✅ `repeat` with recycling controls |
| Light DOM by default | ✅ (shadow DOM opt-in via `static shadowRootInit`) | ❌ shadow DOM by default | ✅ (shadow opt-in) | ❌ shadow DOM by default |
| Scoped styles | ✅ `static styles` + constructable stylesheets (needs shadow root) | ✅ shadow-scoped CSS | ✅ scoped without shadow DOM | ✅ shadow-scoped + design tokens |
| SSR / hydration story | ✅ attribute-driven state renders from any server | ✅ `@lit-labs/ssr` + hydration | ✅ HTML/CSS-first, server utilities | ⚠️ experimental SSR |
| SSR / hydration story | ✅ attribute-driven state renders from any server | ✅ `@lit-labs/ssr` + hydration | ✅ server-rendered markup + hydration utilities | ⚠️ experimental SSR |
| Works with zero build tooling | ✅ import from CDN, no compiler | ✅ (buildless possible, decorators need tooling) | ✅ | ⚠️ practical with tooling |
| Editor/IDE tooling | ✅ typed props + [CEM analyzer plugin](/cem-plugin/) | ✅ extensive (analyzer, TS decorators, IDE plugins) | ✅ CEM-focused | ✅ TS-first |
| Lifecycle hooks | `onInit`, `afterViewInit`, `onChanges`, `onDestroy` | full reactive update lifecycle | `willUpdate`, `firstUpdated`, `updated` | full lifecycle + behaviors |
| Backing / ecosystem | solo maintainer, small surface | Google, huge ecosystem | new (2026), design-system focus | Microsoft, powers Fluent UI |
| Backing / ecosystem | solo maintainer, small surface | Google, large ecosystem | new (2026), solo-authored | Microsoft, powers Fluent UI |
:::note[Why 11ty WebC isn't here]
WebC is a compile-time tool: it resolves components during an Eleventy build and ships plain HTML with no client runtime. Every row above is about what a library does _in the browser at runtime_, so a side-by-side comparison would be measuring two different things. If your components are static at build time, WebC solves a different problem, and solves it well.
WebC is a compile-time tool: it resolves components during an Eleventy build and ships plain HTML with no client runtime. Every row above is about what a library does _in the browser at runtime_, so a side-by-side comparison would be measuring two different things. If your components are static at build time, WebC solves a different problem.
:::
For what these numbers and capabilities add up to (and when they don't) see [Why would anyone use WCB?](/why/).
---
_WCB re-measured 2026-07-20 at v6.1.0; the other libraries measured 2026-07-19, with esbuild, Node zlib (gzip 9, brotli q11), at the pinned versions above. Methodology: identical counter component per library, bundled per library, compressed. Re-run them yourself. The benchmark is trivially reproducible with the versions pinned above._
_WCB re-measured 2026-07-20 at v6.1.0; the other libraries measured 2026-07-19, with esbuild, Node zlib (gzip 9, brotli q11), at the pinned versions above. Methodology: identical counter component per library, bundled per library, compressed. Re-run them yourself — the counters and the [`measure.mjs`](https://demo.webcomponent.io/examples/library-comparison/) script live in the demo workspace (`demo/examples/library-comparison/`). The benchmark is trivially reproducible with the versions pinned above._

File diff suppressed because it is too large Load diff