Electron 发布 别拿 load 事件当页面就绪

自动发布脚本里最省事的一行代码,大概是这个:

python 复制代码
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 轮实测):

ini 复制代码
t=1285ms  load 事件到达,列表 0 条,骨架屏 13 个节点
t=1806ms  业务请求发出   /content_api/v1/article/list_by_user
t=1975ms  响应回来       6599 字节(跨域,页内读不到 body)
t=2521ms  .entry-list li 首次出现,100 条一次性渲染

三段分别是:

  1. load → 请求发出:521 ms。浏览器跑完 hydration、初始化路由和状态管理,才轮到业务代码发请求。
  2. 请求 → 响应:169 ms。这段反而是最快的,接口本身不慢。
  3. 响应 → DOM 出现:546 ms。数据已经到手,还要经过状态更新、虚拟列表计算、组件渲染。

真正耗时的是第 1 段和第 3 段------两段都是纯前端开销,网络优化在这两段上一分钱都省不下来。等网络空闲只能覆盖第 2 段,而它是三段里最短的一段。

骨架屏让「元素存在」变成假信号

第一反应往往是「那我等元素出现总行了吧」。但要先看清 load 时刻的 DOM 长什么样:

ini 复制代码
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 官方文档本身也不建议依赖它判就绪。

落地:三级就绪判据

改法不复杂,核心是把「等什么」从时间/网络换成业务锚点 + 内容约束 + 稳定性三层:

python 复制代码
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,语义一致:

javascript 复制代码
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 上,这两件事差一个多秒,而这一秒正好是发布脚本读数据的地方。

相关推荐
paopaokaka_luck1 小时前
灾害应急物资管理系统(AI应急助手、Echarts可视化数据看板、捐赠入库联动、 物资全流程管理、库存变动追溯、灾情资讯与互动反馈、物资发放公示)
java·前端·javascript·spring boot·mysql·tomcat·echarts
kyriewen1 小时前
我验了 7 种 sticky 失效说法:3 种根本不成立
前端·javascript·程序员
程序边界1 小时前
一台数据集成软件的拆解笔记(下):一致性、容错、跑批与迁移,KFS 真正难的地方在这
java·前端·笔记
健康活着就好1 小时前
# 前端图片优化实战,首屏加载提速60%
前端
不一样的少年_2 小时前
不花一分额外 API 钱,用现成的 Claude Code / Codex / Cursor 搭建 AI 个人知识库
前端·后端·ai编程
tryCbest2 小时前
ES6 ( ECMAScript 2015)核心语法
前端·ecmascript·es6
李小轰_Rex3 小时前
零开销 AI 数字人方案|Live2D 数字人驱动方案分享
前端·aigc
ModyQyW3 小时前
vite-plugin-uni-manifest 0.7:在 uni-app 读取之前生成
前端·uni-app
我命由我123453 小时前
Python 实用操作 - 文件操作(打开与关闭文件、读取文件、文件写入)
java·开发语言·前端·javascript·数据库·python·java-ee