返回知识中心
平台

Chrome headless 新与旧:shell、模式与实际差异

统一 headless 是无窗口的完整 Chrome,旧 shell 是独立二进制。一次实测记录说明哪些字段真的变了,哪些仍与有窗口模式不同。

BotBrowser Team

文档中心

想直接进入 平台 文档吗?

这篇文章属于知识中心的指南库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

Chrome 用一个名字交付了两套 headless 实现,差别属于架构层面,而不是外观修饰。BotBrowser 提供已声明的配置档案,使页面可读取的各项表面在多次启动之间保持一致,它不会更改 Chrome 启动的是哪个二进制文件;模式这个问题必须在配置档案生效之前回答。这条分界容易说明,也容易在脚本里写错:一种模式是没有可见窗口的完整浏览器,另一种是不共享渲染器、也不共享扩展系统的独立可执行文件。

摘要

  • 自 Chrome 112 起,--headless 运行无窗口的完整浏览器;这就是该术语今天的意思。
  • 旧实现是独立二进制 chrome-headless-shell,自 Chrome 132 起只通过 Chrome for Testing 分发。
  • 两者的差别在页面可观测之处:在下面记录的运行中,shell 报告零个插件、没有 window.chrome 对象,而统一 headless 两者都有。
  • 两者都不是有窗口模式,也都不与有窗口模式完全一致;webdriver 在三种情况下都是 true,因为那是客户端设置的,与模式无关。
  • Puppeteer 用 headless: true 与 headless: 'shell' 在两者之间选择,Playwright 使用 executablePath 或自带 channel。
  • 判断启动的是哪一个,要读二进制文件名和用户代理 token,不要假设参数。

目录

三列。统一 headless 是无窗口的完整 Chrome 二进制,报告插件与 chrome 对象。headless shell 是独立二进制,报告零个插件、没有 chrome 对象。有窗口模式是第三列,带真实窗口。底部说明:记录实际运行的是哪一种靠用户代理 token,而不是启动参数。

两种实现,一个参数

这段历史重要,是因为 2023 年以前写的大多数脚本都固化了旧假设。最初的 headless 是一套独立实现,恰好随同一个 Chrome 二进制一起发布;Chrome 文档说明它并不共享 //chrome 浏览器代码。Chrome 112 给浏览器加入了统一 headless 模式:Chrome 会创建它在有窗口模式下会创建的平台窗口,只是不显示它们,文档称其余所有功能都可用且没有限制。

old headless   -> separate implementation, own binary since Chrome 132
new headless   -> full Chrome, no visible window, since Chrome 112

自 Chrome 132 起,旧实现只以 chrome-headless-shell 形式存在,这是通过 Chrome for Testing 面板发布的独立二进制,而 Puppeteer 文档补充了一个运维细节:shell 并不完全复现常规 Chrome 的行为,不过对不需要完整功能集的任务性能更好。

仅这一句就解释了搜索结果里的大部分混淆。chrome headless new 和 chrome headless new vs old 问的不是某个参数两次改变含义,而是脚本正在启动两个可执行文件中的哪一个,这个答案决定了扩展加载、媒体编解码器和插件枚举是否符合作者的预期。

实用的检验不是参数字符串,而是产物。包含 chrome-headless-shell 的构建目录就是旧实现,与参数无关;指向完整浏览器并带 --headless 的启动就是统一模式,哪怕命令行上出现的是同一个词。

两者长期共用一个名字是有原因的。它们回答同一个问题,浏览器能否在无人注视的地方运行,而且都做到了。只有当调用方依赖 shell 省略掉的组件时,它们才分道扬镳:扩展系统、插件注册表、编解码器集合,以及页面仍能通过 window 对象观察到的那些浏览器界面部分。只对静态页面截图的 pipeline 不会注意到差别,而加载扩展的 pipeline 立刻就会注意到。

这也是为什么迁移 pipeline 是分两步的改动,而不是改一个参数。从 shell 转到统一 headless 改变了浏览器报告的内容,所以按 shell 数值写的断言会因为正确的原因开始失败;反向迁移又让它们通过,这更糟,因为测试通过的同时,测的是一个比用户实际运行更小的浏览器。固定版本的 Puppeteer 让这一点变得具体:v22 之前默认是 shell,文档记录现在默认是统一模式,所以仅升级依赖就会改变实际运行的实现。

