在DOM事件体系里,focus和blur属于比较特殊的一类事件:它们不会冒泡。这意味着如果你在某个容器元素上监听focus事件,是捕获不到内部输入框获得焦点这一行为的。为了解决这个问题,浏览器后来引入了focusin和focusout事件,它们与focus、blur行为一致,但支持冒泡。然而早期浏览器(尤其是旧版IE之前的Firefox等)并不支持这两个新事件,jQuery为了让所有浏览器表现一致,在内部通过捕获阶段模拟的方式手动实现了focusin和focusout。这种兼容手法非常经典,值得仔细剖析。

一、从事件传播的三个阶段说起
要理解jQuery的模拟思路,首先必须清楚DOM事件传播的完整流程。当一个事件被触发时,它的传播过程分为三个阶段:捕获阶段、目标阶段和冒泡阶段。捕获阶段从window(或document,取决于标准版本)开始,沿着DOM树一路向下直到目标元素的父节点;目标阶段则是在目标元素本身触发;冒泡阶段再从目标元素逐层向上冒回window。
关键点在于:虽然focus和blur事件不冒泡,但它们的捕获过程依然存在。也就是说,当内部的输入框获得焦点时,如果我们在document上以捕获的方式(addEventListener的第三个参数传true)监听focus事件,是完全可以捕获到的。捕获监听器会在事件到达目标元素之前执行,这正是整个模拟方案的基石。
jQuery正是利用了这个特性:既然focus不冒泡导致外层元素收不到通知,那就换个方向,从上往下“拦截”。在document层面以捕获方式监听focus和blur,捕获到之后再手动构造并派发focusin和focusout事件,让这些模拟事件沿着冒泡路径正常传播,外层元素就能像监听普通冒泡事件一样工作了。
二、jQuery源码中的simulate实现
在jQuery的事件模块中,有一个内部函数simulate,它负责把捕获到的原生事件转换成模拟事件并派发出去。核心逻辑并不复杂:先创建一个事件对象,把原生事件的关键属性复制过去,然后挂上一个模拟标记,最后在目标元素上触发。
// jQuery 内部的简化示意
function simulate( type, elem, event ) {
var e = jQuery.extend(
new jQuery.Event(),
event,
{
type: type,
isSimulated: true
}
);
// 在目标元素上触发模拟事件(默认没有原生的preventDefault/stopPropagation)
jQuery.event.trigger( e, null, elem );
}
jQuery.event.special.focusin = {
setup: function() {
// 通过 attach 或捕获监听,在 document 上监听原生 focus
if ( document.attachEvent ) {
// 旧 IE 走 attachEvent 分支,焦点事件本身会冒泡,直接映射即可
} else {
document.addEventListener( "focus", focusinHandler, true );
}
}
};
function focusinHandler( event ) {
// 捕获阶段收到原生 focus 事件后,
// 手动派发一个 focusin 事件,使其沿冒泡路径传播
simulate( "focusin", event.target, event );
}这段代码体现了两个精妙之处。第一,jQuery.Event构造出的模拟事件对象带有isSimulated标记,jQuery内部据此跳过一些只对真实原生事件有效的处理逻辑,避免派发过程中出现递归触发或者默认行为被错误执行的问题。第二,setup是jQuery特殊事件机制的钩子,只有当用户第一次绑定focusin监听时才会执行,也就是说document上的捕获监听器是按需挂载的,不会有无谓的性能开销。
还有一个分支值得注意:旧版IE使用attachEvent绑定事件,而在attachEvent模型里,focus和blur默认就是冒泡的(IE的事件模型与标准不同,几乎所有事件都冒泡)。所以jQuery在IE分支里并不需要做捕获模拟,直接把focus映射成focusin即可。这也解释了为什么源码里会有document.attachEvent的判断——针对不同的事件模型采用不同的兼容策略,而不是一刀切。
三、动手实现一个自己的兼容方案
理解了原理之后,我们完全可以脱离jQuery自己实现一遍。思路归纳为三步:检测浏览器是否原生支持focusin;如果不支持,则在document上以捕获方式监听focus和blur;在捕获回调里创建CustomEvent并派发到事件目标上。
(function() {
// 特性检测:创建一个不插入DOM的元素,看focusin是否在原型链上
var supportsFocusin = "onfocusin" in document.createElement("input");
if ( !supportsFocusin ) {
// 以捕获方式在 document 上监听原生 focus 与 blur
document.addEventListener( "focus", function( event ) {
dispatchSimulated( event.target, "focusin" );
}, true );
document.addEventListener( "blur", function( event ) {
dispatchSimulated( event.target, "focusout" );
}, true );
}
function dispatchSimulated( target, type ) {
var e;
if ( typeof CustomEvent === "function" ) {
// 现代浏览器:bubbles 为 true 保证事件沿冒泡路径传播
e = new CustomEvent( type, { bubbles: true, cancelable: false } );
} else {
// 退路:传统创建方式
e = document.createEvent( "Event" );
e.initEvent( type, true, false );
}
target.dispatchEvent( e );
}
})();这段代码有几个细节需要说明。特性检测没有直接检查onfocusin是否在window上,而是借助一个动态创建的input元素判断,这样能规避某些环境下window属性被意外污染的干扰。派发模拟事件时bubbles必须设为true,否则模拟出来的focusin还是不冒泡,整个方案就失去了意义。
另一个细节是cancelable设为false。focus本身是一个不可取消默认行为语义的事件,模拟出来的focusin如果允许preventDefault,可能会让调用方误以为能阻止焦点移动,造成隐蔽的bug。jQuery的isSimulated标记本质上也是在处理同一类问题——让模拟事件的行为语义与真实事件保持一致。
使用时还有一个常见的坑:在捕获回调里拿到的是原生focus事件,此时事件尚未到达目标阶段,所以event.target才是真正需要派发模拟事件的位置,如果误用了event.currentTarget(此时是document),模拟事件就会从document开始派发,方向完全反了。
四、这套方案的适用边界与现代实践
捕获模拟不仅适用于focus和blur,同样适用于另一对不冒泡的事件:mouseenter和mouseleave。事实上jQuery对这两对事件采用了完全相同的套路,jQuery.event.special对象中的focusin、focusout、mouseenter、mouseleave都属于这种“捕获加模拟”的特殊事件。理解了focusin这一个,其余的也就触类旁通了。
从兼容性角度看,如今各大浏览器早已原生支持focusin和focusout,包括长期“钉子户”Firefox也在较新版本中补齐了支持。所以在现代项目里,这套模拟逻辑基本不会再被触发。但它的价值并没有消失:一方面,维护老代码时经常会遇到依赖这一行为的场景,比如事件委托中监听输入框聚焦做表单校验高亮;另一方面,这种“利用捕获阶段弥补事件不冒泡”的思路,在处理Shadow DOM场景下的焦点穿透、自研组件库的事件设计时依然非常实用。
总结一下核心要点:focus和blur不冒泡是天然限制;捕获阶段总是先于目标阶段执行,document上的捕获监听器必然能感知到内部元素的焦点变化;把捕获到的原生事件转换为可冒泡的模拟事件,就实现了行为统一的focusin和focusout。这个由jQuery发扬光大的技巧,是事件委托体系中不可绕过的一块基石,掌握它对理解整个前端事件体系大有裨益。