梦亦同趋

【碎碎念】关于浏览器的渲染

保持思考,保持学习。

试问哪个前端开发不想深入了解浏览器呢,想了解它的过去,彼此互动的细节,日日陪伴的点点滴滴...🙂‍↔️

#VSync

VSync,全称为Vertical Synchronization(垂直同步),是一种图形技术,用于将游戏或应用程序的帧率与显示器的刷新率同步,从而消除画面撕裂(Screen Tearing)现象。 画面撕裂是指显卡输出帧率与显示器刷新率不匹配时,屏幕上同时显示多个帧的部分内容,导致图像水平分裂。其核心是通过等待显示器的垂直空白间隔(VBlank)信号,将 GPU 的帧渲染输出与显示器的刷新率精确对齐,从而消除屏幕撕裂(Screen Tearing)。

#基础概念

  • 刷新率(Refresh Rate):显示器每秒刷新屏幕的次数,例如 60Hz 表示每 16.67ms 刷新一帧。
  • 帧率(Frame Rate):GPU 每秒渲染的帧数(FPS)。
  • VBlank(垂直空白间隔):显示器完成一帧扫描(从上到下逐行绘制)后,短暂的“空白期”(约 1-2ms),用于重置扫描位置。在此期间安全交换帧数据。
  • 屏幕撕裂原因:无 VSync 时,GPU 在显示器扫描中途提交新帧,导致屏幕同时显示旧帧上半部和新帧下半部,形成水平断裂线。

#显示器刷新原理

显示器(无论 CRT、LCD 或 OLED)刷新一帧的原理类似:逐行扫描

  • 从屏幕顶部开始,一行一行绘制像素(Active Scan 期,约 15ms)。
  • 扫描完底部后,进入 VBlank 期(约 1.67ms),电子束/扫描器返回顶部,准备下一帧。
  • 显示器在 VBlank 开始时发送 VSync 信号给 GPU(通过 HDMI/DisplayPort 等接口)。
  • 如果 FPS > 刷新率:GPU 渲染过快,会在 VBlank 后闲置等待(帧率上限锁定)。
  • 如果 FPS < 刷新率:可能跳帧(显示重复旧帧)或卡顿(等待累积)。

#与浏览器的关系

VSync(垂直同步)与浏览器密切相关,主要体现在浏览器渲染流水线中:浏览器使用 VSync 信号同步网页帧渲染与显示器刷新率(通常 60Hz,每 16.67ms 一帧),确保动画、滚动和视频播放平滑无撕裂(screen tearing)。 这类似于游戏中的 VSync,但浏览器优化为合成线程(Compositor)主导,减少主线程(JS/布局)阻塞导致的卡顿(jank)

#VSync 信号的来源(Source)

VSync 并非 Chrome 自己产生,而是来自操作系统或硬件

平台VSync 来源
AndroidSurfaceFlinger + HWC(Hardware Composer)通过 DispSync 软件 PLL 生成 1
LinuxDRM/KMS 子系统(Direct Rendering Manager / Kernel Mode Setting)提供vblank 事件
WindowsDirectX 的IDXGIOutput::WaitForVBlank() 或 DWM(Desktop Window Manager)合成器
macOSCore Video 的CVDisplayLink,与显示器刷新率同步
ChromeOS基于 Linux DRM,但通过Ozone 抽象层接入

#VSync 的捕获:VSyncProvider

