非バブリングイベントのシミュレーション
ブラウザの標準的なイベントモデルにおいて、focus、blur、mouseenter、mouseleaveといったイベントはイベントバブリングをサポートしていません。これにより、親要素でのイベントデリゲーション(イベント委譲)が困難になります。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等):focus → focusin → blur → focusout の順序でログが出力されます。Zeptoでは内部的にこの性質を利用して、focusinをfocusのバブリング版として扱います。
mouseenter/mouseleaveの取り扱い
mouseenterとmouseleaveは、要素境界をまたいだ際にのみ発火するべきイベントですが、バブリングしません。これらは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の上に、互換性層、キャッシュ機構、そしてデリゲーションを含む高機能なイベントシステムを構築しています。