技術 約10分で読めます

当たり前を疑う開発スタック:Webコンポーネント vs React、govulncheck vs Dependabot

「ReactがデファクトになったのはWebが未熟だったから」という言い方を最近よく見るようになった。同じ週に、全く異なるエコシステムで似た構造の主張が出てきた。Webフロントエンドでは「Custom Elements + Shadow DOMでReactが不要になった」、Goの開発現場では「Dependabotをオフにしてgovulncheckに切り替えるべき」という話。どちらも「当たり前に使っているものを問い直す」という構造を持っている。


Webコンポーネントで本当にReactが不要になってきた話

「Webコンポーネントでフレームワーク不要」と聞くたびに「またこの話か」と思ってしまっていた。ところがStephan Schwabの記事を読んで、「ブラウザ自体がフレームワークになった」というフレーミングが意外と的確だと感じた。

フレームワークの隠れたコスト

ReactやVueを使い続けるコストは「学習コスト」だけではない。メジャーバージョンアップ対応、廃止されたパターンの書き直し、ビルドツールチェインとの依存関係管理がある。React 18からのConcurrent Modeへの移行や、Vue 2→3のComposition API移行で苦労したチームは多いはずだ。

これに対し、Web標準ベースのコードはブラウザベンダーが後方互換性を保証する。10年前に書いたCustom Elementsのコードが現在のブラウザで動作し続ける。フレームワークメンテナは予算・人員・優先度の変化でメンテナンスを停止できるが、ブラウザベンダーはそれができない。Web標準に入った機能は事実上永久にサポートされる。

3つのAPI

Custom Elements

カスタムHTMLタグに振る舞いを登録する仕組み。ライフサイクルコールバックが4つある。

class TaskCard extends HTMLElement {
  // 監視する属性を宣言
  static observedAttributes = ['title', 'description', 'status'];

  connectedCallback() {
    // DOMに追加されたとき(Reactのマウントに相当)
    this.render();
  }

  disconnectedCallback() {
    // DOMから除去されたとき(クリーンアップ処理)
    this.cleanup();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // observedAttributesに列挙した属性が変更されたとき
    if (oldValue !== newValue) this.render();
  }

  adoptedCallback() {
    // document.adoptNode()で別ドキュメントに移動したとき(iframe間の移動など)
  }

  render() {
    this.innerHTML = `
      <div class="task ${this.getAttribute('status') || ''}">
        <h3>${this.getAttribute('title') || ''}</h3>
        <p>${this.getAttribute('description') || ''}</p>
      </div>
    `;
  }

  cleanup() {
    // イベントリスナーの解除など
  }
}
customElements.define('task-card', TaskCard);

observedAttributes に列挙した属性だけが attributeChangedCallback を発火する。列挙していない属性は変更を検知しない。属性はすべて文字列なので、オブジェクトや配列を渡したい場合はプロパティとして直接アサインする。

// 属性(文字列のみ)
document.querySelector('task-card').setAttribute('title', 'タスク名');

// プロパティ(任意の型)
document.querySelector('task-card').data = { id: 1, tags: ['urgent'] };

この「属性は文字列、複雑なデータはプロパティ」という二重性はReactのpropsとは設計が異なるため、最初に理解しておかないとハマる。

Shadow DOM

スタイルと構造のカプセル化を提供する。

class StyledCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; }
        :host([variant="highlighted"]) { border-left: 3px solid blue; }
        .card { padding: 1rem; border-radius: 8px; background: var(--card-bg, #f5f5f5); }
      </style>
      <div class="card"><slot></slot></div>
    `;
  }
}

:host セレクタでコンポーネント自身にスタイルを当て、:host([attr]) で属性に応じたバリエーションを出せる。<slot> 要素でコンポジションパターンも実現できる。

Shadow DOMの内部にはCSS変数(カスタムプロパティ)が透過する。上のコードの var(--card-bg, #f5f5f5) のように、外部からテーマを注入する仕組みとして使える。これがShadow DOM越しにスタイルを調整する唯一の公式な方法だ。

ネイティブイベントシステム

グローバルな状態管理やpropsのバケツリレーを不要にするのが、DOMのネイティブイベント。

// 子コンポーネントがイベントを発火
this.dispatchEvent(new CustomEvent('item-selected', {
  detail: { itemId: this.selectedId, metadata: this.itemData },
  bubbles: true,
  composed: true  // Shadow DOM境界を越えて伝搬
}));

// 親がリッスン
document.addEventListener('item-selected', (e) => {
  document.querySelectorAll('[data-filterable]').forEach(panel => {
    panel.applyFilters(e.detail);
  });
});

bubbles: true でDOMツリーを上向きに伝搬し、composed: true でShadow DOM境界を越える。composed: false だとShadow Root内で止まるので、コンポーネント内部のプライベートなイベントとして使い分けられる。

Shadow DOMのハマりポイント

Shadow DOMは強力だが、罠もそこそこある。

フォーム参加の問題

Shadow DOM内の <input> は、外側の <form> に値を送信しない。ElementInternals API を使って明示的にフォームに参加する必要がある。

class FormInput extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `<input type="text" />`;
    this.shadowRoot.querySelector('input').addEventListener('input', (e) => {
      this.internals.setFormValue(e.target.value);
    });
  }
}

static formAssociated = trueattachInternals() がセットで必要。これを忘れるとフォーム送信時に値が欠落する。

::slotted() の制限

::slotted() 擬似要素はスロットに差し込まれた直接の子要素にしかスタイルを当てられない。孫要素以降は対象外。

/* 動く: slotに差し込まれた直接の子 */
::slotted(p) { color: red; }

/* 動かない: slotに差し込まれた要素のさらに子要素 */
::slotted(p span) { font-weight: bold; }

この制限を回避するには、CSS変数でスタイルを渡すか、slotに差し込む側で事前にスタイルを当てておくしかない。

アクセシビリティとARIA

Shadow DOM内のIDは外部から参照できない。aria-labelledbyaria-describedby が別のShadow Root内の要素を指定できないため、ElementInternalsariaLabel 等のプロパティを使うか、aria-label 属性を直接設定する必要がある。

グローバルCSSのリセット

Shadow DOM内にはリセットCSSやノーマライズCSSが適用されない。各コンポーネントで独自にリセットを書くか、@import で共有シートを読み込むか、Constructable Stylesheetsを使う。

const sharedStyles = new CSSStyleSheet();
sharedStyles.replaceSync(`*, *::before, *::after { box-sizing: border-box; }`);

class MyComponent extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.adoptedStyleSheets = [sharedStyles];
  }
}

adoptedStyleSheets を使うと複数のコンポーネントでスタイルシートのインスタンスを共有でき、メモリ効率が良い。

Declarative Shadow DOM

従来のShadow DOMはJavaScriptでしか生成できなかった。SSRやプログレッシブエンハンスメントと相性が悪いという批判に対して、Declarative Shadow DOMが策定された。

<my-card>
  <template shadowrootmode="open">
    <style>
      .card { padding: 1rem; background: var(--card-bg, #f5f5f5); }
    </style>
    <div class="card"><slot></slot></div>
  </template>
  <p>カードの中身</p>
</my-card>

HTMLパーサーが <template shadowrootmode="open"> を見つけると、JSの実行を待たずにShadow Rootを生成する。サーバーサイドでHTMLを組み立てれば、JSが読み込まれる前にShadow DOMが適用された状態でレンダリングされる。Chrome、Firefox、Safariのすべてでサポート済み。

ReactとWebコンポーネントの使い分け

観点ReactWeb Components
コンポーネント間通信props・Context・状態管理ライブラリDOMイベント・CSS変数
スタイルスコープCSS Modules・CSS-in-JSShadow DOM
ライフサイクルuseEffect等のフックconnectedCallback等
ビルドツール必須不要(ネイティブ実行)
後方互換性バージョン依存ブラウザベンダー保証
バンドルサイズランタイム分のオーバーヘッドゼロ
SSRNext.js等が必要Declarative Shadow DOM
フォーム統合標準的ElementInternals APIが必要
テストTesting Library等が充実標準DOM APIでテスト可能

Reactが合う場面: チームにReactの知識があり高速な機能開発が優先、状態管理が複雑なSPA、エコシステムのライブラリ資産を活かしたい場合。

Web Componentsが合う場面: 社内デザインシステムを複数プロダクトで共有したい、長期保守が前提でフレームワーク依存を最小化したい、マイクロフロントエンドの境界コンポーネント。

どちらかを全面採用する二者択一ではない。ReactアプリケーションにWeb Componentsを部分的に混在させることは技術的に問題ない。Web ComponentsはフレームワークのHTML境界で動作するため、段階的な導入が可能だ。

元記事: Web Components: The Framework-Free Renaissance


DependabotをオフにしてGo脆弱性チェックに切り替える

Filippo Valsordaが「Turn Dependabot Off」という記事を書いた。タイトルから強烈だが、中身も突き刺さる。

Dependabotが生み出すノイズ

DependabotはGitHubの依存関係自動更新ツール。脆弱性を検知してPRを開く機能があるが、その検知ロジックが問題だ。

filippo.io/edwards25519に修正が入ったときの話が具体的でわかりやすい。MultiScalarMult メソッドに問題があったが、このメソッドを実際に呼んでいるプロジェクトはほぼ存在しなかった。にもかかわらず、Dependabotは数千のリポジトリにPRを開いた。パッケージを import しているだけで、該当のメソッドを一切使っていないプロジェクトにも「セキュリティアラート」が飛んだ。

Wycheproofリポジトリのような、そもそもそのパッケージの脆弱なコードパスを使用していないプロジェクトまでアラートを受け取った。誤検知というよりノイズだ。

大量のPRが来ると何が起きるか。レビューコストが増え、本当に対応が必要なアラートが埋もれる。Dependabotの通知を「ひとまず自動マージ」に設定しているプロジェクトは、無関係な更新をどんどん取り込む。テストが落ちるかもしれないし、APIの挙動が変わるかもしれない。そもそもsemverを正しく運用しているパッケージばかりではないので、パッチバージョンの更新でも破壊的変更が入ることはある。

govulncheckの仕組み

Dependabotは「どのパッケージのどのバージョンを使っているか」だけを見る。go.mod に書かれたバージョンがCVEの影響範囲に含まれていたらアラートを出す。実際にそのコードを呼んでいるかは確認しない。

govulncheck はここが根本的に違う。Go脆弱性データベースは、CVEごとに「脆弱な関数・メソッドのシンボル名」をメタデータとして保持している。govulncheck はこのシンボル情報と、プロジェクトのコールグラフを突き合わせる。

具体的には以下の流れで動く。

  1. go.mod から依存パッケージの一覧を取得
  2. Go Vulnerability Databaseに問い合わせ、該当パッケージに既知の脆弱性があるか確認
  3. 脆弱性がある場合、そのCVEに紐づく脆弱なシンボル(関数・メソッド)を取得
  4. プロジェクトのソースコードを静的解析し、 main 関数からのコールグラフを構築
  5. コールグラフ上で脆弱なシンボルに到達可能かどうかを判定

到達可能でなければ報告しない。これが「シンボルレベルの到達可能性解析」だ。

# プロジェクト全体をチェック
govulncheck ./...

# バイナリを直接スキャン(ソースがない場合)
govulncheck -mode=binary ./cmd/myapp

バイナリモードではコンパイル済みバイナリのシンボルテーブルを解析する。CIでビルド成果物を直接チェックする使い方もできる。

出力は3段階に分かれる。

  • Called: 脆弱なシンボルを実際に呼んでいる(対応必須)
  • Imported: 脆弱なパッケージをimportしているが、脆弱なシンボルは呼んでいない(リスク低)
  • Stdlib: 標準ライブラリに脆弱性がある(Goのバージョンアップで対応)

Dependabotなら「Imported」と「Called」の区別なく全部アラートになる。govulncheckなら「Called」だけに集中して対応できる。

scheduled testへの移行

Filippoが提案する代替アプローチは2つのGitHub Actionsの組み合わせ。

ひとつは govulncheck のスケジュール実行。

# .github/workflows/govulncheck.yml
name: govulncheck
on:
  schedule:
    - cron: '0 9 * * 1'  # 毎週月曜日9時UTC
  push:
    branches: [main]
jobs:
  govulncheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: golang/govulncheck-action@v1

もうひとつは、最新バージョンの依存関係に対してテストスイートを実行するAction。go get -u ./... で全依存を最新化してからテストを走らせる。

name: Test with latest deps
on:
  schedule:
    - cron: '0 9 * * 1'
jobs:
  test-latest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: stable
      - run: go get -u ./...
      - run: go mod tidy
      - run: go test ./...

テストが落ちたら対応する。PRを大量に生成してレビュアーを疲弊させるのではなく、実際に壊れる変更だけを検知するフロー。Dependabotの「バージョンを上げること自体が目的」になったアプローチとは方向性が違う。

Goエコシステム以外への波及

govulncheckのシンボルレベル解析はGoのコンパイラ・型システムと密結合しているが、同様の思想を持つツールは他の言語にも出てきている。

  • Rust: cargo audit はcrate単位だが、cargo-vet でサプライチェーン検証を組み合わせる手法がある
  • Node.js: npm audit はパッケージバージョン単位。到達可能性解析はまだ主流ではないが、Socket.devが依存関係の挙動解析を行っている
  • Python: pip-audit + OSV Databaseの組み合わせ

「パッケージバージョンだけでなく、実際に呼んでいるコードパスを見る」という発想自体は言語を選ばない。