🦁

dom 操作で追加した要素にイベントリスナーを追加する

2022/09/28に公開

下記のようなシンプルな ToDo リストを考えます。

ul の中に li が並んで、やることが書かれています。
各 li の中には削除用の button があります。
ul の下にはやることを追加するための form と input と button があります。

<div id="app">
  <p>やることリスト</p>
  <ul class="list">
    <li>洗濯 <button class="remove">削除</button></li>
    <li>炊飯 <button class="remove">削除</button></li>
    <li>掃除 <button class="remove">削除</button></li>
    <li>買い物 <button class="remove">削除</button></li>
  </ul>
  <form class="add">
    <input type="text" name="todo" />
    <button type="submit">追加</button>
  </form>
</div>

削除ボタンが押されたら対応するやることを消す必要があります。

// jQuery の場合
$('button.remove').on('click', function () {
  $(this).closest('li').remove()
})

追加ボタンが押されたら新しいやることを追加する必要があります。

$('form.add').on('submit', function () {
  const val = $(this).find('input').val()
  // !!! XSS のある危険なコードになってます。後述します。
  const html = '<li>' + val + ' <button class="remove">削除</button></li>'
  $('ul.list').append(html)
  $(this).find('input').val('')
  return false
})

上記コードでは append された li の中の button にはイベントリスナーが追加されていません。
これをどうするかがこの記事の内容になります。

下記の 4 つの解決策があります。

解決策 1. 追加するときにリスナーを追加する

そもそも上記のような XSS の入りやすい書き方はよくありません。
冗長になってしまいますが jQuery を使うのであれば下記のように書いた方が良いでしょう。
この書き方ならリスナーの追加も難しくはありません。

// 削除処理を名前付き関数にしておく
function remove() {
  $(this).closest('li').remove()
}
// 削除ボタンが押されたら消す
$('button.remove').on('click', remove)
// 追加ボタンが押されたら追加
$('form.add').on('submit', function () {
  const val = $(this).find('input').val()
  // li の生成
  const li = $('<li/>')
    // テキストの追加
    .text(val + ' ')
    // ボタンの追加 !!! ついでにリスナーの追加
    .append($('<button class="remove"/>').text('削除').on('click', remove))
  $('ul.list').append(li)
  $(this).find('input').val('')
  return false
})

解決策 2. 親要素に追加しておく

イベントリスナーを消えたり足されたりする要素にあてるのではなくより上位の要素に当てておけば悩みは減ります。

// 削除ボタンが押されたら消す
$('#app').on('click', 'button.remove', function () {
  $(this).closest('li').remove()
})
// 追加ボタンが押されたら追加
$('form.add').on('submit', function () {
  const val = $(this).find('input').val()
  const li = $('<li/>')
    .text(val + ' ')
    .append($('<button class="remove">削除</button>'))
  $('ul.list').append(li)
  $(this).find('input').val('')
  return false
})

解決策 3. 一旦全リスナーを消してからつけ直す

新しい要素だけにつけるのが面倒なら毎回全要素からリスナーを消してつけ直せば楽かもしれません…。

// 各処理を名前付き関数にしておく
function remove() {
  $(this).closest('li').remove()
}
function add() {
  const val = $(this).find('input').val()
  const li = $('<li/>')
    .text(val + ' ')
    .append($('<button class="remove">削除</button>'))
  $('ul.list').append(li)
  $(this).find('input').val('')
  // !!! リスナーの再設定
  setListeners()
  return false
}
function setListeners() {
  // 各リスナーの割り当てを一旦消してから追加
  // 削除ボタンが押されたら消す
  $('button.remove').off('click', remove).on('click', remove)
  // 追加ボタンが押されたら追加
  $('form.add').off('submit', add).on('submit', add)
}
// !!! 最初もリスナーの設定が必要
setListeners()

解決策 4. View を扱うライブラリを使う (オススメ!)

最近流行りの React とか Vue とかを使おうという話です。
無理にサイト全体を React で描画する必要はありません。複雑な動作をできるようにしたい部分だけで良いと思います。
AlpineJS や Stimulus などは PHP などでのウェブアプリ向けに作られているらしいのでより使いやすいかもしれません。

Preact を使うのであれば html 全体は下記のようになります。
シンプルに書けることがわかると思います。

<div id="app"></div>
<script src="https://unpkg.com/htm/preact/standalone.umd.js"></script>
<script>
  const { html, render, useState } = htmPreact
  const App = () => {
    const [todo, setNewTodo] = useState('')
    const [list, setList] = useState(['洗濯', '炊飯', '掃除', '買い物'])
    const remove = i => () => setList(list.filter((_, j) => j !== i))
    const add = e => {
      setList([...list, todo])
      setNewTodo('')
      e.preventDefault()
    }
    return html`
      <p>やることリスト</p>
      <ul class="list">
        ${list.map(
          (todo, i) =>
            html`<li>${todo} <button onClick=${remove(i)}>削除</button></li>`,
        )}
      </ul>
      <form onSubmit=${add}>
        <input
          type="text"
          value=${todo}
          onInput=${e => setNewTodo(e.target.value)}
        />
        <button type="submit">追加</button>
      </form>
    `
  }
  render(html`<${App} />`, document.getElementById('app'))
