纯 Rust 的浏览器截图是怎么做的:不依赖 Chromium,喂给 Blitz 布局和绘制

AginxBrowser 工程笔记 · 2026-08-21 · English

AginxBrowser 的产品承诺是「单二进制、零 Chromium」。这意味着截图功能不能走「下载 500MB Chromium 再调 headless」的路——那会直接毁掉整个卖点。但 agent 需要「看见」页面,截图是刚需。这个矛盾逼出了一条完全不同的实现路径,也让我们踩进了几个上游都不一定见过的坑。这篇文章是完整的技术复盘。

为什么不能用 headless Chromium

Puppeteer / Playwright 的截图依赖 Chromium,而 Chromium 本身是个浏览器——它的 JS 引擎、渲染引擎、网络栈全都要。AginxBrowser 已经有自己的 V8 和 HTTP 栈(obscura 内核),再拖一个 Chromium 进去是重复造两个浏览器。

我们的选型是 Dioxus Blitz:纯 Rust 的渲染栈,包含 Stylo(Servo 的 CSS 引擎)、Taffy(flexbox/grid 布局)、parley(文本排版)和 vello_cpu(纯 CPU 光栅化)。关键点在于 vello_cpu 不需要 GPU/wgpu——我们的生产服务器是无显存的,这条路径直接决定了可行性。

集成模型:Blitz 只画「已经看到的世界」

最早我们试过把 curl 抓到的原始 HTML 喂给 Blitz,结果复杂站点全白。原因是现代网页的可见内容依赖 JS 动态切换 display 和 CSS @media,原始 HTML 里的节点大多是 display:none

修正后的模型很干净:obscura 的 V8 先把页面 JS 跑完,拿到真正的最终 DOM,序列化成 HTML 字符串,再喂给 Blitz 只做布局 + 绘制。Blitz 不负责看世界,它负责把「agent 已经看到的世界」画出来。这样 JS、stealth、搜索、session 全部保留在原栈不动,Blitz 只接管布局和光栅化这一层。

实测数据:百度全页(6217px 高)从 JS 渲染到出 PNG 约 4 秒,paint_scene 本身只要几毫秒到十几毫秒;人民日报 17495px 高也在这个量级。vello_cpu 纯 CPU 渲染,单页耗时比 markdown 转换还低。

坑一:全白屏——一个上游假设带来的 bug

集成后第一个大问题是:真实站点喂进去全是白图,自构造的简单页却正常。逐层排查后根因链非常清晰:

  1. 我们传 net_provider: None,Blitz 会用 DummyNetProvider——一个 fetch 是空操作的假网络层;
  2. <head> 里的 <link rel="stylesheet"> 会被无条件记进 pending_critical_resources
  3. 但 DummyNetProvider 永远不会回调 → 资源状态永远不结束;
  4. paint_scene 开头检查「还有关键资源未加载」→ 直接 return,整页不绘制。

根因不是 Stylo/Taffy 能力不行——布局和样式解析一直是正确的。是「资源加载门控」逻辑没考虑「没有 net provider」的场景,错误地永久阻塞绘制。这是上游假设「总有 net provider 拉资源」的盲区,而「喂预渲染 DOM」正是我们这种集成的专属场景。

修复:给 NetProvider trait 加默认方法 is_noop()DummyNetProvider 返回 true;只有非 noop 时才把 head 里的样式表记进 pending。修复后百度从 1 种颜色(全白)到 1653 种,GitHub 从 1 到 516,人民日报从 1 到 615。这个 patch 后来提了 PR,被上游合并(#636),fork 就此退役。

坑二:CJK 挂死——为什么我们钉在旧版 parley

把依赖切回上游主线后,发现重 CJK 页面行布局无限挂死。百度连 release 构建都 240 秒+ 不返回,profile 定位到 parley 的 BreakLines::break_remaining 死循环(while break_next().is_some() {} 无进展)。二分后发现是 parley 0.11 的纯依赖 bump 引入的 regression——非 CJK 文字也能触发,触发条件是一组特定的内联 span 嵌套 block + CSS 约束。

我们给上游提了最小复现(686KB → 1.4KB,linebender/parley#752),然后选择把 blitz 钉在 2fa6434d(parley 0.10)这个已知良好的版本。代价是收不到后面几个无关的修复,但换来确定性的渲染。这是「钉住 vs 追新」的权衡,对生产服务来说答案很明确。

坑三:元素坐标——parent-relative 的位置和自相矛盾的裁剪

agent 要基于屏幕坐标操作页面,光有截图不够,得知道元素在哪。Taffy 的 final_layout().location 是父元素相对的,所以绝对坐标 = 沿 layout_parent 链(包含匿名块盒子,和绘制遍历一致)累加每个节点的 location。我们验证:百度结果卡片 9 个 rect,x=150、宽 608 精确堆叠;GitHub trending 17 篇卡片,x=8、宽 1264 均匀排列。

裁剪这块踩了个反直觉的坑。paint_scene 的 x/y offset 参数会平移绘制位置,但视口裁剪用的是 translate(-initial_x) 把它抵消掉——所以元素在未滚动窗口外会被裁成白图(上游只传过 0,0,从来没人触发这个路径)。正确机制是 set_viewport_scroll,它同时平移绘制和裁剪,两者保持一致。

还有一层诚实的边界:纯行内元素(<a>文字</a>)在 Taffy 里没有独立盒子(0×0),它的内容属于包含块的 inline layout。我们做了兜底——取元素后代盒的并集,能救 <a><img></a> 这种混合内容;仍为空的就报错,引导用户选块级祖先。

为什么这条路值得

Blitz 还是 beta,复杂站点的 CSS 是近似而非像素级精准,图片子资源不单独拉取(会缺图)。这些我们都如实写在 README 的已知限制里。但选择这条路换来的是:一个二进制、零 Chromium、无 GPU 也能出图、中文正常渲染、坐标和截图同源。对「给 agent 装眼睛」这件事,这是目前最合理的技术路线——而且所有坑都是我们自己蹚出来的,文档、patch、issue 都在,可复现。

想看真实的截图效果?

去体验 GitHub 开源