首页
关于本站
私人 DNS 加密设置教程
全部文章
推荐
加密 DNS 服务
DNS 实时战报
过滤规则订阅
搜索
网络与 DNS
加密 DNS 实操
路由器与家庭网络
自建服务
AdGuard Home
服务器运维
开发与工具
安全与隐私
登录
搜索
23-4.CN
累计撰写
59
篇文章
累计收到
3
条评论
首页
分类
网络与 DNS
加密 DNS 实操
路由器与家庭网络
自建服务
AdGuard Home
服务器运维
开发与工具
安全与隐私
页面
关于本站
私人 DNS 加密设置教程
全部文章
推荐
加密 DNS 服务
DNS 实时战报
过滤规则订阅
注册丨登录
搜索到
3
篇
分类为
开发与工具
的文章
2026-10-24
HTTP 的 Vary 和缓存键:CDN 上那些「明明改了却没生效」
这篇是协议与缓存笔记。站内的加密 DNS 服务在dns.23-4.cn。「我明明改了 CSS,刷新还是旧的。」「这个页面在 Chrome 上是新的,在手机上还是旧的。」「CDN 上明明 purge 了,用户说没生效。」这类问题的共同点:你以为缓存键只包含 URL,但实际上不止。HTTP 里有一个响应头专门用来声明「这个资源的缓存键还包含哪些请求头」——它就是 Vary。这篇讲清三件事:Vary 到底怎么工作、写错了会有什么后果、以及怎么用命令验证你的站点有没有写错。下面所有响应头都是我从真实站点上抓的。一、缓存键是什么先建立一个基本认知:缓存(浏览器、CDN、反向代理)判断「这份响应能不能复用」,靠的是缓存键。最朴素的理解是「缓存键 = URL」。但实际的缓存键通常是:缓存键 = URL + Vary 里声明的那些请求头的值举个例子。假设响应是:Vary: Accept-Encoding那么缓存会为下面两个请求分别存两份:请求头缓存键Accept-Encoding: gzipURL + gzipAccept-Encoding: identityURL + identity如果响应是 Vary: Accept-Language,那就会按语言分别缓存。关键:Vary 里没列出来的请求头,不参与缓存键。 所以 Vary 写漏了 = 该分开缓存的没分开,用户拿到别人的版本。二、抓一个真实站点的响应头看一个正常配置的站点:curl -sSI https://www.23-4.cn/ | head -20实测:HTTP/2 200 server: nginx date: Fri, 23 Oct 2026 16:42:02 GMT content-type: text/html; charset=UTF-8 vary: Accept-Encoding x-pingback: https://www.23-4.cn/action/xmlrpc strict-transport-security: max-age=31536000 x-content-type-options: nosniff referrer-policy: strict-origin-when-cross-origin只有一个 vary: Accept-Encoding。 这是最常见的配置——因为 nginx 的 gzip 模块会自动加上它。再看静态资源(这里的差别很有意思):curl -sSI https://www.23-4.cn/brand.css | grep -iE "^(cache-control|expires|etag|last-modified|vary)"实测:last-modified: Thu, 22 Oct 2026 06:29:27 GMT vary: Accept-Encoding etag: "6ad9ad47-5ebc" expires: Fri, 30 Oct 2026 16:42:02 GMT cache-control: max-age=604800 cache-control: public静态资源有完整的 etag / expires / cache-control: max-age=604800(7 天),HTML 页面一个缓存头都没有。这是合理的默认:HTML 有用户态内容(登录状态、评论数),不该被中间层长缓存;静态资源内容不变,长缓存没问题。再确认一下 HTML 确实一个都没有:for u in https://www.23-4.cn/ https://www.23-4.cn/archives/55/; do printf "%-40s " "$u" curl -sSI "$u" | grep -icE "^(cache-control|expires|etag|last-modified)" done实测:https://www.23-4.cn/ 0 https://www.23-4.cn/archives/55/ 0都是 0。 注意这是 grep -c 的计数,不是「有没有 vary」——vary 是有的,但没有缓存控制头。这本身不算 bug,但它意味着缓存器只能靠「启发式规则」(RFC 7234 允许的)来猜缓存时长。对小站来说通常无所谓,但如果你前面挂了 CDN,CDN 会按自己的默认值来——那时候你可能不希望它缓存 HTML。注意 cache-control 出现了两次(max-age=604800 和 public)。这在 nginx 的 expires + add_header 组合下很常见,不是 bug——HTTP 规范允许同一个头出现多次,等效于合并成一个逗号分隔的列表:cache-control: max-age=604800, public三、Vary: * 是灾难如果你写:add_header Vary "*";后果是:这个资源永远无法被缓存。因为 * 的语义是「缓存键包含所有可能变化的请求头」——那等于每个请求都是独一无二的。缓存命中率归零,所有请求都打到源站。这是最严重的 Vary 错误,而且不会报错、不会有日志,只是性能悄悄变差。判断方法:curl -sSI https://www.example.com/ | grep -i "^vary"出现 vary: * 就是这个情况。四、Vary 漏了什么会怎样场景 1:多语言站点漏了 Accept-Language# ⛔ 错:返回不同语言,但没声明 Vary location / { if ($http_accept_language ~* "zh") { rewrite ^ /zh$uri last; } }第一个中文用户访问 → 缓存了中文版;接着一个英文用户访问 → 拿到中文版。正确写法:add_header Vary "Accept-Language";场景 2:Vary: Accept-Encoding 漏了如果你的站点开了 gzip,但没声明 Vary: Accept-Encoding,可能出现:一个支持 gzip 的客户端先访问,缓存了 gzip 版本;接着一个不支持 gzip 的客户端拿到 gzip 响应 → 乱码/解压失败。nginx 的 gzip 模块会自动加这个头,但如果你用了 gzip_static 或者自己在 CDN 上做压缩,就需要手动确认。验证缓存是否真的按 Accept-Encoding 分开了:for ae in gzip identity; do printf "AE=%-10s " "$ae" curl -sSI -H "Accept-Encoding: $ae" https://www.23-4.cn/ | grep -iE "^(content-encoding|vary)" | tr '\n' ' ' echo done实测:AE=gzip vary: Accept-Encoding content-encoding: gzip AE=identity vary: Accept-Encoding关键观察:请求 gzip 时返回了 content-encoding: gzip,请求 identity 时没有这个头。两个版本的响应体不同——所以必须有 Vary: Accept-Encoding 来告诉缓存「这两个不是同一份」。如果这里没有 vary,一个缓存就可能把 gzip 版本发给不支持 gzip 的客户端。场景 3:Vary 写多了,缓存命中率暴跌反过来也有问题。写了 Vary: User-Agent:Vary: User-AgentUser-Agent 的取值空间是几万到几十万种(每个浏览器版本、每个爬虫、每个 SDK 都不同)。结果就是缓存里每个 URL 有几万个副本,命中率接近 0,还浪费存储。Vary: User-Agent 几乎总是错的。 要按设备区分,应该在 URL 或 cookie 上做,不是在 Vary 上做。五、一个更隐蔽的问题:Vary 在 CDN 上会「被吃掉」很多 CDN 默认不按 Vary 分缓存,或者只支持有限的几个字段(通常只支持 Accept-Encoding)。这意味着:你源站写了 Vary: Accept-Language,但 CDN 忽略了它,仍然按 URL 缓存。验证方法:直接对比「直连源站」和「走 CDN」的响应头。# 直连源站(绕过 DNS) curl -sSI --resolve www.example.com:443:ORIGIN_IP https://www.example.com/ | grep -i "^vary" # 走 CDN curl -sSI https://www.example.com/ | grep -i "^vary"两个 vary 应该一致。如果不一致(比如源站有 Accept-Language,CDN 只返回 Accept-Encoding),说明中间层改了它。这类问题只能靠对比发现,因为两边单独看都「正常」。六、怎么验证缓存行为是对的验证 1:看响应头齐不齐curl -sSI https://www.example.com/ | grep -iE "^(cache-control|expires|etag|last-modified|vary|age)"头作用缺失的后果vary声明缓存键的额外维度缓存串味(给别人返回错误版本)cache-control声明缓存时长和范围缓存器用启发式规则猜,可能缓存太久etag / last-modified支持条件请求(304)每次都要全量传输age已被缓存多久(CDN 加)无法判断命中情况验证 2:304 是否正常工作# 先拿 ETag ETAG=$(curl -sSI https://www.23-4.cn/brand.css | grep -i '^etag' | tr -d '\r' | cut -d' ' -f2) echo "ETag = $ETAG" # 再用 If-None-Match 请求,应该返回 304 curl -sS -o /dev/null -w "code=%{http_code} size=%{size_download}\n" \ -H "If-None-Match: $ETAG" https://www.23-4.cn/brand.css期望 code=304 size=0。 如果返回 200 且 size 不为 0,说明条件请求没生效——每个用户每次都要重新下载。实测这个站点:ETag="6ad9ad47-5ebc" with-INM code=304 size=0 no-INM code=200 size=24252条件请求工作正常:带上 If-None-Match 拿到 304 且传输 0 字节,不带则是完整体积。这一条是「静态资源缓存配置正确」最硬的证据。踩坑点:ETag 的值带引号("6ad9ad47-5ebc")。提取的时候别把引号弄丢,更别自作聪明去掉:# ✅ 正确:整行取第二个字段,引号原样保留 etag=$(curl -sSI "$URL" | grep -i '^etag' | tr -d '\r' | cut -d' ' -f2) # → "6ad9ad47-5ebc" # ⛔ 错误:tr -d '"' 去掉引号后,服务端会认为 ETag 不匹配,返回 200而且注意响应头行尾有 \r(cat -A 能看到 ^M$)。不 tr -d '\r' 的话,变量里会带一个回车,-H 传过去就是坏值。验证 3:404 页面有没有被缓存这是一个经典的坑:某个 URL 曾经 404,被 CDN 缓存了,之后你发布了这个页面,用户还是看到 404。curl -sSI https://www.example.com/some-page/ | grep -iE "^(HTTP|cache-control|vary)"实测这个站点的 404:HTTP/2 404 vary: Accept-Encoding x-pingback: https://www.23-4.cn/action/xmlrpc strict-transport-security: max-age=31536000404 没有 cache-control: no-store。 这意味着 CDN 可能用启发式规则缓存这个 404——比如几百秒。要避免这个坑,给错误页加上明确的缓存指令:error_page 404 /404.html; location = /404.html { internal; add_header Cache-Control "no-store" always; }always 参数很重要——它让这个头对错误响应也生效(默认 add_header 只对 2xx/3xx 生效,这是个经典陷阱)。顺便说一句:这个站点没有自定义 404 页面(/var/www/typecho/404.html 不存在,nginx 配置里也没有 error_page),404 是 Typecho 自己渲染的。所以上面那段 nginx 配置是「如果要加该怎么加」,不是对本站现状的描述。踩坑点:add_header 不加 always 时,nginx 只在 200/201/204/206/301/302/303/304/307/308 上加头。404、500 这些全都不加——这正好是缓存错误页最危险的地方。七、nginx 里的正确写法server { # 默认:所有响应按 Accept-Encoding 区分(gzip 模块通常会自动加,这里是显式兜底) add_header Vary "Accept-Encoding" always; # HTML 不缓存(有用户态内容) location ~ \.php$ { add_header Cache-Control "no-cache, must-revalidate" always; } # 静态资源长缓存 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { expires 7d; add_header Cache-Control "public" always; } # 错误页绝不缓存 location = /404.html { internal; add_header Cache-Control "no-store" always; } }注意 add_header 的继承规则(另一个经典陷阱):如果某个 location 里写了任何 add_header,父级的 add_header 就全部失效——不是合并,是覆盖。所以上面的配置里,每个 location 都得自己写一遍 Vary,否则静态资源那一段就没有 Vary 头了。想避免这个坑,用 map + 变量,或者依赖 nginx 的 gzip 自动加(但那只覆盖 gzip 场景)。八、诊断清单URL=https://www.example.com/ echo "=== 1. Vary 是否合理(出现 * 就是灾难)===" curl -sSI "$URL" | grep -i "^vary" echo "=== 2. 是否有缓存控制 ===" curl -sSI "$URL" | grep -iE "^(cache-control|expires)" echo "=== 3. 压缩版本是否分开 ===" for ae in gzip identity; do printf " AE=%-10s " "$ae" curl -sSI -H "Accept-Encoding: $ae" "$URL" | grep -iE "^(content-encoding|vary)" | tr '\n' ' ' echo done echo "=== 4. 条件请求是否工作 ===" ETAG=$(curl -sSI "$URL" | grep -i '^etag' | tr -d '\r' | cut -d' ' -f2) [ -n "$ETAG" ] && curl -sS -o /dev/null -w " 304 测试: code=%{http_code}\n" -H "If-None-Match: $ETAG" "$URL" || echo " 无 ETag" echo "=== 5. 错误页是否被缓存 ===" curl -sSI "${URL}definitely-not-exist/" | grep -iE "^(HTTP|cache-control)"怎么判断结果是对的:vary 里不能有 *,不该有 User-Agent;返回不同内容的分支(gzip/identity、zh/en)必须在 vary 里;cache-control 要和内容性质匹配(HTML 短、静态资源长、错误页 no-store);304 测试要返回 304;错误页要能明确看到 no-store,而不是什么都没有。九、一句话总结Vary 是「缓存键的额外维度声明」。写漏了会导致用户拿到别人的版本,写多了会导致命中率归零,写 * 会导致完全不缓存。而它最容易出问题的地方不在语法,在于:CDN 可能忽略它(要对比源站和 CDN 的响应头);add_header 在 location 里会覆盖父级(每个 location 都要重写);add_header 不加 always 对错误页无效(错误页正是最不该被缓存的)。这三个都不是"配置写错了",而是"配置看起来对但行为不对"——所以只能靠 curl -I 实际抓一遍,不能靠读配置文件判断。相关阅读curl --resolve 与 --connect-to 的区别:只换连接目标,不换 SNI 和 Hostnginx error.log 里那 5 类 warn 到底要不要管谁在占着这个端口:ss / lsof / /proc 三条路各自的盲区
2026年10月24日
45 阅读
0 评论
0 点赞
2026-10-24
curl --resolve 与 --connect-to 的区别:只换连接目标,不换 SNI 和 Host
这篇是排查工具笔记,和 DNS 加密本身无关。站内的加密 DNS 服务在 dns.23-4.cn。有三种场景会让你需要「连到某个特定 IP,但让 TLS 和 Host 头还是原来的域名」:换了服务器,DNS 还没切,想先在新机器上验证站点能不能正常起来;怀疑 DNS 解析到了旧 IP,想证明「问题在解析,不在服务」;同一台机器上跑了多个虚拟主机,想精确命中其中一个。/etc/hosts 能解决第一种,但它要 root、会影响整机、还要记得改回来。curl 有两个参数是专门为这件事设计的:--resolve 和 --connect-to。它们看起来像同一件事,实际差别很关键——用错了会在证书和 SNI 上踩坑,而且报错信息完全指不到真正的原因。下面的输出都是我在一台 Ubuntu 20.04、curl 7.68 上实际跑出来的。一、先看手册怎么说两条命令的 man 原文,差别一眼就能看出来。--resolve:--resolve <host:port:address[,address]...> Provide a custom address for a specific host and port pair. Using this, you can make the curl requests(s) use a specified address and prevent the otherwise normally resolved address to be used. Consider it a sort of /etc/hosts alternative provided on the command line. The port number should be the number used for the specific protocol the host will be used for.--connect-to:--connect-to <HOST1:PORT1:HOST2:PORT2> For a request to the given HOST1:PORT1 pair, connect to HOST2:PORT2 instead. This option is suitable to direct requests at a specific server, e.g. at a specific cluster node in a cluster of servers. This option is only used to establish the network connection. It does NOT affect the hostname/port that is used for TLS/SSL (e.g. SNI, certificate verification) or for the application protocols. "HOST1" and "PORT1" may be the empty string, meaning "any host/port". "HOST2" and "PORT2" may also be the empty string, meaning "use the request's original host/port".关键就那一句:--connect-to 只管建立网络连接,不影响 TLS/SSL 用的主机名和端口(也就是 SNI、证书校验),也不影响应用层协议。--resolve 手册里没写这句,但它的行为是一样的——它替换的只是「域名到地址」的解析结果,SNI 和 Host 头仍然来自你请求的 URL。一句话概括两者: 改连接目标能改端口SNIHost 头证书校验用哪个名字--resolve✅❌(只能匹配,不能换成别的端口)不变不变URL 里的域名--connect-to✅✅不变不变URL 里的域名裸 IP 访问✅✅变成 IP变成 IPIP,几乎必然失败二、--resolve:命令行版的 /etc/hosts先看基准。不加任何参数:curl -sS -o /dev/null -w 'code=%{http_code} ip=%{remote_ip}\n' https://www.23-4.cn/实测:code=200 ip=240e:95d:3020:43::4006注意 remote_ip 是个 IPv6 地址。这台机器同时有 A 和 AAAA 记录,curl 优先选了 IPv6。这本身就是个坑——你想测 IPv4 那台的时候,可能一直在打 IPv6。现在强制走 IPv4:curl -sS -o /dev/null -w 'code=%{http_code} ip=%{remote_ip}\n' \ --resolve www.23-4.cn:443:160.202.238.204 \ https://www.23-4.cn/实测:code=200 ip=160.202.238.204remote_ip 变成了 160.202.238.204,状态码还是 200。说明:连接确实打到了指定 IP,而 TLS 握手、SNI、Host 头全都还是 www.23-4.cn——所以服务端正常返回了这个站点的内容。--resolve 的格式是 host:port:address,端口必须写,而且必须和 URL 里的端口一致(https 就是 443)。它匹配的是「主机 + 端口」这个组合,所以同一个主机不同端口要写多条:curl --resolve www.example.com:443:10.0.0.1 \ --resolve www.example.com:8443:10.0.0.2 \ https://www.example.com/三、--connect-to:能换端口,这是它真正的价值--resolve 不能表达「连到 443 但从 9999 端出去」。--connect-to 可以,格式是 HOST1:PORT1:HOST2:PORT2。先验证端口真的生效——把它指到一个没人监听的端口:curl -sS -o /dev/null -w 'code=%{http_code}\n' \ --connect-to www.23-4.cn:443:160.202.238.204:9999 \ https://www.23-4.cn/实测:curl: (7) Failed to connect to 160.202.238.204 port 9999: Connection refused code=000报错里明确是 port 9999。这就证明了 PORT2 被真正用上了,不是摆设。同样的思路可以证明 --resolve 真的改了目标 IP。故意指一个不可达地址:curl -sS -o /dev/null -w 'code=%{http_code}\n' \ --resolve www.23-4.cn:443:192.0.2.1 \ https://www.23-4.cn/实测:curl: (28) Connection timed out after 6001 milliseconds code=000192.0.2.1 是 RFC 5737 保留的文档用地址,永远不可达。超时说明 curl 真的去连它了,而不是忽略了参数。四、最容易踩的坑:SNI 不会跟着变这是两个参数最反直觉的地方,也是它们的设计意图。curl -sS -o /dev/null -w 'code=%{http_code}\n' \ --resolve example.com:443:160.202.238.204 \ https://example.com/实测:curl: (60) SSL: no alternative certificate subject name matches target host name 'example.com'TLS 握手阶段就失败了。因为 SNI 发出去的是 example.com,而对方服务器出示的证书是 *.23-4.cn:timeout 8 openssl s_client -connect www.23-4.cn:443 -servername www.23-4.cn </dev/null 2>/dev/null \ | openssl x509 -noout -subject -ext subjectAltName实测:subject=CN = *.23-4.cn X509v3 Subject Alternative Name: DNS:*.23-4.cn, DNS:23-4.cnexample.com 不在这个证书的 SAN 里,校验就过不去。这个「失败」恰恰证明参数工作正常:--resolve 只换了「连哪里」,没换「我是谁」。如果它把 SNI 也一起换了,你反而失去了用它验证真实站点的能力——因为那样测的就不是你的配置,而是另一个域名的配置了。想看这个错配后面的真实响应,加 -k:curl -sS -o /dev/null -w 'code=%{http_code}\n' -k \ --resolve example.com:443:160.202.238.204 \ https://example.com/实测:code=301301 是因为请求落到了默认虚拟主机,被重定向走了。这就是「Host 不匹配」的典型表现:TLS 能过(因为 -k 跳过了校验),但 nginx 找不到对应的 server_name,于是交给默认 server 处理。五、和「直接写裸 IP」有什么区别很多人图省事直接写 IP:curl -sS -o /dev/null -w 'code=%{http_code}\n' -k https://160.202.238.204/实测:code=301看起来和上面一样,但两者不是一回事: --resolve / --connect-to裸 IP连接目标指定 IP就是这个 IPSNIwww.23-4.cn无(或 IP)Host 头www.23-4.cn160.202.238.204命中哪个 server_name正确的那个默认 server证书校验正常通过必然失败,只能 -k用裸 IP 测出来的结果不能代表真实用户拿到的结果。它走的是默认虚拟主机,可能是另一个站、可能被重定向、可能直接 404。用它来判断「新机器部署好了没」会得到完全错误的结论。这也是为什么我在 nginx 配置里留了这么一段注释:# curl --resolve dns.23-4.cn:443:160.202.238.204 ... /dns-query -> 200 ✓ # curl https://160.202.238.204/dns-query(裸 IP、无 SNI) -> 301 ✗ # curl --resolve foo.23-4.cn:443:160.202.238.204 ... /dns-query -> 301 ✗第二条是裸 IP,第三条是证书能过但 server_name 不存在的子域名——注意 foo.23-4.cn 是能通过证书校验的(*.23-4.cn 覆盖它),所以它返回 301 而不是 TLS 错误。这两种 301 长得一样,原因完全不同。六、什么时候用哪个场景用哪个理由验证新机器上的站点能否正常响应--resolve不需要改端口,最直观想测某个非标准端口上的服务--connect-to唯一能改端口的复现「DNS 解析到了旧 IP」--resolve精确指定旧 IP测内网某台机器上的同域名服务--connect-to常常端口也不一样(比如 8080)排查「是不是 SNI 的问题」两者都行 + openssl s_client -servernamecurl 不方便单独控制 SNI想故意构造 Host 不匹配裸 IP 或改 -H 'Host:'这两个参数做不到如果只是想让整台机器临时认某个 IP,用 --resolve 就够,别去改 /etc/hosts——改完忘了删,是另一类故障的开头。七、验证你到底连到了哪排查时最容易犯的错是:以为参数生效了,其实打到了别的地方。用 -w 把连接信息打出来,别靠猜。curl -sS -o /dev/null \ -w 'http=%{http_code} ip=%{remote_ip} port=%{remote_port} time=%{time_total}\n' \ --resolve www.23-4.cn:443:160.202.238.204 \ https://www.23-4.cn/%{remote_ip} 是关键字段——它告诉你实际连到哪个地址。配合 %{remote_port} 可以确认端口,%{time_connect} 能看出握手耗时是否异常。想看得更细,用 -v,重点看这三行:* Connected to www.23-4.cn (160.202.238.204) port 443 (#0) * TLSv1.3 (OUT), TLS handshake, Client hello (1) * Server certificate: * subject: CN = *.23-4.cn第一行证明「连到哪个 IP、哪个端口」,第二、三行证明「TLS 是跟谁握手的」。如果 Connected to 后面的 IP 不是你指定的那个,说明参数没生效(最常见原因是 port 写错,比如 URL 是 https:// 但 --resolve 里写了 :80)。踩坑点汇总:--resolve 的端口必须和 URL 一致。https:// 对应 443,写成 80 会静默失效——curl 找不到匹配项,就退回正常解析,你还以为参数起作用了。IPv6 优先。目标站点同时有 A/AAAA 时,不加 --resolve 可能一直在打 IPv6,看起来「换了机器没用」。加 -4 或 --resolve 强制指定。--connect-to 的空字符串表示「任意」或「沿用原值」。--connect-to ::10.0.0.1:8080 表示「所有主机所有端口都连到 10.0.0.1:8080」——调试方便,但很容易连错自己都不知道。两者都不改变 Host 头。要测「Host 头不对会怎样」,得用 -H 'Host: other.example.com',那是另一条路径。相关阅读怎么验证加密 DNS 真的生效:dig +tls / +https 与 curl 四步逐层排查网站被误拦了怎么办:怎么确认是 DNS 拦的,以及反馈后多久能修好HTTP 的 Vary 和缓存键:CDN 上那些「明明改了却没生效」
2026年10月24日
16 阅读
0 评论
0 点赞
2026-10-04
不装 Puppeteer:用 Node 内置 WebSocket 直接驱动 Chrome DevTools 协议
这篇是开发工具笔记,和 DNS 无关。站内的加密 DNS 服务在dns.23-4.cn。要做浏览器自动化,第一反应通常是装 Puppeteer 或 Playwright。但如果你的需求只是「打开页面、等它渲染完、取几个值、点几下」,那 Node 24 已经内置了需要的一切——全局 WebSocket 和全局 fetch,加起来不到 120 行就能驱动 Chrome DevTools 协议(CDP)。这篇是我自己写回归脚本时踩出来的完整做法,含几个会让人白白调试半小时的坑。一、先启动一个带调试端口的 Chromechrome --headless=new --remote-debugging-port=9357 \ --user-data-dir=/tmp/cdp-profile \ --no-first-run --disable-gpu \ --window-size=1440,900 about:blank关键点:--remote-debugging-port 是唯一入口,之后所有操作都通过 CDP 走 WebSocket。--user-data-dir 单独给一个目录,避免污染你日常的浏览器配置。二、开一个新标签页,拿到它的 WebSocket 地址const CDP_PORT = 9357; async function newTab() { // 注意:必须是 PUT,GET 在较新的 Chrome 上已不再创建标签页 const r = await fetch(`http://127.0.0.1:${CDP_PORT}/json/new?about:blank`, { method: 'PUT', }); return r.json(); // { id, webSocketDebuggerUrl, ... } }三、一个最小可用的 CDP 会话封装CDP 是「发一条带 id 的消息,等一条同 id 的回复」,另外还会不停推事件。把它包成 Promise 就够了:async function attach(wsUrl) { const ws = new WebSocket(wsUrl); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); let id = 0; const pending = new Map(); const events = []; ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { // 这是某条命令的回复 const { res, rej } = pending.get(m.id); pending.delete(m.id); m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); } else if (m.method) { // 这是事件推送 events.push(m); } }; const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); setTimeout(() => { // 一定要有超时,否则一条丢失的回复会永久挂住 if (pending.has(i)) { pending.delete(i); rej(new Error('timeout ' + method)); } }, 40000); }); const evaluate = async (expression) => { const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true, }); if (r.exceptionDetails) { throw new Error('eval: ' + (r.exceptionDetails.exception?.description || r.exceptionDetails.text)); } return r.result.value; // returnByValue 时结果在这里 }; return { send, evaluate, events, close: () => ws.close() }; }四、打开页面并「确认它真的渲染了」const s = await attach((await newTab()).webSocketDebuggerUrl); await s.send('Page.enable'); await s.send('Runtime.enable'); await s.send('Log.enable'); // 收集 console 错误 await s.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true, }); await s.send('Page.navigate', { url: 'https://example.com' }); // 等 load for (let i = 0; i < 120; i++) { await new Promise(r => setTimeout(r, 250)); if (await s.evaluate('document.readyState').catch(() => null) === 'complete') break; }到这里就有一个必须做的动作:// 先断言页面确实渲染了,再断言别的东西 const rendered = await s.evaluate(`({ title: document.title, bodyLen: document.body ? document.body.innerHTML.length : 0, items: document.querySelectorAll('.some-selector').length, })`); if (!rendered.bodyLen) throw new Error('页面根本没渲染,后面的断言全是假的');为什么必须先做这一步:如果导航其实没成功(比如 URL 写错了),document 还是 about:blank。此时 document.querySelectorAll(...).length 会老老实实返回 0,而你如果断言的是「不应该有某个元素」,它会通过——测试全绿,功能全坏。这种「空断言」是最难发现的假阳性。五、五个会浪费你半小时的坑1. requestAnimationFrame 在无头模式下不会按你预期触发。没有合成器时,rAF 可能永远不触发,或者只触发一两次。用 setTimeout 代替所有等待:const sleep = (ms) => new Promise(r => setTimeout(r, ms)); await sleep(2500); // 让 JS 跑完2. 不要把你的变量命名成 URL。URL 是 Node 的全局 WHATWG URL 类。写 const URL = 'https://...' 之后,某些库内部 new URL(...) 会抛错或被静默吞掉,导航「看起来成功了」但实际去了 about:blank——于是所有断言真空通过。这是个非常隐蔽的坑,换个名字就好。3. 每个视口开一个新标签页。Emulation.setDeviceMetricsOverride 改尺寸之后,页面里的媒体查询、懒加载、以及已经算好的布局不会自动全部重算。做「桌面 / 移动」两套验证时,开新标签页重新加载,别在同一页上反复改尺寸。4. 用 process.exitCode,不要 process.exit()。process.exit() 会立刻切断进程,WebSocket 里的响应还没读完,输出可能被截断。设置 process.exitCode = 1 然后自然退出。5. 收集 console 错误。Runtime.exceptionThrown 给的是未捕获异常,Log.entryAdded 给的是 console 输出。两个都开,才能看到「页面加载时的报错」:const errors = s.events .filter(e => e.method === 'Runtime.exceptionThrown' || (e.method === 'Log.entryAdded' && e.params.entry.level === 'error')) .map(e => e.method === 'Runtime.exceptionThrown' ? 'EXC: ' + (e.params.exceptionDetails?.exception?.description || '') : 'LOG: ' + e.params.entry.text);六、和 Puppeteer 比,值得吗 原生 CDPPuppeteer / Playwright依赖0,Node 内置需要下载浏览器 + 一堆依赖体积一个脚本文件几百 MB上手成本需要理解协议API 友好能力上限协议本身,最全封装了常用部分,偶尔要 evaluate 绕回 CDP适合场景固定环境的回归脚本、CI 里的定点验证复杂交互、爬虫、端到端测试如果你只是要跑几个固定页面的定点验证(比如「这个链接是不是 200」「这个元素在移动端有没有被遮挡」),原生 CDP 更省事,而且不会因为浏览器版本升级而整体失效——你依赖的是协议,不是封装层。相关阅读在 Alpine Linux 上从零搭一个 Typecho:nginx + php-fpm + SQLite 的完整清单服务器磁盘满了怎么查:df / du / inode / 日志轮转的可执行排查顺序
2026年10月04日
26 阅读
0 评论
0 点赞