この記事は「構造パスで作るシンプルなフロントエンドフレームワーク(Structive)」Advent Calendarの8日目です。
Structiveについて詳しくはこちらより
Week 2 のスタート
Week 1では構造パスの基礎を学びました。Week 2では、より高度な機能に踏み込んでいきます。今日は、配列を扱う際に必須となる「forブロックとループコンテキスト」を詳しく見ていきます。
forブロックの基本
forブロックは、配列の各要素に対してUIを繰り返し描画するための構文です。
基本的な使い方
<ul>
{{ for:users }}
<li>{{ users.*.name }}</li>
{{ endfor: }}
</ul>
export default class {
users = [
{ name: "Alice", age: 25 },
{ name: "Bob", age: 30 },
{ name: "Carol", age: 28 }
];
}
展開されるHTML:
<ul>
<li>Alice</li>
<li>Bob</li>
<li>Carol</li>
</ul>
構文の要素
{{ for:パス }}
<!-- ループ内容 -->
{{ endfor: }}
-
{{ for:users }}:users配列に対してループを開始 -
{{ users.*.name }}: 各要素のnameプロパティにアクセス -
{{ endfor: }}: ループを終了
ワイルドカードの解決問題
forブロック内でusers.*.nameという構造パスをどう解釈するかが重要です。
問題の本質
{{ for:users }}
<li>{{ users.*.name }}</li>
{{ endfor: }}
users.*.nameは抽象的な表現です:
- 0番目の要素では
users.0.name("Alice") - 1番目の要素では
users.1.name("Bob") - 2番目の要素では
users.2.name("Carol")
疑問: フレームワークはどうやって「今何番目の要素を処理しているか」を知るのか?
解決策:ループコンテキスト
ループコンテキストは、現在処理中の配列インデックスを記録する仕組みです。
class LoopContext {
constructor() {
this.stack = []; // インデックスのスタック
}
push(index) {
this.stack.push(index);
}
pop() {
this.stack.pop();
}
current() {
return this.stack[this.stack.length - 1];
}
}
forブロックの実行フロー
forブロックがどのように処理されるか、ステップバイステップで見ていきましょう。
Step 1: テンプレートのパース
{{ for:users }}
<li>{{ users.*.name }}</li>
{{ endfor: }}
フレームワークはこれを以下のように解釈:
{
type: "for",
arrayPath: "users",
template: "<li>{{ users.*.name }}</li>"
}
Step 2: ループの実行
function renderForBlock(forNode, state) {
const array = state[forNode.arrayPath]; // users配列を取得
const fragment = document.createDocumentFragment();
array.forEach((item, index) => {
// ループコンテキストをプッシュ
loopContext.push(index);
// テンプレートを描画
const element = renderTemplate(forNode.template, state);
fragment.appendChild(element);
// ループコンテキストをポップ
loopContext.pop();
});
return fragment;
}
Step 3: ワイルドカードの解決
テンプレート内でusers.*.nameに遭遇したとき:
function resolveWildcard(path, loopContext) {
if (!path.includes('*')) {
return path;
}
const currentIndex = loopContext.current();
return path.replace('*', currentIndex);
}
// 例
// index=0 のとき: "users.*.name" → "users.0.name"
// index=1 のとき: "users.*.name" → "users.1.name"
// index=2 のとき: "users.*.name" → "users.2.name"
実装例:シンプルなforブロック
実際のコードで確認しましょう。
class ForBlockRenderer {
constructor(state) {
this.state = state;
this.loopContext = [];
}
render(arrayPath, template, container) {
const array = this.getByPath(this.state, arrayPath);
// 既存の内容をクリア
container.innerHTML = '';
array.forEach((item, index) => {
// コンテキストをプッシュ
this.loopContext.push(index);
// テンプレートを描画
const element = this.renderTemplate(template);
container.appendChild(element);
// コンテキストをポップ
this.loopContext.pop();
});
}
renderTemplate(template) {
const element = document.createElement('div');
element.innerHTML = template;
// {{ ... }} を解決
this.resolveBindings(element);
return element.firstChild;
}
resolveBindings(element) {
const textNodes = this.getTextNodes(element);
textNodes.forEach(node => {
const match = node.textContent.match(/\{\{\s*(.+?)\s*\}\}/);
if (match) {
const path = match[1];
const resolvedPath = this.resolveWildcard(path);
const value = this.getByPath(this.state, resolvedPath);
node.textContent = node.textContent.replace(match[0], value);
}
});
}
resolveWildcard(path) {
if (!path.includes('*')) {
return path;
}
const currentIndex = this.loopContext[this.loopContext.length - 1];
return path.replace('*', currentIndex);
}
getByPath(obj, path) {
const keys = path.split('.');
let current = obj;
for (const key of keys) {
if (current == null) return undefined;
current = current[key];
}
return current;
}
getTextNodes(element) {
const textNodes = [];
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null
);
let node;
while (node = walker.nextNode()) {
textNodes.push(node);
}
return textNodes;
}
}
使用例
const state = {
users: [
{ name: "Alice", age: 25 },
{ name: "Bob", age: 30 }
]
};
const renderer = new ForBlockRenderer(state);
const container = document.querySelector('#user-list');
const template = '<li>{{ users.*.name }} ({{ users.*.age }})</li>';
renderer.render('users', template, container);
ネストしたforブロック
forブロックは入れ子にできます。
{{ for:categories }}
<div>
<h3>{{ categories.*.name }}</h3>
<ul>
{{ for:categories.*.items }}
<li>{{ categories.*.items.*.name }}</li>
{{ endfor: }}
</ul>
</div>
{{ endfor: }}
export default class {
categories = [
{
name: "Electronics",
items: [
{ name: "Laptop" },
{ name: "Phone" }
]
},
{
name: "Books",
items: [
{ name: "Novel" },
{ name: "Textbook" }
]
}
];
}
ループコンテキストのスタック
ネストの場合、複数のインデックスを追跡する必要があります:
// 外側のループ: index=0 (Electronics)
loopContext.push(0); // stack = [0]
// 内側のループ: index=0 (Laptop)
loopContext.push(0); // stack = [0, 0]
// categories.0.items.0.name → "Laptop"
loopContext.pop(); // stack = [0]
// 内側のループ: index=1 (Phone)
loopContext.push(1); // stack = [0, 1]
// categories.0.items.1.name → "Phone"
loopContext.pop(); // stack = [0]
loopContext.pop(); // stack = []
// 外側のループ: index=1 (Books)
loopContext.push(1); // stack = [1]
// ...
重要: ワイルドカードが複数ある場合、内側から順に解決します。
"categories.*.items.*.name"
↓ 外側のコンテキスト(index=0)で解決
"categories.0.items.*.name"
↓ 内側のコンテキスト(index=1)で解決
"categories.0.items.1.name"
特殊変数:ループインデックス
多くのフレームワークと同様、ループインデックスを直接参照できます。
{{ for:items }}
<li>{{ $1 }}. {{ items.*.name }}</li>
{{ endfor: }}
$1は現在のループインデックス(0始まり)を表します。
展開結果:
<li>0. Laptop</li>
<li>1. Mouse</li>
<li>2. Keyboard</li>
1始まりのインデックス
$1|inc,1のようにパイプフィルターを使って調整できます:
{{ for:items }}
<li>{{ $1|inc,1 }}. {{ items.*.name }}</li>
{{ endfor: }}
展開結果:
<li>1. Laptop</li>
<li>2. Mouse</li>
<li>3. Keyboard</li>
forブロック内のイベントハンドラ
forブロック内のイベントハンドラにも、ループコンテキストが渡されます。
{{ for:items }}
<li>
{{ items.*.name }}
<button data-bind="onclick:deleteItem">Delete</button>
</li>
{{ endfor: }}
export default class {
items = [
{ name: "Laptop" },
{ name: "Mouse" },
{ name: "Keyboard" }
];
deleteItem(event, index) {
// index は自動的に渡される
this.items = this.items.toSpliced(index, 1);
}
}
ポイント: deleteItemメソッドの第2引数indexは、フレームワークが自動的に渡すループインデックスです。
仕組み
// イベントハンドラの登録時
button.addEventListener('click', (event) => {
const currentIndex = loopContext.current();
this.deleteItem(event, currentIndex);
});
実践例:商品リスト
forブロックを使った実用的な例を見てみましょう。
<template>
<div class="product-list">
<h2>Products</h2>
<div class="products">
{{ for:products }}
<div class="product-card">
<h3>{{ products.*.name }}</h3>
<p class="price">${{ products.*.price }}</p>
<p class="stock">
Stock: {{ products.*.stock }}
{{ if:products.*.isLowStock }}
<span class="warning">⚠️ Low Stock</span>
{{ endif: }}
</p>
<button data-bind="onclick:addToCart">Add to Cart</button>
</div>
{{ endfor: }}
</div>
</div>
</template>
<script type="module">
export default class {
products = [
{ id: 1, name: "Laptop", price: 999, stock: 5 },
{ id: 2, name: "Mouse", price: 29, stock: 2 },
{ id: 3, name: "Keyboard", price: 79, stock: 15 }
];
get "products.*.isLowStock"() {
return this["products.*.stock"] < 5;
}
addToCart(event, index) {
const product = this.products[index];
console.log(`Added ${product.name} to cart`);
// カートへの追加処理
}
}
</script>
この例では:
- forブロックで商品リストを描画
- 派生状態(getter)で在庫警告を判定
- イベントハンドラでインデックスを受け取る
まとめ
今日は、forブロックとループコンテキストの仕組みを学びました:
forブロックの役割:
- 配列の各要素に対してUIを繰り返し描画
-
{{ for:配列パス }}〜{{ endfor: }}の構文
ループコンテキスト:
- 現在処理中のインデックスを追跡
- スタック構造でネストに対応
- ワイルドカードを具体的なインデックスに解決
重要な概念:
- ワイルドカード解決:
users.*.name→users.0.name - ネストしたループ: 複数のインデックスを管理
- 特殊変数:
$1でインデックスを参照 - イベントハンドラ: インデックスが自動的に渡される
次回予告:
明日は、「ifブロックで条件付きレンダリング」を学びます。条件によってUIの表示/非表示を切り替える仕組みと、DOMの効率的な追加/削除を解説します。
次回: Day 9「ifブロックで条件付きレンダリング」
forブロックやループコンテキストについて質問があれば、コメントでぜひ!