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 10: 属性バインディングとイベントハンドラ

Last updated at Posted at 2025-12-09

この記事は「構造パスで作るシンプルなフロントエンドフレームワーク」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.isActivetrueclass="active"が追加される
  • state.isActivefalseactiveクラスが削除される

複数のクラス

<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)でロジックをシンプルに」

属性バインディングやイベントハンドラについて質問があれば、コメントでぜひ!

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?