自动发布脚本里最省事的一行代码,大概是这个:
page.goto(url, wait_until="load")
rows = page.query_selector_all(".entry-list li")
页面开了,事件也到了,看起来一切正常。但如果 rows 是空的,你多半会怀疑选择器写错了、账号没登录、或者平台改版了——而真实原因是:load 事件到达的那一刻,这个页面上一条数据都还没有。
今天用只读探针在掘金创作者中心把这件事量了一遍:load 事件 1.28 秒就到,此刻列表条目数 0;列表第一条出现在 2.5 秒。中间那 1.2 秒不是网络慢,是 load 这个事件本身就没承诺过「数据已经渲染」。
load 承诺的到底是什么
load 的语义是「页面及其所有依赖资源(图片、样式、脚本、iframe)加载完毕」。它管的是资源加载,不管资源加载完之后 JavaScript 还要不要发请求、要不要把数据渲染成 DOM。
对一个纯服务端渲染的页面,这两件事差不多同时发生,所以老写法一直能用。但对掘金这种 SPA,load 发生时 React 才刚挂载完,列表数据要靠挂载后的 fetch 去拿——load 事件在结构上就早于数据。
这不是某个平台的怪癖,而是 SPA 的常态。老写法在 SSR 时代是对的,在 SPA 时代是错的,而错误表现恰好是「空数组」,看起来像别的问题。
三次开页,load 时刻全是 0 条
先看最直接的一组数据。同一台机器、同一个账号、连开三次创作者中心文章列表页,在页面内用 add_init_script 埋点记录 DOMContentLoaded、load 和业务锚点(.entry-list li)首次出现的时刻:
| 轮次 | domcontentloaded | load | 首条列表出现 | load 时条目数 |
|---|---|---|---|---|
| 1 | 1109 ms | 1282 ms | 2412 ms | 0 |
| 2 | 1136 ms | 1285 ms | 2521 ms | 0 |
| 3(首页) | 1004 ms | 1172 ms | 2312 ms | 0 |
三比三,load 时刻的条目数都是 0。首页再跑三轮,锚点分别在 2237 / 2329 / 2312 ms 出现,相对 load 的错位是 1074 / 1081 / 1140 ms——错位量稳定在 1.1 秒左右,不是偶发抖动。
稳定错位比随机错位更值得警惕:随机错位还能靠「多试几次」蒙过去,稳定错位意味着旧写法在每一次运行里都拿到空数组,只是有时候后面恰好没人用这个数组。
把 1.2 秒拆成三段
光知道「晚了 1.2 秒」不够,得知道晚在哪一段,才知道该等什么。用 XHR/fetch 打点抓请求时刻,链条是这样(第 2 轮实测):
t=1285ms load 事件到达,列表 0 条,骨架屏 13 个节点
t=1806ms 业务请求发出 /content_api/v1/article/list_by_user
t=1975ms 响应回来 6599 字节(跨域,页内读不到 body)
t=2521ms .entry-list li 首次出现,100 条一次性渲染
三段分别是:
- load → 请求发出:521 ms。浏览器跑完 hydration、初始化路由和状态管理,才轮到业务代码发请求。
- 请求 → 响应:169 ms。这段反而是最快的,接口本身不慢。
- 响应 → DOM 出现:546 ms。数据已经到手,还要经过状态更新、虚拟列表计算、组件渲染。
真正耗时的是第 1 段和第 3 段——两段都是纯前端开销,网络优化在这两段上一分钱都省不下来。等网络空闲只能覆盖第 2 段,而它是三段里最短的一段。
骨架屏让「元素存在」变成假信号
第一反应往往是「那我等元素出现总行了吧」。但要先看清 load 时刻的 DOM 长什么样:
li = 0
sk = 13 ← class 含 skeleton / sk- / loading 的节点
mainText = 105 ← 主内容区正文只有 105 字符
bodyText = 117
load 时刻页面不是白屏,它是一副骨架屏:占位方块已经在了,main 元素也在,只是里面没有真实数据。这就制造了两个层次的误判:
- 用
main或容器选择器判就绪 → 一到就命中,因为容器从一开始就在。 - 用
[class*=loading]之类判「还在加载」 → 永远为真。实测列表渲染完成后,仍有 12 个节点的 class 里带 skeleton/loading 字样残留(懒加载图片占位之类),这个条件从头到尾没变成假过。
所以「元素存在」和「元素有内容」必须分开判。前者在骨架屏阶段就成立了,后者才对应可用状态。
顺带一个观察:列表不是分批到齐的,li 从 0 直接跳到 100,100ms 采样从来没抓到中间值。也就是说没有「渐进式就绪信号」可以依赖——你要么等到 100 条,要么就是 0 条。
四种等待写法的实测对比
同一页面,四种策略各跑一遍,记录耗时和拿到手的条目数:
| 策略 | 耗时 | 拿到条目数 |
|---|---|---|
wait_until="domcontentloaded" | 1.01 s | 0 |
wait_until="load" | 1.28 s | 0 |
wait_until="networkidle" | 3.48 s | 100 |
wait_for_selector(".entry-list li") | 2.49~2.50 s | 100 |
两个结论:
networkidle 能拿到数据,但它是四者里最慢的——比等锚点慢了将近 1 秒(39%)。它把「网络安静了」当成「渲染完了」,于是必须等到第 3 段渲染结束之后、且恰好 500ms 内没有新请求,才肯返回。为了一个它并不理解的信号,多等了一整段渲染时间。
而且 networkidle 本质上是猜。 这轮抓到的 58 个请求里,按域名分布是这样:
mcs.snssdk.com 20 ← 埋点上报
api.juejin.cn 19 ← 业务接口
mon.zijieapi.com 8 ← 监控
mcs.zijieapi.com 3
vcs.zijieapi.com 2
mssdk.bytedance.com 2
(其余各 1)
埋点 + 监控占了 28 个,接近一半。这些请求的发送节奏由上报 SDK 的采样和批量策略决定,跟你要的那块内容毫无关系。今天恰好能静下来,明天 SDK 改个批量窗口,networkidle 就可能一直等不到——Playwright 官方文档本身也不建议依赖它判就绪。
落地:三级就绪判据
改法不复杂,核心是把「等什么」从时间/网络换成业务锚点 + 内容约束 + 稳定性三层:
def wait_list_ready(page, sel=".entry-list li", min_rows=1, settle=300, timeout=20000):
# 第一级:锚点出现(结构就绪)
page.wait_for_selector(sel, state="attached", timeout=timeout)
# 第二级:内容非空且条数稳定(数据就绪)
page.wait_for_function(
"""([sel, minRows, settle]) => {
const rows = document.querySelectorAll(sel);
if (rows.length < minRows) return false;
// 首条必须有实际文本,排除空壳节点
const txt = (rows[0].innerText || "").trim();
if (txt.length < 10) return false;
// 条数连续 settle 毫秒不变,才算稳定
const key = rows.length + ":" + txt.length;
const now = Date.now();
if (window.__rk !== key) { window.__rk = key; window.__rt = now; return false; }
return (now - window.__rt) >= settle;
}""",
arg=[sel, min_rows, settle],
timeout=timeout,
)
return page.eval_on_selector_all(sel, "els => els.length")
三个参数各有来源,不是拍脑袋:
state="attached"而不是默认的visible。列表行可能被父容器裁剪,等可见性会引入额外的布局依赖,而你要的是「节点在」。min_rows默认 1,但对「核验文章在不在列表里」这种场景应该直接要求 ≥ 预期条数。实测是 100 条一次到位,不存在慢慢长出来的情况,所以这个阈值是可靠的。settle=300是给「条数稳定」留的观察窗。实测 100 条之后 5 秒内数字一动不动(3315 字符稳定到 7780ms),300ms 有余量又不拖时间。
在 Electron + puppeteer 的通道里,等价写法是 page.waitForFunction,语义一致:
await page.goto(url, { waitUntil: "load" }); // 只用于拿到导航完成
await page.waitForFunction(
(sel) => {
const rows = document.querySelectorAll(sel);
return rows.length > 0 && (rows[0].innerText || "").trim().length > 10;
},
{ timeout: 20000, polling: 100 },
".entry-list li"
);
注意 waitUntil: "load" 在这里不是判据,它只是「导航结束了、可以开始等了」的起点。把它当判据用,就是前面那个 0 条的故事。
和「核验假阴性」是两件事
这套判据解决的是就绪时刻问题:页面还没准备好就去读,读到空。它跟我之前写过的「核验假阴性」不是同一层——那篇讲的是判据对了、但一次查不到该怎么处置(重启核验、次日复验、四态判定),这篇讲的是判据本身就早了一个多秒。
两层叠起来才有意义:先保证每次读都在页面真的就绪之后,再谈读不到时该重试还是该判失败。否则重试十次,每次都在同一秒里读到空数组,只会把一个确定性问题误诊成偶发问题——而偶发问题最容易被写成「加重试就好了」。
一句话收尾:load 事件告诉你资源加载完了,没告诉你数据渲染完了。在 SPA 上,这两件事差一个多秒,而这一秒正好是发布脚本读数据的地方。