JavaScript

JavaScriptのイベントをたくさん見られるサイトを作るついでに、イベントのハマりどころをまとめました

好きなイベントはなんですか?

JavaScriptのイベント、数が多いうえに似たようなものもあったりして、案外ややこしいですよね。特にイベントの実行タイミングや、渡されるオブジェクトを忘れることが多かったので、それらをコンソールでまとめて確認できるサイトを作りました。

忘れたときに調べればいいだけの話なので、わざわざサイトを作る必要はなかったのですが、イベントを眺めるのが好きな人に見ていただけたら嬉しいです。

JavaScriptのイベントをたくさん見られるサイト

ひたすらaddEventListenerしていたら、イベントに愛着が湧いたので、イベントの実装でハマりそうなところをまとめています。

なぜイベントを使うのか

マウスなどはイベントじゃないと値が取得できませんが、頑張ればイベントを使わずとも実装できるものもあります。たとえば、テキストボックスの変更を知りたいだけであれば、値を頻繁に確認し続ければ実装できなくはありません。しかしこれは明らかに資源の無駄遣いです。この例は極端ですが、イベントを使えば、値が変更したときに知らせてくれるので、そこらへんの無駄がなく実装することができます。

const input = document.getElementById('input')

// 1秒に10回ぐらいテキストの値を確認し続ける
setInterval(() => {
  console.log(input.value)
}, 100)

// テキストが変更されたら実行される。嬉しい。
input.addEventListener('input', e => {
 console.log(input.value)
})

イベントのややこしいところ

イベントオブジェクト(e)

イベントの実行時によく e などの変数名で渡されるイベントオブジェクトというものがあります。このオブジェクトにどんなプロパティがあるかを忘れてしまうことが多いので、その都度調べたりConsoleで確認したりしますが、基本的にはEventというインターフェースをベースにして、MouseEventKeyboardEventなどの派生したものがあるだけなので、思いのほかシンプルです。

よく使うイベントのインターフェースを表にまとめました。

インターフェース 継承 プロパティ/メソッドの例
Event - target, preventDefault
MouseEvent Event / UIEvent clientX, clientY
KeyboardEvent Event / UIEvent key, shiftKey
AnimationEvent Event animationName, elapsedTime

targetとcurrentTarget

イベントオブジェクトの中には、targetcurrentTargetという似たようなプロパティがあります。

イベントが発生した要素を特定する event.target とは対照的に、常にイベントハンドラがアタッチされた要素を参照します。

Event.currentTarget | MDN

イベントが発生した要素が targetで、イベントを設定した要素が currentTarget になっています。多くの場合、これらは同じになりますが、子要素でイベントが発生した場合などに異なるときがあります。下に貼り付けているCodepenで子要素をクリックした場合、子要素( target )で発生したイベントがバブリングして、イベントを設定した要素( currentTarget )へ到達しているため、 targetcurrentTarget が異なります。

この挙動は、イベントのキャプチャリングとバブリングを頭に入れて使わないとハマるかもしれません。バブリングとキャプチャリングについては、上手い絵を描く自信がなかったので、わかりやすかったMDNのページでご確認を。

イベントのバブリングとキャプチャリング | MDN

リスナーの中のthis

JavaScriptとthisについてはもう語り尽くされているとは思いますが、イベントリスナーにおいてもハマるポイントになります。リスナーにふつうの関数(アローじゃない)を設定した場合、 this に 要素への参照(currentTarget)がバインドされています。これを意図して使う場合にはありがたいのですが、リスナーの外の this を使いたい場合は、 thatself などの変数に this を逃がす方法が使われていました。

モダンなブラウザでは、アロー関数という、 this を束縛しない関数を使えるので、 苦しい実装からは解放されます。要素への参照が欲しい場合は、 e.currentTarget で取得できるので、思い通りにイベントを扱うことができて嬉しいですね。

アロー関数はIEをはじめとした古いブラウザには対応していないため、babelでトランスパイルする必要があります。

const obj = {
  hoge: function() {
    return 'hoge'
  },
  listen: function() {
    var self = this

    // ふつうの関数
    document.getElementById('hoge').addEventListener('click', function(e) {
      console.log(this)        // e.currentTarget
      console.log(this.hoge()) // this.hoge is not a function

      // 今は昔
      console.log(self)        // obj
      console.log(self.hoge()) // hoge
    })

    // アロー関数
    document.getElementById('hoge').addEventListener('click', e => {
      console.log(this)        // obj
      console.log(this.hoge()) // hoge

      console.log(e.currentTarget) // イベントを設定した要素への参照
    })
  }
}

obj.listen()

無名関数とremoveEventListener

removeすることを考えずに適当にaddしているとハマります。

// addするぞ!
window.addEventListener('scroll', () => {
  console.log(window.scrollY)
})

// 不要になったから削除したい・・・気合だ! => 無理
window.removeEventListener('scroll', () => {
  console.log(window.scrollY)
})

(誰もこんなことしないと思いますが)anonymous なものを remove しようなんてのは、どだい無理な話です。苦し紛れに同じ内容の無名関数で remove しようと試みても、別の関数なので思い通りには削除できません。

素直に名前をつけましょう。

const handleScroll = () => {
  console.log(window.scrollY)
}

window.addEventListener('scroll', handleScroll)
window.removeEventListener('scroll', handleScroll)

もしかしたら、リスナーに引数を渡したくて、イベントオブジェクトも必要なときがあるかもしれません。そんなときは、イベントオブジェクトを引数に持つ関数を作ってあげることで解決できます。(もっといい方法があるかもしれませんが)

