chromedp 评测:真正的浏览器,依然离不开正确的就绪条件

最后更新于 August 17, 2026
chromedp 评测:真正的浏览器,依然离不开正确的就绪条件
AI 摘要
chromedp 是一个纯 Go、MIT 许可的库,通过 Chrome DevTools Protocol 直接驱动真实的 Chrome。它在 Go 程序内部等页面 JavaScript 执行完之后再读取 DOM,不需要单独的 WebDriver,也不依赖 Node 运行时。Go 模块会被编译进应用里,但真正运行时还是要有外部 Chrome 可执行文件,而它的生命周期由 Go contexts 管理。安装过程基本就是一次 go get,再加上我自己提供的 Chrome:先构建 allocator,再派生 context,然后把一组 actions 交给 Run。

Keywords

chromedp 评测, 网页抓取, 开源, 基准测试

Content

chromedp 是一个纯 Go、MIT 许可的库,它通过 Chrome DevTools Protocol 直接驱动真实的 Chrome。它在 Go 程序内部等页面 JavaScript 执行完之后再读 DOM,不需要单独的 WebDriver,也不依赖 Node 运行时。Go 模块会被编译进应用里,但真正运行时还是要有外部 Chrome 可执行文件,而它的生命周期由 Go contexts 管理。

安装过程基本就是一次 go get,再加上我自己提供的 Chrome:先构建 allocator,再派生 context,然后把一组 actions 交给 Run。在这台 macOS arm64 主机上,使用本地磁盘里已预热的 headless shell,从新进程到首次脚本结果的中位数是 102 ms。这个数字只是本地基线,并不能说明启动永远不是瓶颈。对于一个会在页面加载后 800 毫秒才注入链接的 fixture,我测试的四种读取策略里,有两种会在链接出现之前就返回。

浏览器是真的,内容也确实存在,但代码就是没有等它。这个空档正是 chromedp 给我最有价值的一课,而且这不是 bug——它反映的是“我渲染了页面”和“我等到了自己真正想要的内容”之间的区别。关于无头浏览器的很多经验之谈,往往把这两者混为一谈。决定你能不能拿到数据的,不是浏览器本身,而是等待策略。这里的每一个数字都来自我可控的本地 fixture,ground truth 在任何运行之前就已经登记好了,原始汇总放在我们的 chromedp 基准测试仓库目录 里。

chromedp 到底是什么

chromedp 的设计思路就是轻量。没有 Selenium server,没有 WebDriver 适配层,也没有 Node 运行时藏在底层。你的 Go 程序会通过 WebSocket 连接到一个 Chrome 实例,并直接和它说 CDP;这和 Puppeteer 使用的底层传输协议大体相同,只是没有那层 JavaScript。

我在 2026 年 7 月 27 日查看时,这个仓库有 13,212 个 star 和 178 个 open issues,许可证是 MIT。我测试的版本是 v0.16.0,也是仓库里最新的 tag。这里要提前说明一下,免得你后面被绕晕:GitHub 的 Releases 页面上,v0.15.1(发布于 2026-04-01)仍然显示为最新 release object,而 go get github.com/chromedp/chromedp@latest 解析出来的是 v0.16.0。Go modules 和 GitHub release objects 在这里已经有点脱节了。不是坏掉了,只是在你想确认自己到底在跑什么版本时,会非常烦人。

它的心智模型几乎就是 Go contexts 的层层嵌套。你先建一个 allocator context(它知道如何启动 Chrome),再从中派生 browser context,然后调用 chromedp.Run(ctx, actions...) 执行一组 Actions。browser context 的子 context 就是一枚新标签页。取消一个 context,它所代表的对象也会一起消失。如果你写过 Go 并发,这套逻辑会非常熟悉;如果没有,我们的 Go 网页抓取入门指南 会比 chromedp 的 godoc 更容易上手。

还有一个边界要先说清:chromedp 给你的是渲染后的 DOM,不是结构化数据。你从 DOM 里提取出来的字段、表格、价格,都是你自己写、自己维护的代码。它是一个驱动器,不是一个完整的抓取框架。

底层机制是什么样的

chromedp 里的一切都是一个 Action,而 Run 会按顺序在目标上执行这一串 action。NavigateClickEvaluateOuterHTMLWaitVisible——接口都一样,能自由组合,本质上也只是披着 Go 类型外衣的 CDP 命令。这个统一设计是它最好的地方,因为无论“糖衣 API”还是底层协议,都处在同一层级。

这点很重要,因为它的“糖”故意做得很薄。chromedp 基于 cdproto 构建,后者是自动生成的、覆盖整个 DevTools Protocol 的 Go typed bindings,而 chromedp godoc 会把这两层并排展示。当天方便 action 不存在时,你可以在同一个 Run 里直接切到 domain call——比如 network.Enable()page.CaptureScreenshot()runtime.Evaluate()。这里没有“好用 API”和“真正 API”之间的隔阂,这一点并不是每个浏览器驱动都做得到。

日常使用里,真正需要你判断的地方就是等待 action,而且它们比大多数人会用到的还多:

等待动作它会阻塞到什么条件
WaitReady(sel)直到节点 挂载 到 DOM
WaitVisible(sel)直到节点 真正可见
WaitNotPresent(sel) / WaitNotVisible(sel)上述条件的反向,适合加载中的 spinner
Poll(js, res)按间隔执行 JavaScript 断言,直到结果为 true

进程管理是另一个值得知道的部分,因为它决定了你的程序退出后会不会把浏览器留在后台。chromedp 是通过 Go 的 exec.CommandContext 启动 Chrome 的。取消这个 context,就会杀掉进程。这个单一实现细节解释了我在测试中看到的良好行为,也解释了我遇到的尖锐边缘问题。

安装:一个 Go 二进制,再加一个你必须自己提供的 Chrome

go get github.com/chromedp/chromedp 顺利解析到了 v0.16.0,没有任何波折,而且依赖树里没有 cgo import。所以你经常会看到的那句“纯 Go、无外部依赖”——如果指的是 Go 模块本身——是成立的。

但如果说的是运行时,那就不成立了。chromedp 驱动的是外部 Chrome,机器上如果没有 Chrome,一运行就会立刻失败。我做的每一次测量,都是通过 chromedp.ExecPath 明确指定了可执行文件,指向 Chrome for Testing 151.0.7922.10 的 headless shell。这里不是在批评它——驱动浏览器当然需要浏览器——但“没有外部依赖”和“你必须把一个 155 MB 的 Chrome 跟二进制一起交付”是完全不同的部署故事,而 README 里通常只会出现其中一个。

第二个安装坑也花了我不少时间,值得在你写代码前先知道。chromedp 的 issue #1591 报告说 Go 1.25+ 的 go test runner 会在 NewExecAllocator 启动到一半时把它取消;同样的代码作为编译后的二进制运行则没问题。我专门用 go build 做了一个 probe binary,并在所有测量里都运行这个二进制,而不是走 go test。这里使用的 Go 版本是 1.26.5,系统是 macOS arm64。如果你第一次接触 chromedp,就是一个在 Chrome 启动阶段就挂掉的测试文件,那你该先看这个 issue,而不是先怀疑自己的代码。

实测:同一页面的四种读取方式,其中两种会读空

Measured results chart: Which read strategy saw each link?

这个 fixture 是本地 127.0.0.1 服务器,提供三类内容,它们唯一的区别在于什么时候进入 DOM:一种是服务端 bytes 里直接带着的静态 <a>,一种是在初始解析过程中由内联 <script> 创建的 <a>,还有一种是在 load 事件之后可配置延迟的 setTimeout 创建的 <a>。那两个由脚本创建的链接,其标记和 href 都是由 JavaScript 里的字符串片段拼出来的,所以服务端 bytes 中根本不存在连续的字面量。因此,只要“找到了”,就能证明 Chrome 执行了 JavaScript,而不是只是读取了 HTML。

Recall 是在 Python 里根据预先登记的 ground truth 标记计算的,不是在 Go probe 里算的,所以 probe 没办法靠“偷看答案”作弊。每种策略都跑了三次,找到了哪些内容的集合三次都完全一致。

读取策略静态 HTML 链接解析时注入的链接load 后 800 ms 注入的链接耗时
Navigate + 直接读取,不等待foundfoundmissed317 ms
WaitReady("body")foundfoundmissed107 ms
WaitVisible("#delayed-injected")foundfoundfound912 ms
轮询直到标记出现foundfoundfound972 ms

两行结果都只拿到了三个链接中的两个。最朴素的读取会漏掉,是因为 Navigate 在 load 事件就返回了,而第三个链接那时还不存在。WaitReady("body") 漏掉的原因更隐蔽,而且在实际工作里更危险:body 在 load 时就已经挂载,所以等待条件立刻满足,你会误以为自己已经做了“正确的等待”。它返回只用了 107 ms,比完全不等待还快,但拿到的仍然是一页不完整的内容。

为了确认机制,而不是靠猜,我把注入延迟逐步拉长,并重新跑了两个极端情况(recall-summary.json):

load 后注入延迟不等待的读取能看到吗WaitVisible 能看到吗WaitVisible 耗时
0 msyes(有竞态)yes109 ms
100 msnoyes208 ms
400 msnoyes519 ms
800 msnoyes911 ms
1500 msnoyes1625 ms

WaitVisible 的耗时在这个 fixture 上会跟着注入延迟走——100 对 208、400 对 519、800 对 911、1500 对 1625——这说明它确实是在等节点出现,而不是提前读了。0 ms 这一行是边界:setTimeout(…, 0) 可能会在立即读取前触发,所以不等待的路径有机会抓到它。但在这次 sweep 里,从 100 ms 往上,不等待的路径每次都漏掉了。

在生产环境里,同样的时序错误可能会生成合法 HTML,却抽取出零行数据,最后仍然 exit 0,除非流水线去检查输出数量。这个失败模式是由 fixture 行为支持的合理推断,不是这里实际测到的事故。渲染只完成了一半要求;真正的读取还必须等待一个和目标数据绑定的、应用层级的条件。

WaitReady 和 WaitVisible 不是谁更强,而是在回答不同问题

常见说法是 WaitVisibleWaitReady“更可靠”。这种说法不够准确,甚至会误导人。在一个节点已经挂到 DOM 上、但样式是 display: none 的页面里,两者会清楚分开(waitsem-summary.json,三次结果完全一致):

目标节点动作结果时间
已挂载,display:noneWaitReady返回~6 ms
已挂载,display:noneWaitVisible超时,context deadline exceeded4000 ms
可见节点WaitVisible,默认 query返回4–12 ms
可见节点WaitVisibleByID返回1–2 ms
可见节点WaitVisibleByQuery返回1 ms

WaitReady 的意思是“已经挂载”。WaitVisible 的意思是“已经可见”。你问错了,就要么会穿过一段根本还没渲染出来的内容,要么会在一个本来就不可能可见的节点上把整个超时时间都耗掉。deadline 行为本身是干净的——会在恰好 4 秒时返回一个正常的 context deadline exceeded,没有卡死,也没有僵尸状态——这已经比有些驱动强得多了。

还有一个被报告过的坑在这里没有复现。issue #440 说默认 query 下 WaitVisible("#id") 会挂住,但在 v0.16.0 上没有复现——默认 query、ByIDByQuery 在每一次运行里都成功返回了可见节点。没有复现不等于已经修复:这只是一个页面上的一种 selector 形态,远不足以彻底排除该 issue。

你漏掉的 defer cancel(),正在把屋顶顶起来

看进程数,不看返回值。每次生命周期测试都使用唯一的 --user-data-dir,并通过 pgrep 统计真实的 Chrome browser 进程,同时排除 renderer 子进程。每条路径都跑了三次(lifecycle-summary.json)。

退出路径(macOS,每种 3 次)启动的 chrome-headless-shell 最后怎样了耗时
取消 context 和 allocator已消失13、13 和 12 毫秒
取消就退出 Go 进程比你的程序还活着——探测前是 0 个浏览器进程,探测退出后变成 1 个;3/3 次都留下了孤儿进程

取消是干净的,也很快,完全符合 exec.CommandContext 的承诺。(所有孤儿进程最后都被 harness 强制杀掉了;主机也被清理干净了。)

这件事是已知的、文档里写过的、并且只在特定平台上成立的行为——这个测量结果是我的,但发现本身不是。 chromedp 的 issue tracker 从多个角度记录过它:#774 描述了 FreeBSD 上同样的不退出现象,#752 报告了 macOS 上 Chromium 进程挂起的问题,而 #562#1566 则解释了机制。我补充的是进程计数和两边的时间数据,而这些定性报告里没有给出这些内容。

这个机制本身和 build tag 关系很大,值得知道。在 v0.16.0 的源码里,allocate_linux.go 会给子进程设置 Pdeathsig = SIGKILL,所以 Linux 拥有内核级的父进程死亡信号。而 macOS 编译到的是 allocate_other.go,这里这一步是 no-op。darwin 上没有等价信号,所以当你的程序退出时,没有东西会替你杀掉 Chrome。与此同时,godoc 的表述看起来像一个通用承诺——默认命令“在 Go 程序退出时向任何打开的浏览器发送 SIGKILL”——而 Linux 作用域只藏在带 build tag 的源码里,不去读源码根本看不到。把文档说得过满是合理的,但把它叫做 chromedp 的 bug 并不合理。

无论你怎么理解,实际影响都一样:在 macOS 上,defer cancel() 是承重结构。跳过它,每次运行都会泄漏一个浏览器进程。我没有测试 Linux,所以不会把这个孤儿进程结论推广到 Linux;源码暗示 Linux 的行为不同,但“暗示”不是测量。

冷启动、并发,以及决定你能不能部署的那些枯燥细节

Measured results chart: Cold start and two concurrency shapes

102 ms 是这样一个完整流程的中位数:新进程、allocator、context、本地导航、第一次 Evaluate,共五个进程,范围 98 到 111 ms(coldstart-summary.json)。在这台 macOS arm64 机器上,使用已预热的 headless shell,启动成本相对于延迟内容的等待来说很小。容器、冷文件系统、CI、serverless 环境和生产环境中的导航都没有被测量。

在并发方面,chromedp 给你两种形态——一个 browser 配多个 child contexts(也就是 tab),或者多个彼此独立的 browser。四次导航,每种模式都跑三次(concurrency-summary.json):

模式总耗时(p50)范围Chrome browser 进程峰值
共享 browser,4 个 child contexts214 ms209–2191
4 个独立 browser264 ms261–2784

这里真正被测出来的结论是进程数:对这四次本地、极轻量的导航,前者只有 1 个 Chrome browser 进程,后者有 4 个。总耗时范围没有重叠,但它仍然只是方向性信息,不算真正的吞吐基准。RSS 和 PSS 没有测,所以这个测试不能证明内存节省。

这里的错误路径探测还不够细,不能支持任何关于鲁棒性的结论:草稿没有说明是 HTTP status、navigation error、event 还是 harness logic 暴露了各个条件。在没有发布精确 API 结果和原始 artifact 之前,500/dead-link 处理都应视为未报告。

以下内容没有测试,因此不属于这些数字覆盖的范围:Linux 生命周期行为、超过 N=4 的并发或带真实页面工作量的并发、内存差异(我数的是进程,不是 RSS)、网络拦截和请求捕获,以及 #168#1593 里提到的 WaitReady 超时问题——这些说的是间歇性超时,而我测的是等待的语义,这是另一个问题。单机、单个 Chrome 构建。

chromedp 不是这个测试台上唯一的 Go CDP 驱动:rod 在同一时间、同一 fixture、同一 harness、同一主机、同一个 Chrome 构建上走了完全相同的流程,并且它会有自己的独立文章。

优缺点

优点:

  • 真正直连 CDP——便利 action 和原始 cdproto domain call 可以在同一个 Run 里组合,API 上限几乎不存在。
  • 在测试的 macOS fixture 上,本地冷启动基线是 102 ms p50,范围 98–111 ms。
  • 取消后 Chrome 会在约 13 ms 内被回收,而且每次都稳定如此。
  • child contexts 能让多个 tab 共享一个 browser 进程(1 个进程对比 4 个独立 browser)。
  • 测试结果稳定——recall 集合、等待语义和生命周期结果在每组三次重复中都完全一致。
  • deadline 处理干净:对不可达条件的 WaitVisible 会在恰好 4 秒时返回正常的 context deadline exceeded,而不是挂死。
  • 纯 Go 模块(无 cgo),MIT 许可;但运行时仍然需要外部 Chrome 可执行文件。

缺点:

  • 运行时必须依赖外部 Chrome;“没有依赖”的说法只适用于 Go 模块本身。
  • WaitReady("body") 是一个看起来很合理、实际上会静默错过 load 后内容的陷阱——它在 107 ms 内返回,但页面并不完整。
  • 朴素的 Navigate + 直接读取路径,会对所有在 load 后 ≥ ~100 ms 才注入的内容稳定漏读,而且不会报错。
  • 在 macOS 上,如果退出时不 cancel(),就会留下孤儿浏览器(3/3 次)。这是已知且只在特定平台发生的行为,但也很容易踩坑。
  • godoc 里关于退出时发送 SIGKILL 的说法看起来像全平台适用,但实际机制只在 Linux 的 build-tagged 源码里。
  • Go 1.25+ 下 go test 可能会取消 allocator 启动(#1591);更稳妥的是直接构建二进制运行。
  • 它返回的是 DOM,而不是结构化数据——每一个字段都要你自己写 selector、测试,并在网站变更时修复。
  • 最新 tag(v0.16.0)比 GitHub Releases 页面上的最新 release object(v0.15.1)更靠前,版本判断时会短暂让人困惑。

chromedp 适合谁,不适合谁

如果你的服务本身就是 Go,而且你需要在里面塞进一个真实浏览器,chromedp 几乎是顺理成章的选择。不需要去监管 Node 进程,不需要维持一个 WebDriver server,只要一个编译后的二进制,再加一个你自己交付或安装的 Chrome。它的 context 模型和 Go 的并发原语几乎天然贴合,所以浏览器生命周期最后会像代码里其他东西一样,交给同一套 defer 纪律来管理。若你需要的功能超出便利 API 范围——比如 CDP 网络事件、精确的 page-lifecycle hook、协议层技巧——你可以直接切进 cdproto,而不必离开这个库。

如果你希望对等待有明确控制,它也很合适。等待动作是原语,不是启发式;它们只做自己声明的事情。一旦你接受“正确选择等待条件是你自己的责任”,这反而会成为一个优点。

如果你的团队不用 Go,就跳过它——真正的成本不是库,而是语言本身。如果你想要那种自动帮你猜对时机的 auto-wait 体验,也跳过它,因为 chromedp 不会替你猜;它只会精确执行你的指令,并返回页面在那个时刻的状态。如果你真正需要的是结构化记录而不是 DOM,那也应该认真考虑是否值得使用它:每个字段都要你自己写 selector、测试,并在站点改版后修复。而如果你的要求只是“把这 500 个 URL 的数据给我”,在 Go 里搭一套浏览器编排系统,实际上是很重的机器。我们的 浏览器自动化指南 会讲清楚什么时候这套机器物有所值,什么时候不会。

替代方案,以及我们自己的方案放在什么位置

在浏览器驱动这一类里,rod 也是一个 Go 的 CDP 驱动;而 Playwright 和 Puppeteer 则是 Node 侧方案,我们在 Playwright 与 Puppeteer 对比 里做过梳理。它们的等待契约并不互通。Playwright 会在很多动作前自动等待可操作性;但这仍然不能告诉它,动作之后应用数据到底什么时候才真正到齐。chromedp 提供的是更底层的等待原语,把“可操作性”和“应用层就绪条件”都留给调用者。如果你想看更广的领域,我们的 开源抓取器实测总览 也涵盖了这条线另一侧的静态爬虫和抽取库。

相关文章:Browserless 评测

托管式抽取服务属于另一类工具。它用外包的渲染和 schema shaping,换掉你对浏览器和 selector 的控制权。当交付物是结构化记录而不是 DOM 时,这种服务会很有用;而当浏览器必须始终由你的 Go 服务掌控时,chromedp 更合适。我们自己也做 Thunderbit 这类服务,但这次没有拿它和这个 fixture 做对比,因此本测试并不能支持它与 chromedp 在等价性、延迟、抽取质量或成本上的比较。

免费试用 Thunderbit 做网页数据提取

结论

要不要用 chromedp?如果你写 Go,而且希望一个真实浏览器完全由你掌控,答案是要。这个本地 fixture 里,它把首次脚本结果的中位时间做到了 102 ms,取消后大约 13 ms 就能回收 Chrome,并且四个并发 tab 只用了一个 browser 进程。这些都是有边界的观测,不是放之四海皆准的性能承诺;它真正持久的吸引力,在于当便利层不够用时,你依然能从 Go 直接访问 CDP。

但前提是你要把这些说法的尺度看准,因为它的口碑容易把两件事说过头。“纯 Go、无依赖”描述的是模块;在运行时,你还是要自己交付和管理 Chrome 二进制。而“用无头浏览器就能拿到动态内容”只有在你的等待条件绑定到你想要的节点时才成立——朴素读取和 WaitReady("body") 都会让我拿到一页缺少内容的结果,而这些内容是在 load 后 800 ms 才注入的,而且每一次都静默失败。在 macOS 上,defer cancel() 不是一个风格偏好;如果不写,每次运行都会漏掉一个浏览器,这属于已知的平台行为,但处理它仍然是你的责任。把这三件事做对了,chromedp 就是我测过的更可预测的浏览器驱动之一。做错了,它会悄无声息地失败,而这正是抓取器最糟糕的失败方式。

免费试用 Thunderbit 做网页数据提取 Get Started Free

常见问题

chromedp 真的能看到 JavaScript 渲染出来的内容吗? 可以——但前提是你要用与目标节点绑定的等待条件。在一个链接于 load 事件后 800 ms 才注入的 fixture 上,Navigate 加直接读取会漏掉它,WaitReady("body") 也会漏掉它;但对那个节点使用 WaitVisible,或者用 JavaScript 轮询,都能在三次运行中全部拿到。把延迟逐步拉长后,不等待的读取在从 100 ms 起的所有设置里都漏掉了这个节点。渲染是必要条件;正确等待才让它成为充分条件。

WaitReadyWaitVisible 有什么区别? WaitReady 是等节点已经挂到 DOM 上。WaitVisible 是等它真正可见。对于一个已经挂载但样式是 display: none 的节点,WaitReady 大约 6 ms 就返回,而 WaitVisible 会一直等到 4 秒的 context deadline,最后返回干净的 context deadline exceeded。它们并不是谁“更可靠”,而是在回答不同的问题,选错才是真正的坑。

chromedp 真的需要 defer cancel() 吗? 在 macOS 上,需要。取消 context 和 allocator 后,启动的 Chrome 在每次运行里都能在 12–13 ms 内被回收;如果 Go 进程退出前不取消,就会在全部三次运行里留下孤儿浏览器进程。这是已知的、只在特定平台出现的行为——chromedp 的 issue tracker 记录了其他非 Linux 系统上的类似不退出模式,而负责处理它的父进程死亡杀进程机制,只存在于 Linux 的 build-tagged 源码里。我没有测试 Linux,所以请把这个孤儿结论视为 macOS 范围内的结果。

chromedp 需要单独安装 Chrome 吗? 需要。Go 模块本身是纯 Go、没有 cgo,但它驱动的是外部浏览器,没有浏览器就会立刻失败。我明确通过 chromedp.ExecPath 提供了 Chrome for Testing 151.0.7922.10 的 headless shell。好处是启动成本很小:在五个全新进程里,到首次脚本结果的完整冷启动中位数是 102 ms,范围 98–111 ms。

应该多个标签页共享一个浏览器,还是直接起多个浏览器? 如果目标是尽量减少 browser 进程数,优先用 child contexts。四次本地导航,共享一个 browser 时只用了 1 个 Chrome browser 进程;开四个独立 browser 则用了 4 个。总耗时也更偏向共享方案(214 ms 对 264 ms 的中位数),不过四个轻量本地页面并不能算吞吐基准。内存没有测。只有在你需要更强的会话隔离、不同代理,或者更小的故障影响范围时,独立 browser 才可能更合适;这些权衡不在这次测试范围内。

Ke
Ke
Thunderbit 首席技术官 | 高级数据科学家与机器学习专家 Ke Shen 拥有近十年的机器学习和数据科学经验,毕业于哥伦比亚大学,曾任 Walmart Labs 高级数据科学家。他在 Python、R、Java 和统计学方面拥有深厚且备受同行认可的专业能力,并分享如何将复杂的 AI 算法从理论落地到生产级架构的实战经验。
目录
Thunderbit · AI 网页数据代理

一键 内提取任意页面数据

深受 250,000+ 用户信赖
提供免费方案
从网页到表格
描述你的需求——Thunderbit 的 AI 代理会帮你抓取并导出到 Excel、Google Sheets、Airtable 或 Notion。可免费开始。
Chrome Store Rating
PRODUCT HUNT#1 Product of the Week