本文是关于阅读Mariko Kosaka的系列文章Inside look at modern web browser,结合进击的大葱的译文所做的阅读笔记。有任何理解有误之处,欢迎各位在评论区指正。
本文分为四大板块:从浏览器的架构开始说起,先有一个浏览器整体运行的大体框架。再以Chrome为例,先介绍tab页以外的进程在做哪些事情,再聊到tab页内的渲染进程做了哪些事,最后再聊聊浏览器是如何处理事件输入。
Part 1: CPU, GPU, Memory, and multi-process architecture
CPU,GPU,内存和多进程架构
电脑的核心:CPU和GPU
CPU只能一个接一个的,串行的处理任务。
GPU数量多,能够并行处理多个简单任务,就像他的名字那样,GPU能够处理图像。当我们一说到“使用GPU”或者“GPU支持”,都会和快速渲染以及流畅的交互联系在一起。
当你在手机或者电脑上打开某个应用的时候,背后是CPU和GPU支撑着这个应用程序的运行。通常来说,你的应用要通过操作系统提供的一些机制,才能在CPU和GPU上面运行。
线程和进程
当你启动一个应用程序的时候,操作系统会为这个程序创建一个进程同时还为这个进程分配一片私有的内存空间,这片空间会被用来存储所有程序相关的数据和状态。当你关闭这个程序的时候,这个程序对应的进程也会随之消失,进程对应的内存空间也会被操作系统释放掉。
有时候,创建的进程会叫系统创建另外一些进程去处理其它任务,新建的进程会拥有全新的独立的内存空间。如果这些进程需要通信,它们需要通过IPC机制(Inter Process Communication)来进行。很多应用程序都会采取这种多进程的方式来工作,这样一来,即使其中一个工作进程(worker process)挂掉了,其他进程也不会受到影响,而且挂掉的进程还可以重启。
浏览器架构
架构类型
大概可以分为两种架构,一种是单进程架构,也就是只启动一个进程,这个进程里面有多个线程工作。第二种是多进程架构,浏览器会启动多个进程,每个进程里面有多个线程,不同进程通过IPC进行通信。
谷歌浏览器的架构
Chrome浏览器会有一个浏览器进程(Browser Process),这个进程会和其他进程一起协作来实现浏览器的功能。对于渲染进程(Renderer Process)来说,Chrome会尽可能为每一个tab甚至是页面里面的每一个iframe都分配一个单独的进程。
根据图例,Browser Process 和每个 process 都会有协作,而 Render Process 下有很多图层,表示 Chrome 会尽可能为每一个tab分配一个单独的进程。
谷歌浏览器里各个进程的分工
| 进程 | 工作 |
|---|---|
| Browser 浏览器 | 控制浏览器的“chrome”的部分,包括地址栏、书签、前进后退按钮。同时也控制不可见的部分,比如网络请求和文件权限 |
| Render 渲染 | 控制tab内和网页所展示的全部 |
| Plugin 插件 | 控制网站的所有插件,比如flash |
| GPU 图形处理器 | 负责独立于其他进程的所有GPU任务。之所以被独立于其他进程,是因为GPU需要处理多个tab的请求,并最终将他们画在一个页面上。 |
多进程架构的优劣
优势
优势1:当你开着三个页签的时候,一个页签未响应,另外两个不会受到影响。如果是单进程,一个未响应的话,另外两个页签也会挂掉。
优势2:提供安全性和沙盒性(sanboxing)。因为操作系统可以提供方法让你限制每个进程所拥有的能力,所以浏览器可以让某些进程不具备某些特定的功能。例如,渲染进程可能会处理来自用户的随机输入,所以Chrome限制了它们对系统文件随机读写的能力。
劣势
由于每个进程都有各自独立的内存空间,所以它们不能像存在于同一个进程的线程那样共用内存空间,这就造成了一些基础的架构(例如V8 JavaScript引擎)会在不同进程的内存空间同时存在的问题,这些重复的内容会消耗更多的内存。所以为了节省内存,Chrome会限制被启动的进程数目,当进程数达到一定的界限后,Chrome会将访问同一个网站的tab都放在一个进程里面跑。
单帧渲染进程 - 网站隔离
这个功能会为网站内不同站点的iframe分配一个独立的渲染进程。之前说过Chrome会为每个tab分配一个单独的渲染进程,可是如果一个tab只有一个进程的话,不同站点的iframe都会跑在这个进程里面,这也意味着它们会共享内存,这就有可能会破坏同源策略。
同源策略是浏览器最核心的安全模型,它可以禁止网站在未经同意的情况下去获取另外一个站点的数据,因此绕过同源策略是很多安全攻击的主要目的。而进程隔离(Proces Isolation)是隔离网站最好最有效的办法了。再加上CPU存在Meltdown和Spectre的隐患,网站隔离变得势在必行。因此在Chrome 67版本之后,桌面版的Chrome会默认开启网站隔离功能,这样每一个跨站点的iframe都会拥有一个独立的渲染进程。
它从根本上改变了各个iframe之间的通信方式。网站隔离后,对于有iframe的网站,当用户打开右边的devtool时,Chrome浏览器其实要做很多幕后工作。对于一些很简单的功能,例如在devtool里面用Ctrl + F键在页面搜索某个关键词,Chrome都要遍历多个渲染进程去完成。所以我们的浏览器工程师在网站隔离这个功能发布后都感叹这是一个里程碑式的成就。
Inside look at modern web browser (part 1)
Part 2: What happens in navigation
导航的时候发生了什么
用户输入网址并摁下回车,浏览器获取数据并展示页面。咱们重点关注在这个简单场景中,网站的数据请求以及浏览器在呈现网页之前做的准备工作。
一切从浏览器开始
一次简单的导航
第一步,处理输入
UI线程要进行一系列的解析来判定是将用户输入发送给搜索引擎还是直接请求你输入的站点资源
第二步,开始导航
当用户按下回车键的时候,UI线程会叫网络线程(network thread)初始化一个网络请求来获取站点的内容。这时候tab上会展示一个提示资源正在加载中的旋转圈圈,而且网络线程会进行一系列诸如DNS寻址以及为请求建立TLS连接的操作。
这时如果网络线程收到服务器的HTTP 301重定向响应,它就会告知UI线程进行重定向然后它会再次发起一个新的网络请求。
第三步,读取响应
网络线程在收到HTTP响应的主体(payload)和流(stream)时,在必要的情况下它会先检查一下流的前几个字节,来确定响应主体的具体媒体类型(MIME Type)。响应主体的媒体类型一般可以通过HTTP头部的Content-Type来确定,不过Content-Type有时候会缺失或者是错误的,这种情况下浏览器就要进行MIME类型嗅探来确定响应类型了。MIME类型嗅探并不是一件容易的事情,可以看Chrome源码注释。
网络线程在把内容交给渲染进程之前还会对内容做SafeBrowsing检查。如果请求的域名或者响应的内容和某个已知的病毒网站相匹配,网络线程会给用户展示一个警告的页面。除此之外,网络线程还会做CORB(Cross Origin Read Blocking)检查来确定那些敏感的跨站数据不会被发送至渲染进程。
第四步,寻找一个渲染进程
在网络线程做完所有的检查后并且能够确定浏览器应该导航到该请求的站点,它就会告诉UI线程所有的数据都已经被准备好了。UI线程在收到网络线程的确认后,会为这个网站寻找一个渲染进程(Renderer Process)来渲染界面。
由于网络请求可能需要长达几百毫秒的时间才能完成,为了缩短导航需要的时间,浏览器会在之前的一些步骤里面做一些优化。例如在第二步中当UI线程发送URL链接给网络线程后,它其实已经知晓它们要被导航到哪个站点了,所以在网络线程干活的时候,UI线程会主动地为这个网络请求启动一个渲染线程。如果一切顺利的话(没有重定向之类的东西出现),网络线程准备好数据后页面的渲染进程已经就准备好了,这就节省了新建渲染进程的时间。不过如果发生诸如网站被重定向到不同站点的情况,刚刚那个渲染进程就不能被使用了,它会被摒弃,一个新的渲染进程会被启动。
第五步,提交导航
数据和渲染进程都已经准备好了,浏览器进程(Browser Process)会通过IPC告诉渲染进程去提交本次导航(Commit Navigation)。除此之外浏览器进程还会将刚刚接收到的响应数据流传递给对应的渲染进程让它继续接收到来的HTML数据。一旦浏览器进程收到渲染线程的 回复说导航已经被提交了(commit),导航这个过程就结束了,文档的加载阶段(document loading phase)会正式开始。
到了这个时候,导航栏会被更新,安全指示符(security indicator)和站点设置UI(site settings UI)会展示新页面相关的站点信息。当前tab的会话历史(session history)也会被更新,这样当你点击浏览器的前进和后退按钮也可以导航到刚刚导航完的页面。为了方便你在关闭了tab或窗口(window)的时候还可以恢复当前tab和会话 (session)内容,当前的会话历史会被保存在磁盘上面。
额外步骤:初始加载完成
导航到另一个站点
如果重新导航是在页面内被发起的呢?例如用户点击了页面的一个链接或者客户端的JavaScript代码执行了诸如window.location = "newsite.com" 的代码。这种情况下,渲染进程会自己先检查一下它有没有注册beforeunload事件的监听函数,如果有的话就执行,执行完后发生的事情就和之前的情况没什么区别了,唯一的不同就是这次的导航请求是由渲染进程给浏览器进程发起的。
如果是重新导航到不同站点(different site)的话,会有另外一个渲染进程被启动来完成这次重导航,而当前的渲染进程会继续处理现在页面的一些收尾工作,例如unload事件的监听函数执行。Overview of page lifecycle states这篇文章会介绍页面所有的生命周期状态,the Page Lifecycle API会教你如何在页面中监听页面状态的变化。
Service Worker的用例
Service worker可以用来写网站的网络代理(network proxy),所以开发者可以对网络请求有更多的控制权,例如决定哪些数据缓存在本地,以及哪些数据需要从网络上面重新获取等等。如果开发者在service worker里设置了当前的页面内容从缓存里面获取,当前页面的渲染就不需要重新发送网络请求了,这就大大加快了整个导航的过程。
这里要重点留意的是service worker其实只是一些跑在渲染进程里面的JavaScript代码。那么问题来了,当导航开始的时候,浏览器进程是如何判断导航的站点存不存在对应的service worker并启动一个渲染进程去执行它的呢?
其实service worker在注册的时候,它的作用范围(scope)会被记录下来(你可以通过文章The Service Worker Lifecycle了解更多关于service worker作用范围的信息)。在导航开始的时候,网络线程会根据请求的域名在已经注册的service worker作用范围里面寻找有没有对应的service worker。如果有命中该URL的service worker,UI线程就会为这个service worker启动一个渲染进程(renderer process)来执行它的代码。Service worker既可能使用之前缓存的数据也可能发起新的网络请求。
导航预加载
在上面的例子中,你应该可以感受到如果启动的service worker最后还是决定发送网络请求的话,浏览器进程和渲染进程这一来一回的通信包括service worker启动的时间其实增加了页面导航的时延。导航预加载就是一种通过在service worker启动的时候并行加载对应资源的方式来加快整个导航过程效率的技术。预加载资源的请求头会有一些特殊的标志来让服务器决定是发送全新的内容给客户端还是只发送更新了的数据给客户端。
Inside look at modern web browser (part 2)
Part 3: Inner workings of a Renderer Process
渲染进程的内部工作
渲染进程主要负责页签里发生的一切。在渲染进程中,主线程负责处理多数你发给用户的代码。如果你使用了web worker或者service worker,相关的代码会有工作线程处理。合成和光栅线程也在渲染进程运行,来保证高效和流畅地渲染页面。
渲染进程的主要工作是将HTML, CSS, JavaScript转变为我们可以进行交互的网页内容。
解析
第一步,构建DOM
渲染进程在导航结束的时候会收到来自浏览器进程提交导航(commit navigation)的消息,在这之后渲染进程就会开始接收HTML数据,同时主线程也会开始解析接收到的文本数据(text string)并把它转化为一个DOM(Document Object Model)对象。
DOM对象既是浏览器对当前页面的内部表示,也是Web开发人员通过JavaScript与网页进行交互的数据结构以及API。
第二步,子资源加载
主线程会按照在构建DOM树时遇到各个资源的循序一个接着一个地发起网络请求,可是为了提升效率,浏览器会同时运行“预加载扫描”(preload scanner)程序。如果在HTML文档里面存在诸如 <img> 或者 <link> 这样的标签,预加载扫描程序会在HTML解析器生成的token里面找到对应要获取的资源,并把这些要获取的资源告诉浏览器进程里面的网络线程。
第三步,JavaScript会阻塞加载
因为script标签中的JavaScript可能会使用诸如document.write()这样的代码改变文档流(document)的形状,从而使整个DOM树的结构发生根本性的改变(HTML规范里面的overview of the parsing model有很好的示意图)。因为这个原因,HTML解析器不得不等JavaScript执行完成之后才能继续对HTML文档流的解析工作。
给浏览器一点如何加载资源的提示
Web开发者可以通过很多方式告诉浏览器如何才能更加优雅地加载网页需要用到的资源。如果你的JavaScript不会使用到诸如document.write()的方式去改变文档流的内容的话,你可以为script标签添加一个async或者defer属性来使JavaScript脚本进行异步加载。你也可以使用JavaScript Module来满足你的需求。同时<link rel="preload">资源预加载可以用来告诉浏览器这个资源在当前的导航肯定会被用到,你想要尽快加载这个资源。更多相关的内容,你可阅读Resource Prioritization - Getting the Browser to Help You这篇文章。
样式计算
拥有了DOM树我们还不足以知道页面的外貌,因为我们通常会为页面的元素设置一些样式。主线程会解析页面的CSS从而确定每个DOM节点的计算样式(computed style)。计算样式是主线程根据CSS样式选择器(CSS selectors)计算出的每个DOM元素应该具备的具体样式。
即使你的页面没有设置任何自定义的样式,每个DOM节点还是会有一个计算样式属性,这是因为每个浏览器都有自己的默认样式表。
布局
只知道网站的文档流以及每个节点的样式是远远不足以渲染出页面内容的,还需要通过布局(layout)来计算出每个节点的几何信息(geometry)。布局的具体过程是:主线程会遍历刚刚构建的DOM树,根据DOM节点的计算样式计算出一个布局树(layout tree)。布局树上每个节点会有它在页面上的x,y坐标以及盒子大小(bounding box sizes)的具体信息。布局树长得和先前构建的DOM树差不多,不同的是这颗树只有那些可见的(visible)节点信息。
举个例子,如果一个节点被设置为了display:none,这个节点就是不可见的就不会出现在布局树上面(visibility:hidden的节点会出现在布局树上面,你可以思考一下这是为什么)。同样的,如果一个伪元素(pseudo class)节点有诸如p::before{content:"Hi!"}这样的内容,它会出现在布局上,而不存在于DOM树上。
即使页面的布局十分简单,布局这个过程都是非常复杂的。例如页面就是简单地从上而下展示一个又一个段落,这个过程就很复杂,因为你需要考虑段落中的字体大小以及段落在哪里需要进行换行之类的东西,它们都会影响到段落的大小以及形状,继而影响到接下来段落的布局。
绘画
你已经知道了画布上每个元素的大小,形状以及位置,你还是得思考一下每个元素的绘画顺序,因为画布上的元素是会互相遮挡的(z-index)。
在绘画这个步骤中,主线程会遍历之前的到的布局树(layout tree)来生成一系列的绘画记录(paint records)。绘画记录是对绘画过程的注释,例如“首先画背景,然后是文本,最后画矩形”。如果你曾经在canvas画布上有使用过JavaScript绘制元素,你可能对这个过程不会感到陌生。
高成本的渲染流水线更新
关于渲染流水线有一个十分重要的点,就是流水线的每一步都要使用到前一步的结果来生成新的数据,这就意味着如果某一步的内容发生了改变的话,这一步后面所有的步骤都要被重新执行以生成新的记录。举个例子,如果布局树有些东西被改变了,文档上那些被影响到的部分的绘画顺序是要重新生成的。
如果你的页面元素有动画效果(animating),浏览器就不得不在每个渲染帧的间隔中通过渲染流水线来更新页面的元素。我们大多数显示器的刷新频率是一秒钟60次(60fps),如果你在每个渲染帧的间隔都能通过流水线移动元素,人眼就会看到流畅的动画效果。可是如果流水线更新时间比较久,动画存在丢帧的状况的话,页面看起来就会很“卡顿”。
即使你的渲染流水线更新是和屏幕的刷新频率保持一致的,只要这些更新是运行在主线程上面的,这就意味着它可能被同样运行在主线程上面的JavaScript代码阻塞。
对于这种情况,你可以将要被执行的JavaScript操作拆分为更小的块然后通过requestAnimationFrame这个API把他们放在每个动画帧中执行。想知道更多关于这方面的信息的话,可以参考Optimize JavaScript Execution。当然你还可以将JavaScript代码放在WebWorkers中执行来避免它们阻塞主线程。
到这一步,我还很推荐你做一下延伸阅读,关于左耳耗子的浏览器的渲染原理简介,用一种更加通俗易懂,且更具实用性的角度,告诉你浏览器的渲染过程,以及如何减少reflow(回流)/repaint(重绘)。
合成
到目前为止,浏览器已经知道了关于页面以下的信息:文档结构,元素的样式,元素的几何信息以及它们的绘画顺序。那么浏览器是如何利用这些信息来绘制出页面来的呢?将以上这些信息转化为显示器的像素的过程叫做光栅化(rasterizing) 。
可能一个最简单的做法就是只光栅化视口内(viewport)的网页内容。如果用户进行了页面滚动,就移动光栅帧(rastered frame)并且光栅化更多的内容以补上页面缺失的部分。Chrome的第一个版本其实就是这样做的。然而,对于现代的浏览器来说,它们往往采取一种更加复杂的叫做合成(compositing)的做法。
什么是合成
合成是一种将页面分成若干层,然后分别对它们进行光栅化,最后在一个单独的线程 - 合成线程(compositor thread)里面合并成一个页面的技术。当用户滚动页面时,由于页面各个层都已经被光栅化了,浏览器需要做的只是合成一个新的帧来展示滚动后的效果罢了。页面的动画效果实现也是类似,将页面上的层进行移动并构建出一个新的帧即可。
页面分层
为了确定哪些元素需要放置在哪一层,主线程需要遍历渲染树来创建一棵层次树(Layer Tree)(在DevTools中这一部分工作叫做“Update Layer Tree”)。如果页面的某些部分应该被放置在一个单独的层上面(滑动菜单)可是却没有的话,你可以通过使用will-change 这个CSS属性来告诉浏览器对其分层。
你可能会想要给页面上所有的元素一个单独的层,然而当页面的层超过一定的数量后,层的合成操作要比在每个帧中光栅化页面的一小部分还要慢,因此衡量你应用的渲染性能是十分重要的一件事情。想要获取关于这方面的更多信息,可以参考文章Stick to Compositor-Only Properties and Manage Layer Count。
在主线程之外光栅化和合成页面
一旦页面的层次树创建出来并且页面元素的绘制顺序确定后,主线程就会向合成线程(compositor thread)提交这些信息。然后合成线程就会光栅化页面的每一层。因为页面的一层可能有整个网页那么大,所以合成线程需要将它们切分为一块又一块的小图块(tiles)然后将图块发送给一系列光栅线程(raster threads)。光栅线程会栅格化每个图块并且把它们存储在GPU的内存中。
合成线程可以给不同的光栅线程赋予不同的优先级(prioritize),进而使那些在视口中的或者视口附近的页面可以先被光栅化。为了响应用户对页面的放大和缩小操作,页面的图层(layer)会为不同的清晰度配备不同的图块。
当图层上面的图块都被栅格化后,合成线程会收集图块上面叫做绘画四边形(draw quads)的信息来构建一个合成帧(compositor frame)。
- 绘画四边形:包含图块在内存的位置以及图层合成后图块在页面的位置之类的信息。
- 合成帧:代表页面一个帧的内容的绘制四边形集合。
上面的步骤完成之后,合成线程就会通过IPC向浏览器进程(browser process)提交(commit)一个渲染帧。这个时候可能有另外一个合成帧被浏览器进程的UI线程(UI thread)提交以改变浏览器的UI。这些合成帧都会被发送给GPU从而展示在屏幕上。如果合成线程收到页面滚动的事件,合成线程会构建另外一个合成帧发送给GPU来更新页面。
合成的好处在于这个过程没有涉及到主线程,所以合成线程不需要等待样式的计算以及JavaScript完成执行。这也就是为什么说只通过合成来构建页面动画是构建流畅用户体验的最佳实践的原因了。如果页面需要被重新布局或者绘制的话,主线程一定会参与进来的。
Inside look at modern web browser (part 3)
Part4: Input is coming to the Compositor
到达合成线程的输入
接下来让我们来了解下,当用户在网页上输入内容的时候,合成线程(compositor)做了些什么来保证流畅的用户体验的。
从浏览器的角度看什么是输入事件
首先,用户滚动页面,触碰屏幕以及移动鼠标等操作都可以看作来自于用户的输入事件。
在上面的图例中,点击事件由浏览器进程先得知,然后路由到渲染进程进行处理。
合成线程接收到输入事件
在Part3中,我们明确了合成线程是如何通过合并页面已经光栅化好的层来保障流畅的滚动体验(scroll smoothly)的。如果当前页面不存在任何用户事件的监听器(event listener),合成线程完全不需要主线程的参与就能创建一个新的合成帧来响应事件。可是如果页面有一些事件监听器(event listeners)呢?合成线程是如何判断出这个事件是否需要路由给主线程处理的呢?
先来了解一下什么是非快速滚动区域
因为页面的JavaScript脚本是在主线程(main thread)中运行的,所以当一个页面被合成的时候,合成线程会将页面那些注册了事件监听器的区域标记为“非快速滚动区域”(Non-fast Scrollable Region)。由于知道了这些信息,当用户事件发生在这些区域时,合成线程会将输入事件发送给主线程来处理。如果输入事件不是发生在非快速滚动区域,合成线程就无须主线程的参与来合成一个新的帧。
Web开发的一个常见的模式是事件委托(event delegation)。由于事件会冒泡,你可以给顶层的元素绑定一个事件监听函数来作为其所有子元素的事件委托者,这样子节点的事件就可以统一被顶层的元素处理了。
只用一个事件监听器就可以服务到所有的元素,乍一看这种写法还是挺实惠的。可是,如果你从浏览器的角度去看一下这段代码,你会发现上面给body元素绑定了事件监听器后其实是将整个页面都标记为一个非快速滚动区域,这就意味着即使你页面的某些区域压根就不在乎是不是有用户输入,当用户输入事件发生时,合成线程每次都会告知主线程并且会等待主线程处理完它才干活。因此这种情况下合成线程就丧失提供流畅的用户体验的能力了(smooth scrolling ability)。
我们可以给document.body.addEventListener事件后面加一个{passive: true}的选项,来告诉浏览器我们仍要在主线程中侦听事件,可是合成线程也可以继续合成新的帧。
检查事件是否可以取消
假设页面中有一个框,你希望将滚动方向限制为仅水平滚动。
在指针事件中使用passive: true选项意味着页面的滚动可以平滑,但垂直滚动可能在你想要preventDefault来限制滚动方向的开始。你可以使用 event.cancelable 方法对此进行检查。
找到事件的目标对象
当合成线程向主线程发送输入事件时,主线程要做的第一件事是通过命中测试(hit test)去找到事件的目标对象(target)。具体的命中测试流程是遍历在渲染流水线中生成的绘画记录(paint records)来找到输入事件出现的x, y坐标上面描绘的对象是哪个。
最小化发送给主线程的事件数
上一篇文章中我们有说过,显示器的刷新频率通常是一秒钟60次,以及我们可以通过让JavaScript代码的执行频率和屏幕刷新频率保持一致,来实现页面的平滑动画效果(smooth animation)。对于用户输入来说,触摸屏一般一秒钟会触发60到120次点击事件,而鼠标一般则会每秒触发100次事件,因此输入事件的触发频率其实远远高于我们屏幕的刷新频率。
如果每秒将诸如touchmove这种连续被触发的事件发送到主线程120次,因为屏幕的刷新速度相对来说比较慢,它可能会触发过量的点击测试以及JavaScript代码的执行。
为了最大程度地减少对主线程的过多调用,Chrome会合并连续事件(例如wheel,mousewheel,mousemove,pointermove,touchmove),并将调度延迟到下一个requestAnimationFrame之前。
任何诸如keydown,keyup,mouseup,mousedown,touchstart和touchend等相对不怎么频繁发生的事件都会被立即派送给主线程。
使用getCoalesecedEvents来获取帧内(intra-frame)事件
对于大多数web应用来说,合并事件应该已经足够用来提供很好的用户体验了,然而,如果你正在构建的是一个根据用户的touchmove坐标来进行绘图的应用的话,合并事件可能会使页面画的线不够顺畅和连续。在这种情况下,你可以使用鼠标事件的getCoalescedEvents来获取被合成的事件的详细信息。
Inside look at modern web browser (part 4)
附录
Inside look at modern web browser (part 1)
Inside look at modern web browser (part 2)
Inside look at modern web browser (part 3)