Zepto.jsのEventモジュールにおけるイベント処理の仕組みと実装詳細

非バブリングイベントのシミュレーション

ブラウザの標準的なイベントモデルにおいて、focusblurmouseentermouseleaveといったイベントはイベントバブリングをサポートしていません。これにより、親要素でのイベントデリゲーション(イベント委譲)が困難になります。ZeptoのEventモジュールでは、これらの制約を解決するために、バブリングをサポートする別のイベントを用いて挙動をエミュレートしています。

focus/blurの取り扱い

focusおよびblurイベントの代替として、モダンブラウザでサポートされているfocusinおよびfocusoutイベントを使用します。これらはバブリングをサポートするため、イベントデリゲーションが可能になります。ブラウザがこれらをサポートしていない場合のフォールバックも考慮されています。

以下のコードは、これらのイベントが発生する順序を確認するための例です。

<input id="targetInput" type="text" />

<script>
const el = document.getElementById('targetInput')
el.addEventListener('focusin', () => console.log('focusin triggered'))
el.addEventListener('focus', () => console.log('focus triggered'))
el.addEventListener('blur', () => console.log('blur triggered'))
el.addEventListener('focusout', () => console.log('focusout triggered'))
</script>

実行結果(Chrome等):focusfocusinblurfocusout の順序でログが出力されます。Zeptoでは内部的にこの性質を利用して、focusinfocusのバブリング版として扱います。

mouseenter/mouseleaveの取り扱い

mouseentermouseleaveは、要素境界をまたいだ際にのみ発火するべきイベントですが、バブリングしません。これらはmouseoverおよびmouseoutを用いてシミュレートされます。

キーとなるのはMouseEventオブジェクトのrelatedTargetプロパティです。mouseover時のrelatedTargetは「カーソルが来る前にいた要素」を指します。したがって、relatedTargetが現在の要素(this)でもなく、その子要素でもない場合にのみ実際のハンドラを実行することで、mouseenterと同等の挙動を実現します。

// ロジックの概念図
element.addEventListener('mouseover', function(e) {
  const related = e.relatedTarget
  // カーソルが自身または子要素から移動してきた場合は無視
  if (!related || (related !== this && !$.contains(this, related))) {
    // 実際の処理を実行
    originalHandler.apply(this, arguments)
  }
})

内部構造とキャッシュ機構

イベント処理を効率化し、手動トリガーや解除を容易にするために、Zeptoはイベントハンドラを内部のキャッシュプールで管理しています。

要素の一意な識別

どの要素にどのイベントがバインドされているかを管理するため、要素ごとに一意なIDを付与します。

let uidCounter = 1
function getUid(element) {
  return element._zeptoUid || (element._zeptoUid = uidCounter++)
}

イベント名の解析

click.myNamespaceのような形式の名前空間をサポートするため、イベント文字列を解析します。

function parseEvent(eventString) {
  const chunks = ('' + eventString).split('.')
  return {
    type: chunks[0],
    namespaces: chunks.slice(1).sort().join(' ')
  }
}

ハンドラの検索

イベント解除やトリガーの際、キャッシュから条件に合致するハンドラを検索します。

const eventStorage = {}

function findHandlers(element, event, fn, selector) {
  const parsedEvt = parseEvent(event)
  const cacheKey = getUid(element)
  const cached = eventStorage[cacheKey] || []
  
  return cached.filter(function(handler) {
    return handler
      && (!parsedEvt.type || handler.e === parsedEvt.type)
      && (!parsedEvt.namespaces || handler.ns && checkNamespace(handler.ns, parsedEvt.namespaces))
      && (!fn || getUid(handler.fn) === getUid(fn))
      && (!selector || handler.sel === selector)
  })
}

イベントオブジェクトの正規化

ブラウザ間の実装差異を吸収するため、ネイティブのイベントオブジェクトをラップし、Zepto独自のプロパティやメソッドを付与します。

const nativeProps = /^([A-Z]|returnValue$|layer[XY]$)/

function createProxy(event) {
  const key, proxy = { originalEvent: event }
  for (key in event) {
    if (!nativeProps.test(key) && event[key] !== undefined) {
      proxy[key] = event[key]
    }
  }
  return normalizeEvent(proxy, event)
}

