0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Day 15: SFC(シングルファイルコンポーネント)の設計思想

Last updated at Posted at 2025-12-15

この記事は「構造パスで作るシンプルなフロントエンドフレームワーク(Structive)」Advent Calendarの15日目です。
Structiveについて詳しくはこちらより

Week 3 のスタート

Week 1と2で、構造パスの基礎とコア機能を学びました。Week 3では、アプリケーションをスケールさせるためのコンポーネント化について学びます。今日は、その基盤となるSFC(シングルファイルコンポーネント)の設計思想を見ていきます。

SFCとは?

**SFC(Single File Component)**は、1つのファイルにコンポーネントのすべてを記述する形式です。

基本構造

<template>
  <!-- HTML: UIの構造 -->
</template>

<style>
  /* CSS: スタイル */
</style>

<script type="module">
  // JavaScript: ロジックと状態
  export default class {
    // 状態とメソッド
  }
</script>

3つのセクション:

  1. <template>: UIの構造(HTML)
  2. <style>: スタイル(CSS)
  3. <script>: ロジックと状態(JavaScript)

なぜSFCなのか?

問題:分散したコード

従来の開発では、1つの機能が複数のファイルに分散します:

components/
  ProductCard.html      # HTML
  ProductCard.css       # CSS
  ProductCard.js        # JavaScript

問題点:

  • ファイル間を行き来する必要がある
  • 関連コードが見つけにくい
  • コピー&ペーストでファイルを揃える手間
  • リネーム時に複数ファイルを変更

解決:SFCによる集約

components/
  ProductCard.st.html   # すべてが1つに

利点:

  • 関連コードが一箇所に
  • ファイル間の移動不要
  • コンポーネント単位で管理
  • リネームが簡単

SFCの実例

シンプルなボタンコンポーネント

<template>
  <button 
    data-bind="
      onclick:handleClick;
      class.primary:isPrimary;
      disabled:isDisabled
    "
  >
    {{ label }}
  </button>
</template>

<style>
  button {
    padding: 0.5em 1em;
    border: none;
    border-radius: 4px;
    font-size: 1em;
    cursor: pointer;
    background: #ccc;
    color: #333;
  }
  
  button.primary {
    background: #007bff;
    color: white;
  }
  
  button:hover:not(:disabled) {
    opacity: 0.9;
  }
  
  button:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
</style>

<script type="module">
export default class {
  label = "Click me";
  isPrimary = true;
  isDisabled = false;
  
  handleClick() {
    console.log("Button clicked!");
  }
}
</script>

このコンポーネントの特徴:

  • 自己完結している
  • UIとロジックが明確に分離
  • スタイルが閉じ込められている
  • 再利用可能

関心の分離(Separation of Concerns)

SFCは「関心の分離」の原則に従っています。

1. template: 構造の関心

<template>
  <div class="product-card">
    <img data-bind="src:product.imageUrl">
    <h3>{{ product.name }}</h3>
    <p>${{ product.price|fix,2 }}</p>
    <button data-bind="onclick:addToCart">カートに追加</button>
  </div>
</template>

役割:

  • UIの構造を定義
  • 何を表示するか
  • どのように配置するか

2. style: 見た目の関心

<style>
  .product-card {
    border: 1px solid #ccc;
    padding: 1em;
    border-radius: 8px;
  }
  
  .product-card img {
    width: 100%;
    height: 200px;
    object-fit: cover;
  }
  
  .product-card h3 {
    margin: 0.5em 0;
  }
</style>

役割:

  • 見た目を定義
  • レイアウト
  • 色やフォント

3. script: ロジックの関心

<script type="module">
export default class {
  product = {
    name: "Laptop",
    price: 999,
    imageUrl: "/images/laptop.jpg"
  };
  
  addToCart() {
    console.log("Added to cart:", this.product);
  }
}
</script>

役割:

  • 状態の管理
  • ビジネスロジック
  • イベント処理

カプセル化の実現

SFCは、コンポーネントをカプセル化します。

スタイルのスコープ

このフレームワークでは、Web ComponentsのShadow DOMを使ってスタイルをカプセル化できます。

Shadow DOMなし:

<!-- コンポーネントA -->
<style>
  .title { color: red; }
</style>

<!-- コンポーネントB -->
<style>
  .title { color: blue; }  /* コンフリクト! */
