1. 简介
1.1. 概述
本节为非规范性内容。
当用户与网站交互时,他们希望自己的操作能够快速引发网站的变化。 实际上,研究 表明,任何未在100毫秒内处理的用户输入都会被认为是慢的。 因此,及时展示未达到这一标准的输入事件的性能时序信息非常重要。
监测事件延迟的常见方法是注册一个事件监听器。
可以通过事件的 timeStamp
获取事件被创建的时间戳。
此外,还可以在事件处理逻辑开始和结束时调用 performance.now()
。
将硬件时间戳与事件处理开始时获得的时间戳相减,开发者可以计算输入延迟:即输入开始被处理所需的时间。
将事件处理开始和结束时的时间戳相减,可以计算事件处理器内执行的同步工作量。
最后,对于同步处理的输入,事件的硬件时间戳到处理事件后的下一次绘制之间的持续时间是一个有用的用户体验指标。
这种方法存在几个根本性缺陷。 首先,要求事件监听器会导致在页面加载初期无法测量事件延迟,因为那时监听器可能还未注册。 其次,只对输入延迟感兴趣的开发者可能被迫为原本没有监听器的事件新增监听器。 这会为事件延迟计算带来不必要的性能开销。 最后,通过这种方法很难测量事件引起的异步工作。
本规范为事件延迟监测提供了另一种解决方案,解决了上述部分问题。 由于时间戳由用户代理计算,测量性能不再需要事件监听器。 这意味着,即使是页面加载早期发生的事件也能被捕获。 同时,这种方式能让分析服务商无需尝试订阅所有可能的事件,也能观察到慢事件。 此外,网站性能不会因不必要的事件监听器而受到影响。 最后,本规范允许开发者获取事件处理后紧接着发生的渲染时序的详细信息, 这有助于衡量事件触发的网站修改所带来的性能开销。
1.2. 交互
本节为非规范性内容。
单次用户交互
(有时称为手势)通常由多个物理硬件输入事件组成。
每个物理输入事件可能会导致用户代理分发多个UIEvent
,
每个事件又可能触发多个自定义事件监听器,或触发不同的默认操作。
例如,用户在触屏设备上的一次“点击”交互实际上由一系列物理输入事件构成:
-
触摸开始,
-
微小的触摸移动,
-
触摸结束。
这些物理输入事件可能会分发一系列UIEvent
:
-
……以及可能间穿插的一些焦点事件、输入事件等。
这些单独的UIEvent
都将成为各自PerformanceEventTiming
条目报告的候选对象,这对于详细时序分析很有价值。
注意:pointermove
和touchmove
当前未纳入事件时序考虑。
不过,本规范还定义了一种机制,可通过interactionId
将相关的PerformanceEventTiming
分组为交互
。
这一机制可以用于定义页面响应指标,称为Interaction to
Next Paint (INP)。
1.3. 首次输入
本节为非规范性内容。
用户的第一个交互
通常对用户体验影响极大,也往往表现出较慢的响应。
为此,事件时序API会暴露 首次输入 的时序信息,
即 Window
的第一个 PerformanceEventTiming
条目,且 interactionId
不为0。
与大多数 PerformanceEventTiming
不同,
首次输入条目即使未超过指定的 durationThreshold
也会被报告,并且即使未超过默认104毫秒的阈值也会被缓冲。
这一机制可用于定义页面响应性指标,称为首次输入延迟(FID)。
这也使开发者能够更好地衡量百分位数和性能提升,即使是那些始终响应非常快的页面,也能纳入数据,无需注册事件处理器。
1.4. 暴露的事件
事件时序 API 只为特定事件暴露时序信息。
-
如果 event 的
isTrusted
属性值为 false,则返回 false。 -
如果 event 的
type
是以下之一:auxclick
、click
、contextmenu
、dblclick
、mousedown
、mouseenter
、mouseleave
、mouseout
、mouseover
、mouseup
、pointerover
、pointerenter
、pointerdown
、pointerup
、pointercancel
、pointerout
、pointerleave
、gotpointercapture
、lostpointercapture
、touchstart
、touchend
、touchcancel
、keydown
、keypress
、keyup
、beforeinput
、input
、compositionstart
、compositionupdate
、compositionend
、dragstart
、dragend
、dragenter
、dragleave
、dragover
、drop
, 返回 true。 -
返回 false。
注意:mousemove
、
pointermove
、
pointerrawupdate
、
touchmove
、
wheel
、
以及 drag
被排除在外,因为这些属于“连续”事件。
当前API没有足够的指导来统计和聚合这些事件,从而基于条目获得有意义的性能指标。
因此,这些事件类型不被暴露。
1.5. 事件的测量时机
本节为非规范性内容。 它以较高层次说明了§ 3 处理模型部分所暴露的信息。
事件时序信息只在特定事件发生时被暴露,并且仅当用户输入与后续绘制操作之间的时间差超过某个持续时间阈值时才会暴露。
事件时序 API 会暴露一个 duration
值,表示物理用户输入发生的时间
(通过 Event
的
timeStamp
估算)
到其 Event
的
相关全局对象的 关联文档被更新的下一次渲染之间的时间。
此值以 8 毫秒粒度提供。
默认情况下,事件时序 API 会在 duration
大于等于 104 毫秒时进行缓冲和暴露,
但开发者可以通过 PerformanceObserver
设置不同的阈值来观察未来条目。
注意,这不会改变已缓冲的条目,因此 buffered
标志仅用于接收持续时间大于等于默认阈值的历史条目。
一个 Event
的
延迟是浏览器即将运行事件处理器的时间与 Event
的
timeStamp
之间的差值。
前者作为 PerformanceEventTiming
的
processingStart
暴露,
后者作为 PerformanceEventTiming
的
startTime
暴露。
因此,事件的延迟可按如下方式计算:
。
processingStart
startTime
需注意,事件时序 API 会为事件创建条目,无论是否有事件监听器。
特别是,第一个点击或第一个按键可能并不是用户实际打算与页面功能交互;
许多用户在阅读时会选择文本,或点击空白区域以控制焦点。
这是为了捕获页面监听器注册过晚的问题,也为了捕获那些虽然没有监听器但仍有意义的输入(如悬停效果)的性能。
开发者可以通过忽略那些
基本为零的条目来忽略这些情况,
因为 processingEnd
processingStart
processingEnd
是事件分发算法结束的时间。
1.6. 使用示例
const observer= new PerformanceObserver( function ( list, obs) { for ( let entryof list. getEntries()) { // 输入延迟 const inputDelay= entry. processingStart- entry. startTime; // 处理时长 const processingDuration= entry. processingEnd- entry. processingStart; // 呈现延迟(近似) const presentationDelay= Math. max( 0 , entry. startTime+ entry. duration- entry. processingEnd); // 获取一些关于此事件目标的信息,如id。 const targetId= entry. target? entry. target. id: 'unknown-target' ; console. log( entry. entryType, entry. name, entry. duration, { inputDelay, processingDuration, presentationDelay}); } }); observer. observe({ type: 'first-input' , buffered: true }); observer. observe({ type: 'event' , buffered: true , durationThreshold: 40 });
如下示例计算一个字典,将interactionId
映射到其事件的最大持续时间。
该字典随后可聚合并上报给分析系统。
let maxDurations= {}; new PerformanceObserver( list=> { for ( let entryof list. getEntries()) { if ( entry. interactionId> 0 ) { let id= entry. interactionId; if ( ! maxDurations[ id]) { maxDurations[ id] = entry. duration; } else { maxDurations[ id] = Math. max( maxDurations[ id], entry. duration); } } } }). observe({ type: 'event' , buffered: true , durationThreshold: 16 });
下列为使用此API可实现的示例场景:
-
收集网站上的首次输入延迟数据,并跟踪其性能变化。
-
点击按钮后改变表格排序顺序。测量从点击到重新排序内容显示所需的时间。
-
用户拖动滑块控制音量。测量拖动滑块的延迟。
-
鼠标悬停菜单项触发弹出菜单。测量弹出菜单出现的延迟。
-
测量首次用户点击(当点击是首次交互时)的第75百分位延迟。
2. 事件时序
事件时序增加了如下接口:
2.1.
PerformanceEventTiming
接口
[Exposed =Window ]interface :
PerformanceEventTiming PerformanceEntry {readonly attribute DOMHighResTimeStamp processingStart ;readonly attribute DOMHighResTimeStamp processingEnd ;readonly attribute boolean cancelable ;readonly attribute Node ?;
target readonly attribute unsigned long long interactionId ; [Default ]object (); };
toJSON
PerformanceEventTiming
对象报告一个关联的Event
的时序信息。
每个PerformanceEventTiming
对象都具有关联概念,初始值均设为
:
-
一个eventTarget 包含关联的
Node
。
target
属性的getter需执行以下步骤:
-
如果 this 的 eventTarget 不满足 为绘制时序暴露(参数为null),则返回null。
-
返回 this 的 eventTarget。
注意: 实现事件时序API的用户代理需在supportedEntryTypes
中包含"first
"和"event
",
适用于Window
环境。
这样开发者可以检测事件时序的支持情况。
本节其余内容为非规范性说明。
PerformanceEventTiming
的属性值在§ 3 处理模型中设定,
此处为各属性设定方式的概要说明。
PerformanceEventTiming
扩展了如下PerformanceEntry
接口属性:
name
name
属性的getter返回关联事件的type
。entryType
entryType
属性的getter返回"event"(用于长事件)或"first- input"(用于第一次用户交互)。startTime
startTime
属性的getter返回关联事件的timeStamp
。duration
duration
属性的getter返回 更新渲染步骤在关联事件的Document
被分发后完成的时间,与startTime
的差值,结果四舍五入到最近的8ms。
PerformanceEventTiming
还具备以下附加属性:
processingStart
-
processingStart
属性的getter返回在事件分发算法开始时捕获的时间戳。即事件处理器即将执行时。 processingEnd
-
processingEnd
属性的getter返回在事件分发算法结束时捕获的时间戳。即事件处理器执行完毕时。若无处理器,则等于processingStart
。 cancelable
-
cancelable
属性的getter返回关联事件的cancelable
属性值。 target
-
target
属性的getter返回关联事件的最后一个target
,且该Node
未断开连接且不在shadow DOM中。 interactionId
-
interactionId
属性的getter返回唯一标识触发关联事件的用户交互
的编号。 当关联事件的type
不属于以下情况时,该属性为0:-
一次
pointerdown
、pointerup
或click
且属于点击或拖拽手势。需要注意,结束于滚动的pointerdown
被排除。
-
2.2. EventCounts
接口
[Exposed =Window ]interface {
EventCounts readonly maplike <DOMString ,unsigned long long >; };
EventCounts
对象是一个映射,其中键为事件类型,值为已分发该type
的事件数量。
仅当事件的type
被PerformanceEventTiming
条目支持(见§ 1.4 暴露的事件)时,才会被此映射统计。
2.3. 对Performance
接口的扩展
[Exposed =Window ]partial interface Performance { [SameObject ]readonly attribute EventCounts ;
eventCounts readonly attribute unsigned long long ; };
interactionCount
eventCounts
属性的 getter 返回this的相关全局对象的eventCounts。
interactionCount
属性的 getter 返回this的相关全局对象的interactionCount。
3. 处理模型
3.1. 对 DOM 规范的修改
一旦 [DOM] 被修改,本节将被移除。
在第1步之后,添加以下步骤:
-
令 interactionId 为 计算 interactionId(传入 event)的结果。
-
令 timingEntry 为 初始化事件时序(传入 event、当前高精度时间 和 interactionId)的结果。
在该算法返回步骤之前,添加如下步骤:
注意: 如果某个用户代理跳过了 事件分发算法,它仍然可以选择为该 Event
包含一个条目。
在这种情况下,它会估算 processingStart
的值,并将 processingEnd
设置为同一值。
3.2. 对 HTML 规范的修改
一旦 [HTML] 被修改,本节将被移除。
每个 Window
具有以下相关概念:
-
待入队条目,一个列表,用于存储
PerformanceEventTiming
对象,初始为空。 -
已分发输入事件,一个布尔值,初始设为 false。
-
用户交互值,一个整数,初始设为 100 到 10000 之间的随机整数。
注意: 用户交互值 被设置为随机整数而非0,是为了防止开发者依赖它来统计页面交互次数。 以随机值起始,开发者更不容易将其作为页面交互次数的唯一数据来源。
-
待处理 keydown,一个 有序映射,键为整数,值为
PerformanceEventTimings
,初始为空。 -
指针交互值映射,一个 有序映射,初始为空。
-
待处理 pointerdown,一个 有序映射,键为整数,值为
PerformanceEventTimings
,初始为空。 -
已触发 contextmenu,一个布尔值,初始设为 false。
-
eventCounts,一个映射,条目格式为 type → numEvents。 表示已分发 numEvents 个
type
类型的事件。 构造Performance
对象且其 相关全局对象为Window
时, 其 eventCounts 必须初始化为一个包含所有用户代理支持的事件类型(见 § 1.4 暴露的事件)且值为0的映射。 -
interactionCount,一个整数,统计所有唯一
interactionId
(通过 计算 interactionId 得到)对应的不同用户交互总数。
-
对于 docs 中每个完全激活的
Document
, 调用算法 分发待处理事件时序条目,传入该Document
。
3.3. 对 Performance Timeline 规范的修改
一旦 [PERFORMANCE-TIMELINE-2] 被修改,本节将被移除。
PerformanceObserverInit
字典进行了增强:
partial dictionary PerformanceObserverInit {DOMHighResTimeStamp ; };
durationThreshold
3.4. 应添加 PerformanceEventTiming
注意: 下述算法用于 [PERFORMANCE-TIMELINE-2]
规范中,以决定何时将 PerformanceEventTiming
条目加入 PerformanceObserver
的缓冲区或性能时序,如 注册表所述。
PerformanceEventTiming
entry 和一个 PerformanceObserverInit
options,判断是否应添加 PerformanceEventTiming,传入 entry
和可选 options,按以下步骤执行:
-
如果 entry 的
entryType
属性值等于 "first
",返回 true。- input -
断言 entry 的
entryType
属性值等于 "event"。 -
令 minDuration 计算如下:
-
若 options 不存在,或 options 的
durationThreshold
不存在,则 minDuration 设为 104。 -
否则,minDuration 取 16 与 options 的
durationThreshold
值之间的最大值。
-
-
若 entry 的
duration
属性值大于等于 minDuration,返回 true。 -
否则,返回 false。
3.5. 增加交互计数
Window
window 对象时,执行以下步骤:
-
将 window 的 用户交互值 增加一个由用户代理选择的小数。
-
令 interactionCount 为 window 的 interactionCount。
-
将 interactionCount 设为 interactionCount + 1。
注意:用户交互值每次增加一个由用户代理选择的小数,而不是1,是为了防止开发者将其视为网页应用中发生的用户交互次数计数器。 这样,用户代理可以选择在pointerdown时主动分配一个用户交互值,然后在(如pointercancel)后丢弃,而不是延迟计算该值。
用户代理可以选择每次增加一个小的随机整数,或者选择一个常数。
用户代理不得为所有 Windows
使用共享的全局 用户交互值,因为这可能导致跨域泄露。
3.6. 计算 interactionId
-
如果 event 的
isTrusted
属性值为 false,则返回 0。 -
令 type 为 event 的
type
属性值。 -
若 type 不为以下之一:
keyup
、compositionstart
、input
、pointercancel
、pointerup
、click
、 或contextmenu
, 返回 0。注意:
keydown
和pointerdown
会在 完成事件时序时标记为待处理,然后在后续事件(如keyup
和pointerup
)计算 interactionId 时更新。 -
令 window 为 event 的 相关全局对象。
-
令 pendingKeyDowns 为 window 的 待处理 keydown。
-
令 pointerMap 为 window 的 指针交互值映射。
-
令 pendingPointerDowns 为 window 的 待处理 pointerdown。
-
若 type 为
keyup
:-
若 event 的
isComposing
属性值为 true,则返回 0。 -
令 code 为 event 的
keyCode
属性值。 -
若 pendingKeyDowns[code] 不存在,则返回 0。
-
令 entry 为 pendingKeyDowns[code]。
-
增加交互计数于 window。
-
令 interactionId 为 window 的 用户交互值。
-
将 entry 的
interactionId
设为 interactionId。 -
将 entry 添加到 window 的 待入队条目。
-
移除 pendingKeyDowns[code]。
-
返回 interactionId。
-
-
若 type 为
compositionstart
: -
若 type 为
input
:-
若 event 不是
InputEvent
的实例,则返回 0。 注意:此检查用于排除Event
的type
为input
,但不是文本内容修改的情况。 -
若 event 的
isComposing
属性值为 false,则返回 0。 -
增加交互计数于 window。
-
返回 window 的 用户交互值。
-
-
否则(type 为
pointercancel
、pointerup
、click
、 或contextmenu
):-
令 pointerId 为 event 的
pointerId
属性值。 -
若 type 为
click
:-
若 pointerMap[pointerId] 不存在,则返回 0。
-
令 value 为 pointerMap[pointerId]。
-
移除 pointerMap[pointerId]。
-
返回 value。
-
-
断言 type 为
pointerup
、pointercancel
、 或contextmenu
。 -
若 pendingPointerDowns[pointerId] 不存在:
-
若 type 为
contextmenu
, 返回 window 的 用户交互值。 -
若 type 为
pointerup
且 window 的 已触发 contextmenu 标志为 true:-
将 window 的 已触发 contextmenu 标志设为 false。
-
返回 window 的 用户交互值。
-
-
否则,返回 0。
-
-
令 pointerDownEntry 为 pendingPointerDowns[pointerId]。
-
断言 pointerDownEntry 是
PerformanceEventTiming
条目。 -
若 type 为
pointerup
或contextmenu
:-
增加交互计数于 window。
-
将 pointerMap[pointerId] 设为 window 的 用户交互值。
-
将 pointerDownEntry 的
interactionId
设为 pointerMap[pointerId]。
-
-
将 pointerDownEntry 添加到 window 的 待入队条目。
-
移除 pendingPointerDowns[pointerId]。
-
若 type 为
contextmenu
, 将 window 的 已触发 contextmenu 设为 true。 -
若 type 为
pointercancel
, 返回 0。 -
返回 pointerMap[pointerId]。
-
注意: 该算法尝试将事件分配到对应的 interactionId。
对于键盘事件,keydown
触发新交互 ID,keyup
需与之前的 keydown
匹配。
对于指针事件,获取 pointerdown
后需等待 pointercancel
或 pointerup
才能确定其 interactionId
。
我们尝试让 click
匹配之前 pointerdown
的交互 ID。
如果出现 pointercancel
或 pointerup
,
就可以为存储的 pointerdown
条目设置 interactionId
。
如果是 pointercancel
,
表示不需要为 pointerdown
分配新交互 ID。
如果是 pointerup
,
则计算新的交互 ID,并分别设置到 pointerdown
和 pointerup
(以及稍后出现的
click
)。
3.7. 初始化事件时序
-
如果用于判断 event 是否纳入事件时序考虑的算法返回 false,则返回 null。
-
令 timingEntry 为在 event 的 相关领域中新建的
PerformanceEventTiming
对象。 -
将 timingEntry 的
entryType
设为 "event
"。 -
将 timingEntry 的
processingStart
设为 processingStart。 -
将 timingEntry 的
cancelable
设为 event 的cancelable
属性值。 -
将 timingEntry 的
interactionId
设为 interactionId。 -
返回 timingEntry。
3.8. 完成事件时序
-
若 timingEntry 为 null,则返回。
-
令 relevantGlobal 为 target 的 相关全局对象。
-
将 timingEntry 的
processingEnd
设为 processingEnd。 -
注意:由于事件时序API支持的事件类型,该断言成立。
-
将 timingEntry 的关联 eventTarget 设为 target。
注意:这会将 eventTarget 设为最后的事件目标。如果发生重定目标,则使用最靠近根节点的最后目标。
-
若 event 的
type
属性值为pointerdown
:-
令 pendingPointerDowns 为 relevantGlobal 的 待处理 pointerdown。
-
令 pointerId 为 event 的
pointerId
。 -
若 pendingPointerDowns[pointerId] 存在:
-
令 previousPointerDownEntry 为 pendingPointerDowns[pointerId]。
-
将 previousPointerDownEntry 添加到 relevantGlobal 的 待入队条目。
-
-
将 pendingPointerDowns[pointerId] 设为 timingEntry。
-
将 window 的 已触发 contextmenu 设为 false。
-
-
否则,若 event 的
type
属性值为keydown
:-
若 event 的
isComposing
属性值为
:true -
将 timingEntry 添加到 relevantGlobal 的 待入队条目。
-
返回。
-
-
令 pendingKeyDowns 为 relevantGlobal 的 待处理 keydown。
-
令 code 为 event 的
keyCode
属性值。 -
若 pendingKeyDowns[code] 存在:
-
令 previousKeyDownEntry 为 pendingKeyDowns[code]。
-
若 code 不为 229:
-
将 window 的 用户交互值 增加一个由用户代理选择的小数。
-
将 previousKeyDownEntry 的
interactionId
设为 window 的 用户交互值。
注意:229 是特殊情况,对应于输入法键盘事件。用户代理有时会多次发送这些事件,并不对应于长按某键。
-
-
将 previousKeyDownEntry 添加到 window 的 待入队条目。
-
-
将 pendingKeyDowns[code] 设为 timingEntry。
-
-
否则:
-
将 timingEntry 添加到 relevantGlobal 的 待入队条目。
-
3.9. 分发待处理事件时序条目
Document
doc 时,执行以下步骤:
-
令 window 为 doc 的 相关全局对象。
-
令 renderingTimestamp 为 当前高精度时间。
-
对于 window 的 待入队条目中的每个 timingEntry:
-
设置事件时序条目持续时间,传入 timingEntry、window 和 renderingTimestamp。
-
-
清空 window 的 待入队条目。
-
对于 window 的 待处理 pointerdown中的所有值中的每个 pendingPointerDownEntry:
-
设置事件时序条目持续时间,传入 pendingPointerDownEntry、window 和 renderingTimestamp。
-
-
对于 window 的 待处理 keydown中的所有值中的每个 pendingKeyDownEntry:
-
设置事件时序条目持续时间,传入 pendingKeyDownEntry、window 和 renderingTimestamp。
-
PerformanceEventTiming
timingEntry、Window
window 和 DOMHighResTimeStamp
renderingTimestamp 时,执行以下步骤:
-
若 timingEntry 的
duration
属性值非零,则返回。 -
令 start 为 timingEntry 的
startTime
属性值。 -
将 timingEntry 的
duration
设为DOMHighResTimeStamp
类型的renderingTimestamp
,粒度为 8ms 或更小。- start -
令 name 为 timingEntry 的
name
属性值。 -
执行以下步骤以更新事件计数:
-
令 eventCounts 为 window 的 eventCounts。
-
断言 eventCounts 包含 name。
-
设定 eventCounts[name] 为 eventCounts[name] + 1。
-
-
若 window 的 已分发输入事件为 false,且 timingEntry 的
interactionId
不为 0,执行以下步骤:
4. 安全与隐私注意事项
我们不希望为 Web 平台引入更多高精度定时器,因为这类定时器会带来安全隐患。
事件处理器的时间戳与 performance.now()
的精度相同。
由于 processingStart
和 processingEnd
可以在不使用本 API 的情况下被计算出来,
暴露这些属性不会带来新的攻击面。
因此,duration
是唯一需要额外考虑的属性。
duration
具有 8 毫秒的粒度(通过四舍五入计算)。
因此,无法利用此时间戳生成高精度定时器。
但它确实引入了开发者以往无法直接获取的新信息:事件处理后像素绘制所需的时间。
我们认为在暴露该时间戳时不存在安全或隐私方面的担忧,尤其考虑到其粒度。
为了只暴露最有用且信息量最少的新信息,我们决定将粒度设为 8 毫秒。
这也能保证在 120Hz 显示器上获得较为精准的时序。
选择 104ms 作为 duration
的默认阈值,只是第一个大于 100ms 的 8 的倍数。
一个事件如果经四舍五入后的持续时间大于等于 104ms,则其未四舍五入前的持续时间必然大于等于 100ms。
这类事件未能在 100ms 内被处理,极可能对用户体验造成负面影响。
选择 16ms 作为 durationThreshold
的最小允许值,是因为这能满足典型场景需求,
即确保响应足够流畅。在 120Hz 显示器下,响应若跳过超过一帧,至少会达到 16ms,因此该用户输入对应的条目会在 API 中以最小值被暴露。