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 11: 派生状態(getter)でロジックをシンプルに

Last updated at Posted at 2025-12-10

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

前回のおさらい

Day 10では、属性バインディングとイベントハンドラを学びました。今日は、基本状態から計算される「派生状態(Derived State)」をgetterで実装する方法を詳しく見ていきます。

派生状態とは?

アプリケーションの状態には2種類あります:

基本状態(Primary State)

直接保持されるデータ:

export default class {
  product = {
    name: "Laptop",
    price: 999
  };
  taxRate = 0.1;
}

派生状態(Derived State)

基本状態から計算される値:

export default class {
  product = {
    name: "Laptop",
    price: 999
  };
  taxRate = 0.1;
  
  // 派生状態:基本状態から計算
  get "product.priceWithTax"() {
    return this["product.price"] * (1 + this.taxRate);
  }
}

重要な違い:

  • 基本状態は直接変更する
  • 派生状態は計算される(計算結果はキャッシュされる)

getterによる派生状態の定義

JavaScriptのgetterを使って派生状態を定義します。

基本的な使い方

export default class {
  firstName = "Alice";
  lastName = "Smith";
  
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}
<p>氏名: {{ fullName }}</p>
<!-- 表示: 氏名: Alice Smith -->

構造パスを使ったgetter

構造パスを文字列キーとして使えます:

export default class {
  product = {
    name: "Laptop",
    price: 999
  };
  
  get "product.displayName"() {
    return this["product.name"].toUpperCase();
  }
  
  get "product.formattedPrice"() {
    return `$${this["product.price"].toFixed(2)}`;
  }
}
<h2>{{ product.displayName }}</h2>
<!-- 表示: LAPTOP -->

<p>価格: {{ product.formattedPrice }}</p>
<!-- 表示: 価格: $999.00 -->

ポイント: getterの名前を構造パスにすることで、UIから自然にアクセスできます。

複雑な計算の集約

ビジネスロジックをgetterに集約することで、UIがシンプルになります。

例:ショッピングカートの合計計算

export default class {
  cart = {
    items: [
      { price: 999, quantity: 1 },
      { price: 29, quantity: 2 }
    ]
  };
  taxRate = 0.1;
  shippingFee = 500;
  
  get "cart.subtotal"() {
    return this.cart.items.reduce((sum, item) => {
      return sum + (item.price * item.quantity);
    }, 0);
  }
  
  get "cart.tax"() {
    return this["cart.subtotal"] * this.taxRate;
  }
  
  get "cart.total"() {
    return this["cart.subtotal"] + this["cart.tax"] + this.shippingFee;
  }
  
  get "cart.itemCount"() {
    return this.cart.items.reduce((sum, item) => {
      return sum + item.quantity;
    }, 0);
  }
}
<div class="cart-summary">
  <p>商品点数: {{ cart.itemCount }}点</p>
  <p>小計: ${{ cart.subtotal }}</p>
  <p>税金: ${{ cart.tax }}</p>
  <p>送料: ${{ shippingFee }}</p>
  <hr>
  <p><strong>合計: ${{ cart.total }}</strong></p>
</div>

利点:

  • UIに計算ロジックが入らない
  • getterを再利用できる(cart.subtotalcart.taxで使用)
  • テストしやすい

ループコンテキスト内でのgetter

ワイルドカードを使ったgetterは、ループコンテキストで威力を発揮します。

基本パターン

export default class {
  users = [
    { firstName: "Alice", lastName: "Smith" },
    { firstName: "Bob", lastName: "Johnson" }
  ];
  
  get "users.*.fullName"() {
    const first = this["users.*.firstName"];
    const last = this["users.*.lastName"];
    return `${first} ${last}`;
  }
}
{{ for:users }}
  <li>{{ users.*.fullName }}</li>
{{ endfor: }}

<!-- 展開結果:
<li>Alice Smith</li>
<li>Bob Johnson</li>
-->

仕組み

forブロック内でusers.*.fullNameが評価されるとき:

  1. ループコンテキストがindex=0をプッシュ
  2. getter内のusers.*.firstNameusers.0.firstNameに解決される
  3. getter内のusers.*.lastNameusers.0.lastNameに解決される
  4. "Alice Smith"が返される
  5. 次のループでindex=1として同様に処理

実用例:商品リスト

export default class {
  products = [
    { name: "Laptop", price: 999, stock: 5 },
    { name: "Mouse", price: 29, stock: 2 },
    { name: "Keyboard", price: 79, stock: 0 }
  ];
  
  get "products.*.isAvailable"() {
    return this["products.*.stock"] > 0;
  }
  
