1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Lit 3 で同じ landing を書いたら gzip 9.70 kB、Solid 同格。Web Components は現代的に使える

1
Last updated at Posted at 2026-04-29

きっかけ

フレームワーク比較シリーズ 9 本目、Lit 3

Web Components は仕様上「ブラウザ内蔵のコンポーネントシステム」ですが、生で使うと DX が非常に辛い(customElements.defineattachShadow、手動テンプレート…)。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 stylesimport 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 本目です。

次回(030)は Preact。React のソースコードをほぼそのまま流用して gzip 8.75 kB に圧縮できる、という面白い結果。シリーズ最終回。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?