Chrome 定义了 VSyncProvider 接口(//ui/compositor/vsync_provider.h),各平台实现具体逻辑:

  • Android: AndroidVSyncProvider
    • 注册 Choreographer.FrameCallback
    • 通过 JNI 调用 Java 层 Choreographer.postFrameCallback
  • Linux: DrmVSyncProviderWaylandVSyncProvider
    • 监听 DRM 的 DRM_EVENT_VBLANK 或 Wayland 的 frame 事件
  • macOS: DisplayLinkMac
    • 使用 CVDisplayLinkSetOutputCallback

当 VSync 到来时,这些 Provider 会调用:

cpp
vsync_observer_->OnVSync(vsync_time, interval);

#VSync 的分发:Viz Display Compositor

这是 Chrome 图形栈的核心——Viz(Visuals)系统,负责帧调度和合成。

#关键组件:

  • DisplayScheduler//components/viz/service/display/display_scheduler.cc
    • 接收 OnVSync
    • 触发 BeginFrame
    • 管理帧节奏(是否节流、是否跳过)。
  • BeginFrameSource
    • 将 VSync 转换为 BeginFrameArgs(含帧 ID、时间戳、间隔);
    • 广播给所有订阅者(如 Renderer 的合成线程)。
plaintext
[ Hardware VSync ] VSyncProvider::OnVSync() viz::DisplayScheduler::OnBeginFrameSourceNeedsBeginFrames() Generate BeginFrameArgs (frame_id, deadline, interval) Send via IPC to Renderer Process → Compositor Thread

#VSync 在 Renderer 进程中的使用

#合成线程(Compositor Thread)

  • 收到 BeginFrame 后:
    • 更新合成属性动画(transform, opacity
    • 检查是否需要请求主线程更新(needs_main_frame = true
    • 若不需要,则直接合成并提交帧。

#主线程(Main Thread)

终于到了喜闻乐见的主线程环节,下面的东西相对于前端将会相对熟悉些。

  • 若合成线程标记 needs_main_frame,则:
    • 执行 requestAnimationFrame 回调;
    • 运行微任务;
    • 执行 Style/Layout/Paint;
    • 提交 LayerTree 给合成线程。
#Task Queue(任务队列)

HTML Living Standard 定义的 window event loop(主线程事件循环)核心是:

  • Task Queue不是一个队列,而是每个 Task Source 一个独立的 queue(规范允许 UA 按优先级挑选)。 常见 Task Source(优先级由浏览器决定):
    • User interaction(click、keydown、scroll、pointer events)——最高优先
    • Rendering task source(专用于渲染)
    • Timer(setTimeout/setInterval)
    • Networking(fetch/XHR)
    • DOM manipulation、History 等
  • Microtask Queue:全局只有一个。来源:Promise.then/await、queueMicrotask()、MutationObserver、process.nextTick(Node)。
  • Microtask Checkpoint:每次宏任务执行完后立即执行,且一次性清空整个队列(即使中间又 enqueue 新 microtask)。
  • Rendering Opportunity:浏览器在并行监控(不占用主线程),当满足刷新率(通常 60Hz ≈16.7ms)、页面可见、DOM 有变化时,queue 一个 global task 到 Rendering Task Source,触发 "Update the rendering"

可见任务队列有多个,是根据任务来源对任务做了分类的结果。用户的输入事件具有最高优先级,这意味着相关代码会最先执行。

可以用下列伪代码来表示上述流程:

plaintext
1. Call Stack 为空 2. 执行 Microtask Checkpoint(清空所有微任务,直到队列为空) - Promise.then / await - queueMicrotask - MutationObserver ↓(这一步可能持续几毫秒) 3. 浏览器判断是否需要渲染(Rendering Opportunity?) - 是 → 选中 Rendering Task Source 的 task 执行: • rAF 回调(带高精度时间戳) • Style Recalc • Layout • Paint • Commit to Compositor - 否 → 跳过 4. 挑选下一个 Macrotask(从所有 Task Queue 挑,Chrome 优先级:Input > Animation > Default > Idle) - 执行该宏任务(可能又产生新微任务) - 执行完后再次 Microtask Checkpoint 5. 循环回到第 1 步

Chrome 把主线程分成多个阶段,Performance 面板里你能看到精确的黄色块(Main Thread):

一次典型帧(Frame)的流程(~16.7ms 内必须完成)

  1. BeginFrame(VSync 信号到来)
  2. Input 处理(如果有用户事件,优先执行)
  3. JS 执行(当前宏任务 + 所有微任务)
  4. rAF 回调阶段(如果这是 Rendering Task)
  5. Style Recalculation
  6. Layout(Reflow / Recalculate Layout)
  7. Paint(Record paint commands,不真正画像素)
  8. Commit(IPC 发给 Compositor Thread)
  9. Compositor Thread:Layer 合成、GPU Raster(真正画到屏幕)

1_ed5fWSwA7Oe9UwVxFOt5w.jpg

你可以通过下面一段代码来验证流程:

js
console.log('1. sync start'); setTimeout(() => console.log('5. setTimeout'), 0); Promise.resolve().then(() => console.log('3. Promise')); requestAnimationFrame(() => console.log('4. rAF')); queueMicrotask(() => console.log('2. microtask')); console.log('sync end');