三种模式报告了什么

一次本地运行在同一主机的三个会话中记录了相同字段,使用原版 Chromium 构建,每个会话一个一次性配置档案。下面的数值是那次运行打印的结果,不是对每个构建的断言。

unified headless  HeadlessChrome/131.0.0.0      plugins=5  chrome=object   1280x720  outerW=1280
headless shell    HeadlessChrome/131.0.6778.33  plugins=0  chrome=undefined 1280x720 outerW=1280
headed            Chrome/131.0.0.0              plugins=5  chrome=object   1280x720  outerW=1282

webdriver=true in all three (the client set it, not the mode)
devicePixelRatio=1 in all three

两个字段把 shell 与另外两者分开,而且两者都属于页面用单个表达式就能读到的值。shell 报告 navigator.plugins.length === 0,而统一 headless 与有窗口模式都报告 5,这源于 shell 排除了注册这些条目的浏览器组件。shell 还报告 typeof window.chrome === 'undefined',另外两者报告的是对象。

navigator.plugins.length         shell 0   unified 5   headed 5
typeof window.chrome             shell undefined  unified object  headed object
user agent token                 HeadlessChrome in both headless modes, Chrome in headed

窗口尺寸是更有意思的结果,因为三者在这一点上相同:innerWidth 1280、innerHeight 720、screen.width 1280、devicePixelRatio 1。只有 outerWidth 不同,而且只在有窗口会话里差两个像素,那属于窗口装饰的产物,不是模式属性。这是对一个常见假设的有用更正:这三种模式在视口上并无差异,所以它们之间的截图比较并没有在测作者以为的东西。

该发现的实用价值在于对照。在把渲染差异归因于模式之前,先用同一模式运行两次同一 fixture 并确认稳定,再用另一种模式运行一次并比对两次结果。若同模式的一对结果不同,差异就是动画、懒加载或定时器带来的测量噪声,在修正之前不能解读跨模式比较。若同模式一对完全相同而跨模式一对不同,模式或二进制就是可信原因,四个已记录字段会说明是哪一个动了。

有三个被刻意排除在表外的字段值得点名,因为读者最先问的正是它们。navigator.webdriver 在三个会话中都是 true,但那是客户端的作为:这里每个自动化库都会在任何脚本运行之前在页面上设置它,所以它说明不了模式,拿它做跨模式比较只会得到假结论。Notification.permission 在三次中都读到 denied,navigator.hardwareConcurrency 在三次中都读到 16,那是主机的核心数,不是窗口状态能改变的。

field                       unified   shell   headed
navigator.webdriver         true      true    true    (client-set, not mode)
Notification.permission     denied    denied  denied
navigator.hardwareConcurrency  16      16      16
navigator.language          en-US     en-US   en-US

这个代码块就是模式比较需要的否定结果,因为它在归因任何差异之前先排除三个候选解释。读者若在两次运行的这些字段里发现差异,看到的是主机或客户端的差异,不是 headless 与有窗口之间的差异。

用户代理 token 是应当记录而不是推断的字段。两种 headless 模式都报告 HeadlessChrome,有窗口模式报告 Chrome,版本尾号不同是因为两个构建处于不同补丁级别,这是要把构建信息与模式一并记录的第二个理由。

关于这个 token 还有一个提醒,对任何跨时间比较版本的人都重要。它既是客户端覆盖值,也是二进制属性:设置了自定义用户代理的启动会替换页面读到的字符串,而底下的二进制并未改变。所以 token 告诉你页面看到了什么,也就是站点读到的东西,而二进制路径告诉你实际运行了什么。同时记录两者,后来的读者才能区分升级与覆盖。

每个工具如何选择模式

三种常用客户端表达选择的方式各不相同,其中只有一种有专门选项。

puppeteer      headless: true      -> unified headless
               headless: 'shell'   -> chrome-headless-shell
               headless: false     -> headed
playwright     executablePath: ... -> whichever binary the path names
               channel: 'chromium' -> Playwright's bundled build, unified headless by default
