上一篇写 Renderer Pipeline 时,我关注的是资源回来之后,页面如何从 DOM/CSS 变成屏幕上的一帧。
写完那一篇之后,很自然会换到另一个方向:
text
如果用户已经看到页面了,接下来点了一下、滚了一下,Chromium 里会发生什么?
我以前很容易把这个过程想成:
text
用户点击页面
-> 触发 JS click handler
-> 页面更新
这个说法只描述了最表面的结果。
从 Chromium 内部看,点击不是直接调用 JavaScript,滚动也不一定走 Blink main thread。
一次输入进来之后,Chromium 要先判断它属于浏览器 UI,还是属于网页内容;如果属于网页内容,还要跨进程送到正确的 renderer;到 renderer 之后,又可能走 compositor fast path,也可能进入 Blink main thread,最后才可能变成 DOM event、JS handler 和下一帧渲染。
这篇我想讲清楚的就是这个问题:
text
一次点击或滚动为什么可能走 Browser UI、compositor fast path 或 Blink main thread?
输入不是一开始就属于网页
用户在 Chrome 窗口里点了一下,这个输入最开始并不知道自己属于谁。
它可能是:
text
点了地址栏
点了返回按钮
点了权限弹窗
点了网页里的按钮
在页面里滚动
按了浏览器快捷键
按了页面监听的键盘事件
这些行为看起来都是"用户输入",但它们的目标完全不同。
所以输入进入 Chrome 后,第一件重要的事不是马上进入网页,而是先在 Browser process 侧判断目标。
如果目标是地址栏、工具栏、菜单、权限弹窗,事件会被浏览器 UI 自己处理。
如果目标是网页内容区域,它才会进入 WebContents / renderer 这条链路。

这张图的重点是:Input Pipeline 不是"点击 -> JS"一条直线,而是先经过 Browser process 判断目标,再决定是否进入页面 renderer。
浏览器 UI 和网页内容是两条分支
这一点对理解 Chromium 很重要。
Chrome 本身不是一个透明壳子。它有自己的地址栏、标签页、菜单、下载气泡、权限弹窗、快捷键系统。
所以 Browser process 必须先回答:
text
这个输入是给浏览器自己的?
还是给网页内容区域的?
这就形成了两个分支。

浏览器 UI 自己处理
例如:
text
点击地址栏
点击返回 / 刷新按钮
按 Ctrl+L / Ctrl+T
点击浏览器菜单
操作权限弹窗
这些事件通常停留在 Browser process 的 UI 层,由 Views、Aura、Chrome UI 或命令系统处理。
它们不会进入 Blink,也不会变成 DOM event。
页面内容继续路由
例如:
text
点击网页按钮
在输入框里输入文字
页面内滚动
触发 pointermove / click / keydown listener
这些事件会被送到对应的 WebContents,再通过 Browser 侧的 renderer host 体系路由到目标 renderer。
这条路才是我们通常说的"输入进入页面"。
页面输入也不是直接进入 JS
即使输入目标是网页内容,它也不会直接调用 JavaScript。
Browser process 侧大致会经过这样一段链路:
text
OS native input event
-> platform window receives event
-> ui::Event
-> target WebContents / RenderWidgetHostView
-> RenderWidgetHostImpl
-> InputRouter
-> WebInputEvent / WebCoalescedInputEvent
-> target renderer
这里有几个关键对象。
ui::Event 可以理解成 Chromium UI 层对平台输入事件的统一抽象。操作系统给 Chrome 的是平台原生事件,Chromium 不希望上层到处直接处理 macOS、Windows、Linux 各自的事件格式,所以会先转换成内部事件表示。
RenderWidgetHostView 更像 Browser 侧代表"页面可显示、可接收输入的那块区域"的 view。它知道页面内容嵌在浏览器窗口里的位置。
RenderWidgetHostImpl 是 Browser 侧代表 renderer widget 的核心对象。在输入链路里,它负责把页面相关输入转发给 renderer,并参与输入 ack、状态和路由。
InputRouter 负责排队、合并、发送和 ack 管理。
这也是为什么 Input Pipeline 不是"每个硬件事件都完整走一次 JS"。高频事件可能被合并,renderer 忙的时候事件可能排队,有些事件还需要等待页面是否取消默认行为。
从数据形态看,可以先记成:
text
OS event
-> ui::Event
-> WebInputEvent / WebCoalescedInputEvent
-> Blink DOM Event
-> JS callback / default action
-> DOM/CSS/layout invalidation
-> next frame
这条链路里,WebInputEvent 还不是 DOM event。
比如底层 mouse down / mouse up 进入 Blink 后,还要经过 hit test 和 DOM event dispatch,才可能进一步产生页面 JS 能看到的 click。
renderer 侧为什么会分两条路?
输入到达 renderer 后,也不是所有事件都走同一条路径。
一个很关键的分叉是:
text
compositor thread fast path
renderer main thread / Blink path

