この記事は「構造パスで作るシンプルなフロントエンドフレームワーク(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.subtotalをcart.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が評価されるとき:
- ループコンテキストが
index=0をプッシュ - getter内の
users.*.firstNameがusers.0.firstNameに解決される - getter内の
users.*.lastNameがusers.0.lastNameに解決される -
"Alice Smith"が返される - 次のループで
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)について質問があれば、コメントでぜひ!