一覧WB-10-2 · DOMとイベント

イベントの伝播と委譲

パネル内のボタンにlistenerを登録し、target・currentTarget・eventPhaseをログに残します。伝播の停止、登録の解除、一度だけの処理、親への委譲を試し、最後にチェックボックスでpreventDefaultの効果を確認します。

説明

ボタンで発生したイベントは、そのボタンだけに通知されるとは限りません。祖先で先に受け取るcaptureと、対象から祖先へ戻るbubbleがあり、どこへlistenerを登録するかで処理順が変わります。イベントの伝播を止めることと、チェック状態の変更などの既定動作を取り消すことも別の操作です。

パネル内のボタンにlistenerを登録し、target・currentTarget・eventPhaseをログに残します。伝播の停止、登録の解除、一度だけの処理、親への委譲を試し、最後にチェックボックスでpreventDefaultの効果を確認します。

1通知対象の親子関係

外枠・パネル・ボタンとチェックボックスを用意します。

(globalThis.doc=createFormDocument入れ子の操作対象を作成('<!doctype html><html lang="ja"><head><style>body{background:#111;color:#eee;font:16px/1.6 system-ui;margin:16px}#outer,#panel{border:1px solid #555;padding:12px}button{margin:4px}</style></head><body><div id="outer">外枠<div id="panel"><button id="run"><span id="label">実行</span></button></div></div><label><input type="checkbox" id="agree">通知を受け取る</label></body></html>'),globalThis.outer=doc.getElementById("outer"),globalThis.panel=doc.getElementById("panel"),globalThis.button=doc.getElementById("run"),globalThis.log=[]通知ログを用意,globalThis.controller=new AbortController()まとめて解除するための制御,[button.id,button.parentElement.id,panel.parentElement.id])

runの親がpanel、その親がouterです。logへ通知順を記録します。AbortControllerはこの後でlistenerをまとめて解除するために使います。

2captureとbubbleの順序

各要素にcapture用とbubble用の処理を付け、ボタンを起動します。

([outer,panel,button].forEach(el=>[true,false].forEach(capture=>el.addEventListener各要素に処理を登録("click",e=>log.push({listener:el.id+(capture?":capture":":bubble"),target:e.target.id,current:e.currentTarget.id,phase:e.eventPhase}),{capture,signal:controller.signal}段階と解除用signalを指定))),button.click()ボタンのclickを発生,log)

outer→panelのcapture、対象run、panel→outerのbubbleという順です。targetは発生元runで一定、currentTargetは処理を登録した要素へ変わります。対象上ではcapture用もbubble用もphase=2で、祖先側のcaptureは1、bubbleは3です。

3bubbleしないイベント

bubblesをfalseにしたclickをdispatchEventで送ります。

(log.length=0ログを空にする,button.dispatchEvent(new MouseEvent("click",{bubbles:false祖先へbubbleしない})),log)

祖先のcaptureと対象上の処理は実行されますが、祖先のbubbleはありません。bubbles=falseは祖先のcaptureまで無効にする指定ではありません。dispatchEventはlistenerの処理を同期的に実行して戻ります。

4stopPropagationの範囲

一度listenerを解除し、対象上で伝播を止める処理と、その後ろの処理を登録します。

(controller.abort()前のlistenerを解除,controller=new AbortController(),log.length=0,button.addEventListener("click",e=>{log.push("first");e.stopPropagation()他の要素への伝播を停止},{signal:controller.signal}),button.addEventListener("click",()=>log.push("second"),{signal:controller.signal}),panel.addEventListener("click",()=>log.push("parent"),{signal:controller.signal}),button.click()通知順を記録,log)

firstとsecondは実行され、parentへは進みません。stopPropagationは同じ対象上に登録された後続listenerまで停止する操作ではありません。

5同じ要素の後続処理も止める

今度は最初のlistenerでstopImmediatePropagationを呼びます。

(controller.abort()前のlistenerを解除,controller=new AbortController(),log.length=0,button.addEventListener("click",e=>{log.push("first");e.stopImmediatePropagation()同じ要素の後続処理も停止},{signal:controller.signal}),button.addEventListener("click",()=>log.push("second"),{signal:controller.signal}),panel.addEventListener("click",()=>log.push("parent"),{signal:controller.signal}),button.click(),log)

記録されるのはfirstだけです。同じ要素上の後続listenerも止まり、祖先への伝播も止まります。どの処理まで止める必要があるかで二つのAPIを選びます。

6同じ式でも別の関数

登録後に、同じ形の無名関数をremoveEventListenerへ渡します。

(controller.abort(),log.length=0,globalThis.handler=()=>log.push("registered"),button.addEventListener("click",handler)関数参照を保存して登録,button.removeEventListener別の関数では解除されない("click",()=>log.push("registered")),button.click(),log)