function normalizeEvent(proxy, source) {
  // preventDefault等の挙動正規化ロジック
  if (!proxy.isDefaultPrevented) {
    proxy.isDefaultPrevented = function() { return false }
    proxy.preventDefault = function() {
      this.isDefaultPrevented = function() { return true }
      if (source) source.preventDefault()
    }
  }
  // timeStamp等の互換性処理...
  return proxy
}

イベントの登録と解除

内部のregisterListener(元add)およびunregisterListener(元remove)がコアロジックです。

イベントの登録

この関数は、イベントタイプの解析、名前空間の処理、プロキシ関数の作成、そしてaddEventListenerの呼び出しを行います。

function registerListener(element, types, fn, data, selector, delegator, capture) {
  const id = getUid(element)
  const elementCache = (eventStorage[id] || (eventStorage[id] = []))
  
  types.split(/\s/).forEach(function(typeStr) {
    if (typeStr === 'ready') return $(document).ready(fn)
    
    const handler = parseEvent(typeStr)
    handler.fn = fn
    handler.sel = selector
    handler.del = delegator

    // mouseenter/leaveのエミュレーション用ロジック
    let callback = fn
    if (handler.e in { mouseenter: 1, mouseleave: 1 }) {
      callback = function(e) {
        let related = e.relatedTarget
        if (!related || (related !== this && !$.contains(this, related))) {
          return fn.apply(this, arguments)
        }
      }
    }

    // 実際にDOMにバインドされるプロキシ関数
    handler.proxy = function(e) {
      e.data = data
      const result = callback.apply(element, [e].concat(e._args || []))
      if (result === false) e.preventDefault(), e.stopPropagation()
    }

    handler.i = elementCache.length
    elementCache.push(handler)
    
    if (element.addEventListener) {
      element.addEventListener(getNativeType(handler.e), handler.proxy, capture)
    }
  })
}

イベントの解除

キャッシュからハンドラを削除し、DOMからリスナーを取り外します。

function unregisterListener(element, types, fn, selector, capture) {
  const id = getUid(element)
  ;(types || '').split(/\s/).forEach(function(typeStr) {
    findHandlers(element, typeStr, fn, selector).forEach(function(handler) {
      delete eventStorage[id][handler.i]
      if (element.removeEventListener) {
        element.removeEventListener(getNativeType(handler.e), handler.proxy, capture)
      }
    })
  })
}

APIメソッドの実装

公開されているメソッド群は、これらの内部関数をラップし、引数の正規化を行っています。

.on() と .off()

.on()は柔軟な引数を受け付けます。オブジェクト形式での一括指定、セレクタを用いたデリゲーション、ワンタイムイベント(one)の実装などが含まれます。

デリゲーションの場合、イベント発生時にclosestを使用してセレクタにマッチする祖先要素を探索し、見つかった場合にハンドラを実行します。その際、event.currentTargetをマッチした要素に書き換える処理が行われます。

.trigger() と .triggerHandler()

イベントを手動で発火させるメソッドです。

  • .trigger(): DOMのdispatchEventを使用するため、実際のブラウザイベントと同様にバブリングが発生し、デフォルトのブラウザ動作も起こります。
  • .triggerHandler(): バブリングせず、登録されたハンドラ関数を直接実行します。デフォルト動作(フォーム送信など)は発生せず、最初の要素のハンドラのみを実行してその戻り値を返します。
$.fn.triggerHandler = function(event, extraArgs) {
  let result, evt
  this.each(function() {
    evt = createProxy(typeof event === 'string' ? $.Event(event) : event)
    evt._args = extraArgs
    evt.target = this
    
    $.each(findHandlers(this, event.type || event), function(_, handler) {
      result = handler.proxy(evt)
      if (evt.isImmediatePropagationStopped()) return false
    })
  })
  return result
}

このように、ZeptoのEventモジュールは、ブラウザの標準APIの上に、互換性層、キャッシュ機構、そしてデリゲーションを含む高機能なイベントシステムを構築しています。

タグ: zepto.js javascript event-handling dom source-code-analysis

6月23日 23:43 投稿