Chromium 源码学习笔记(五):一次点击,在进入 JS 之前先经历了什么?

上一篇写 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 侧消费;如果目标是页面内容,就会通过 RenderWidgetHostViewRenderWidgetHostImplInputRouter 等对象转发给目标 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。

相关推荐
hunterandroid1 小时前
WorkManager 可靠性实战:唯一任务、重试与幂等设计
android·前端
鹿目1 小时前
使用 Vant CLI 搭建 UI 组件库:从 H5 到小程序跨平台
前端·javascript
默_笙1 小时前
😭 我花了两小时找了一个数据结构 bug,才把"迷你 Cursor"跑起来,绝望(bushi)
前端·javascript
竹林8181 小时前
wagmi v2 监听合约事件踩坑记:从 `useContractEvent` 到 `watchContractEvent`,我重构了三版才搞定实时数据流
前端·javascript
天若有情6732 小时前
纯HTML+Tailwind单页作品集网站——零框架静态前端完整源码
前端·html
WebGirl2 小时前
H5唤起app前端实现方案
前端
heimeiyingwang2 小时前
【架构实战】SQL注入与XSS防御:常见Web漏洞的系统性修复
前端·sql·架构
Larcher2 小时前
从零实现一个可运行的 RAG - LangChain、内存向量库与检索增强生成
前端·javascript