JS Event

事件传播过程

标准模型(W3C Model)将事件流分为三个阶段:

事件的生产和消费

主线程是“消费者”。生产者是一条跨越了硬件、操作系统和多个进程的流水线。

事件生产

步骤 A: 硬件中断 (Hardware Interrupt)

步骤 B: 浏览器进程 (Browser Process)

步骤 C: 渲染进程 IO 线程 (Renderer Process IO Thread)

注意:此时主线程可能正在忙着算 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);
        }
    }
}

注意,这一整个处理先捕获后冒泡的过程 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

所有 UI Event

UIEvent Types

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.

FocusEvent Types

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.

MouseEvent Types

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.

WheelEvent Types

wheel :Fired when the user rotates a mouse wheel or similar user interface component such as a touchpad.

InputEvent Types

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).

KeyboardEvent Types

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.

CompositionEvent Types

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.

PointerEvent Types

其他和 UI 有关的 Event

CSSOM Events

网站如何监听输入事件

你可能会想:“能不能不监听事件,直接在一个死循环里读取鼠标坐标?”

答案是:不行。

在浏览器 JS 环境中,不存在 一个全局变量叫 window.mouseX 让你去随时读取。

想要拿到 e.clientX,你必须依赖一个事件(Event Object)。所以,无论通过什么手段,最终的源头一定是一个 Event。

addEventListener

通常,高级反爬(如 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);
};

onXXX

在 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;
    }
});

偏门方法:CSS :hover 像素网格攻击

这是一种不需要任何 JS 鼠标事件就能追踪鼠标的“变态”方法。这种方法经常出现在 CTF 安全竞赛中。

原理:

这种方法非常重(DOM 节点极多),而且不够实时(依赖网络请求)。所以 Cloudflare 这种商业反爬很少用,但理论上它是存在的。

事件调试

观察发生的事件

事件接口

// 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 事件成对出现:

在当前 tab,点击视口里面的按钮,鼠标还在视口范围中,当前 tab 的 url 改变,当前 tab 加载新页面之后,鼠标移动,产生的第一个 pointermove 的 movementX movementY 都是 0。

在当前 tab,点击视口里面的按钮,鼠标还在视口范围中,浏览器新建了一个 tab,新 tab 被聚焦,鼠标移动,在新 tab 里产生的第一个 pointermove 的 movementX movementY 都是 0。

鼠标单击产生的事件

键盘产生的事件

[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;
};