為什麼寫在 label 上的 click 事件會觸發兩次?

這是我在寫 react 時遇到的問題,不過這問題跟 react 無關,簡化後的程式碼長這樣。

1
2
3
<label onClick={() => console.log('label trigger')}>
render from react <input type="checkbox" />
</label>

觸發點擊事件後 console.log 印了兩次,當時一直搞不懂為什麼,就先把 onClick 事件移到 input 去,解決了重複觸發事件的問題。

1
2
3
<label>
render from react <input type="checkbox" onClick={() => console.log('label trigger')} />
</label>

或是在 label 上把 onClick 事件加上 stopPropagation 也行。

1
2
3
4
5
6
<label onClick={(e) =>{
e.stopPropagation();
console.log('label trigger')
}}>
render from react <input type="checkbox" />
</label>

為什麼會這樣?

真的想破頭搞不懂,去 google 後看到 stackoverflow 也有人問了一樣的問題 Why the onclick element will trigger twice for label element
解答是說在觸發 label 上的點擊事件時也會將這 click 事件傳到跟這 label 有關的 input 去,然後 input 出現冒泡事件才會讓寫在 label 上的 click 事件被觸發兩次。

當時還是不太懂意思又跑去看 label 的 mdn 文件,在 其他使用事項 有看到解釋,在 label 上的事件也會連帶觸發相關標籤(input) 的事件。

複習事件捕獲和冒泡

當時看完 stackoverflow 後大概知道跟冒泡事件有關,但我那時還是不知道為什麼 label 的點擊事件會往下傳給 input,難道捕獲事件不是停在 label 而是繼續往下傳嗎?

當時我對事件捕獲和冒泡的想法:

1
2
3
<label onClick={()=>console.log('label click')}>
render from react <input type="checkbox">
</label>

捕獲順序: document -> label
冒泡順序: label -> document

前面有說到因為 label 的特性也會連帶觸發 input 的事件,所以我們能拆成兩件事來看。可以想成我們點 label 的同時也在點 input。

先從 label 來看,捕獲冒泡順序:
捕獲順序: document-> label
冒泡順序: label -> document
這邊我們沒有理解錯誤。

再來是 input 的捕獲冒泡順序:
捕獲順序: document -> label -> input
冒泡順序: input -> label -> document

我們在 label 觸發點擊事件時印出了 label click,與此同時 input 也開始它自己的點擊事件,然後 input 事件開始冒泡到 label 時又會再次觸發到 label 的點擊事件,所以我們會看到 label click 印了兩次。

為了方便理解我們在 input 內多加個點擊事件就能更了解來龍去脈了。

1
2
3
<label onClick={()=>console.log('label click')}>
render from react <input type="checkbox" onClick={()=>console.log('input click')}>
</label>

點擊 label 的字 render from react 這樣印出的結果會是這樣:

1
2
3
label click
input click
label click

如果是直接點擊 input 會印出這樣的結果:

1
2
input click
label click

覺得這地雷滿可怕的,沒注意到的話,真的會搞不懂為什麼有時候點擊 label 會觸發兩次,為什麼有時候又會正常只觸發一次,其實差別就是在點字 render from react 或是直接點 input。

額外補充

事件傳遞的順序是先捕獲再冒泡,那在觸發事件的 label 上是先捕獲還是先冒泡?
這要看監聽事件的先後順序。

1
2
3
<label>
<input/>
</label>

這樣會先印 capture 再來是 bubble。

1
2
input.addEventListener('click',()=>console.log('capture'),true);
input.addEventListener('click',()=>console.log('bubble'));

這樣會先印 bubble 再來是 capture。

1
2
input.addEventListener('click',()=>console.log('bubble'));
input.addEventListener('click',()=>console.log('capture'),true);

不過在 react 的話都是先捕獲再冒泡。

1
2
3
<label>
render from react <input type="checkbox" onClick={() => console.log('label bubble trigger')} onClickCapture={() => console.log('label capture trigger')}/>
</label>

腦洞大開換 props 順序也是一樣。

1
2
3
<label>
render from react <input type="checkbox" onClickCapture={() => console.log('label capture trigger')} onClick={() => console.log('label bubble trigger')}/>
</label>

都是先印 capture 再來 bubble。