event.target vs currentTarget — アイコンの線の上を押した時だけ起きるバグ
data-* を読むならcurrentTarget。SVGは塗られた線だけがクリックを受ける
リスナーを付けた要素の data-* を読むコードなら event.currentTarget を使う。event.target だと、その要素に子要素がひとつでもあった瞬間、クリックしたピクセル次第で結果が変わる。読み終えればこの二つを取り違えなくなり、非同期の中で currentTarget が null になる罠も避けられる。
実際に壊れたコード
一括削除ボタンはこうなっていた。
<button data-action="click->bulk#delete" data-folder-uuid="abc123">
<svg fill="none" stroke="currentColor"><path d="m14.74 9-.346 9…"/></svg>
削除
</button>
delete(event) {
const folderUuid = event.target.dataset.folderUuid // バグ
fetch(`/bulk/delete/new?folder_uuid=${folderUuid}`)
}
ゴミ箱アイコンの線の上をちょうど押すと、event.target が <path> になる。<path> には data-folder-uuid がないので undefined。サーバーはフォルダ指定なしで呼ばれ、見当違いのキーで選択状態を探し、「0件を削除しますか?」というモーダルを出した。
同じボタンなのに、押したピクセルで動きが分かれた。再現しにくくて、けっこう長く生き残ったバグだ。
二つは何が違うのか
event.target はイベントが実際に起きた一番内側の要素。伝播の間ずっと変わらない。event.currentTarget は今実行中のリスナーが付いている要素で、バブリングで上がるたびに変わる。
<button> にリスナーを付けて中の <path> を押すと、target は <path>、currentTarget は <button> になる。
混乱の半分は層が混ざることから来る。event は「何が起きたか」を持つオブジェクトで、dataset や classList はDOM要素側のプロパティだ。event.dataset は undefined。target と currentTarget は、イベントが持っている要素へのポインタ二本にすぎない。
なぜSVGアイコンで起きるのか
SVG図形の pointer-events の既定値は visiblePainted で、実際に塗られた部分だけがクリックを受ける。fill="none" のアイコンなら、当たり判定は太さ1.5px程度の線だけだ。線の上なら <path>、線の間なら <svg> か <button> になる。どこが何になるかはブラウザとマークアップで違うので、ログを出して見るのが早い。
document.querySelector('button').addEventListener('click', (e) => {
console.log(e.target.tagName, e.currentTarget.tagName)
})
アイコンのあちこちを押すと、target だけが path・svg・BUTTON と変わり、currentTarget は常に BUTTON だ。
うちのコードでは <svg> にも data-folder-uuid がコピーされていた。誰かが同じバグに出会って、一段だけ深く貼った跡だ。これでたいていのクリックは直るので「直った」と思い込む。線の上のクリックは相変わらず抜けて、確率が下がった分だけ捕まえにくくなっただけだった。属性をコピーする方向がそもそも間違っている。
罠 — currentTargetはハンドラが終わるとnull
target はイベントオブジェクトに残るが、currentTarget はディスパッチが終わると null に戻る。setTimeout や await の後で読むと TypeError になる。
async delete(event) {
const button = event.currentTarget // 同期のうちに掴んでおく
await fetch('/x')
console.log(event.currentTarget) // null
console.log(button.dataset.folderUuid) // 'abc123'
}
const button = event.currentTarget と先に受けておくのは好みの問題ではなく、寿命の管理だ。
逆にtargetが正しい場合 — イベント委譲
親ひとつにリスナーを付けて子をまとめて扱うときは、currentTarget が常に親なので役に立たない。target から始めて closest() で上にたどる。
list.addEventListener('click', (event) => {
const row = event.target.closest('[data-contract-uuid]')
if (!row || !list.contains(row)) return
open(row.dataset.contractUuid)
})
target をそのまま使わず closest() を通すのがポイントだ。「内側の要素が取れてしまう」問題にここでも同じように出会うからだ。
| やりたいこと | 使うもの |
|---|---|
| リスナーを付けた要素のdata-*を読む、ボタンをdisabledにする | event.currentTarget |
| 親ひとつで子を複数扱う(委譲) | event.target.closest('[data-x]') |
| Stimulusで値を渡す | data-bulk-folder-uuid-param → event.params.folderUuid |
Stimulusでは data-action が付いた要素がそのまま currentTarget だ。event.params は数値・真偽値・JSONへの型変換までしてくれるので、新しいコードならこれが一番すっきりする。既存コードが data-folder-uuid 式で揃っているなら、わざわざ混ぜない。
ボタンの中にアイコンがあって target で読んでいるコードを見かけたら、ほぼ確実に同じバグだ。こういう「たまにしか再現しない」フロントのバグを追っているなら、negabaroに一緒に見てもらうのも手だ。
キーポイント
リスナーを付けた要素のdata-*はevent.currentTargetで読む
SVGアイコンは塗られた線だけがクリックを受けるのでtargetが<path>になる
await・setTimeoutの前にcurrentTargetを変数に取っておく。終わるとnullになる
イベント委譲ではtarget.closest()で上にたどる