标准模型(W3C Model)将事件流分为三个阶段:
-
捕获阶段 (Capturing Phase):事件从 Window 对象开始,向下穿过 DOM 树,直到到达目标元素的父级。
方向: 自顶向下 (Outer -> Inner)。
在捕获事件阶段,如果 addEventListener 的第三个参数 capture 是 true,第二个参数回调函数会被调用。不需要管 event 本身的 bubbles。
-
目标阶段 (Target Phase):事件确实到达了触发事件的元素(即
event.target)。 -
冒泡阶段 (Bubbling Phase):事件从目标元素开始,向上回传,直到 Window 对象。
方向: 自底向上 (Inner -> Outer)。
注意: 现代开发中,我们绝大多数时候是在冒泡阶段处理事件,因为这符合直觉(点击了按钮,也意味着点击了按钮所在的容器)。
在冒泡阶段,addEventListener 的回调函数被调用需要两个条件:event 本身的 bubbles 为 true,addEventListener 的第三个参数 capture 是 false。
主线程是“消费者”。生产者是一条跨越了硬件、操作系统和多个进程的流水线。
步骤 A: 硬件中断 (Hardware Interrupt)
- 物理层: 你的鼠标传感器检测到位移。
- OS 层: 鼠标驱动触发 CPU 中断,OS 更新光标位置,并把这个消息放入 OS 的消息队列 (Message Loop)。
步骤 B: 浏览器进程 (Browser Process)
- 捕获: Chrome 的 Browser Process (主控进程,负责窗口管理) 从 OS 队列里拿到了这个消息。
- 命中测试 (Hit Test) - 初步: Browser Process 计算出当前光标位于哪个 Tab (WebContents) 上。
- IPC 发送: Browser Process 通过 IPC (Inter-Process Communication) 通道,把数据包发给对应的 Renderer Process (渲染进程,也就是你的网页所在的进程)。
步骤 C: 渲染进程 IO 线程 (Renderer Process IO Thread)
- 接收: 渲染进程里有一个专门负责通信的 IO 线程 (不是主线程) 收到 IPC 消息。
- 转发: IO 线程把这个消息封装成一个 Task,此时它才真正被扔进了 主线程的宏任务队列 (Macrotask Queue)。
注意:此时主线程可能正在忙着算 JS,根本不知道有个鼠标事件来了,这个 Task 只是静静地排在队列末尾。
主线程在宏任务阶段的时候,会取出一个宏任务进行执行。如果这个宏任务是和事件有关的,那么主线程会执行以下操作:
// 这是主线程从宏队列里取出的那个 Task 的内部逻辑
void ProcessInputEventTask(Event* event) {
// 1. 找到目标节点 (Hit Testing)
Node* target = FindTargetNode(event->x, event->y);
// 2. 构建传播路径 (Capture -> Target -> Bubble)
vector<Node*> path = BuildPropagationPath(target);
// 3. 遍历路径 (同步循环!)
for (Node* node : path) {
// 查找该节点上绑定的所有监听器
auto listeners = node->getListeners(event->type);
// 4. 同步执行用户回调
for (auto listener : listeners) {
// 这里才是调用你的 test 函数的地方!
// 它是直接同步调用的,不是异步的!
listener->callback(event);
}
}
}- 首先,event 的 type 已经确定了
- 然后,根据 event 的坐标找到 target。event.target 也确定下来了
- 然后,根据 target 构建传播路径
- 对传播路径上的每个节点,查看该节点通过
obj.addEventListener(type, ...)绑定的所有和 type 有关的监听器。如果存在这样的监听器,执行对应的回调函数。
注意,这一整个处理先捕获后冒泡的过程 ProcessInputEventTask,是作为一个宏任务被执行的。如果你在 test 函数里写了个死循环,主线程就会卡在上面第 4 步的循环里,整个 ProcessInputEventTask 任务永远结束不了,后面的 Layout 和 Paint 也永远没机会执行。
更加具体的版本:
void ProcessInputEventTask(Event* event) {
// 1. Hit Testing: 命中测试 (利用渲染树 RenderTree)
// 根据坐标 (x,y) 找到最顶层、最具体的那个元素
Node* target = HitTest(event->x, event->y);
event->target = target;
// 2. Build Path: 构建传播路径
// 路径通常是一个数组: [target, parent, ..., body, html, document, window]
// 注意:路径在分发开始前就已经确定了!如果在回调里把父节点移除了,路径通常不会变。
vector<Node*> propagationPath = BuildPropagationPath(target);
// 3. Dispatch: 分发过程 (三个阶段)
// --- Phase 1: 捕获阶段 (Capture Phase) ---
event->eventPhase = CAPTURING_PHASE;
// 从 Window 往下遍历到 Parent (反向遍历路径)
for (int i = propagationPath.size() - 1; i > 0; i--) {
if (event->propagationStopped) break; // 🛑 检查 stopPropagation
Node* node = propagationPath[i];
// 🔥 重点:只执行注册为 {capture: true} 的监听器
ExecuteListeners(node, event, true);
}
// --- Phase 2: 目标阶段 (Target Phase) ---
event->eventPhase = AT_TARGET;
if (!event->propagationStopped) {
// 在目标元素上,捕获和冒泡的监听器都会被触发(按注册顺序)
ExecuteListeners(target, event, false);
}
// --- Phase 3: 冒泡阶段 (Bubbling Phase) ---
if (event->bubbles) { // 只有支持冒泡的事件才走这一步
event->eventPhase = BUBBLING_PHASE;
// 从 Parent 往上遍历到 Window (正向遍历路径)
for (int i = 1; i < propagationPath.size(); i++) {
if (event->propagationStopped) break; // 🛑 检查 stopPropagation
Node* node = propagationPath[i];
// 🔥 重点:只执行没有设置 capture 的监听器
ExecuteListeners(node, event, false);
}
}
}
// 辅助函数:执行监听器
void ExecuteListeners(Node* node, Event* event, bool isCapturePhase) {
auto listeners = node->getListeners(event->type);
for (auto listener : listeners) {
// 🛑 检查 stopImmediatePropagation
if (event->immediatePropagationStopped) return;
// 匹配阶段:
// 如果是捕获阶段,只执行 capture=true 的 listener
// 如果是冒泡阶段,只执行 capture=false 的 listener
if (listener->useCapture == isCapturePhase || event->eventPhase == AT_TARGET) {
// 👉 同步直接调用!这里会阻塞主线程!
listener->callback(event);
}
}
}Event -> UIEvent -+-> FocusEvent
|-> MouseEvent ------+--> WheelEvent
|-> KeyboardEvent +--> PointerEvent
|-> InputEvent
+-> CompositionEvent
-
DOM Events
-
UI Events
https://www.w3.org/TR/uievents/
不要因为它是 Working Draft 就觉得它不稳定。UI Events 的 Working Draft 是目前处理键盘和鼠标事件的唯一权威参考。
-
Pointer Events
https://www.w3.org/TR/pointerevents2/
已经成为标准。
https://www.w3.org/TR/pointerevents4/
working draft
-
Pointer Lock
https://www.w3.org/TR/pointerlock-2/
Working Draft
-
CSSOM View Module
https://www.w3.org/TR/cssom-view-1/
Working Draft
-
Touch Events
已经过时,是 Apple 最先搞出来的东西,虽然能用,但是建议还是用 pointer events。
https://www.w3.org/TR/touch-events/
已经成为标准。
-
MDN
https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Events
load :Fired when the whole page has loaded, including all dependent resources such as stylesheets and images.
unload :Fired when the document or a child resource are being unloaded.
abort :Fired when loading a resource has been aborted (for example, because the user canceled it).
error :Fired when a resource fails to load or can't be processed (for example, if an image is invalid or a script has an error).
select :Fired when a user selects some text. This event is dispatched after the selection has occurred.
blur :Fired when an element has lost focus.
focus :Fired when an element has received focus.
focusin :Fired when an element is just about to receive focus.
focusout :Fired when an element is just about to lose focus.
auxclick (PointerEvent) :Fired when the user clicks the non-primary pointer button.
click (PointerEvent) :Fired when the user clicks the primary pointer button.
contextmenu (PointerEvent) :Fired just before a context menu is invoked.
dblclick :Fired when the user double-clicks the primary pointer button.
mousedown :Fired when the user presses a button on a mouse or other pointing device, while the pointer is over the element.
mouseup :Fired when the user releases a button on a mouse or other pointing device, while the pointer is over the element.
-
冒泡进入离开
mouseover(冒泡进入) :Fired when a mouse or other pointing device is moved onto the boundaries of an element.
从 parent 进入 child,会触发,target 为 child。
从 child 进入 parent,会触发,target 为 parent。
mouseout(冒泡离开) :Fired when a mouse or other pointing device is moved outside the boundary of the element.
从 parent 进入 child,会触发,target 为 parent。
从 child 进入 parent,会触发,target 为 child。
-
不冒泡进入离开
mouseenter(不冒泡进入) :Fired when a mouse or other pointing device is moved onto the boundaries of an element or one of its descendent elements.
从 parent 进入 child,会触发,target 为 child。
从 child 进入 parent,不会触发。
mouseleave(不冒泡离开) :Fired when a mouse or other pointing device is moved outside the boundary of the element and all of its descendants.
从 parent 进入 child,不会触发。
从 child 进入 parent,会触发,target 为 child。
-
移动
mousemove :Fired when a mouse or other pointing device is moved while over an element.
wheel :Fired when the user rotates a mouse wheel or similar user interface component such as a touchpad.
beforeinput :Fired just before the DOM is about to be updated with some user input.
input :Fired just after the DOM has been updated with some user input (for example, some text input).
keydown :Fired when the user has pressed a key.
keypress Deprecated :Fired when the user has pressed a key, only if the key produces a character value. Use keydown instead.
keyup :Fired when the user has released a key.
compositionend :Fired when a text composition system (such as a speech-to-text processor) has finished its session; for example, because the user has closed it.
compositionstart :Fired when the user has started a new session with a text composition system.
compositionupdate :Fired when a text composition system updates its text with a new character, reflected in an update to the data property of the CompositionEvent.
- The
pointerdownevent - The
pointerupevent - The
pointerenterevent - The
pointerleaveevent - The
pointeroutevent - The
pointermoveevent - The
pointeroverevent - The
pointercancelevent - The
gotpointercaptureevent - The
lostpointercaptureevent
- resize(原型为 Events)
- scroll(原型为 Events)
- scrollend(原型为 Events)
你可能会想:“能不能不监听事件,直接在一个死循环里读取鼠标坐标?”
答案是:不行。
在浏览器 JS 环境中,不存在 一个全局变量叫 window.mouseX 让你去随时读取。
- Windows API 有 GetCursorPos。
- 浏览器 JS 没有。
想要拿到 e.clientX,你必须依赖一个事件(Event Object)。所以,无论通过什么手段,最终的源头一定是一个 Event。
通常,高级反爬(如 Cloudflare, Akamai, ReCaptcha)不会傻傻地只把监听器挂在某个具体的“登录按钮”上。
为什么?因为如果只监听按钮,我只能看到你“点击”那一瞬间的状态。 它们需要的是“前摇动作” (The Approach)。
它们通常会在 window 或 document 上挂载全局捕获(Capture Phase)监听器:
// 它们真正干的事情(伪代码)
window.addEventListener('mousemove', recordTrace, { capture: true });并且回调函数会被疯狂混淆。不过不管代码混淆成什么样子,最终 window.addEventListener 必须被调用。
我们可以重写(Monkey Patch)浏览器原生的 addEventListener 方法,在它调用系统 API 之前,把它的参数截获下来。
你可以在 Playwright 初始化时注入这段脚本,或者直接在 Console 里跑:
// 保存原生的 addEventListener 指针
const originalAddEventListener = EventTarget.prototype.addEventListener;
// 重写 (Hook)
EventTarget.prototype.addEventListener = function(type, listener, options) {
// 我们只关心鼠标相关的间谍活动
if (type.includes('mouse') || type.includes('click') || type.includes('key')) {
console.log(`🕵️ 发现间谍!有人在监控: ${type}`);
console.log('📍 监控目标:', this); // 是 window 还是 button?
// 甚至可以打印出那段混淆后的代码位置
// console.log('📜 源代码:', listener.toString());
}
// 这一步是通透模式 (Pass-through):
// 执行完我们的监控逻辑后,必须调用原生方法,
// 否则网页功能就坏了(虽然这也可以用来做反监控屏蔽)
return originalAddEventListener.apply(this, arguments);
};在 addEventListener 成为标准之前,早期的 JS(DOM Level 0)使用的是直接属性赋值。
// 这种写法根本不经过 EventTarget.prototype.addEventListener
document.onmousemove = function(e) {
console.log("Gotcha!", e.clientX, e.clientY);
};
// 或者直接写在 HTML 标签里 (更加隐蔽)
// <body onmousemove="record(event)">如何防御:
// 针对 onmousemove 的防御代码
Object.defineProperty(document, 'onmousemove', {
set: function(newValue) {
console.log("⚠️ 发现有人在使用古老的 onmousemove 进行监控!");
// 你可以在这里把 newValue 替换掉,或者包一层
this._hiddenHandler = newValue;
},
get: function() {
return this._hiddenHandler;
}
});这是一种不需要任何 JS 鼠标事件就能追踪鼠标的“变态”方法。这种方法经常出现在 CTF 安全竞赛中。
原理:
- 攻击者在页面上铺满 100x100 个微小的透明 div(像像素点一样)。
- 给每个 div 设置 CSS
:hover样式,当鼠标划过时,触发去请求一张背景图片(或触发动画)。 - 攻击者只需在后端看服务器日志,看哪张图片被请求了,就能还原出你的鼠标轨迹。
这种方法非常重(DOM 节点极多),而且不够实时(依赖网络请求)。所以 Cloudflare 这种商业反爬很少用,但理论上它是存在的。
-
如何观察发生的事件
要么使用
monitorEvents(object [,events]),要么使用object.addEventListener(event_name, callback)。所以必须要有一个具体的 object 和具体的 event。
-
如何观察 UIEvent 包含的所有事件
首先必须获得这些事件的具体名称。可以通过官方文档获取。
然后
monitorEvents(window, ...)或者window.addEventListener(...)即可。window是最完美的选择,但有一个强制前提:window.addEventListener( '某个事件名', callback, { capture: true, // 关键:必须用捕获,解决不冒泡事件的问题,也防止被 stopPropagation 拦截 passive: true // 建议:告诉浏览器我不阻止滚动,提升 scroll/touch 性能 } );
// WHATWG 中摘录
[Exposed=*]
interface Event {
constructor(DOMString type, optional EventInit eventInitDict = {});
const unsigned short AT_TARGET = 2;
const unsigned short BUBBLING_PHASE = 3;
const unsigned short CAPTURING_PHASE = 1;
const unsigned short NONE = 0;
readonly attribute boolean bubbles;
attribute boolean cancelBubble; // legacy alias of .stopPropagation()
readonly attribute boolean cancelable;
readonly attribute boolean composed;
sequence<EventTarget> composedPath();
readonly attribute EventTarget? currentTarget;
readonly attribute boolean defaultPrevented;
readonly attribute unsigned short eventPhase;
undefined initEvent(DOMString type, optional boolean bubbles = false, optional boolean cancelable = false); // legacy
undefined preventDefault();
attribute boolean returnValue; // legacy
readonly attribute EventTarget? srcElement; // legacy
undefined stopImmediatePropagation();
undefined stopPropagation();
readonly attribute EventTarget? target;
// 单位毫秒,只有第一位小数可信,即精度为 0.1 毫秒
readonly attribute DOMHighResTimeStamp timeStamp;
readonly attribute DOMString type;
// chrome console 里看不到
[LegacyUnforgeable] readonly attribute boolean isTrusted;
};
dictionary EventInit {
boolean bubbles = false;
boolean cancelable = false;
boolean composed = false;
};
// UIEvent 标准中摘录
[Exposed=Window]
interface UIEvent : Event {
constructor(DOMString type, optional UIEventInit eventInitDict = {});
readonly attribute long detail;
undefined initUIEvent(DOMString typeArg,
optional boolean bubblesArg = false,
optional boolean cancelableArg = false,
optional Window? viewArg = null,
optional long detailArg = 0);
// chrome 里还有 sourceCapabilities,属于 chrome 私有实现
readonly attribute Window? view;
// chrome 里还有 which,已经被废弃
};
// UIEvent 标准中摘录
[Exposed=Window]
interface MouseEvent : UIEvent {
constructor(DOMString type, optional MouseEventInit eventInitDict = {});
readonly attribute boolean altKey;
readonly attribute short button;
readonly attribute unsigned short buttons;
readonly attribute long clientX; // 相对于 viewport 左上角
readonly attribute long clientY; // 相对于 viewport 左上角
readonly attribute boolean ctrlKey;
// chrome 里还有 fromElement,ie 时代产物
boolean getModifierState(DOMString keyArg);
// chrome 里还有 initMouseEvent()
readonly attribute long layerX;
readonly attribute long layerY;
readonly attribute boolean metaKey;
// chrome 里还有 movementX,pointer lock 标准规定的
// chrome 里还有 movementY,pointer lock 标准规定的
// chrome 里还有 offsetX,CSSOM 标准规定的,相对元素左上角
// chrome 里还有 offsetY,CSSOM 标准规定的,相对元素左上角
// chrome 里还有 pageX,CSSOM 标准规定的,相对整个网页左上角
// chrome 里还有 pageY,CSSOM 标准规定的,相对整个网页左上角
readonly attribute EventTarget? relatedTarget;
readonly attribute long screenX; // 相对于电脑屏幕左上角
readonly attribute long screenY; // 相对于电脑屏幕左上角
readonly attribute boolean shiftKey;
// chrome 里还有 toElement,ie 时代产物
// chrome 里还有 x,CSSOM 标准规定的,clientX 的别名
// chrome 里还有 y,CSSOM 标准规定的,clientY 的别名
};
// PointerEvent4 标准中摘录
[Exposed=Window]
interface PointerEvent : MouseEvent {
constructor(DOMString type, optional PointerEventInit eventInitDict = {});
// 对鼠标来说, pi/2
readonly attribute double altitudeAngle;
// 对鼠标来说,永远是 0
readonly attribute double azimuthAngle;
[SecureContext] sequence<PointerEvent> getCoalescedEvents();
sequence<PointerEvent> getPredictedEvents();
// 对鼠标来说,1 (或极少数情况下 0)
readonly attribute double height;
// 对鼠标来说,永远是 mouse
readonly attribute boolean isPrimary;
// 对鼠标来说,一般都是 0
readonly attribute long persistentDeviceId;
// 对鼠标来说,通常是一个固定的小整数,在页面的整个生命周期内,都不会变
readonly attribute long pointerId;
// 对鼠标来说,必须是 mouse
readonly attribute DOMString pointerType;
// 对鼠标来说,悬停时是 0
readonly attribute float pressure;
// 对鼠标来说,永远是 0
readonly attribute float tangentialPressure;
// 对鼠标来说,永远是 0
readonly attribute long tiltX;
// 对鼠标来说,永远是 0
readonly attribute long tiltY;
// 对鼠标来说,永远是 0
readonly attribute long twist;
// 对鼠标来说,1 (或极少数情况下 0)
readonly attribute double width;
};
单纯的鼠标移动只会产生以下事件,mouse 事件和 pointer 事件成对出现:
- mouseenter
- mouseleave
- mousemove
- mouseout
- mouseover
- pointerenter
- pointerleave
- pointermove
- pointerout
- pointerover
在当前 tab,点击视口里面的按钮,鼠标还在视口范围中,当前 tab 的 url 改变,当前 tab 加载新页面之后,鼠标移动,产生的第一个 pointermove 的 movementX movementY 都是 0。
在当前 tab,点击视口里面的按钮,鼠标还在视口范围中,浏览器新建了一个 tab,新 tab 被聚焦,鼠标移动,在新 tab 里产生的第一个 pointermove 的 movementX movementY 都是 0。
- pointerdown
- mousedown
- focus
- focusin
- pointerup
- mouseup
- click
[Exposed=Window]
interface KeyboardEvent : UIEvent {
constructor(DOMString type, optional KeyboardEventInit eventInitDict = {});
// KeyLocationCode
const unsigned long DOM_KEY_LOCATION_LEFT = 0x01;
const unsigned long DOM_KEY_LOCATION_NUMPAD = 0x03;
const unsigned long DOM_KEY_LOCATION_RIGHT = 0x02;
const unsigned long DOM_KEY_LOCATION_STANDARD = 0x00;
readonly attribute boolean altKey;
// chrome 里还有 charCode,deprecated
readonly attribute DOMString code;
readonly attribute boolean ctrlKey;
boolean getModifierState(DOMString keyArg);
// initKeyboardEvent
readonly attribute boolean isComposing;
readonly attribute DOMString key;
// chrome 里还有 keyCode,deprecated
readonly attribute unsigned long location;
readonly attribute boolean metaKey;
readonly attribute boolean repeat;
readonly attribute boolean shiftKey;
};
[Exposed=Window]
interface InputEvent : UIEvent {
constructor(DOMString type, optional InputEventInit eventInitDict = {});
readonly attribute USVString? data;
// chrome 里还有 dataTransfer,w3c input events level 2 定义
// chrome 里还有 getTargetRanges(),w3c input events level 2 定义
readonly attribute DOMString inputType;
readonly attribute boolean isComposing;
};
-
按下一个按键,松开这个按键
- keydown
- keypress
- beforeinput
- input
- keyup
-
按住一个修饰键,然后按下一个按键,松开这个按键,松开修饰键