registeredが残ります。新しく作った関数は、同じコードでも登録時の関数オブジェクトとは別です。解除には同じイベント種別・関数・capture指定を渡す必要があります。

7保存した関数で解除する

登録時に保持したhandlerを渡して解除します。

(log.length=0,button.removeEventListener("click",handler)登録した関数を指定して解除,button.click(),log)

ログは空になります。解除後のclickではその関数が呼ばれません。captureを指定して登録した場合は、解除側も同じcapture指定にします。

8一度だけ受け取る

onceで登録し、clickを二回呼びます。

(log.length=0,button.addEventListener("click",()=>log.push("once"),{once:true}最初の一回だけ実行),button.click(),button.click()二回起動して比較,log)

通知は一回だけです。onceはlistenerの登録を最初の通知時に解除します。ボタンそのものが操作不能になるわけではなく、別のlistenerは通常どおり呼ばれます。

9親へ委譲して子の文字を押す

panelへ一つだけlistenerを付け、ボタン内のspanを起動します。

(log.length=0,controller=new AbortController(),panel.addEventListener("click",e=>{const b=e.target.closest("button")発生元からボタンを探す;if(b&&panel.contains(b)親の配下かを確認)log.push({target:e.target.id,current:e.currentTarget.id,button:b.id})},{signal:controller.signal}),doc.getElementById("label").click()ボタン内の文字要素を起動,log)

発生元はlabel、listenerの登録先はpanelです。closestでボタンまでさかのぼり、runの操作として扱えます。親で子のイベントを受ける方式をイベント委譲と呼びます。containsはその親の配下にある対象かを確かめます。

10後から加えたボタン

新しいボタンを加え、listenerは追加せずに起動します。

(log.length=0,globalThis.added=doc.createElement("button"),added.id="later",added.textContent="追加分",panel.append(added)子を追加,added.click()追加した子から通知,log)

新しいlaterでも親のlistenerが動きます。イベント委譲なら、子を追加するたびに同じlistenerを各子へ登録し直す必要がありません。ただし対象イベントが親まで届くことが条件です。

11signalでまとめて解除する

委譲のlistenerを解除してから同じボタンを起動します。

(controller.abort()signalに結び付いた登録を解除,log.length=0,added.click()解除後の通知を確認,log)

ログは空です。signalへ結び付けたlistenerはabortで解除されます。画面の部品を閉じた後に不要な処理を残さないためにも、登録と解除を対応させます。

12チェックボックスの既定動作

チェックボックスをclickで起動します。

(globalThis.agree=doc.getElementById("agree"),agree.click()チェックボックスを起動,{checked:agree.checked確定した選択状態})

checkedはtrueになります。チェック状態の切替はブラウザが行う既定動作です。次はlistenerでclickを取り消し、この状態変化がどうなるか確認します。

13取消し後も親へ通知される

一回だけclickを取り消し、bodyで受け取るイベントも記録します。

(log.length=0,agree.addEventListener("click",e=>{e.preventDefault()既定動作を取り消す;log.push({where:"target",canceled:e.defaultPrevented取消し済みかを記録})},{once:true}),doc.body.addEventListener("click",e=>log.push({where:"body",canceled:e.defaultPrevented}),{once:true}),agree.click(),{checked:agree.checkedclick完了後の状態,log})

チェック状態はtrueのままで、切替は取り消されます。一方、bodyも通知を受けます。preventDefaultは既定動作の取消しであり、伝播停止ではありません。listener内で見える一時的な状態と、取消しを反映した後の状態が異なる場合にも注意します。

14取り消せるイベントの条件

独自イベントをcancelable=falseとtrueで送ります。

(agree.addEventListener("review",e=>e.preventDefault()取消しを試みる),[false,true]cancelableだけ変更.map(cancelable=>{const e=new Event("review",{cancelable});return{cancelable,returned:agree.dispatchEvent(e)取消し結果を戻り値で確認,canceled:e.defaultPrevented}}))

cancelable=falseではpreventDefaultを呼んでも取消し済みになりません。trueではdefaultPrevented=trueとなり、dispatchEventの戻り値はfalseです。これはlistenerの戻り値ではなく、イベントが取り消されたことを表します。

まとめ

targetは発生元、currentTargetは現在の登録先です。capture・対象・bubbleの順で通知されます。

伝播停止と既定動作の取消しは別で、stopPropagationとstopImmediatePropagationにも範囲の差があります。

関数参照・once・signalで登録期間を管理し、親への委譲で追加要素も扱えます。

操作履歴と解説を振り返れます。

ブラウザ / DOM・イベント