selenium       options.addArguments('--headless')            -> unified in current Chrome
               options: binary at chrome-headless-shell       -> old implementation

Puppeteer 是唯一在 API 里点名 shell 的,文档记录了默认值改变的版本:v22 之前 Puppeteer 默认启动旧 headless,当前默认是 headless: true,也就是统一模式。因此固定在旧版 Puppeteer 的脚本与今天写的脚本,即使代码同一行,也运行不同的实现。

依赖文件因此也是记录的一部分。两个仓库即使测试套件在同一 commit,也可能运行不同的浏览器,因为它们的 lockfile 对 Puppeteer 的解析不同,而共享的启动行对此只字不提。把解析出的 Puppeteer 版本与解析出的二进制路径记录在一起,就能把它从谜团变成 diff。

Playwright 没有 shell 选项,因为它启动的就是路径所指的二进制。把路径指向 chrome-headless-shell 是可行的,会得到 shell 的行为,包括缺失的插件列表,这在测试断言插件数量时很重要。把路径指向完整浏览器并保留默认 headless,就得到统一模式。

Selenium 完全通过二进制与参数表达选择。当前 Chrome 中的 --headless 意味着统一模式,旧实现则要把浏览器二进制路径配置成 shell。机制就是这两项:参数描述窗口,路径决定实现。

Selenium choice = (options.addArguments) + (browser binary path)
                 flag picks a window; the path picks the implementation

较早的教程有时会给出另一种参数变体,而不是指向 shell 的路径。当前 Chrome 文档页面描述的是 --headless 与 chrome-headless-shell 二进制,并未记载那个变体,所以可靠的核验方式与全文其他部分一致:解析实际运行的可执行文件路径,然后再读用户代理 token。

Chromedriver 另有一条已写入文档、也容易违反的要求:driver 版本必须与它所驱动的浏览器版本匹配。ChromeDriver 的版本选择页面写明了这条规则,其中包括 115 之前要求 major、minor 与 build 号都一致的划分。版本不匹配属于 driver 问题,而不是 headless 模式问题,表现为会话无法启动,而不是会话报告了错误的模式。

--headless              current Chrome: unified
chrome-headless-shell   separate download from Chrome for Testing
driver version          must match the browser it drives

要留意的错配是 chromedriver 的版本与它所驱动的浏览器版本不一致,因为模式变更常与浏览器升级同时发生,两种失败看起来相似。固定 Chrome for Testing 版本 讲的是让浏览器、driver 与 shell 保持同一版本的三产物规则。

与有窗口模式仍有哪些差异

统一模式消除了独立实现,但没有消除无人值守浏览器与有窗口浏览器之间的差别。上面的运行中仍有三处差异存留,比较 headless 与有窗口模式的测试必须把它们考虑进去。

field                 unified vs headed
user agent token      HeadlessChrome vs Chrome
outerWidth            1280 vs 1282 (window decoration)
window focus/visibility   assumptions about focus do not hold in an unattended session

扩展支持是随切换而改变、并且至今仍让人踩坑的差异。旧 shell 没有实现扩展系统,而统一模式运行完整浏览器,可以在有窗口启动所需的条件下加载未打包扩展;持久上下文的要求与成对的参数记录在在 Puppeteer 与 Playwright 中加载扩展。

这一差异也解释了一类因为错误原因而通过的测试。期望扩展注入辅助函数并在 shell 上运行的套件不会在加载时失败;它会稍后失败,或者根本不失败,因为页面从未收到辅助函数,依赖它的断言被跳过了。在启动时断言已安装集合,如那篇文章所述,能把静默情形变成具名情形。

渲染路径是第二个容易被夸大的差异。在这个主机、这个构建和硬件加速的有窗口会话中,合成输出一致,但那只是一次测量,不是保证:没有可用 GPU 的机器会落到软件光栅化,而容器提供的字体决定文本能否绘制。那些是主机属性,不是模式属性,它们随参数之外的因素变化。

mode decides:   whether a window exists, which binary runs, which extensions load
host decides:   GPU path, font set, system libraries, clock and locale
record field:   navigator.plugins.length (0 or 5), window.outerWidth, --load-extension present