</style>

Shadow DOMあり:

<!-- コンポーネントAのスタイルは外部に漏れない -->
<!-- コンポーネントBのスタイルも独立している -->

Shadow DOMの制御

<!-- Shadow DOMを使う(デフォルト) -->
<script type="module" src="components.js"></script>

<!-- Shadow DOMを使わない -->
<script type="module" src="components--shadow-dom-mode-none.js.js"></script>

使い分け:

  • Shadow DOM ON: コンポーネントを完全に独立させたい場合
  • Shadow DOM OFF: グローバルなスタイルを適用したい場合

実践例:商品カードコンポーネント

実用的なコンポーネントを作ってみましょう。

product-card.st.html

<template>
  <div class="product-card">
    <div class="image-container">
      <img data-bind="src:product.imageUrl" alt="商品画像">
      
      {{ if:product.isOnSale }}
        <div class="sale-badge">SALE</div>
      {{ endif: }}
      
      {{ if:product.stock|lt,5 }}
        <div class="stock-badge low">残り{{ product.stock }}点</div>
      {{ endif: }}
    </div>
    
    <div class="content">
      <h3 class="name">{{ product.name }}</h3>
      <p class="description">{{ product.description|slice,0,100 }}...</p>
      
      <div class="price-section">
        {{ if:product.isOnSale }}
          <span class="original-price">${{ product.originalPrice|fix,2 }}</span>
          <span class="sale-price">${{ product.price|fix,2 }}</span>
          <span class="discount">{{ product.discountRate|percent }}%OFF</span>
        {{ else: }}
          <span class="price">${{ product.price|fix,2 }}</span>
        {{ endif: }}
      </div>
      
      <div class="actions">
        {{ if:product.isAvailable }}
          <button 
            class="btn-primary" 
            data-bind="onclick:addToCart"
          >
            カートに追加
          </button>
        {{ else: }}
          <button class="btn-secondary" disabled>
            在庫切れ
          </button>
        {{ endif: }}
        
        <button 
          class="btn-icon" 
          data-bind="onclick:toggleFavorite"
        >
          {{ favoriteIcon }}
        </button>
      </div>
    </div>
  </div>
</template>

<style>
  .product-card {
    border: 1px solid #e0e0e0;
    border-radius: 8px;
    overflow: hidden;
    transition: box-shadow 0.3s;
  }
  
  .product-card:hover {
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  }
  
  .image-container {
    position: relative;
    width: 100%;
    height: 250px;
    background: #f5f5f5;
  }
  
  .image-container img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
  
  .sale-badge {
    position: absolute;
    top: 10px;
    right: 10px;
    background: #e74c3c;
    color: white;
    padding: 0.3em 0.6em;
    border-radius: 4px;
    font-weight: bold;
    font-size: 0.9em;
  }
  
  .stock-badge {
    position: absolute;
    bottom: 10px;
    left: 10px;
    background: #f39c12;
    color: white;
    padding: 0.3em 0.6em;
    border-radius: 4px;
    font-size: 0.85em;
  }
  
  .content {
    padding: 1em;
  }
  
  .name {
    margin: 0 0 0.5em 0;
    font-size: 1.2em;
  }
  
  .description {
    color: #666;
    font-size: 0.9em;
    line-height: 1.4;
    margin: 0 0 1em 0;
  }
  
  .price-section {
    margin-bottom: 1em;
    display: flex;
    align-items: center;
    gap: 0.5em;
  }
  
  .original-price {
    text-decoration: line-through;
    color: #999;
  }
  
  .sale-price {
    font-size: 1.5em;
    font-weight: bold;
    color: #e74c3c;
  }
  
  .price {
    font-size: 1.5em;
    font-weight: bold;
    color: #333;
  }
  
  .discount {
    background: #e74c3c;
    color: white;
    padding: 0.2em 0.5em;
    border-radius: 4px;
    font-size: 0.85em;
    font-weight: bold;
  }
  
  .actions {
    display: flex;
    gap: 0.5em;
  }
  
  .btn-primary {
    flex: 1;
    padding: 0.75em;
    background: #007bff;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-weight: bold;
  }
  
  .btn-primary:hover {
    background: #0056b3;
  }
  
  .btn-secondary {
    flex: 1;
    padding: 0.75em;
    background: #ccc;
    color: #666;
    border: none;
    border-radius: 4px;
    cursor: not-allowed;
  }
  
  .btn-icon {
    width: 40px;
    padding: 0.75em;
    background: white;
    border: 1px solid #ccc;
    border-radius: 4px;
    cursor: pointer;
  }
  
  .btn-icon:hover {
    background: #f5f5f5;
  }
