2019.7.21 增加参考资料
说起DOM,除了HTML到DOM树结点的映射之外,最让人印象深刻的,大概也是最重要的,就是事件了。我们在浏览器中的操作,大多属于UI事件。我们先来看看W3C对UI事件的定义:
This specification defines UI Events which extend the DOM Event objects defined in DOM. UI Events are those typically implemented by visual user agents for handling user interaction such as mouse and keyboard input.
从这个定义里可以看出,UI事件是用户代理(agent,主要指的是浏览器)对用户操作的响应,其实并不属于我们能控制的部分。但是为什么我们能“控制”这些机制?因为JavaScript本身是一门脚本语言,依赖于宿主环境,同时也能利用宿主环境。在这里,用户事件会被映射为DOM事件(如果用专业一点的说法,叫“分发”),让我们对用户事件有了响应的能力。JavaScript之于浏览器,如同Shell之于Linux内核,是一个对用户友好的“接口”,也能屏蔽一些不必要的细节;我们并不需要知道浏览器是怎么响应用户操作的,我们只需要知道我们能够响应用户操作,这就够了;至于浏览器用了几个线程,用的是流水线还是并行,我们并不关心。这也是分层的特点。
对于事件的响应和处理过程,要涉及到浏览器的内部构造。虽然按理来说对我们来说应该是透明的,但是了解一下也无妨,尤其是有些东西需要清楚浏览器的内部构造才能解释;具体的可以看看参考资料里的文章,里面有比较详细的介绍,正文就不加以引用了,有点长。这里只简单概括一下。
我们都知道,为了提高处理效率,主要有两种方式,流水线和并行。对于浏览器来说(暗示Chrome,Firefox采用了APZ优化),为了提高性能,这两种方式是同时采用的:将页面拆成多个帧,进行流水线操作;同时,每一帧的UI渲染和事件处理分属不同的线程,有所谓的内核线程和合成线程,进行并行操作。内核处理DOM构建、脚本执行和图层记录,合成线程负责图层合成和展示,也就是所谓的线程化渲染框架(Threaded Compositor Architecture)。示意图如下:
咳……说了一堆我自己也不太了解的东西之后,切入正题。整个事件机制中,我觉得最重要的两个,一个是事件流event flow,另一个是事件队列event queue。事件队列属于异步部分,暂且不论;这次的重头戏是事件流。
事件流其实指的是DOM里事件的传播顺序,一共有三个阶段:捕获阶段、目标阶段、冒泡阶段。在事件按照决定好的路径传播到目标(event target)之前,会先进行捕获;在事件分发到目标之后,会进行冒泡。这是W3C的定义:
- The capture phase: The event object propagates through the target’s ancestors from the Window to the target’s parent. This phase is also known as the capturing phase.
- The target phase: The event object arrives at the event object’s event target. This phase is also known as the at-target phase. If the event type indicates that the event doesn’t bubble, then the event object will halt after completion of this phase.
- The bubble phase: The event object propagates through the target’s ancestors in reverse order, starting with the target’s parent and ending with the Window. This phase is also known as the bubbling phase.
简单来说,当DOM事件被触发时(用户事件的映射!),事件会从根结点window
开始由外到内进行传播,直到到达目标为止,这是事件捕获;与此相反的,事件从目标由内而外传播,这是事件冒泡。需要强调的是,无论是捕获还是冒泡,其中有一个端点必然是window
,而不是很多人所说的document
;只不过我们平时没有必要从window
这个级别开始处理事件而已,并不代表根结点是document
。
从表现形式上看,以点击事件为例,点击了子元素,如果父元素注册了捕获事件,会先触发父元素事件;如果是冒泡,则会先触发子元素事件。如同下图所示(图来自W3C):
这三个阶段,并不像有些人所说的对立关系,而是可以并存的;只不过有先后顺序而已。假如一个组件同时注册了捕获和冒泡,就会在两个阶段各自被触发,并不矛盾。如果非要举个例子,那大概就是洋葱和中间件吧(图来自某篇介绍KOA 2的文章,但一时半会找不到了):
不过这三个阶段并不一定同时存在。有些浏览器不支持捕获(没错,说的就是你,IE),自然就没有捕获阶段;而如果调用了stopPropagation()
,所有的还没进行的阶段都会被跳过。当然,正在进行的阶段还是会继续的。需要强调的是,这个方法并不是一贯认为的用来停止冒泡的方法,而是用来停止事件的进一步传播的,从字面意思上也能看出来。
除了stopPropagation()
之外,还有一个很有意思的方法,叫stopImmediatePropagation()
。这个方法在stopPropagation()
的停止传播的基础上,进一步阻止了同级的其他监听。可以看一下这个demo:
See the Pen MMdpKx by Wen Sun (@HermitSun) on CodePen.
有了大概的了解之后,我们看看用TypeScript是怎么实现的接口。因为是要响应用户事件的,所以肯定需要一个监听机制,以及配套的分发、删除:interface EventTarget {
addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void;
dispatchEvent(event: Event): boolean;
removeEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: EventListenerOptions | boolean): void;
}
这里将添加、分发、删除监听的职责交给了事件目标event target。不过想想也是合理的,按照信息和行为集中原则,拥有信息的对象也应该拥有职责;事件目标肯定是最清楚监听需要什么数据的。
从类型上可以看出,监听器可以是一个对象,也可以一个函数;只不过我们习惯了用一个匿名函数,这样比较灵活:
declare type EventListenerOrEventListenerObject = EventListener | EventListenerObject;
interface EventListener {
(evt: Event): void;
}
interface EventListenerObject {
handleEvent(evt: Event): void;
}
值得一提的是这里的options。这里的options既可以是一个布尔值,又可以是一个对象,还是挺有意思的。我们先看看这个对象的内容是什么:
interface EventListenerOptions {
capture?: boolean;
}
interface AddEventListenerOptions extends EventListenerOptions {
once?: boolean;
passive?: boolean;
}
options一共有三个选项,capture、once、passive。因为once和passive是后来加的,早期只有一个捕获选项(那时候叫useCapture),所以可以是一个布尔值;后来加了两个选项,就变成一个对象了。
需要注意的是,在老式浏览器里,因为并不支持对象的选项,只支持布尔值,所以无论在对象里写什么,都会被视作useCapture = true,因为在JavaScript里对象一定是一个truthy值,哪怕是空对象(更何况这里还不是空对象)。
详细的解释,引用MDN吧:
An options object that specifies characteristics about the event listener. The available options are:
capture
: ABoolean
indicating that events of this type will be dispatched to the registeredlistener
before being dispatched to anyEventTarget
beneath it in the DOM tree.once
: ABoolean
indicating that thelistener
should be invoked at most once after being added. Iftrue
, thelistener
would be automatically removed when invoked.passive
: ABoolean
which, iftrue
, indicates that the function specified bylistener
will never callpreventDefault()
. If a passive listener does callpreventDefault()
, the user agent will do nothing other than generate a console warning.
capture是捕获,once是只调用一次,都好理解;这个passive呢?说起来很复杂,可以看看参考资料里的详细说明。简单说来,从设计初衷来说,是为了改善移动端滑动体验;因为如果没有passive,在监听回调里是有可能会调用preventDefault
的,而这个方法需要经过浏览器内核处理,显然会降低性能;滑动、触摸事件密集的时候,可能会卡住。如果声明passive,说明不用经过内核,整个过程会被优化,就不容易卡住了。示意图如下:
当然,以上说明仅限于Chrome。在其他浏览器里,并没有这一系列操作。所以从表现上看,passive除了声明不使用preventDefault
之外(如果使用控制台会报错),也并没有更多的作用了。
参考资料
- UI Events, W3C Working Draft, 30 May 2019
- 让页面滑动流畅得飞起的新特性:Passive Event Listeners
- passive 的事件监听器
- JavaScript 详说事件机制之冒泡、捕获、传播、委托
- Smoother scrolling in Firefox 46 with APZ
目录
从TypeScript视角看HTML DOM(二):Node与Element
从TypeScript视角看HTML DOM(三):NodeList与HTMLCollection