这张图想解释的是:部分滚动和手势可以基于已经提交的合成状态,由 compositor thread 更快处理;而 click、keydown 这类需要 Web 平台语义的事件,通常要进入 Blink main thread。
为什么滚动有时可以更快?
Renderer main thread 很容易被 JS、style、layout、paint 占住。
如果所有滚动都必须等待 main thread,那么页面只要 JS 忙一点,滚动就会非常容易卡。
Compositor fast path 的价值就在这里:
text
在满足条件时,部分滚动可以基于已有的 layer / scroll tree,直接由 compositor thread 更新视觉结果。
这样即使 main thread 正在跑 JS,滚动也可能保持流畅。
但这条快路径不是万能的。
如果页面注册了可能取消默认滚动行为的非 passive listener,或者存在 main-thread scrolling reason,滚动就可能需要回到 main thread。
所以我现在会这样理解:
text
compositor fast path 不是替代 Blink,
而是在某些输入场景下绕开 main thread 阻塞。
为什么 click / keydown 通常要进 main thread?
click、keydown 这类事件涉及 Web 平台语义。
它们需要知道:
text
点中了哪个 DOM 节点?
事件捕获和冒泡怎么走?
页面有没有注册 listener?
默认行为是什么?
JS 是否调用 preventDefault?
这些问题都需要 Blink main thread 参与。
所以输入到 renderer 后,还要经过 Blink 的 hit test、event dispatch,再根据页面注册的 listener 进入 V8 执行 JS。
一个按钮点击如何让页面变色?
用一个简单例子串起来会更清楚。
html
<button id="save">Save</button>
<script>
save.addEventListener('click', () => {
document.body.classList.add('saved');
});
</script>
用户点击这个按钮后,大致发生的是:
text
mouse down / mouse up
-> OS sends native input event to Chrome window
-> Browser process converts it to ui::Event
-> Browser process decides target is web content
-> RenderWidgetHostView receives content input
-> RenderWidgetHostImpl forwards WebInputEvent
-> InputRouter queues / sends event to target renderer
-> WidgetInputHandler receives event in renderer
-> Blink performs hit test and dispatches DOM click event
-> V8 executes click listener
-> JS changes body.classList
-> Blink marks style/layout/paint dirty
-> Renderer Pipeline produces next frame
-> Viz / GPU presents it
这里可以看到,JS handler 只是中间一段。
点击要真正变成"用户看到页面变色",还要继续进入 Renderer Pipeline,并最终显示到屏幕。
这也解释了为什么点击后不一定马上刷新。
如果 JS 没有修改页面,或者修改的是非视觉状态,用户可能看不到任何变化。
如果 JS 很慢、layout 很重、raster 或 presentation 慢,用户看到反馈也会延迟。
Input Pipeline 和 INP 的关系
学 Input Pipeline 之后,我对 INP 这类交互性能指标也更容易拆了。
INP 关注的是一次交互到下一次可见反馈之间的延迟。
它不只是 JS 执行时间。
一次交互慢,可能慢在:
text
input delay
-> 事件等待多久才开始处理
processing duration
-> handler / default action 执行多久
presentation delay
-> 处理完成后多久显示到屏幕
所以 INP 高不能简单归因于"JS 慢"。
它可能是 input routing 延迟,可能是 main thread 正忙,可能是 JS handler 太重,也可能是后续 style/layout/paint 或最终 present 慢。
这也把第五篇和第四篇连起来了:
text
Input Pipeline 把用户输入送到页面执行环境;
Renderer Pipeline 把页面状态变化变成下一帧。
点击没反应时,我现在会反着查
以前看到"点击没反应",我很容易直接问:
text
JS handler 有没有执行?
现在我会把它放回整条链路里看。