这种划分正是为什么只记录模式的 headless 与有窗口比较无法解释差异。headless 与有窗口的信号比较 是记录两侧的方法;headless 服务器环境搭建 是配置这份清单中主机那一半的地方。两者都不能替代知道正在运行哪个二进制,而后者正是这组参数控制的唯一事实。

一次差异实际排除了什么

两次运行之间的差异,只有在便宜的解释被排除之后才有信息量,上面的测量把这个顺序写清楚了。当截图在切换模式后改变,清单上有四个候选,其中三个可以在碰浏览器之前排除。

1. same-mode rerun        -> identical? animation and lazy loading are excluded
2. viewport fields        -> innerWidth 1280 and dpr 1 in all three here
3. plugin count           -> 0 for the shell, 5 for unified and headed
4. binary path and build  -> the only candidate left when 1 to 3 match

同样的纪律适用于迁移决策,这也是大多数读者接触这个主题的原因。把套件从 shell 迁到统一 headless,代价是重建那些按更小浏览器写下的假设:插件数为零、没有 chrome 对象、不支持扩展。从统一模式退回到 shell 看起来像性能收益,却悄悄恢复了这三者,唯一能发现它的办法是把四个字段留在报告里,并在改动前后对比。

就这个主题而言,值得避免的说法是统一模式不留任何可观测之处。它留了。它是无窗口的完整浏览器,这意味着它呈现与有窗口模式相同的组件,并且仍在用户代理 token、窗口焦点行为,以及部署对 GPU 和字体的处理中报告模式。希望模式不再出现在每个字段里的读者,是在要求任何启动参数都无法交付的东西;想测量它是否消失的读者,应当记录上面的字段,而不是相信参数。

其中一部分噪声值得在归因于模式之前量化。在上面三个会话中,视口完全相同,所以它们之间任何布局差异都来自页面而不是窗口尺寸;唯一变动的是 outerWidth,而且只在唯一带真实窗口框架的会话里差了两个像素。当两个 headless 会话之间的截图比较出现差异时,这是第一个要检查的替代解释,因为同一主机上同一实现的两个会话在布局上本不该有任何差异。

第二个替代解释是字体替换。不提供 CJK 字体的容器会把这些字形画成方框或 notdef,这在两种 headless 模式下完全相同;它不是参数的属性,切换模式也修不好。区分两者的方法是渲染一个含一个拉丁字符和一个 CJK 字符的固定字符串,比较文本区域的像素数:缺字体时它会变,只换模式时它不变。

实践结论

用命名二进制的方式确定模式,再通过读记录来确认。在 Puppeteer 中,headless: true 与 headless: 'shell' 是不同实现,不是同一个设置的两个取值;在 Playwright 和 Selenium 中,选择存在于可执行文件路径里,这意味着复制来的配置可能在不改变任何可见选项的情况下改变实现。 使用 Selenium 的读者有同一清单的已解版本,包含设定模式的 ChromeOptions 与在 CI 中不同的四个宿主属性:Selenium 无头浏览器。 使用 Selenium 的读者有同一清单的已解版本,包含设定模式的 ChromeOptions 与在 CI 中不同的四个宿主属性:Selenium 无头浏览器。

每次运行记录四个字段:解析出的二进制路径、用户代理 token、插件数量和构建版本。这四项把 shell 与统一 headless 分开,也把任一种与有窗口模式分开,而且是在页面一侧做的,测试可以直接断言而不用读启动代码。两次运行之间出现差异时,先查二进制,因为那正是这个主题讨论的变量。

三方比较的诚实总结很短。统一 headless 表现得像无窗口的完整浏览器;shell 表现得像省略了组件的更小浏览器;有窗口模式表现得像带窗口的完整浏览器。不同机器上两次运行之间变化的其余一切,属于主机,不属于参数。

来源

#Chrome Headless#Headless Shell#Puppeteer#Chromedriver#渲染

本文属于指南: Web 平台能力面:一致性复核地图

在 BotBrowser 里跑一遍同样的检查

加载 profile,让刚读到的这些信号在浏览器内部保持一致,并在正式上线前先在自己的机器上确认结果。