1
(globalThis.doc=createFormDocument('<!doctype html><html lang="ja"><head><style>body{background:#111;color:#eee;font:16px/1.6 system-ui;margin:16px}#outer,#panel{border:1px solid #555;padding:12px}button{margin:4px}</style></head><body><div id="outer">外枠<div id="panel"><button id="run"><span id="label">実行</span></button></div></div><label><input type="checkbox" id="agree">通知を受け取る</label></body></html>'),globalThis.outer=doc.getElementById("outer"),globalThis.panel=doc.getElementById("panel"),globalThis.button=doc.getElementById("run"),globalThis.log=[],globalThis.controller=new AbortController(),[button.id,button.parentElement.id,panel.parentElement.id])
[
  "run",
  "panel",
  "outer"
]
2
([outer,panel,button].forEach(el=>[true,false].forEach(capture=>el.addEventListener("click",e=>log.push({listener:el.id+(capture?":capture":":bubble"),target:e.target.id,current:e.currentTarget.id,phase:e.eventPhase}),{capture,signal:controller.signal}))),button.click(),log)
[
  {
    "listener": "outer:capture",
    "target": "run",
    "current": "outer",
    "phase": 1
  },
  {
    "listener": "panel:capture",
    "target": "run",
    "current": "panel",
    "phase": 1
  },
  {
    "listener": "run:capture",
    "target": "run",
    "current": "run",
    "phase": 2
  },
  {
    "listener": "run:bubble",
    "target": "run",
    "current": "run",
    "phase": 2
  },
  {
    "listener": "panel:bubble",
    "target": "run",
    "current": "panel",
    "phase": 3
  },
  {
    "listener": "outer:bubble",
    "target": "run",
    "current": "outer",
    "phase": 3
  }
]
3
(log.length=0,button.dispatchEvent(new MouseEvent("click",{bubbles:false})),log)
[
  {
    "listener": "outer:capture",
    "target": "run",
    "current": "outer",
    "phase": 1
  },
  {
    "listener": "panel:capture",
    "target": "run",
    "current": "panel",
    "phase": 1
  },
  {
    "listener": "run:capture",
    "target": "run",
    "current": "run",
    "phase": 2
  },
  {
    "listener": "run:bubble",
    "target": "run",
    "current": "run",
    "phase": 2
  }
]
4
(controller.abort(),controller=new AbortController(),log.length=0,button.addEventListener("click",e=>{log.push("first");e.stopPropagation()},{signal:controller.signal}),button.addEventListener("click",()=>log.push("second"),{signal:controller.signal}),panel.addEventListener("click",()=>log.push("parent"),{signal:controller.signal}),button.click(),log)
[
  "first",
  "second"
]
5
(controller.abort(),controller=new AbortController(),log.length=0,button.addEventListener("click",e=>{log.push("first");e.stopImmediatePropagation()},{signal:controller.signal}),button.addEventListener("click",()=>log.push("second"),{signal:controller.signal}),panel.addEventListener("click",()=>log.push("parent"),{signal:controller.signal}),button.click(),log)
[
  "first"
]
6
(controller.abort(),log.length=0,globalThis.handler=()=>log.push("registered"),button.addEventListener("click",handler),button.removeEventListener("click",()=>log.push("registered")),button.click(),log)
[
  "registered"
]
7
(log.length=0,button.removeEventListener("click",handler),button.click(),log)
[]
8
(log.length=0,button.addEventListener("click",()=>log.push("once"),{once:true}),button.click(),button.click(),log)
[
  "once"
]
9
(log.length=0,controller=new AbortController(),panel.addEventListener("click",e=>{const b=e.target.closest("button");if(b&&panel.contains(b))log.push({target:e.target.id,current:e.currentTarget.id,button:b.id})},{signal:controller.signal}),doc.getElementById("label").click(),log)
[
  {
    "target": "label",
    "current": "panel",
    "button": "run"
  }
]
10
(log.length=0,globalThis.added=doc.createElement("button"),added.id="later",added.textContent="追加分",panel.append(added),added.click(),log)
[
  {
    "target": "later",
    "current": "panel",
    "button": "later"
  }
]
11
(controller.abort(),log.length=0,added.click(),log)
[]
12
(globalThis.agree=doc.getElementById("agree"),agree.click(),{checked:agree.checked})
{
  "checked": true
}
13
(log.length=0,agree.addEventListener("click",e=>{e.preventDefault();log.push({where:"target",canceled:e.defaultPrevented})},{once:true}),doc.body.addEventListener("click",e=>log.push({where:"body",canceled:e.defaultPrevented}),{once:true}),agree.click(),{checked:agree.checked,log})
{
  "checked": true,
  "log": [
    {
      "where": "target",
      "canceled": true
    },
    {
      "where": "body",
      "canceled": true
    }
  ]
}
14
(agree.addEventListener("review",e=>e.preventDefault()),[false,true].map(cancelable=>{const e=new Event("review",{cancelable});return{cancelable,returned:agree.dispatchEvent(e),canceled:e.defaultPrevented}}))
[
  {
    "cancelable": false,
    "returned": true,
    "canceled": false
  },
  {
    "cancelable": true,
    "returned": false,
    "canceled": true
  }
]

演習完了