const handleScroll = param => e => {
  console.log(param)
  console.log(e)
}

const callback = handleScroll('hoge')

window.addEventListener('scroll', callback)
window.removeEventListener('scroll', callback)

頻度が高いイベントへの対応

イベントには click のように扱いやすいものがある一方で、 scrollmousemove などのように大量に実行されてしまうものもあります。そのようなイベントをそのまま実装してしまうと不要な呼び出しが多く、動作が重たくなる原因になってしまうので、適度に間引いて処理をしたほうがブラウザに優しいです。

最近のブラウザでは、指定した関数を描画の前に呼び出してくれるrequestAnimationFrameという便利なメソッドがあるので、それを使用して最適化する例をいくつか載せています。

スクロール

let isRunning = false

window.addEventListener('scroll', () => {
  // 呼び出されるまで何もしない
  if (!isRunning) {
    isRunning = true

    // 描画する前のタイミングで呼び出してもらう
    window.requestAnimationFrame(() => {

      // ここでなにかする
      console.log(window.scrollY)

      isRunning = false
    })
  }
})

ドラッグを例にしたマウスムーブ

let isDragging = false
let startPosition = null
let lastPosition = null
let requestId = null

// ドラッグしたい要素
const circle = document.getElementById('circle')

const translate = (x, y) => {
  circle.style.transform = `translate3d(${x}px, ${y}px, 0)`
}

// この関数で描画する
const animate = () => {
  const x = lastPosition.x - startPosition.x
  const y = lastPosition.y - startPosition.y

  translate(x, y)

  requestId = window.requestAnimationFrame(animate)
}

// 最後にリセット
const leave = () => {
  isDragging = false
  translate(0, 0)

  // もう呼んでほしくないのでキャンセル
  if (requestId) {
    window.cancelAnimationFrame(requestId)
  }
}

// マウスが押されたらアニメーション開始
circle.addEventListener('mousedown', e => {
  isDragging = true
  startPosition = { x: e.clientX, y: e.clientY }
  lastPosition = { x: e.clientX, y: e.clientY }

  animate()
})

// 動いてるときは座標を更新
circle.addEventListener('mousemove', e => {
  if (isDragging) {
    lastPosition = { x: e.clientX, y: e.clientY }
  }
})

// 離れたらもとに戻す
circle.addEventListener('mouseup', e => {
  leave()
})

circle.addEventListener('mouseleave', e => {
  leave()
})

今では少ないと思いますが、requestAnimationFrameに対応していない、IE9以前/Android4.3以前などのブラウザにも対応する必要がある場合は、非対応のときにsetTimeoutに置き換えてくれるPolyfillのようなもの使うか、別の実装を検討する必要があります。

requestAnimationFrame | Can I use

ここでは requestAnimationFrame を使っていますが、それ以外の実装が適している場合もあります。resize の場合は、 連続している場合は実行させない debounce の方が適切なこともあると思うので、イベントや状況に応じて適切な間引き処理を行わなければなりません。 debouncethrottle についてわかりやすいページがあったのでリンクを張らせてもらいます。

JavaScriptでの多発するイベントの間引き処理 | つみきブログ

似たようなイベントの違い

mouseenterとmouseover

名前を見ただけでは、いまいちよくわかりませんが、違いは子要素にも反応するかどうかです。 mouseenter はイベントを設定した要素のみに反応し、 mouseover はその子要素にも反応します。mouseenter に対する mouseleave と、 mouseover に対する mouseout も同様の挙動です。

mouseenter
mouseover

keydownとkeypress

keydown は、キーが押されたとき反応するのでわかりやすいですが、 keypress はShiftやAltなどの修飾キーや、IMEの入力には反応せず、値を変化させるキー入力のみに反応します。

keydown
keypress

inputとchange

テキストボックスの場合、 input は値が変わるごと反応しますが、 change はフォーカスが外れて値の変更が確定したときに反応します。実際の挙動は、今回作ったサイトでコンソールを開いてご確認ください。

フォーム | JavaScriptのイベントをたくさん見られるサイト

サイトを作るときに諦めたところ

drop系のイベント

面倒だったのでスルーしました。そのうち追加するかもしれません。
mouseやtouchで代用できるので使ったことがないのですが、実際にどのくらい使われているのだろうか。

開発ツールが別ウィンドウで開かれたときに検出できない

大切なことはすべてコンソールに出力することにしたので、開発ツールが開かれていないときにはメッセージを出すようにしました。開発ツールが開いているかの判定には、devtools-detectというライブラリを使わせてもらっています。コードを確認すると、window.outerWidthwindow.innerWidthの違いなどをもとに判定しているようなのですが、開発ツールを別ウィンドウで開かれてしまった場合には、その判定では対応できません。

https://github.com/sindresorhus/devtools-detect/blob/gh-pages/index.js

いろいろと探したり考えたりしたのですが、思いつかなかったのでそのままにしました。

テスト

要素のイベントをひたすらリッスンするサイトなのでテストがつらい、というか労力に見合わない気がしたのでブラウザ上でエラーが出ていないのを確認するだけで済ませました。

できあがり

JavaScriptのイベントをたくさん見られるサイト

ソースコード
https://github.com/noplan1989/javascript-listener

ひたすらイベントを追加する作業だったので単調でつらかったのですが、とくに好きでも嫌いでもなかったイベントに対して謎の愛着が湧いたので、わざわざ作った甲斐がありました。あと、Nuxt.jsが使いやすかったおかげで、想定していたよりも早くできたのが嬉しかったです。