  get "products.*.isLowStock"() {
    const stock = this["products.*.stock"];
    return stock > 0 && stock < 5;
  }
  
  get "products.*.stockStatus"() {
    const stock = this["products.*.stock"];
    if (stock === 0) return "在庫切れ";
    if (stock < 5) return "残りわずか";
    return "在庫あり";
  }
  
  get "products.*.displayPrice"() {
    return `$${this["products.*.price"].toFixed(2)}`;
  }
}
{{ for:products }}
  <div class="product-card">
    <h3>{{ products.*.name }}</h3>
    <p class="price">{{ products.*.displayPrice }}</p>
    
    <p data-bind="class.warning:products.*.isLowStock">
      {{ products.*.stockStatus }}
    </p>
    
    {{ if:products.*.isAvailable }}
      <button data-bind="onclick:addToCart">カートに追加</button>
    {{ else: }}
      <button disabled>在庫切れ</button>
    {{ endif: }}
  </div>
{{ endfor: }}

getterから他のgetterを参照

getterは他のgetterを呼び出せます。

export default class {
  cart = {
    items: [
      { productId: 1, quantity: 2 },
      { productId: 2, quantity: 1 }
    ]
  };
  
  products = [
    { id: 1, name: "Laptop", price: 999 },
    { id: 2, name: "Mouse", price: 29 }
  ];
  
  productMap = new Map(
    this.products.map(p => [p.id, p])
  );
  
  get "cart.items.*.product"() {
    const productId = this["cart.items.*.productId"];
    return this.productMap.get(productId);
  }
  
  get "cart.items.*.price"() {
    const product = this["cart.items.*.product"];
    const quantity = this["cart.items.*.quantity"];
    return product.price * quantity;
  }
  
  get "cart.totalPrice"() {
    // ループを回して各アイテムの価格を取得
    return this.$getAll("cart.items.*.price", [])
      .reduce((sum, price) => sum + price, 0);
  }
}
<table>
  <tbody>
    {{ for:cart.items }}
      <tr>
        <td>{{ cart.items.*.product.name }}</td>
        <td>{{ cart.items.*.quantity }}</td>
        <td>${{ cart.items.*.price }}</td>
      </tr>
    {{ endfor: }}
    <tr>
      <td colspan="2"><strong>合計</strong></td>
      <td><strong>${{ cart.totalPrice }}</strong></td>
    </tr>
  </tbody>
</table>

注意: $getAllはフレームワークが提供するヘルパーメソッドで、ワイルドカードを含むパスから全ての値を配列として取得します。

条件判定のためのgetter

ifブロックの条件としてgetterを使います(boolean値を返す)。

export default class {
  user = {
    age: 25,
    isPremium: true,
    lastLoginDate: new Date("2024-12-01")
  };
  
  get "user.isAdult"() {
    return this["user.age"] >= 18;
  }
  
  get "user.canAccessPremiumContent"() {
    return this["user.isPremium"] && this["user.isAdult"];
  }
  
  get "user.isRecentlyActive"() {
    const lastLogin = this["user.lastLoginDate"];
    const daysSince = (Date.now() - lastLogin.getTime()) / (86400000);
    return daysSince < 7;
  }
}
{{ if:user.isAdult }}
  <p>成人向けコンテンツを表示</p>
{{ endif: }}

{{ if:user.canAccessPremiumContent }}
  <div class="premium-content">
    プレミアム限定コンテンツ
  </div>
{{ endif: }}

{{ if:user.isRecentlyActive }}
  <span class="badge active">アクティブ</span>
{{ else: }}
  <span class="badge inactive">非アクティブ</span>
{{ endif: }}

実践例:タスク管理アプリ

派生状態を活用した実用的な例:

<template>
  <div class="task-manager">
    <h2>タスク管理</h2>
    
    <div class="stats">
      <span>全タスク: {{ taskCount }}</span>
      <span>完了: {{ completedCount }}</span>
      <span>進行中: {{ pendingCount }}</span>
      <span>進捗率: {{ completionRate }}%</span>
    </div>
    
    <div class="filters">
      <button 
        data-bind="onclick:showAll; class.active:filter.isAll"
      >
        すべて
      </button>
      <button 
        data-bind="onclick:showPending; class.active:filter.isPending"
      >
        進行中
      </button>
      <button 
        data-bind="onclick:showCompleted; class.active:filter.isCompleted"
      >
        完了
      </button>
    </div>
    
    {{ if:hasVisibleTasks }}
      <ul class="task-list">
        {{ for:visibleTasks }}
          <li data-bind="class.completed:visibleTasks.*.isCompleted">
            <input 
              type="checkbox" 
              data-bind="checked:visibleTasks.*.isCompleted"
            >
            <span>{{ visibleTasks.*.title }}</span>
            <span class="priority">{{ visibleTasks.*.priorityLabel }}</span>
            <button data-bind="onclick:deleteTask">削除</button>
          </li>
        {{ endfor: }}
      </ul>
    {{ else: }}
      <p class="empty">表示するタスクがありません</p>
    {{ endif: }}
    
    <div class="add-task">
      <input 
        data-bind="value:newTaskTitle" 
        placeholder="新しいタスク"
      >
      <button data-bind="onclick:addTask; disabled:cannotAddTask">
        追加
      </button>
    </div>
  </div>