</script>

あるいは Vue であれば下記のように書けます。

<div id="app"></div>
<script src="https://unpkg.com/vue/dist/vue.global.prod.js"></script>
<script>
  const { createApp, ref } = Vue
  createApp({
    template: `
      <p>やることリスト</p>
      <ul class="list">
        <li v-for="(todo, i) in list">
          {{ todo }} <button @click="remove(i)">削除</button>
        </li>
      </ul>
      <form @submit.prevent="add()">
        <input type="text" v-model="todo" />
        <button type="submit">追加</button>
      </form>
    `,
    setup() {
      const todo = ref('')
      const list = ref(['洗濯', '炊飯', '掃除', '買い物'])
      const remove = i => {
        list.value = list.value.filter((_, j) => j !== i)
      }
      const add = () => {
        list.value = [...list.value, todo.value]
        todo.value = ''
      }
      return { remove, add, list, todo }
    },
  }).mount('#app')
</script>

規模がより大きくなれば TypeScript や各種バンドラを追加してもっと書きやすくできます。

補足 サーバサイドとの連携

改めてもとの html を見てみます。

<div id="app">
  <p>やることリスト</p>
  <ul class="list">
    <li>洗濯 <button class="remove">削除</button></li>
    <li>炊飯 <button class="remove">削除</button></li>
    <li>掃除 <button class="remove">削除</button></li>
    <li>買い物 <button class="remove">削除</button></li>
  </ul>
  <form class="add">
    <input type="text" name="todo" />
    <button type="submit">追加</button>
  </form>
</div>
<script src="https://unpkg.com/jquery"></script>
<script>
  // 削除ボタンが押されたら消す
  $('button.remove').on('click', function () {
    $(this).closest('li').remove()
  })
  // 追加ボタンが押されたら追加
  $('form.add').on('submit', function () {
    const val = $(this).find('input').val()
    // !!! XSS のある危険なコードになってます。
    const html = '<li>' + val + ' <button class="remove">削除</button></li>'
    $('ul.list').append(html)
    $(this).find('input').val('')
    // !!! リスナーの割り当てが必要
    return false
  })
</script>

現実のウェブアプリではサーバサイドの DB にデータをいれます。
上記の html が PHP で生成される場合2か所に同じ html が書かれることになります。
無駄が多い上にバグの原因となりやすいです。

<!-- PHP で生成 (Blade の場合) -->
<li>{{ $todo }} <button class="remove">削除</button></li>
const html = '<li>' + val + ' <button class="remove">削除</button></li>'

今回の例では追加後の html もサーバサイドで作ってしまっても良いと思います。
この場合は親要素に追加しておく方法がやりやすいと思います。

<div id="app">
  <p>やることリスト</p>
  <ul class="list">
    <li>
      洗濯 <button class="remove" data-idx="0" data-label="洗濯">削除</button>
    </li>
    <li>
      炊飯 <button class="remove" data-idx="1" data-label="炊飯">削除</button>
    </li>
    <li>
      掃除 <button class="remove" data-idx="2" data-label="掃除">削除</button>
    </li>
    <li>
      買い物
      <button class="remove" data-idx="3" data-label="買い物">削除</button>
    </li>
  </ul>
  <form class="add">
    <input type="text" name="todo" />
    <button type="submit">追加</button>
  </form>
</div>
<script src="https://unpkg.com/jquery"></script>
<script>
  $('body')
    .on('click', '#app button.remove', function () {
      const data = { idx: $(this).data('idx'), label: $(this).data('label') }
      $.post('/remove-todo.php', data).then(html => $('ul.list').html(html))
    })
    .on('submit', '#app form.add', function () {
      const data = $(this).serialize()
      $.post('/create-todo.php', data).then(html => $('ul.list').html(html))
      $(this).find('input').val('')
      return false
    })
</script>

Vue で書く場合、下記のようになりそうですがこの程度の規模ではあまり差はないと思います。

<div id="app"></div>
<script src="https://unpkg.com/vue/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/jquery"></script>
<script>
  const { createApp, ref } = Vue
  createApp({
    template: `
      <p>やることリスト</p>
      <ul class="list">
        <li v-for="(todo, i) in list">
          {{ todo }} <button @click="remove(i)">削除</button>
        </li>
      </ul>
      <form @submit.prevent="add()">
        <input type="text" v-model="todo" />
        <button type="submit">追加</button>
      </form>
    `,
    setup() {
      const todo = ref('')
      const list = ref(['洗濯', '炊飯', '掃除', '買い物'])
      const remove = idx => {
        const data = { idx, label: list.value[idx] }
        $.post('/remove-todo.json', data).then(json => (list.value = json))
      }
      const add = () => {
        const data = { todo: todo.value }
        $.post('/create-todo.json', data).then(json => (list.value = json))
        todo.value = ''
      }
      return { remove, add, list, todo }
    },
  }).mount('#app')
</script>
GitHubで編集を提案

Discussion