</style>

<script type="module">
export default class {
  product = {
    name: "Premium Laptop",
    description: "High-performance laptop with 16GB RAM and 512GB SSD. Perfect for professional work and gaming.",
    price: 899.99,
    originalPrice: 999.99,
    imageUrl: "/images/laptop.jpg",
    stock: 3,
    isOnSale: true,
    isAvailable: true
  };
  
  isFavorite = false;
  
  get "product.discountRate"() {
    if (!this["product.isOnSale"]) return 0;
    const original = this["product.originalPrice"];
    const current = this["product.price"];
    return (original - current) / original;
  }
  
  get favoriteIcon() {
    return this.isFavorite ? "❤️" : "🤍";
  }
  
  addToCart() {
    console.log("Added to cart:", this.product.name);
    // カート追加ロジック
  }
  
  toggleFavorite() {
    this.isFavorite = !this.isFavorite;
  }
}
</script>

使用方法

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Product Card Demo</title>
  <script type="importmap">
    {
      "imports": {
        "@components/product-card": "./product-card.st.html"
      }
    }
  </script>
  <script type="module" src="components.js"></script>
</head>
<body>
  <product-card></product-card>
</body>
</html>

SFCの利点まとめ

1. 保守性の向上

従来:

  • 3つのファイルを開く
  • どれがどれか確認
  • 同期を保つ

SFC:

  • 1つのファイルですべて完結
  • スクロールするだけ
  • 常に同期

2. 再利用性

<!-- 同じコンポーネントを何度でも -->
<product-card></product-card>
<product-card></product-card>
<product-card></product-card>

各インスタンスは独立した状態を持ちます。

3. 理解しやすさ

新しいメンバーがコードを見たとき:

  • ファイルを開く → 全体像がわかる
  • template → 何を表示しているか
  • style → どう見えるか
  • script → どう動くか

4. バージョン管理

Gitでの差分が明確:

<template>
  <div class="product-card">
-   <h3>{{ name }}</h3>
+   <h3>{{ product.name }}</h3>
  </div>
</template>

1つのファイルなので、変更が追いやすい。

SFCのベストプラクティス

1. 適切なサイズ

目安:

  • 200行以下: 理想的
  • 200-500行: 許容範囲
  • 500行以上: 分割を検討

2. 単一責任の原則

1つのコンポーネントは1つの責任だけを持つ:

❌ UserProfileAndSettings.st.html  (2つの責任)

✅ UserProfile.st.html              (プロフィール表示)
✅ UserSettings.st.html             (設定編集)

3. 意味のある命名

❌ Component1.st.html
❌ MyComp.st.html
❌ Thing.st.html

✅ ProductCard.st.html
✅ UserProfile.st.html
✅ ShoppingCart.st.html

4. プロップスのドキュメント

/**
 * 商品カードコンポーネント
 * 
 * 必須の状態:
 * - product.name (string): 商品名
 * - product.price (number): 価格
 * - product.imageUrl (string): 画像URL
 */
export default class {
  product = {
    name: "",
    price: 0,
    imageUrl: ""
  };
}

まとめ

今日は、SFC(シングルファイルコンポーネント)の設計思想を学びました:

SFCとは:

  • template, style, scriptを1ファイルに記述
  • コンポーネントの自己完結性
  • Web Componentsとして実装

なぜSFCか:

  • 関心の分離
  • カプセル化
  • 再利用性
  • 保守性

設計原則:

  • 適切なサイズ(200行以下が理想)
  • 単一責任
  • 意味のある命名
  • ドキュメント化

利点:

  • コードの集約
  • 理解しやすさ
  • テストしやすさ
  • バージョン管理の明確さ

次回予告:
明日は、「Web Componentsとの統合」として、SFCがどのようにWeb標準のカスタム要素として実装されるか、ライフサイクルやイベントについて詳しく学びます。


次回: Day 16「Web Componentsとの統合」

SFCについて質問があれば、コメントでぜひ!

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?