Playwright 的自动等待到底等什么:为什么大多数 sleep 是多余的

2026-08-18

接手别人写的用例时,最常见的一种代码形状是:点击前面挂一句一秒的硬等待,注释写着”等页面加载”。删掉它用例可能还是绿的,留着它就是每次都无条件停在那里,谁也不敢动。

要判断这句该不该删,得先知道这次点击自己已经等过什么。Playwright 仓库把这件事拆在两个地方:docs/src/actionability.md 讲的是对外承诺的检查清单,packages/playwright-core/src/server/ 下的几个文件是这套检查真正的执行顺序。下面按一次 click 的实际路径走一遍。

一次点击走了两层重试,不是一层

第一层在 packages/playwright-core/src/server/frames.ts_retryWithProgressIfNotConnected 里。它先打一条 waiting for ... 的日志,然后进入 retryWithProgressAndBackoff。这个函数里退避间隔是写死在代码里的:

const backoffScale = [20, 50, 100, 100, 500];
if (progress.timeout) {
  // For small timeouts, cap the large backoff values to produce meaningul retries.
  while (backoffScale.length && backoffScale[backoffScale.length - 1] > progress.timeout / 5)
    backoffScale.pop();
}

这一层解决的是”元素还没出现”。locator 解析不到元素就返回 continuePolling,隔一段再试;timeout 很小的时候还会把大的退避值弹掉,免得一次退避就把预算吃光。这是仓库当前代码里的取值,随版本可能变动。

第二层在 packages/playwright-core/src/server/dom.ts_retryAction 里,间隔是另一组:

let retry = 0;
// We progressively wait longer between retries, up to 500ms.
const waitTime = [0, 20, 100, 100, 500];

这一层解决的是”元素找到了但状态不对”。它对 error:notvisibleerror:notinviewporthitTargetDescription(有别的东西挡住了)、missingState 这几类返回值统统走 continue 再来一轮,而不是直接抛错。所以你在 trace 里看到的 retrying click action 是这一层打出来的。

两层之间还夹着一个容易被忽略的动作。_retryAction 每轮开头会在非 force 的前提下调 performActionPreChecks,它在 packages/playwright-core/src/server/page.ts 里的实现是三步:

async performActionPreChecks(progress: Progress) {
  await this._performWaitForNavigationCheck(progress);
  await this._performLocatorHandlersCheckpoint(progress);
  // Wait once again, just in case a locator handler caused a navigation.
  await this._performWaitForNavigationCheck(progress);
}

也就是说,每次动作前它会等待主 frame 上尚未完成的导航落地,再跑一遍你注册过的 locator handler。后者对应公开 API page.addLocatorHandlerdocs/src/api/class-page.md 标注自 v1.42 起可用),文档里写明「Playwright 在每次执行或重试需要可操作性检查的动作前、以及在执行自动重试断言的检查前,都会检查一次这个遮罩」。这就是为什么弹窗类遮罩不需要你自己 sleep 等它出现。

状态检查发生在哪一步

进到 _performPointerAction 之后,非 force 情况下的顺序是:先查状态,再滚动,最后做命中检测。

const elementStates: ElementState[] = waitForEnabled ? ['visible', 'enabled', 'stable'] : ['visible', 'stable'];

hoverdragTo 这类不需要元素可用的动作走的是不带 enabled 的那一支,这与 docs/src/actionability.md 里那张表是对得上的:表里 hoverdragTo 的 Enabled 一列是横杠。

具体判定标准全写在同一份文档里,值得注意的是几个反直觉的边界:opacity:0 的元素可见,display:none 与零尺寸的不算;Enabled 的定义是”不 disabled”,而 disabled 包括了 <fieldset> 上带 [disabled] 时里面的控件,以及任何带 [aria-disabled=true] 元素的后代;Editable 要求既 enabled 又不 readonly,[aria-readonly=true] 且角色支持该属性时也算 readonly。

Stable 这一条的实现最值得单看。packages/injected/src/injectedScript.ts 里的 _checkElementIsStablerequestAnimationFrame 逐帧取 getBoundingClientRect(),位置或尺寸一变就直接返回 false,连续相同的帧数累加到 this._stableRafCount 才算稳定。而这个计数来自各浏览器实现的 rafCountForStablePosition():Chromium 的 crPage.ts 与 Firefox 的 ffPage.ts 都返回 1,WebKit 的 wkPage.ts 写的是 process.platform === 'win32' ? 5 : 1

把这两处放在一起看:文档说的”至少连续两帧保持同一个 bounding box”对应的是计数为 1 的情况;在 Windows 上跑 WebKit 时,源码里要求的连续帧数更多。同一份代码里还留了一句注释,说会丢掉短于 16ms 的帧,标注的原因是 WebKit 在 Windows 上的一个 bug。**Windows 侧的这条分支不要按 Linux 的行为去理解。**上面这几个取值同样来自仓库当前代码,随版本可能变动。

那张表下半部分为什么全是横杠

docs/src/actionability.md 的表里,blurdispatchEventfocuspresspressSequentiallysetInputFiles 这几行五列全是横杠。很多人由此得出”这些方法不等待”,然后在前面补一句 sleep。

