この記事は「構造パスで作るシンプルなフロントエンドフレームワーク」Advent Calendarの10日目です。
Structiveについて詳しくはこちらより
前回のおさらい
Day 9では、ifブロックによる条件付きレンダリングを学びました。今日は、HTML要素の属性を動的に制御する「属性バインディング」と「イベントハンドラ」を詳しく見ていきます。
data-bind 属性の基本
data-bind属性を使うと、HTML要素の属性やイベントを状態に紐づけることができます。
基本構文
<element data-bind="属性名:構造パス">
例:
<input data-bind="value:user.name">
<img data-bind="src:product.imagePath">
<button data-bind="onclick:submitForm">送信</button>
属性バインディングの種類
1. 単方向バインディング(状態 → UI)
状態の値をHTML属性に反映します。
<img data-bind="src:product.imageUrl" alt="商品画像">
<a data-bind="href:product.detailUrl">詳細を見る</a>
<button data-bind="disabled:form.isSubmitting">送信</button>
export default class {
product = {
imageUrl: "/images/laptop.jpg",
detailUrl: "/products/laptop"
};
form = {
isSubmitting: false
};
}
動作:
- 状態が変わると、属性が自動的に更新される
- UIからの変更は状態に反映されない(単方向)
2. 双方向バインディング(状態 ↔ UI)
特定の属性では、UIの変更も状態に反映されます。
<input type="text" data-bind="value:user.name">
<input type="number" data-bind="valueAsNumber:product.price">
<input type="checkbox" data-bind="checked:settings.notifications">
<textarea data-bind="value:post.content"></textarea>
自動的に双方向になる属性:
-
value: テキスト入力(input, textarea, select) -
valueAsNumber: 数値入力 -
checked: チェックボックス
export default class {
user = {
name: "Alice"
};
product = {
price: 999
};
settings = {
notifications: true
};
post = {
content: ""
};
}
3. 複数の属性バインディング
セミコロン(;)で区切って複数の属性をバインドできます。
<input
type="number"
data-bind="
valueAsNumber:product.quantity;
attr.min:minQuantity;
attr.max:maxQuantity;
disabled:product.isOutOfStock
"
>
export default class {
product = {
quantity: 1,
isOutOfStock: false
};
minQuantity = 1;
maxQuantity = 99;
}
attr プレフィックス
attr.プレフィックスを使うと、任意のHTML属性にバインドできます。
基本的な使い方
<div data-bind="attr.id:element.id">コンテンツ</div>
<input data-bind="attr.placeholder:form.placeholderText">
<button data-bind="attr.title:button.tooltipText">送信</button>
export default class {
element = {
id: "main-content"
};
form = {
placeholderText: "名前を入力してください"
};
button = {
tooltipText: "フォームを送信します"
};
}
展開結果:
<div id="main-content">コンテンツ</div>
<input placeholder="名前を入力してください">
<button title="フォームを送信します">送信</button>
data属性のバインディング
<div data-bind="attr.data-user-id:user.id; attr.data-role:user.role">
ユーザー情報
</div>
export default class {
user = {
id: "12345",
role: "admin"
};
}
展開結果:
<div data-user-id="12345" data-role="admin">
ユーザー情報
</div>
aria属性のバインディング
アクセシビリティのためのaria属性もバインドできます。
<button
data-bind="
attr.aria-label:button.label;
attr.aria-expanded:menu.isOpen;
attr.aria-disabled:button.isDisabled
"
>
メニュー
</button>
export default class {
button = {
label: "メインメニューを開く",
isDisabled: false
};
menu = {
isOpen: false
};
}
class と style のバインディング
CSSクラスとスタイルには特別な記法があります。
クラスの追加/削除
<div data-bind="class.active:state.isActive">
アクティブ状態
</div>
export default class {
state = {
isActive: true
};
}
動作:
-
state.isActiveがtrue→class="active"が追加される -
state.isActiveがfalse→activeクラスが削除される
複数のクラス
<div data-bind="
class.active:state.isActive;
class.selected:state.isSelected;
class.disabled:state.isDisabled
">
コンテンツ
</div>
スタイルのバインディング
<div data-bind="
style.color:theme.textColor;
style.background-color:theme.bgColor;
style.width:element.width
">
スタイル付きコンテンツ
</div>
export default class {
theme = {
textColor: "#333",
bgColor: "#f0f0f0"
};
element = {
width: "200px"
};
}
イベントハンドラ
onで始まる属性名でイベントハンドラをバインドできます。
基本的なイベント
<button data-bind="onclick:handleClick">クリック</button>
<input data-bind="oninput:handleInput">
<form data-bind="onsubmit:handleSubmit">
<button type="submit">送信</button>
</form>
export default class {
handleClick(event) {
console.log("ボタンがクリックされました", event);
}
handleInput(event) {
console.log("入力:", event.target.value);
}
handleSubmit(event) {
event.preventDefault();
console.log("フォームが送信されました");
}
}
よく使うイベント
| イベント | 説明 | 例 |
|---|---|---|
onclick |
クリック | data-bind="onclick:handleClick" |
oninput |
入力変更(即座) | data-bind="oninput:handleInput" |
onchange |
入力変更(フォーカス離脱時) | data-bind="onchange:handleChange" |
onsubmit |
フォーム送信 | data-bind="onsubmit:handleSubmit" |
onfocus |
フォーカス取得 | data-bind="onfocus:handleFocus" |
onblur |
フォーカス離脱 | data-bind="onblur:handleBlur" |
onkeydown |
キー押下 | data-bind="onkeydown:handleKeyDown" |
onmouseenter |
マウス進入 | data-bind="onmouseenter:handleMouseEnter" |
onmouseleave |
マウス退出 | data-bind="onmouseleave:handleMouseLeave" |
ループ内のイベントハンドラ
forブロック内でイベントハンドラを使う場合、ループインデックスが自動的に渡されます。
基本パターン
{{ for:items }}
<li>
{{ items.*.name }}
<button data-bind="onclick:deleteItem">削除</button>
</li>
{{ endfor: }}
export default class {
items = [
{ name: "Laptop" },
{ name: "Mouse" },
{ name: "Keyboard" }
];
deleteItem(event, index) {
// indexは自動的に渡される(0, 1, 2...)
console.log(`インデックス ${index} のアイテムを削除`);
this.items = this.items.toSpliced(index, 1);
}
}
重要: 第2引数indexは、フレームワークが自動的に渡すループインデックスです。
ネストしたループ
ネストしたループでは、外側と内側のインデックスが順に渡されます。
{{ for:categories }}
<div>
<h3>{{ categories.*.name }}</h3>
{{ for:categories.*.items }}
<li>
{{ categories.*.items.*.name }}
<button data-bind="onclick:deleteItem">削除</button>
</li>
{{ endfor: }}
</div>
{{ endfor: }}
export default class {
categories = [
{
name: "Electronics",
items: [
{ name: "Laptop" },
{ name: "Phone" }
]
},
{
name: "Books",
items: [
{ name: "Novel" }
]
}
];
deleteItem(event, categoryIndex, itemIndex) {
// categoryIndex: 外側のループ(0, 1)
// itemIndex: 内側のループ(0, 1, ...)
const category = this.categories[categoryIndex];
category.items = category.items.toSpliced(itemIndex, 1);
this.categories = [...this.categories];
}
}
実践例:インタラクティブなフォーム
属性バインディングとイベントハンドラを組み合わせた実例:
<template>
<div class="registration-form">
<h2>ユーザー登録</h2>
<form data-bind="onsubmit:handleSubmit">
<div class="form-group">
<label>
ユーザー名
<input
type="text"
data-bind="
value:form.username;
attr.placeholder:placeholders.username;
class.error:validation.usernameError|truthy
"
>
</label>
{{ if:validation.usernameError|truthy }}
<span class="error-message">{{ validation.usernameError }}</span>
{{ endif: }}
</div>
<div class="form-group">
<label>
メールアドレス
<input
type="email"
data-bind="
value:form.email;
attr.placeholder:placeholders.email;
class.error:validation.emailError|truthy
"
>
</label>
{{ if:validation.emailError|truthy }}
<span class="error-message">{{ validation.emailError }}</span>
{{ endif: }}
</div>
<div class="form-group">
<label>
パスワード
<input
data-bind="
attr.type:passwordFieldType;
value:form.password;
class.error:validation.passwordError|truthy
"
>
<button
type="button"
data-bind="onclick:togglePasswordVisibility"
>
{{ passwordToggleLabel }}
</button>
</label>
{{ if:validation.passwordError|truthy }}
<span class="error-message">{{ validation.passwordError }}</span>
{{ endif: }}
</div>
<div class="form-group">
<label>
<input
type="checkbox"
data-bind="checked:form.agreeToTerms"
>
利用規約に同意する
</label>
</div>
<button
type="submit"
data-bind="
disabled:submitDisabled;
class.loading:form.isSubmitting
"
>
{{ submitButtonLabel }}
</button>
</form>
</div>
</template>
<style>
.form-group {
margin-bottom: 1em;
}
.error {
border-color: red;
}
.error-message {
color: red;
font-size: 0.9em;
}
button.loading {
opacity: 0.6;
cursor: not-allowed;
}
</style>
<script type="module">
export default class {
form = {
username: "",
email: "",
password: "",
agreeToTerms: false,
isSubmitting: false
};
validation = {
usernameError: "",
emailError: "",
passwordError: ""
};
placeholders = {
username: "ユーザー名を入力",
email: "メールアドレスを入力"
};
showPassword = false;
get passwordFieldType() {
return this.showPassword ? "text" : "password";
}
get passwordToggleLabel() {
return this.showPassword ? "隠す" : "表示";
}
get submitDisabled() {
return !this.form.agreeToTerms || this.form.isSubmitting;
}
get submitButtonLabel() {
return this.form.isSubmitting ? "送信中..." : "登録";
}
togglePasswordVisibility(event) {
event.preventDefault();
this.showPassword = !this.showPassword;
}
validateForm() {
let isValid = true;
// ユーザー名検証
if (this.form.username.length < 3) {
this.validation.usernameError = "ユーザー名は3文字以上必要です";
isValid = false;
} else {
this.validation.usernameError = "";
}
// メール検証
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(this.form.email)) {
this.validation.emailError = "有効なメールアドレスを入力してください";
isValid = false;
} else {
this.validation.emailError = "";
}
// パスワード検証
if (this.form.password.length < 8) {
this.validation.passwordError = "パスワードは8文字以上必要です";
isValid = false;
} else {
this.validation.passwordError = "";
}
return isValid;
}
async handleSubmit(event) {
event.preventDefault();
if (!this.validateForm()) {
return;
}
this.form.isSubmitting = true;
try {
// API呼び出しをシミュレート
await new Promise(resolve => setTimeout(resolve, 2000));
console.log("登録成功:", this.form);
alert("登録が完了しました!");
// フォームをリセット
this.form = {
username: "",
email: "",
password: "",
agreeToTerms: false,
isSubmitting: false
};
} catch (error) {
console.error("登録エラー:", error);
alert("登録に失敗しました");
} finally {
this.form.isSubmitting = false;
}
}
}
</script>
この例では:
- 複数の属性バインディング(value, placeholder, class, disabled)
- 双方向バインディング(text, checkbox)
- 条件付きクラス(エラー状態)
- イベントハンドラ(submit, click)
- 派生状態(getter)での動的な値
valueAsNumber の詳細
数値入力にはvalueAsNumberを使います。
<input
type="number"
data-bind="valueAsNumber:product.price"
>
通常のvalueとの違い:
// value: 文字列として扱われる
<input data-bind="value:count">
// this.count = "5" (文字列)
// valueAsNumber: 数値として扱われる
<input data-bind="valueAsNumber:count">
// this.count = 5 (数値)
まとめ
今日は、属性バインディングとイベントハンドラを学びました:
属性バインディング:
- 基本:
data-bind="属性名:構造パス" - 単方向: src, href, disabledなど
- 双方向: value, valueAsNumber, checked
- 複数: セミコロンで区切る
特殊なバインディング:
-
attr.: 任意のHTML属性 -
class.: CSSクラスの追加/削除 -
style.: インラインスタイル
イベントハンドラ:
-
onで始まる属性名(onclick, oninput...) - メソッドの第1引数: event
- ループ内では第2引数以降にインデックス
ベストプラクティス:
- 複雑なロジックはgetterに集約
- バリデーションは明示的に実行
- 派生状態で動的な値を計算
次回予告:
明日は、「派生状態(getter)でロジックをシンプルに」を解説します。getterを使った計算プロパティ、ループコンテキスト内でのgetter、依存関係の自動追跡について学びます。
次回: Day 11「派生状態(getter)でロジックをシンプルに」
属性バインディングやイベントハンドラについて質問があれば、コメントでぜひ!