はじめに
こんにちは!木村です!
普段ReactとTypeScriptを使用して開発しているのですが、非同期処理を扱っているイベントハンドラでつまづいた部分があったので、掘り下げて調べてみたことをまとめました。
よろしくお願いします。
内容
内容としては以下のとおりです。
- つまづいた箇所の解決策
- event.targetとevent.currentTargetの違いについて
- currentTargetがリセットされるタイミングはいつか
問題
1. ボタンコンポーネントを押すと非同期処理でデータを取得する
2. setElementにe.currentTargetを渡して、取得したデータをモーダルに表示する
3. モーダルにはMUIのpopover(出現させる要素の位置を引数に渡す必要があるコンポーネント)を使用
const { useState } = React; const { Popover } = MaterialUI; const sleep = (ms) => { return new Promise((resolve) => setTimeout(resolve, ms)); }; const AsyncComponent = () => { const [anchor, setAnchor] = useState(null); const handleClick = async (event) => { // データを取得する非同期処理を擬似的にsleepで await sleep(2000); console.log(event.currentTarget) setAnchor(event.currentTarget); }; return ( <div> <button onClick={handleClick} className="button" > クリック! </button> <Popover anchorOrigin={{ vertical: "bottom", horizontal: "left", }} transformOrigin={{ vertical: "top", horizontal: "left", }} open={Boolean(anchor)} anchorEl={anchor} onClose={() => setAnchor(null)} > <div style={{ padding: "10px", backgroundColor: "#fff", border: "1px solid #ccc", borderRadius: "4px", }} > データを表示 </div> </Popover> </div> ); }; ReactDOM.render(<AsyncComponent />, document.getElementById('root'));
(実装の仕方の是非は大目にみていただけると助かります💦)
このような実装を行う時に、非同期でデータを取得後、setAnchorにe.currentTargetを渡そうとするとnullになってしまってモーダルが表示されない、という問題に当たってしまいました。
解決策
調べたところ、e.currentTargetが非同期後にクリアされるのは仕様らしく、対応としては「ローカル変数に保存すること」というのが調べた記事のほとんどの結論でした。
e.currentTargetを変数に保存して実行すると確かに動きました。
See the Pen
event.currentTarget 1 by Kimura (@Kinaka)
on CodePen.
一旦タスクは完了となったのでいいのですが、解決策を探す中で気になったことを以降で掘り下げて調べました。
event.targetとevent.currentTargetの違い
自分で簡単に調べただけでもたくさんの記事があるのできっとみんな混乱する部分なのだと安心しました笑
恥ずかしながら今回の問題を解決するにあたって初めてevent.targetの存在と、その違いについて意識しました。
そもそもe(eventObject)とは?
ユーザーイベント(クリックやホバー等)時に、イベントリスナが受け取ることができるオブジェクトです。
イベントバブリングの状態や押下されたボタンの種類、イベントの種類から、イベントの発生座標などをイベントオブジェクトから取得することができます。
主なイベントプロパティ
プロパティ | 内容 |
---|---|
Event.bubbles | 論理値 イベントがDOMを通してバブリングするかどうか |
Event.cancelable | 論理値 イベントがキャンセル可能か |
Event.currentTarget | イベントが現在登録されているターゲットへの参照 |
Event.target | イベントが最初に出されたターゲットへの参照 |
Event.isTrusted | イベントがブラウザーによって(ex.ユーザのクリック後に)開始されたものか、スクリプトによって(ex.イベント作成メソッドを使用して)開始されたものか |
Event.type | イベントの種類を識別する |
バブリングってなんだっけ
簡潔にいえば、クリックした要素の親要素にイベントが伝播する仕組みです。
たとえば、ユーザーによるクリックが行われた場合、
- 上位のwindowオブジェクトから下位の要素にイベントが伝播(キャプチャフェーズ)
- イベントの発生元の要素を特定(ターゲットフェーズ)
- イベントの発生元からルート要素に向かってイベントが伝播(バブリングフェース)
- 最上位のwindowオブジェクトまでたどり着いたところでイベントの伝播が終了
この流れの3番目、バブリングフェーズにおいて、発火したイベントに対応するイベントリスナーが親要素にも設定されていれば、その処理が実行されます。
※キャプチャフェーズでも同様に対応するイベントリスナーが存在すれば順に実行されますが、一旦ここではバブリングフェーズのみに触れます。
以下のような要素を作成して、それぞれにイベントリスナーを付与して試してみます。
<div class='div1'> <div class='div2'> <button class='button'>クリック!</button> </div> </div>
const div1 = document.querySelector('.div1') const div2 = document.querySelector('.div2') const button = document.querySelector('.button') div1.addEventListener('click',()=>{ console.log('div1 clicked') }) div2.addEventListener('click',()=>{ console.log('div2 clicked') }) button.addEventListener('click',()=>{ console.log('button clicked') }) window.document.addEventListener('click',()=>{ console.log('window clicked') })
ボタンをクリックした時、コンソールで以下のように出力されていることがわかります。
子要素から親要素の順番でイベントが発火していることがわかります。
Event.targetとEvent.currentTargetの違い
targetは最初にイベントが発生した要素を参照して、currentTargetはイベントが登録されているターゲットを取得します。
先ほどと同じ要素を使用して試してみます。
See the Pen
bubbling by Kimura (@Kinaka)
on CodePen.
const div1 = document.querySelector('.div1') const div2 = document.querySelector('.div2') const button = document.querySelector('.button') div1.addEventListener('click',(e)=>{ console.log('div1 clicked') console.log('div1 target', e.target) console.log('div1 currentTarget', e.currentTarget) }) div2.addEventListener('click',(e)=>{ console.log('div2 clicked') console.log('div2 target', e.target) console.log('div2 currentTarget', e.currentTarget) }) button.addEventListener('click',(e)=>{ console.log('button clicked') console.log('button target', e.target) console.log('button currentTarget', e.currentTarget) }) window.document.addEventListener('click',(e)=>{ console.log('window clicked') console.log('window target', e.target) console.log('window currentTarget', e.currentTarget) })
`e.target` の出力は、常に一番下のbutton要素を参照しているのに対して、`e.currentTarget` の出力は、そのイベントリスナーが付与されている要素を参照しています。
つまり、(バブリンフェーズでの)イベントの伝播の過程でtargetは不変、currentTargetは可変のものであると解釈できそうです。
currentTargetがリセットされるタイミング
問題に対する解決策で、「仕様です」との説明はありましたが、その仕様が具体的にどこに書いてるのかが気になって調べてみました。
パッと調べて一番それっぽい記述があるのは以下の部分でした。
なお、`currentTarget` の値はイベントハンドラー内でのみ利用できます。イベントハンドラー外では `null` となります。つまり、例えばイベントハンドラー内で `Event` オブジェクトの参照を取得し、その後イベントハンドラー外でその `currentTarget` プロパティにアクセスすると、その値は `null` となります。
developer.mozilla.orgEvent: currentTarget プロパティ - Web API | MDN
また、仕様書の方を確認してみました。
バブリングフェーズの終了後に、currentTargetがnullに設定されるようになっています。
Set event’s `currentTarget` attribute to null.
dom.spec.whatwg.org
確かにイベントハンドラの処理が終了した時に`currentTarget` は`null` になるみたいです。
でも非同期とはいえイベントハンドラー内にいるはず…!じゃあなぜ?にという疑問にぶつかってしまいました。
非同期がどう動くか
必要なのは非同期についての理解かも、ということで、以下の動きについて考えてみます。
function asyncFunction(){ console.log('1') const myPromise = new Promise((resolve, reject) => { setTimeout(() => { const result = 1 + 3; console.log(`add: ${result}`) console.log('2') }, 100); }); console.log('3') } asyncFunction()
この処理で、コンソールに出力される順番は以下の通りです。
このように、同期処理が優先して実行されていることがわかります。では、非同期処理はどこで実行されているのでしょうか。
JSはシングルスレッドなので、同スレッド上で非同期処理のような時間がかかる処理を行うと、その分後のタスクが行えなくなってしまうはずで、じゃあこの動きは矛盾してない?と思いましたが、そうならないための工夫がされていました。
Promiseやfetch()といった非同期処理は、外部API(実行環境が提供するAPI)を利用して実行されているようです。あくまでJS側で直列的に処理する一方で、その「直列の処理」を邪魔しないように、利用して時間のかかる処理を実行環境が提供するAPI側でやってもらう形です。そうしてすすめた「時間のかかる処理」の実行結果を、同期処理にどう合流させるのかという仕組みを理解する必要がありました。
非同期処理の部分をちゃんと詳しく理解するには時間が足りなかったので、次回の担当が回ってきた私に託しますが、とにかく必要なのは非同期部分の処理がどのように扱われているか、という部分の理解です。「イベントループ」というのがその仕組みですが、初めて触れたので概略をまとめます。
イベントループ
JavaScriptで処理の実行順を管理している仕組みのことです。
コールスタック
実行するタスクを積み上げる場所です。
関数が終了するとスタックから自動的に除かれます。
タスクキュー
コールスタックが空になった時点で呼び出される非同期タスクが入ります。
- タスクキュー
- setTimeOutなどのタスク
- マイクロタスクキュー
- Promise.thenやawait以降の処理などのタスク
- タスクキューよりも優先して実行
流れとしては
1. コールスタックに同期実行する処理が積み上げられる
2. コールスタックが空になるまで実行
3. コールスタックが空になったタイミングでイベントループがキューからタスク(非同期処理部分)を取り出してコールスタックへ積み上げられる
4. キューが空になるまで実行
という流れで実行されます。
郵便番号から住所を取得するAPIを用いた関数を実装して、イベントループの動きを見てみます。
Loupe(イベントループ可視化ツール)JS Visualizer 9000
- 実行コード
function asyncFunction(){ console.log('test1'); const json = fetchZipCode(1040033) .then(resultJson => { console.log(resultJson); return resultJson; }); console.log('test2'); return json; } function fetchZipCode(zipCode){ return fetch(`https://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipCode}`, { "headers": { "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "accept-language": "ja,en-US;q=0.9,en;q=0.8", "cache-control": "max-age=0", "priority": "u=0, i", "upgrade-insecure-requests": "1" }, "referrerPolicy": "strict-origin-when-cross-origin", "body": null, "method": "GET", "mode": "cors", "credentials": "include" }) .then(response => response.json()); } asyncFunction().then((json=>{ console.log('end'); }));
動きの順番を文字に起こすとこうですね
- 呼び出された`asyncFunction`がコールスタックに積まれる
- 実行。
- `console.log(’test1’)` が表示される
- 呼び出された`fetchZipCode()` がコールスタックに積まれる
- (同期処理がないので)コールスタックに積まれた`fetchZipCode()`関数が完了になる
- `console.log('test2')` が表示される
- `asyncFunction()` が完了になる
- コールスタックが空になったのでタスクキューに積まれているものが実行される
- `fetchZipCode()` の`.then()` が実行される
- `console.log('test3')` が表示される
- `asyncFunction()` の`.then()` が実行される
- 呼び出し元の`.then()` が実行される
- 終了
イベントハンドラの同期処理部分が終わった時点でイベントハンドラ関数のコールスタックは完了となっています。イベントハンドラ内に記述した非同期処理がバックグラウンドで実行された後、その後に行いたい処理(.thenやawait 以降の処理)がキューに積まれます。同期処理のコールバックが完了してからイベントループによって呼び出され、実行されることになります。
つまり、イベントハンドラの同期処理が終了した時点で、イベントのディスパッチ処理が終了したと見なされるようです。
ドキュメントに書いてあった「`currentTarget` の値はイベントハンドラー内でのみ利用できます。」というのは、「イベントハンドラのコールバックが実行されている間に利用できる」という意味で解釈していいのかな、という結論になりました。
さいごに
ちょっとした疑問のはずだったのですが、いざ調べてみるととても時間がかかってしまいました。なんなら最後はちょっと確信が持てずに終わってしまいましたので、さらに深掘流ことができなかった部分はまたの機会に調べようと思います。
現在弊社ではエンジニアを募集しています!
この記事を読んで少しでも興味を持ってくださった方は、ぜひカジュアル面談でお話ししましょう!
iimon採用サイト / Wantedly / Green
引用・参考
- 改定3版JavaScript本格入門
- https://developer.mozilla.org/ja/docs/Web/API/Event
- https://tech.iimon.co.jp/entry/2023/06/23/190000
- https://stackoverflow.com/questions/66085763/why-currenttarget-value-is-null
- https://github.com/testing-library/dom-testing-library/issues/1039
- https://html.spec.whatwg.org/
- https://qiita.com/manzoku_bukuro/items/35fb44d41fad6087fc04
- https://zenn.dev/estra/books/js-async-promise-chain-event-loop/viewer/part-01-epasync
- https://zenn.dev/canalun/articles/js_async_and_company
- https://zenn.dev/convers39/articles/758f21ea18a860
- https://qiita.com/iwata-goq/items/2d03e6139632d89d32e7