</template>

<script type="module">
export default class {
  tasks = [
    { id: 1, title: "買い物", isCompleted: false, priority: 2 },
    { id: 2, title: "レポート作成", isCompleted: true, priority: 1 },
    { id: 3, title: "メール返信", isCompleted: false, priority: 3 }
  ];
  
  filter = {
    mode: "all" // "all", "pending", "completed"
  };
  
  newTaskTitle = "";
  nextId = 4;
  
  // 統計情報
  get taskCount() {
    return this.tasks.length;
  }
  
  get completedCount() {
    return this.tasks.filter(t => t.isCompleted).length;
  }
  
  get pendingCount() {
    return this.taskCount - this.completedCount;
  }
  
  get completionRate() {
    if (this.taskCount === 0) return 0;
    return Math.round((this.completedCount / this.taskCount) * 100);
  }
  
  // フィルター状態
  get "filter.isAll"() {
    return this["filter.mode"] === "all";
  }
  
  get "filter.isPending"() {
    return this["filter.mode"] === "pending";
  }
  
  get "filter.isCompleted"() {
    return this["filter.mode"] === "completed";
  }
  
  // フィルタリングされたタスク
  get visibleTasks() {
    const mode = this["filter.mode"];
    
    if (mode === "all") {
      return this.tasks;
    }
    if (mode === "pending") {
      return this.tasks.filter(t => !t.isCompleted);
    }
    if (mode === "completed") {
      return this.tasks.filter(t => t.isCompleted);
    }
    
    return this.tasks;
  }
  
  get hasVisibleTasks() {
    return this.visibleTasks.length > 0;
  }
  
  // タスク固有の派生状態
  get "visibleTasks.*.priorityLabel"() {
    const priority = this["visibleTasks.*.priority"];
    const labels = { 1: "", 2: "", 3: "" };
    return labels[priority] || "なし";
  }
  
  // 新規タスク追加の条件
  get cannotAddTask() {
    return this.newTaskTitle.trim().length === 0;
  }
  
  // アクション
  showAll() {
    this["filter.mode"] = "all";
  }
  
  showPending() {
    this["filter.mode"] = "pending";
  }
  
  showCompleted() {
    this["filter.mode"] = "completed";
  }
  
  addTask() {
    if (this.cannotAddTask) return;
    
    const newTask = {
      id: this.nextId++,
      title: this.newTaskTitle.trim(),
      isCompleted: false,
      priority: 2
    };
    
    this.tasks = [...this.tasks, newTask];
    this.newTaskTitle = "";
  }
  
  deleteTask(event, index) {
    // visibleTasksのインデックスを実際のtasksのインデックスに変換
    const task = this.visibleTasks[index];
    const actualIndex = this.tasks.findIndex(t => t.id === task.id);
    
    if (actualIndex >= 0) {
      this.tasks = this.tasks.toSpliced(actualIndex, 1);
    }
  }
}
</script>

この例では:

  • 統計情報を複数のgetterで計算
  • フィルター状態をgetterで管理
  • visibleTasksgetter でフィルタリング
  • ループ内でのgetter(priorityLabel
  • 条件判定のgetter(cannotAddTask

まとめ

今日は、派生状態(getter)でロジックをシンプルにする方法を学びました:

派生状態とは:

  • 基本状態から計算される値
  • getterで定義

構造パスとの統合:

  • getter名を構造パスにできる
  • ワイルドカードを含むgetterも定義可能
  • ループコンテキストで正しく動作

利点:

  • UIからビジネスロジックを分離
  • 計算ロジックの再利用
  • テストしやすい
  • 可読性の向上

ベストプラクティス:

  • 複雑な計算はgetterに集約
  • boolean条件はgetterで定義
  • getterから他のgetterを参照してDRY

次回予告:
明日は、「パイプフィルターの活用」を解説します。値の変換やフォーマットを行うパイプフィルターの使い方、カスタムフィルターの作成方法を学びます。


次回: Day 12「パイプフィルターの活用」

派生状態(getter)について質問があれば、コメントでぜひ!

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?