きっかけ
フレームワーク比較シリーズ 9 本目、Lit 3。
Web Components は仕様上「ブラウザ内蔵のコンポーネントシステム」ですが、生で使うと DX が非常に辛い(customElements.define、attachShadow、手動テンプレート…)。Lit は「この辛さを消す薄いラッパー」 というポジションのライブラリで、Google Chrome チームが開発しています。
実装して測ってみました:
- 021 React: gzip 49.00 kB
- 028 Astro: 3.17 kB (現シリーズ最小)
- 029 Lit 3: 9.70 kB (React 比 −80%、Solid 同格)
Web Components ベースで、この数字なら十分に実用レベル。
作ったもの
Portfolio App (Lit) — https://sen.ltd/portfolio/portfolio-app-lit/
- 仕様は React / Vue / Svelte / Solid / Astro 版と完全同一
- Lit 3 + TypeScript + Vite
- 共通コード (filter.ts 等) は byte-identical
Lit 3 は Web Components を前提にした宣言的 API。ランタイムは 5-6 kB で、React / Vue よりはるかに軽い。
Lit のコンポーネント定義
Lit は LitElement を継承した class として Web Components を書きます:
import { LitElement, html, css } from 'lit'
import { customElement, state, property } from 'lit/decorators.js'
import type { PortfolioData, Lang } from './types'
import { loadPortfolioData } from './data'
import { filterAndSort, type FilterState } from './filter'
import { MESSAGES, detectDefaultLang } from './i18n'
@customElement('portfolio-app')
export class PortfolioApp extends LitElement {
@state() private data: PortfolioData | null = null
@state() private lang: Lang = detectDefaultLang()
@state() private filter: FilterState = {
query: '', category: 'all', stack: 'all', stage: 'all', sort: 'number',
}
@state() private loading = true
@state() private error = ''
connectedCallback() {
super.connectedCallback()
loadPortfolioData()
.then((d) => { this.data = d; this.loading = false })
.catch((e) => { this.error = String(e); this.loading = false })
}
render() {
if (this.loading) return html`<div class="state state-loading">Loading...</div>`
if (this.error) return html`<div class="state state-error">${this.error}</div>`
if (!this.data) return html``
const visible = filterAndSort(this.data.entries, this.filter, this.lang)
const m = MESSAGES[this.lang]
return html`
<header class="site-header">
<h1>${m.title}</h1>
<p class="meta">${visible.length} / ${this.data.entries.length}</p>
</header>
<main>
<input
type="text"
.value=${this.filter.query}
@input=${(e: Event) => this.filter = { ...this.filter, query: (e.target as HTMLInputElement).value }}
/>
${visible.map((entry) => html`
<article class="card">
<h2>${entry.name[this.lang]}</h2>
<p>${entry.pitch[this.lang]}</p>
</article>
`)}
</main>
`
}
}
html タグ付きテンプレートリテラル がテンプレート。${...} で値を埋め込み、@input= でイベントハンドラ、.value= でプロパティバインディング。React の JSX と違って、ブラウザのネイティブテンプレート API を使って変化部分だけ更新する方式。
Shadow DOM の扱い
デフォルトで Lit の各コンポーネントは shadow DOM で隔離されます。この隔離のおかげで CSS が漏れない反面、共通スタイルシート (style.css) が効かない 問題があります。
対策として、Lit コンポーネントの static styles で import style from './style.css?inline' を使う:
import style from './style.css?inline'
@customElement('portfolio-app')
export class PortfolioApp extends LitElement {
static styles = css`${unsafeCSS(style)}`
// ...
}
Vite の ?inline import で CSS ファイル全体を文字列として取り込み、css タグと unsafeCSS で Lit 向けの constructable stylesheet に変換。これで共通 CSS が shadow DOM 内でも有効になる。
この回り道が 1 つのコスト。React のように document-wide に CSS を撒くのが当たり前の設計とは少し違います。
@state() と @property()
@state() private filter: FilterState = { ... } // 内部 state
@property() public entry: Entry // 親から受け取る prop
React の useState + props と等価な概念で、デコレータで宣言します。state が変わると自動的に render() が呼び直されるのは他のフレームワークと同じ。
TypeScript デコレータ対応のため tsconfig.json で "experimentalDecorators": true を有効化する必要があります。Vite のデフォルト設定では OK で、Lit プロジェクトの create-lit テンプレートを使えば勝手に通ります。
イベントハンドラの書き方
Lit の event binding は @ プレフィックス:
<input @input=${(e) => this.handleInput(e)} />
@input, @click, @change 等、どの DOM イベントでも使えます。ハンドラ内で this を参照するには、アロー関数で書くか .bind(this) する必要があります。class メソッドとして書くと this binding が迷子になりやすいのが Lit 特有の罠。
なぜ Solid と同格になったか
Lit のランタイムは 5-6 kB 程度で Svelte より大きく、Solid より小さい。そこにアプリコードが乗って 合計 9.70 kB gzip です:
- Lit runtime: ~5.5 kB
- アプリコード (component + 共通 filter/data/i18n): ~4.2 kB
仮想 DOM ベースではなく、ネイティブ DOM を直接更新するアーキテクチャ。これは Solid と同じ思想で、結果的にバンドルサイズも近くなった。違いは:
- Solid: コンパイラで JSX を DOM 操作に変換
-
Lit: ランタイムで
html``テンプレートリテラルを解析して効率的に更新
どちらも「VDOM を避ける」という同じ目標を、コンパイラ駆動 vs ランタイム駆動で違う手段で達成しています。バンドルサイズが似ているのは、最終的に「必要な機能」が同じだから。
Web Components としての側面
Lit の隠れた強みは、他のフレームワークのアプリに埋め込める ことです。<portfolio-app> というタグ名を登録しているので:
<!-- React アプリの中でも使える -->
<div>
<h1>My React page</h1>
<portfolio-app></portfolio-app>
</div>
相手が React だろうと Vue だろうと、Web Component は普通の HTML 要素として扱えます。<video> タグと同じノリでアプリを貼り付けられる。フレームワークに依存しないコンポーネントを作りたい場合は Lit 一択。
本アプリは単体のランディングなので、この特性は活用していないけど、「将来的に他のページに埋め込む可能性」を考えた場合は Lit を検討する価値があります。
共通コードは不変
$ diff repos/portfolio-app-react/src/filter.ts repos/portfolio-app-lit/src/filter.ts
# no output
テスト
Vitest で 14 ケース通る。filter.ts 抜きで走る。
スコアボード
| 実装 | gzip | vs React |
|---|---|---|
| 021 React | 49.00 kB | — |
| 022 Vue | 28.76 kB | −41% |
| 023 Svelte | 18.92 kB | −61% |
| 024 Solid | 8.33 kB | −83% |
| 025 Nuxt | 52.01 kB | +7% |
| 026 SvelteKit | 32.50 kB | −33% |
| 027 Qwik | 28.60 kB (first-paint) | −42% |
| 028 Astro | 3.17 kB | −94% |
| 029 Lit | 9.70 kB | −80% |
おわりに
SEN 合同会社の ポートフォリオシリーズ 100+ の 29 件目、フレームワーク比較 9 本目です。
- 📦 レポジトリ: https://github.com/sen-ltd/portfolio-app-lit
- 🌐 ライブデモ: https://sen.ltd/portfolio/portfolio-app-lit/
- 🏢 会社: https://sen.ltd/
次回(030)は Preact。React のソースコードをほぼそのまま流用して gzip 8.75 kB に圧縮できる、という面白い結果。シリーズ最終回。
