docs(demo): add the library-comparison counter demo
This commit is contained in:
parent
9d8c8879f8
commit
005db549c0
12 changed files with 728 additions and 189 deletions
29
demo/examples/library-comparison/README.md
Normal file
29
demo/examples/library-comparison/README.md
Normal 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.
|
||||
24
demo/examples/library-comparison/counters/elena.mjs
Normal file
24
demo/examples/library-comparison/counters/elena.mjs
Normal 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()
|
||||
25
demo/examples/library-comparison/counters/fast.mjs
Normal file
25
demo/examples/library-comparison/counters/fast.mjs
Normal 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 }],
|
||||
})
|
||||
17
demo/examples/library-comparison/counters/lit.mjs
Normal file
17
demo/examples/library-comparison/counters/lit.mjs
Normal 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)
|
||||
33
demo/examples/library-comparison/counters/vanilla.mjs
Normal file
33
demo/examples/library-comparison/counters/vanilla.mjs
Normal 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)
|
||||
15
demo/examples/library-comparison/counters/wcb.mjs
Normal file
15
demo/examples/library-comparison/counters/wcb.mjs
Normal 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)
|
||||
231
demo/examples/library-comparison/index.html
Normal file
231
demo/examples/library-comparison/index.html
Normal 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>
|
||||
125
demo/examples/library-comparison/measure.mjs
Normal file
125
demo/examples/library-comparison/measure.mjs
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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._
|
||||
|
|
|
|||
390
pnpm-lock.yaml
390
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue