chromedp 是一個純 Go、採用 MIT 授權的函式庫,透過 Chrome DevTools Protocol 直接驅動真正的 Chrome。它會在頁面內的 JavaScript 執行完後,再從 Go 程式裡讀取 DOM,不需要另外安裝 WebDriver 或 Node runtime。Go 模組會編譯進應用程式,但實際執行時仍然需要外部的 Chrome 可執行檔,而且它的生命週期由 Go contexts 管理。
安裝它只要一個 go get,但 Chrome 得自己提供:建立 allocator、衍生 context、把一串 actions 交給 Run。在這台 macOS arm64 主機上,搭配已預熱、落在磁碟上的 headless shell,從全新程序到讀到第一個 script 結果,中位數是 102 毫秒。這只是本機基準,不代表啟動永遠不是瓶頸。在一個會在載入後 800 毫秒才注入連結的 fixture 上,四種讀取策略裡有兩種在連結出現前就回傳了。
瀏覽器是真的,內容也真的存在,但程式就是沒等它。這正是 chromedp 帶給我最有價值的體會,而且這不是 bug —— 它反映的是「我有把頁面渲染出來」和「我有等到真正想要的東西」之間的差別;而 headless 瀏覽器的坊間印象,常常把這兩者混為一談。決定你能不能拿到資料的,是等待策略,不是瀏覽器本身。這裡的所有數字都來自我可控的本地 fixture,而且 ground truth 在任何測試開始前就已註冊;原始摘要也放在我們 benchmark repo 的 chromedp 資料夾。
chromedp 到底是什麼
chromedp 的架構刻意做得很精簡:沒有 Selenium server,沒有 WebDriver shim,底層也沒有藏一個 Node runtime。你的 Go 程式會直接透過 WebSocket 連上 Chrome,並以 CDP 與它對話;這個 wire protocol 大致上和 Puppeteer 用的是同一套,只是少了 JavaScript 那層。
我在 2026 年 7 月 27 日查看時,這個 repo 有 13,212 個 stars、178 個 open issues,授權是 MIT。我實測的版本是 v0.16.0,也是 repo 裡最新的 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,最後用一串 Actions 呼叫 chromedp.Run(ctx, actions...)。browser context 的子 context 就是一個新的 tab。取消某個 context,對應的東西就會消失。如果你做過任何 Go concurrency 相關工作,這會立刻很熟;如果沒有,我們的 Go 網頁爬蟲入門指南 會比 chromedp 的 godoc 友善得多。
先設好一個邊界:chromedp 交給你的是已渲染的 DOM,不是結構化資料。你從 DOM 裡拉出的欄位、表格、價格,都是你自己寫、自己維護的程式。它是 driver,不是完整的爬蟲框架。
底層機制是怎麼運作的
chromedp 裡的每一個操作都是一個 Action,而 Run 會依序把這些 action 套到 target 上。Navigate、Click、Evaluate、OuterHTML、WaitVisible——介面都一樣、可以互相組合,本質上只是包在 Go 型別裡的 CDP 命令。這種統一性是這個函式庫最好的設計決策之一,因為它讓高階語法糖和原始 protocol 站在同一層。
這點很重要,因為這些糖衣本來就刻意做得很薄。chromedp 建立在 cdproto 之上,也就是一組自動生成的 typed Go bindings,涵蓋整個 DevTools Protocol 的表面;chromedp godoc 則把兩層文件並排呈現。當方便的 action 不存在時,你就直接在同一個 Run 裡呼叫對應的 domain method,例如 network.Enable()、page.CaptureScreenshot()、runtime.Evaluate()。它沒有把「好用的 API」和「真正的 API」分隔成兩座牆,這點並不是所有 browser driver 都做得到。
日常判斷最常發生在等待行為上,而且等待動作比多數人實際會用到的還多:
| 等待動作 | 阻塞條件 |
|---|---|
WaitReady(sel) | 直到節點 附加 到 DOM |
WaitVisible(sel) | 直到節點 真正可見 |
WaitNotPresent(sel) / WaitNotVisible(sel) | 反向條件,適合等 loading spinner 消失 |
Poll(js, res) | 以固定間隔執行 JavaScript 判斷式,直到為真 |
另一個值得知道的機制是程序管理,因為它決定你的程式會不會在結束後把瀏覽器留在背景。chromedp 透過 Go 的 exec.CommandContext 啟動 Chrome;取消那個 context,就會把程序殺掉。這個實作細節同時解釋了它的好處,也解釋了我在測試中碰到的尖銳邊角。
安裝:一個 Go binary,加上一個你得自己提供的 Chrome
go get github.com/chromedp/chromedp 很順地就解析到 v0.16.0,而且依賴樹裡沒有 cgo imports。所以你常會看到的那句「純 Go、沒有外部依賴」沒錯——如果你講的是 Go 模組本身。
但它不適用於 runtime。chromedp 驅動的是一個 外部 Chrome;如果機器上沒有 Chrome,執行會立刻失敗。我每次測量都透過 chromedp.ExecPath 明確指定可執行檔,指向 Chrome for Testing 151.0.7922.10 的 headless shell。這不是批評——要驅動瀏覽器就得有瀏覽器——但「沒有外部依賴」和「你必須在 binary 旁邊一起部署一個 155 MB 的 Chrome」是兩種完全不同的部署故事,而 README 只會讓你看到其中一種。
第二個安裝坑也花了我不少時間,值得在你寫任何程式前先知道。chromedp issue #1591 指出 Go 1.25+ 的 go test runner 會在 NewExecAllocator 啟動中途把它取消;同一份程式碼編成 binary 後卻一切正常。我做測量時是先用 go build 做出 probe binary,再直接執行它,而不是透過 go test 跑。這裡的 Go 版本是 1.26.5,macOS arm64。如果你第一次接觸 chromedp 的經驗是某個 test 檔案在 Chrome 啟動時就死掉,先去看那個 issue,再來懷疑自己的程式。
實測:同一個頁面四種讀法,其中兩種什麼都沒拿到

這個 fixture 是跑在 127.0.0.1 上的本地 server,提供三種內容,它們唯一的差別只在於 何時 進入 DOM:伺服器回傳的 bytes 裡直接有一個靜態 <a>,一個 <a> 會在初始解析期間由 inline <script> 建立,另一個 <a> 則會在載入事件後、經過可調整的毫秒數才由 setTimeout 建立。兩個 script 生成連結的 marker 和 href 都是由 JavaScript 裡的字串片段組裝而成,所以在送出的 bytes 裡根本找不到任何連續的文字常值。也就是說,只要有「找到」,就能證明 Chrome 有執行 JavaScript,而不是單純在讀 HTML。
Recall 是在 Python 裡根據預先註冊的 ground truth marker 計算的,不是在 Go probe 裡算,所以 probe 不可能偷看答案。每一種策略都跑了三次;三次得到的 found-set 完全一致。
| 讀取策略 | 靜態 HTML 連結 | 解析時注入 | 載入後 800 毫秒注入 | 耗時 |
|---|---|---|---|---|
Navigate + 直接讀取,不等待 | found | found | missed | 317 毫秒 |
WaitReady("body") | found | found | missed | 107 毫秒 |
WaitVisible("#delayed-injected") | found | found | found | 912 毫秒 |
| 一直 Poll 到 marker 出現 | found | found | found | 972 毫秒 |
前兩列只拿到三個連結中的兩個。最直覺的讀法會漏掉,是因為 Navigate 在 load event 發生後就回來了,而第三個連結那時還不存在。WaitReady("body") 漏掉的原因更微妙,也更容易在實務上害人:body 在 load 時就已經附加了,所以等待立刻被滿足,你會以為自己做了對的事。它只花了 107 毫秒,比完全不等待還快,但拿到的仍然是同一份不完整頁面。
為了確認機制,而不是只是假設,我把注入延遲掃了一遍,再重跑兩個極端情況(recall-summary.json):
| 載入後注入延遲 | 不等待可否讀到 | WaitVisible 可否讀到 | WaitVisible 耗時 |
|---|---|---|---|
| 0 毫秒 | yes(競態) | yes | 109 毫秒 |
| 100 毫秒 | no | yes | 208 毫秒 |
| 400 毫秒 | no | yes | 519 毫秒 |
| 800 毫秒 | no | yes | 911 毫秒 |
| 1500 毫秒 | no | yes | 1625 毫秒 |
在這個 fixture 上,WaitVisible 的耗時會跟著注入延遲走——100 對 208、400 對 519、800 對 911、1500 對 1625——這表示它確實是等到節點出現才繼續,而不是提早讀取。0 毫秒那列是分界點:setTimeout(…, 0) 可能會在立即讀取前就先執行,所以不等待的路徑有機會剛好抓到。從 100 毫秒以上開始,這個 sweep 裡的不等待路徑每次都漏掉。
在 production 裡,類似的 timing 錯誤可能會得到合法的 HTML,但抽出來的 rows 卻是 0,而 pipeline 若沒檢查輸出數量,仍然會以 0 結束。這是根據 fixture 行為推得出的合理失敗模式,不是我在這次測試裡實際發生的事故。渲染只是需求的一半;真正要的是等待到與目標資料相關的應用層條件。
WaitReady 和 WaitVisible 沒有誰比較高級,它們回答的是不同問題
常見的說法是 WaitVisible 比 WaitReady 更「可靠」。這種講法不夠精準,甚至有害。當某個節點已經附加到 DOM,但樣式設成 display: none 時,兩者會明顯分開(waitsem-summary.json,三次結果完全一致):
| 目標節點 | 動作 | 結果 | 時間 |
|---|---|---|---|
已附加、display:none | WaitReady | 返回 | 約 6 毫秒 |
已附加、display:none | WaitVisible | 超時,context deadline exceeded | 4000 毫秒 |
| 可見節點 | WaitVisible,預設查詢 | 返回 | 4–12 毫秒 |
| 可見節點 | WaitVisible,ByID | 返回 | 1–2 毫秒 |
| 可見節點 | WaitVisible,ByQuery | 返回 | 1 毫秒 |
WaitReady 的意思是已附加。WaitVisible 的意思是可見。問錯了,就不是平白略過一個永遠不會渲染的內容,不然就是在一個根本不可能變可見的節點上,把整個 timeout 都耗掉。deadline 的行為本身很乾淨——正好 4 秒時回傳標準的 context deadline exceeded,沒有卡死,沒有殘留 zombie 狀態——這點已經比某些 driver 好太多。
有一個已知的陷阱這次沒重現。issue #440 回報 WaitVisible("#id") 在預設查詢下會卡住,但在 v0.16.0 上沒有重現——預設 query、ByID、ByQuery 在每次測試裡都能對可見節點正常返回。沒有重現不等於已修好:這只是一種 selector 形狀、只在一個頁面上測過,還不足以關閉這個 issue。
你沒寫 defer cancel(),屋頂就會漏
看 process 數,不看 return value。每次 lifecycle 測試都使用獨立的 --user-data-dir,並透過 pgrep 計算實際的 Chrome browser process,排除 renderer child。每條路徑都跑了三次(lifecycle-summary.json)。
| 離開方式(macOS,每種 3 次) | 啟動的 chrome-headless-shell 最後怎樣了 | 時間 |
|---|---|---|
| 取消 context 與 allocator | 消失 | 13、13、12 毫秒 |
| 不取消就直接結束 Go process | 會活過你的程式——probe 結束前是 0 個 browser process,結束後變成 1 個;三次全都留下 orphan | — |
取消是乾淨的、很快的,而且完全符合 exec.CommandContext 的承諾。(所有 orphan 之後都被 harness 強制殺掉,主機也已清理乾淨。)
這是已知、文件化、而且只適用於特定平台的行為——測量是我做的,但不是我發現的。 chromedp 的 tracker 從不同角度討論過這件事:#774 描述了 FreeBSD 上同樣無法退出的情況,#752 回報 macOS 上 Chromium process 會卡住,#562 和 #1566 則把機制講得更清楚。我補上的,是 process 數量和兩邊對比下的 timing,這些質性報告沒有提供。
這個機制本身和 build tag 有關,值得知道。在 v0.16.0 原始碼裡,allocate_linux.go 會把 Pdeathsig = SIGKILL 設到 child process,因此 Linux 會得到 kernel 層級的 parent-death signal。macOS 會編譯進 allocate_other.go,而那裡這個呼叫等於 no-op。darwin 沒有對應的 signal,所以你的程式一結束,Chrome 不會自動被殺掉。與此同時,godoc 的文字看起來像是在做一般性承諾——預設命令「會在 Go 程式結束時向任何開啟中的瀏覽器送出 SIGKILL」——但 Linux 的範圍其實只藏在你得去讀的 build-tagged 原始碼裡。說文件有點過度承諾是合理的;說這是 chromedp bug 就不對了。
不管怎麼解讀,實際影響都一樣:在 macOS 上,defer cancel() 是支撐系統的一根梁。少了它,每次執行都會漏掉一個 browser process。我沒有測 Linux,所以不會把 orphan 的結論延伸到那裡——原始碼暗示 Linux 會不一樣,但暗示不是測量。
冷啟動、併發,以及決定你能不能部署的那些平凡細節

在五個全新程序中,從新程序、allocator、context、localhost 導航到第一次 Evaluate 的中位數是 102 毫秒,範圍為 98 到 111 毫秒(coldstart-summary.json)。在這個 macOS arm64 fixture 上,搭配已預熱的磁碟 headless shell,啟動成本相對於延遲內容的等待來說很小。容器、冷磁碟、CI、serverless 環境與實際 production 導航都沒有測。
在併發方面,chromedp 會提供兩種形態:一個 browser 配多個 child contexts(tabs),或者多個彼此獨立的 browsers。四次導航、每種模式各跑三輪(concurrency-summary.json):
| 模式 | Wall time(p50) | 範圍 | Chrome browser processes 峰值 |
|---|---|---|---|
| 共用 browser,4 個 child contexts | 214 毫秒 | 209–219 | 1 |
| 4 個獨立 browsers | 264 毫秒 | 261–278 | 4 |
這個測試最明確的發現是 process 數:四次簡單的本地導航,共用一個 Chrome browser process,而不是四個。wall time 的區間沒有重疊,但它仍然只是方向性的結果,不足以當作吞吐量 benchmark。RSS 和 PSS 沒有量測,所以這個測試不能證明有記憶體節省。
這裡的錯誤路徑 probe 太簡略,不足以支持穩健性宣稱:草稿沒有指出每種情況到底是由 HTTP status、navigation error、event 還是 harness 邏輯所觸發。對於 500/dead-link 的處理,除非公開精確的 API 結果和原始 artifact,否則都應視為未報告。
以下內容沒有測,因此不在這些數字涵蓋範圍內:Linux 的 lifecycle 行為、超過 N=4 或含有真實每頁工作負載的併發、記憶體差異(我數的是 process,不是 RSS)、network interception 與 request capture,以及 #168 和 #1593 裡關於 WaitReady timeout 的開放回報——那些描述的是偶發 timeout,而我量測的是等待的 語義,是另一個問題。單一台機器、單一個 Chrome build。
chromedp 不是這次 bench 上唯一的 Go CDP driver:rod 也在同一次環境、同一個 fixture、同一台主機、同一版 Chrome 下,走過完全相同的測試流程,並且有自己的文章。
優點與缺點
優點:
- 真正的 CDP 存取——方便的 actions 和原始的
cdprotodomain calls 可以在同一個Run裡組合,不會碰到 API 天花板。 - 在這次測試的 macOS fixture 上,cold-cycle 基準為 102 毫秒 p50,範圍 98–111 毫秒。
- 取消 context 後,Chrome 大約 13 毫秒內就被回收,而且每次都如此。
- Child contexts 可以讓多個 tab 共用同一個 browser process(N tabs 只要 1 個 process,而不是 4 個)。
- 測試結果具有可重現性——recall sets、等待語義與 lifecycle 結果在三次重複中都完全一致。
- deadline 處理乾淨:對無法達成的條件執行
WaitVisible,會在正好 4 秒時回傳標準的context deadline exceeded,而不是卡住。 - 純 Go 模組(無 cgo),MIT 授權;但 runtime 仍需要外部 Chrome 可執行檔。
缺點:
- runtime 需要外部 Chrome;「沒有依賴」這個形象只適用於 Go 模組本身。
WaitReady("body")是個看起來合理、實際上會默默漏掉載入後內容的陷阱——它只花 107 毫秒就回來了,但頁面是不完整的。- 最直覺的
Navigate+ 直接讀取路徑,對於載入後約 100 毫秒以上才注入的內容,會穩定漏掉,而且不會報錯。 - 在 macOS 上,如果不
cancel()就直接結束,瀏覽器會被 orphan 掉(3/3 次)。這是已知且只適用於特定平台的行為,但還是很容易踩雷。 - godoc 裡關於退出時送出 SIGKILL 的說法,看起來像是通用承諾,但實際機制只在 Linux 的 build-tagged 原始碼中。
- Go 1.25+ 的
go test可能會取消 allocator 啟動(#1591);建議直接 build binary。 - 它回傳的是 DOM,不是結構化資料——你想要的每個欄位,都要自己寫解析程式並負責維護。
- 最新 tag(v0.16.0)比 GitHub 上最新的 Release object(v0.15.1)還新,版本確認時會短暫讓人困惑。
chromedp 適合誰,又該避開誰
如果你的服務本來就是用 Go 寫,且你需要在裡面直接跑一個真實瀏覽器,chromedp 幾乎就是最直覺的選擇。沒有 Node process 要監管,沒有 WebDriver server 要維持,只有一個編譯後的 binary,加上一個你自行部署或安裝的 Chrome。它的 context 模型和 Go 的 concurrency 原語對得非常直接,所以瀏覽器生命週期最後會像程式裡其他東西一樣,由 defer 的規範來控制。如果你需要的是方便 API 沒覆蓋到的東西——CDP network events、精確的 page-lifecycle hooks、protocol-level 技巧——那就直接進到 cdproto,完全不用離開這個函式庫。
當你想明確控制等待條件時,它也很適合。這些等待動作是 primitives,不是 heuristics;它們說什麼就是什麼。只要你接受「選對條件」現在變成你的責任,這反而是好事。
如果你的團隊不用 Go,就跳過它——語言上的投入才是真正成本,不是函式庫本身。若你想要的是那種會幫你自動猜、而且通常猜得對的 auto-wait 體驗,也跳過它,因為 chromedp 不會猜;它只會精準執行你要求的事,然後把頁面當下的樣子回傳給你。若你真正需要的是結構化 records,而不是 DOM,那就要慎重考慮,因為每個欄位都得由你自己寫 selector、測試、並在網站改版時修補。若你的需求只是「把這 500 個 URL 的資料給我」,在 Go 裡搭一套瀏覽器編排流程,對這個目標來說其實有點大材小用。我們的 瀏覽器自動化指南 會說明什麼情況下這些機制值得,什麼情況下不值得。
替代方案,以及我們自己的工具鏈放在哪裡
在 browser-driver 這個類別裡,rod 也是一個 Go 的 CDP driver,而 Playwright 和 Puppeteer 則是 Node 端選項,我們在 Playwright 與 Puppeteer 比較 裡有說明。它們的等待契約並不能互相替換。Playwright 會在很多動作前先自動等待可操作性;但這仍然不能告訴它,動作完成後應用程式資料究竟什麼時候才真正到齊。chromedp 提供的是更底層的等待 primitives,並把可操作性與應用層就緒條件都留給呼叫端。如果你在看更廣泛的市場,我們對 測試過的開源爬蟲整理 也涵蓋了靜態 crawler 和另一側的 extraction libraries。
相關評測: Browserless 評測。
託管式抽取服務是不同類別。它會用外包的 rendering 和 schema shaping,換掉你對瀏覽器與 selector 的直接控制。當交付物是結構化 records 而不是 DOM 時,這種做法很有用;但若瀏覽器必須由你的 Go 服務掌控,chromedp 會是更合適的選擇。我們也有做 Thunderbit 這類服務,但並沒有拿它對這個 fixture 做測試;因此這次結果不能拿來做與 chromedp 在相同性能、抽取品質或成本上的等價比較。
結論
要不要用 chromedp?如果你用 Go,而且想要一個可由你掌控的真實瀏覽器,那答案是要。這次本地 fixture 裡,它把第一個 script 結果帶出來的 p50 是 102 毫秒,取消後約 13 毫秒就能回收 Chrome,並且在四個並行 tab 下只用了 1 個 browser process。這些都是有邊界的觀察,不是普遍性的效能承諾;它真正持久的吸引力,在於當方便層不夠用時,Go 仍能直接存取 CDP。
但請把宣稱的尺度看準,因為這個聲譽有兩件事被講過頭了。所謂「純 Go、沒有依賴」描述的是模組;在 runtime,你仍然要部署並管理一個 Chrome binary。還有「用了 headless browser 就能拿到動態內容」這句話,只有在你的等待條件是對準你要的節點時才成立——最直覺的讀取和 WaitReady("body"),都曾讓我拿到一份少了載入後 800 毫秒才注入的內容的頁面,而且每次都默默發生。在 macOS 上,defer cancel() 不是風格問題;少了它,每次執行都會漏掉一個 browser,而這雖然是已知的平台行為,最後還是你的問題。把這三件事處理好,chromedp 就是我測過較可預測的 browser driver 之一;處理不好,它就會安安靜靜地失敗,而這正是爬蟲最糟糕的失敗方式。
試用 Thunderbit 擷取網頁資料 Get Started Free
常見問題
chromedp 真的能看到 JavaScript 渲染的內容嗎?
可以——但前提是你要用對應目標節點的等待條件。在一個連結於 load event 後 800 毫秒才被注入的 fixture 上,Navigate 加直接讀取會漏掉它,WaitReady("body") 也一樣會漏掉;但對該節點使用 WaitVisible,或用 JavaScript poll,三次測試都能把它抓回來。掃描不同延遲時,不等待的讀法在從 100 毫秒起的每個設定都會漏。渲染是必要條件;正確等待,才讓它成為充分條件。
WaitReady 和 WaitVisible 有什麼差別?
WaitReady 會等到節點附加到 DOM。WaitVisible 則會等到它真正可見。若某個節點已附加但樣式是 display: none,WaitReady 大約 6 毫秒就返回,而 WaitVisible 會一路等到 4 秒的 context deadline,最後乾淨地回傳 context deadline exceeded。兩者沒有誰更「可靠」——它們回答的是不同問題,選錯才是陷阱。
使用 chromedp 一定要 defer cancel() 嗎?
在 macOS 上,是的。取消 context 和 allocator 之後,啟動的 Chrome 在每次測試都會於 12–13 毫秒內被回收;但如果 Go process 結束前沒有取消,三次測試都會留下 orphan browser process。這是已知、且只適用於特定平台的行為——chromedp 的 tracker 也記錄了其他非 Linux 系統上相同的無法退出模式,而處理這件事的 parent-death kill 只存在於 Linux 的 build-tagged 原始碼裡。我沒有測 Linux,所以 orphan 結果請只視為 macOS 範圍內的現象。
chromedp 需要另外安裝 Chrome 嗎?
需要。Go 模組本身是純 Go、沒有 cgo,但它驅動的是外部瀏覽器,若沒有瀏覽器會立刻失敗。我是透過 chromedp.ExecPath 明確指定 Chrome for Testing 151.0.7922.10 的 headless shell。好處是啟動成本很小:五個全新程序到第一個 script 結果的完整 cold cycle,中位數是 102 毫秒,範圍 98–111 毫秒。
我該共用一個 browser 給多個 tab,還是開多個獨立 browser? 如果你的目標是盡量減少 browser process 數,優先用 child contexts。四次本地導航共用一個 browser 時,只用 1 個 Chrome browser process;開四個獨立 browser 時則是 4 個。wall time 也偏向共用設定(中位數 214 毫秒 vs 264 毫秒),但四個簡單本地頁面不能當作吞吐量 benchmark。記憶體沒有測。若你需要更強的 session 隔離、不同 proxy,或更小的 crash blast radius,獨立 browser 仍然可能是對的選擇;這些權衡不在本次測試範圍內。