可以从用户是否看到下一帧开始反查:
text
用户是否看到下一帧反馈?
-> 如果没有,看 Renderer / Viz / GPU 是否提交并显示
-> 如果渲染没开始,看 DOM/CSS 是否被修改
-> 如果 DOM 没变,看 JS handler 是否执行
-> 如果 JS 没执行,看 Blink 是否分发 DOM event
-> 如果 Blink 没收到,看 renderer 是否收到 WebInputEvent
-> 如果 renderer 没收到,看 Browser 是否转发给目标 RenderWidgetHost
-> 如果 Browser 没转发,看事件是否被浏览器 UI 消费或目标判断错误
这个反查路径比一句"点击没到 JS"更有用。
因为输入问题可能发生在 Browser UI 目标判断、跨进程路由、renderer main thread、Blink event dispatch、JS、Renderer Pipeline、Viz/GPU 任意一段。
源码阅读时,我会先抓这些锚点
如果要继续看源码,我会先抓几组入口。
Browser UI / 平台事件侧:
text
ui/events/
ui/views/
ui/aura/
Browser 侧页面输入路由:
text
content/browser/renderer_host/
content/browser/renderer_host/input/
content/browser/renderer_host/render_widget_host_impl.h
content/browser/renderer_host/render_widget_host_impl.cc
输入事件数据结构:
text
third_party/blink/public/common/input/web_input_event.h
third_party/blink/public/common/input/web_coalesced_input_event.h
renderer / Blink 分发侧:
text
content/renderer/input/
third_party/blink/renderer/core/events/event_dispatcher.h
third_party/blink/renderer/core/dom/events/event_target.h
compositor 输入侧:
text
cc/input/
这一轮先顺着这些锚点建立地图,再去看 input ack、coalescing、gesture recognition、IME、hit testing、compositor scrolling reason 这些细节,才不容易陷进局部实现里。
我现在会怎么复述 Input Pipeline?
如果把这一篇压缩成一段话,我会这样讲:
text
Input Pipeline 是 Chromium 把用户输入从 OS 事件路由到浏览器 UI 或页面执行环境,并最终可能触发页面更新的链路。
输入事件先进入 Browser process,被转换成 ui::Event,然后 Browser process 判断目标属于浏览器 UI 还是网页内容。如果是地址栏、菜单、快捷键等浏览器 UI 输入,事件可能直接被 Browser 侧消费;如果目标是页面内容,就会通过 RenderWidgetHostView、RenderWidgetHostImpl、InputRouter 等对象转发给目标 renderer。
到 renderer 后,输入会根据类型和页面状态分流。部分滚动可以走 compositor fast path,避免被 main thread 阻塞;click、keydown 等事件通常进入 Blink main thread,经过 hit test 和 DOM event dispatch,再进入 V8 执行 JS listener 或触发默认行为。
如果 JS 或默认行为改变了 DOM/CSS/滚动状态,后续才会进入 Renderer Pipeline,生成下一帧并交给 Viz/GPU 显示。
更短一点,就是:
text
OS input
-> Browser target routing
-> content renderer host
-> renderer compositor/main thread
-> Blink DOM event
-> JS/default action
-> Renderer Pipeline
-> next frame
最后
写 Renderer Pipeline 时,我关心的是:页面状态如何变成屏幕上的一帧。
写 Input Pipeline 时,我开始关心另一件事:
text
用户的一次点击或滚动,如何被正确送到该处理它的地方?
这也是为什么"点击直接调用 JS"这个说法太简单。
在 Chromium 里,输入先经过 Browser process 的目标判断,再进入 content 的跨进程路由,到了 renderer 后还要根据事件类型和页面状态分流。只有当输入真的触发了 DOM/CSS 状态变化,后续才会接上 Renderer Pipeline,变成用户看到的下一帧。
下一篇就可以继续沿着这条链路往 JS 方向走:当 DOM event 进入 JS listener 后,Blink 和 V8 如何协作执行 JavaScript,并通过 Web API 改变页面状态?这会进入 V8 / JavaScript Pipeline。