EventTarget の継承可能化による EventEmitter の代替

Intro

念願 だった EventTarget の constructible/subclassable が DOM の仕様にマージされた。

これにより、いわゆる EventEmitter のブラウザ移植が不要になることが期待される。

Allow constructing and subclassing EventTarget

Update

Chrome Canary 64 で実装が確認できたため、 DEMO を追加した。

EventTarget

EventTarget には addEventListener, removeEventListener, dispatchEvent が定義されている。

これは、ブラウザが内部で生成する Event や、任意に生成された CustomEvent を発火/補足するために利用される。

callback = console.log.bind(console)
$div = document.createElement('div')
$div.addEventListener('foo', callback)
$div.dispatchEvent(new CustomEvent('foo', {detail:'bar'}))
// CustomEvent {type: "foo", detail: 'bar'...
$div.removeEventListener('foo', callback)

この場合、 $divElement < Node < EventTarget と、祖先に EventTarget を持っている。

同様に EventTarget を祖先に持つ要素では、このパターンのハンドリングが可能であるが、任意のクラスを EventTarget にすることができなかった。

EventEmitter

Node では EventEmitter が、メソッド名は違えど同等の役割を果たしていると言える。

例えば process は EventEmitter を継承している。

callback = console.log.bind(console)
process.on('foo', callback)
process.emit('foo', 'bar')
// bar
process.removeListener('foo', callback)

大きな違いは、 EventEmitter が任意のクラスで継承できる点だ。

したがって、非同期処理をクラスに閉じ込め、加工したイベントとして外に公開するといった設計が可能になる。

以下は setInterval を抽象化したタイマの例だ。

EventEmitter = require('events')
class Timer extends EventEmitter {
  constructor(interval) {
    super()
    setInterval(() => {
      this.emit('tick', 'tick')
    }, interval)
  }
}

timer = new Timer(100)
timer.on('tick', console.log.bind(console))

またこうしたイベントの抽象化の先に stream がある。

EventEmitter porting

これまでは、ブラウザ上で任意の class を EventTarget にすることができなかった。

そこで、 Node における EventEmitter を用いた設計と同等のことを行うためには、 EventEmitter のポーティングなどが利用されていた。

例えば browserify は https://github.com/Gozala/events を使っており、筆者も 同じようなこと をしたことがある。

しかし、こうした汎用的な処理をより効率よく実現するために、 EventTarget が継承可能となる仕様が追加された。

実装されれば、メソッド名をすり合わせる目的以外で EventEmitter porting は不要となるだろう。

constructible/subclassable EventTarget

具体的には以下のようなコードが書けるようになる。

class Timer extends EventTarget {
  constructor(interval) {
    super()
    setInterval(() => {
      this.dispatchEvent(new CustomEvent('tick'))
    }, interval)
  }
}

timer = new Timer(100)
timer.addEventListener('tick', console.log.bind(console))

EventTarget を元に EventEmitter とメソッド名をすり合わせた shim を書く場合は以下のような感じだろうか。

class EventEmitter extends EventTarget {
  constructor() {
    // snip
  }

  on(type, listener) {
    this.addEventListener(type, listener)
  }


  emit(type, val) {
    this.dispatchEvent(new CustomEvent(type, {detail: val}));
  }

  // ... and more
}

もしくは、多くの要素が EventTarget を継承していることを利用して、以下のようなこともできる。

EventTarget.prototype.on  = EventTarget.prototype.addEventListener
EventTarget.prototype.off = EventTarget.prototype.removeEventListener

EventTarget.prototype.emit = function(name, detail) {
  this.dispatchEvent(new CustomEvent(name, {detail}))
}

これで、例えば Button 要素にも on などが生える。

document.querySelector('button').on('click', (e) => {
  console.log('click')
})

ただし、 EventEmitter は EventTarget よりも機能が多く、例えば listeners()eventNames() などは、 EventTarget への移譲だけでは実装できない。

それらが必要な場合は、別途イベントとリスナの管理が必要になるだろう。こうした機能が必要な場合は、要するに EventEmitter そのものを必要としてるということなので、 porting は依然必要になる。

しかし、 EventTarget 相当を実現するためだけに EventEmitter を導入していた場合は、 EventTarget が継承できるだけで十分な場合も少なくはないだろう。

その場合はネイティブの実装だけで足りるようになるため、実装が進むことに期待したい。

DEMO

動作する DEMO を以下に用意した。

EventTarget DEMO