这个结论只对了一半。横杠说的是状态检查一项都不做,但前面那层 _retryWithProgressIfNotConnected 的解析循环照样跑——frames.tsfocussetInputFiles 这些方法走的都是同一个入口。所以元素还没出现时它们仍然会等,只是元素一出现就直接动手,不管它是不是被遮住、是不是还在动画里。

真正需要留意的是这个差值:press 不会等按钮从 disabled 变 enabled。如果你的场景依赖那个变化,得自己补一条断言。

顺带说一个源码与公开面的不一致:dom.tsframes.ts 里有一个 noAutoWaiting 开关,打开后上面那些”重试”分支会直接抛 NonRecoverableDOMError。我们在 docs/ 与仓库的公开类型定义里没有找到对应的公开选项,它目前只出现在服务端代码里,别指望在用例里能传。

断言这一侧也在重试

docs/src/test-assertions-js.md 把 matcher 明确分成两类。web 专用的那批(toBeVisibletoHaveTexttoHaveCounttoHaveURL 这些)会反复重新取元素、重新判断,直到条件满足或超时;通用 matcher(toBetoEqualtoHaveLength 等)不重试,文档原话是大部分网页信息是异步呈现的,用不重试的断言容易写出 flaky 用例。

判断方法很简单:需要 await 的那批就是会重试的那批。

条件不在 DOM 上时,仓库给的是 expect.pollexpect.toPass。前者把一个同步 expect 变成轮询式,后者把一整块代码反复跑到通过为止。两者的轮询间隔文档写明默认是 [100, 250, 500, 1000],可以自己传;另外文档单独提示,toPass 默认超时为 0,且不遵循自定义的 expect 超时。这几个都是仓库当前文档里的默认值,随版本可能变动。

断言超时的默认值文档写的是 5 秒,这同样是仓库当前文档里的默认值,随版本可能变动。JS 侧在配置里通过 TestConfig.expect 改;Python 侧的写法在 docs/src/test-assertions-csharp-java-python.md 里是另一套,放在 conftest.py

from playwright.sync_api import expect

expect.set_options(timeout=10_000)

别把 JS 的配置写法套到 Python 上,这两份文档是分开的。

剩下这几种,确实得显式等

第一是导航到特定 URL。docs/src/navigations.md 写明一次点击可能触发多次导航,推荐显式等目标地址:

await page.getByText('Click me').click();
await page.waitForURL('**/login');

第二是 hydration。同一份文档专门有一节讲:静态页面先到,交互能力后到,Playwright 看到按钮可用就点了,但监听器还没挂上,点击不产生任何效果。文档给的自查办法是在 Chrome DevTools 里切到 Slow 3G 重载页面再手动操作,给的正确修法是在 hydration 完成前把交互控件置为 disabled——不是在用例里加 sleep。这一条自动等待救不了你,因为 DOM 上看不出监听器挂没挂。

第三是 BFCache。navigations.md 写明 Playwright 默认在所有浏览器上禁用 BFCache,并且即使你显式打开,测试 BFCache 恢复也是不支持的:BFCache 恢复跳过网络获取阶段,浏览器不会触发 commitdomcontentloadedload 这些生命周期事件,而 Playwright 的 Page 状态依赖它们,触发恢复会导致超时和状态不同步。这是文档明说的边界,不是调参能绕过去的。

第四是等元素消失或等它进入某个特定状态。Locator.waitFor(自 v1.16 起可用)的 state 有四个取值:attacheddetachedvisiblehidden,默认 visible。而 page.waitForSelectordocs/src/api/class-page.md 里标了 discouraged,建议改用断言或 Locator.waitFor

至于 page.waitForTimeout,它同样标了 discouraged,注释逐字写的是只应该用于调试,生产用例里用计时器天生是 flaky 的。这就是开头那句 sleep 该删的依据。

三个把自动等待关掉的开关

docs/src/api/params.md 里三个选项的语义值得记住:force 绕过可操作性检查,默认 falsetrial 只做检查不执行动作,默认 false,文档特别注明键盘 modifiers 无论如何都会按下;scroll(自 v1.62 起可用)设成 "none" 时不滚动,元素不在视口里就直接失败,适合断言”用户不用再滚就能够到”。以上默认值来自仓库当前文档,随版本可能变动。

还有一处不一致值得知道:Locator.clicknoWaitAfter 引用的是标着”将来会默认为 true”的那段说明,而 Locator.unchecknoWaitAfter 引用的是另一段,写的是”该选项没有任何效果”。两者都标了 deprecated,语义却不同,写代码时按各自方法的文档来。

最后是最容易踩的一处语言差异:动作超时的默认值在 params.md 里分了两份,给 Python / Java / C# 的那份默认 30000 毫秒,给 JS 的那份默认 0(不超时),由配置里的 actionTimeout 控制。同样是”没写 timeout”,两边的兜底行为完全不同。这也是仓库当前文档里的默认值,随版本可能变动。

本文出现的代码片段均照抄仓库文件,未做改写,也未经实测,以仓库最新代码为准。


本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理, 事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测, 因此不涉及运行速度、稳定性与实际表现的任何描述。 该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。 本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。