☰
Playwright 自定义选择器引擎与定位器实战:告别脆皮 CSS 选择器
2026/10/4 14:04:01 网站建设 项目流程

你有没有遇到过这种情况:费了半天劲写了个page.locator('.product-card > div.list > span.price--final'),昨天还跑得好好的,今天产品把 class 从price--final改成final--price,你的用例一瞬间全军覆没。这种时候去改 CSS 路径只是治标不治本,真正该做的是给 Playwright 定制一套符合自己项目语义的选择器引擎,再用定位器(Locator)把元素的访问方式从 UI 细节里彻底解耦出去。

这篇不谈基础用法,只说两件事:怎么手写自定义选择器引擎(包括 midscene 这类工具为什么能实现"自然语言描述元素",本质也离不开这个机制),以及在实际项目中怎么把定位器用到极致,让脚本不再三天两头因为前端改动而崩。适合已经被 Playwright 的npx playwright install折磨过、在 iframe 和动态页面里迷失过、想沉淀一套稳定定位方案的测试开发人员。所有代码都能直接复制到自己的项目里改造,工具链以 TypeScript + Playwright 为准,Python 版思路完全一致。

1. 为什么默认选择器在真实项目里"不够用"

1.1 动态属性与语义缺失的尴尬

先给新手补个背景:Playwright 内置的css=、text=、xpath=已经覆盖了九成场景,但真实项目的 DOM 往往是这样的:

<div class="sc-6f2b1d3a-2 kYpFqW"> <span class="price--final">¥199</span> </div>

class 要么是编译后的哈希字符串,要么带上了sc-前缀这种脚手架产物。更别提项目里常见的动态 id——每刷新一次页面id="item_284729"就变一次。默认选择器在这种环境里就是脆皮,对应关系全靠"当前恰好成立"。

这时候很多人的第一反应是去卷更长的nth-child路径,或者写复杂的 XPath。我的建议是:别卷,卷完还是脆。真正的解法只有两个方向——一是推动前端团队加>page.locator('data-hook=submit-btn')

这里>// selector-engines.ts import { selectors } from '@playwright/test'; await selectors.register('data-hook', { query(root: Element, selector: string): Element | null { return root.querySelector(`[data-hook="${selector}"]`); }, queryAll(root: Element, selector: string): Element[] { return Array.from(root.querySelectorAll(`[data-hook="${selector}"]`)); } });

注册之后,所有page实例都能用:

await page.locator('data-hook=cart/checkout-btn').click();

这里>import { selectors } from '@playwright/test'; const normalize = (s: string) => s.trim().toLowerCase().replace(/\s+/g, '-'); await selectors.register('semantic', { query(root: Element, selector: string): Element | null { const parts = selector.split(',').map(normalize).filter(Boolean); const all = root.querySelectorAll('[data-testid], [data-hook], [data-cy]'); for (let i = 0; i < all.length; i++) { const el = all[i]; const tags = [ el.getAttribute('data-testid'), el.getAttribute('data-hook'), el.getAttribute('data-cy'), ].filter(Boolean).map(normalize); if (parts.every(p => tags.some(t => t === p))) { return el as Element; } } return null; }, queryAll(root: Element, selector: string): Element[] { const parts = selector.split(',').map(normalize).filter(Boolean); const all = root.querySelectorAll('[data-testid], [data-hook], [data-cy]'); const result: Element[] = []; for (const el of Array.from(all)) { const tags = [ el.getAttribute('data-testid'), el.getAttribute('data-hook'), el.getAttribute('data-cy'), ].filter(Boolean).map(normalize); if (parts.every(p => tags.some(t => t === p))) { result.push(el); } } return result; } });

这个引擎的业务价值在于:当你面对一个既有>const card = page.locator('.product-card').filter({ hasText: '无线耳机' }); await card.getByRole('button', { name: '加入购物车' }).click();

这里先用.product-card框定商品卡片范围,再filter({ hasText: '无线耳机' })过滤出"名称包含无线耳机"的那张卡片,最后在这个范围内用getByRole定位按钮。注意 Playwright 的filter是在内部继续沿用自动等待的,不会因为过滤条件而丢失可操作状态检查。

定位器的组合能力还包括:

  • .first()/.last()/.nth(n):处理列表时快速取一个。
  • .all():返回所有匹配元素数组,配合Promise.all做并发校验。
  • .count():统计匹配数量,判断元素是否存在或是否如预期渲染。

3.2 用 count() 判断元素是否存在的正确姿势

很多人写断言时喜欢用page.locator(...).isVisible(),但这是有局限的——isVisible()只对"页面里确实存在且非隐藏"的元素返回 true,如果元素还没渲染出来,它会直接返回 false,不会等。更稳的做法是配合自动等待的断言:

await expect(page.locator('data-hook=empty-tips')).toBeVisible(); await expect(page.locator('data-hook=comment-item').count()).toBeGreaterThan(5);

第二个断言尤其重要,它判定"评论数大于 5 条"这个条件成立,Playwright 会一直重试直到超时,而不是只检查当前某一瞬间的状态。

3.3 动态页面/无限滚动列表的完整处理方案

上面热词里有人提到"爬取评论区"这类需求,我拿它作为动态页面的通用案例来讲思路,但不针对任何特定平台——合规采集和自动化测试的逻辑是相通的。

这类页面的核心问题:元素一开始不在 DOM 里,必须滚动触发加载。写法分四步:

async function scrollThroughList(page: Page, itemLocator: Locator, maxScrolls = 20) { let prevCount = 0; let scrolls = 0; while (scrolls < maxScrolls) { prevCount = await itemLocator.count(); // 滚动到底部 await page.evaluate(() => { window.scrollTo(0, document.body.scrollHeight); }); // 等待新元素渲染:最多等 2 秒 await page.waitForFunction( (prev) => document.querySelectorAll('[data-hook="comment-item"]').length > prev, prevCount, { timeout: 2000 } ).catch(() => { // 超时说明已经没有新内容了 return; }); scrolls += 1; } }

几个要点:

  1. waitForFunction是动态列表场景的杀手锏。它能够反复执行一段浏览器环境里的 JS 表达式,直到条件成立或超时。
  2. 滚动后不要立即 count,给渲染一点时间;但如果每次滚动都没有新增,catch里就退出循环。
  3. 最后用itemLocator.count()拿到总数,再用.all()逐条处理。千万避免边滚动边处理,防止页面结构变化导致定位器指向的元素"漂移"。

这种方案不只适合评论区,凡是瀑布流、动态分页、tab 懒加载的模块都能套用。

3.4 定位器使用中最容易忽略的 3 件事

  • 不要用 page.waitForSelector + page.click 的组合。老写法是先等到元素出现,再执行点击。问题是元素可能出现但仍在变化(比如按钮先出现但还不可点),两步之间有竞态窗口。Playwright 的 locator 把"等待可操作"内建在.click()里,所以永远只写await locator.click(),这一步就包含了等待出现、等待可见、等待稳定。
  • 连招前先确认父级也可操作。如果你在 shadow DOM 或 iframe 里做.getByRole(...),父容器如果处于 pending 状态,子级操作会一直等待直到超时。
  • locator 可以存起来复用,但别跨页面复用。page对象切换导航后,旧 locator 仍会尝试在新页面上查询,结果可能是空的或指向错误元素。

4. iframe、Shadow DOM 与动态脚本防护的实战拆解

4.1 iframe 穿透:用 frameLocator 统一处理

Playwright 处理 iframe 的方式和 Selenium 那种"切换 driver 上下文"完全不同。它推出了frameLocator,可以把它理解成"位于 iframe 内部的定位器起点"。这种设计的最大好处是:可以同时操作主页和多个 iframe 里的元素,不用反复切换上下文。

const frame = page.frameLocator('#payment-frame'); await frame.getByLabel('银行卡号').fill('6222****'); await frame.getByRole('button', { name: '确认支付' }).click();

要注意的是frameLocator的自动等待只在 frame 出现后才开始。如果页面里的 iframe 是动态注入的(比如点击某个按钮后才生成),需要先等待:

await page.locator('#dynamic-wrapper iframe').waitFor();

如果是 scrapy + playwright 这种后端渲染场景,注意 iframe 内容的加载时序:建议在配置里把wait_for_selector设到 iframe 内部的目标元素上,而不是外层 iframe 标签本身。

4.2 Shadow DOM:穿透其实没那么可怕

Shadow DOM 让很多自动化开发者头大,但 Playwright 有一个重要特性:内置选择器默认就能穿透 open shadow root。也就是说page.locator('text=用户名')可以摸到 shadow root 里的元素,不需要手动做任何处理。

只有两种情况需要额外关注:

  • closed模式的 shadow root:默认无法穿透,需要前端配合暴露测试属性,或者用 JS 绕(但 closed 的意义就是不让外部访问,不应强行破解)。
  • 自定义选择器引擎:如果你自己写了querySelector,它天然无法穿透 shadow root。解决办法是引擎内部遍历element.shadowRoot层级,比如:
function deepQuery(root: Element | Document, selector: string): Element | null { const direct = root.querySelector(selector); if (direct) return direct; const all = Array.from(root.querySelectorAll('*')); for (const el of all) { if (el.shadowRoot) { const found = deepQuery(el.shadowRoot, selector); if (found) return found; } } return null; }

强调一下:能穿透 open shadow root 是 Playwright 亲儿子的待遇,自定义引擎里没有这个能力。所以如果你既要自定义引擎又要处理 shadow DOM,就把上面这个函数作为query的内部实现。

4.3 面对动态脚本防护(如瑞数类 WAF)的正确姿势

热词里有一个"playwright 过瑞数",先表个态:不要在没有授权的情况下绕过任何网站的安全防护。自动化测试和合规数据采集,都应该只在你有权限的站点上做。我下面讲的,是面对这类防护机制时测试工程师应该建立的通用认知。

这类防护的核心套路是:服务器返回一段动态脚本,脚本在浏览器里重新生成 cookie 标记,并且会校验浏览器环境指纹。如果你的自动化脚本环境特征和真实浏览器差距太大,页面就会反复停留在验证页或返回 403。常见的成功项是:

  • 使用真实浏览器上下文,不要用--headless或关闭 headless 后尽量加载真实 GPU 驱动。
  • 控制访问节奏,不要一上来就连续快速滚动、点击,先做几次普通的浏览行为。
  • 尽量复用同一个上下文,避免每次新建导致指纹变化太剧烈。
  • 对动态校验接口,可以考虑先单独请求一次拿合法 token,再带着 token 做后续自动化,而不是硬闯校验页。

这些思路的本质是"让测试环境更接近真实用户环境",而不是"对抗防御机制"。如果你的项目真有被 WAF 误伤的情况,正确做法是联系站点负责人把你测试机的 IP 加入白名单,而不是研究绕过。

4.4 多层框架嵌套时的定位策略建议

当一个页面里又是 iframe 又是 shadow DOM 时,不要试图写一长串选择器解决所有问题,而是分层处理:

// 第一层:进入 iframe const frame = page.frameLocator('#outer-frame').frameLocator('#inner-frame'); // 第二层:在 shadow 容器里找元素 const shadowHost = frame.locator('my-checkout-widget'); // 第三层:通过宿主元素进入 shadow 内容(Playwright 的 locator 会自动穿透) await shadowHost.getByText('确认订单').click();

这个分层写法也符合后续维护的心理模型。一旦出问题,你能准确判断是哪一层崩了,排查成本降一半。

5. TypeScript + Playwright 的工程化实践与常见问题速查

5.1 为什么 TypeScript 和 Playwright 是黄金组合

Playwright 官方对 TypeScript 的支持几乎是第一公民。类型系统带来的收益不是"多写几个接口"那么表面,而是写自定义引擎的时候,你直接拿到官方的引擎接口定义,根本不用去猜queryAll的返回值格式。比如注册自定义引擎时的参数类型就是SelectorEngine接口,字段名和返回值一目了然。

另一个实际好处是:定位器的getByRole、filter、locator这些方法的参数都有精确的类型提示,传错字符串会直接编译报错,而不是等你运行到那一行才抛出。对于团队协作项目来说,类型就是最便宜的文档。

5.2 自定义引擎的类型声明实战

如果用了自定义引擎名,Playwright 的 TS 类型还不会自动认识>declare module '@playwright/test' { interface Locator { dataHook(selector: string): Locator; } }

或者在playwright.config.ts里把引擎注册封装到一个独立模块,在globalSetup引用,确保每个测试文件运行时引擎一定可用:

// global-setup.ts import { selectors } from '@playwright/test'; export default async function globalSetup() { await import('./selector-engines/register'); // 引擎注册必须在全局初始化阶段完成 }

这样配置之后,团队成员不用关心"什么时候注册的",只要会用>console.log(await page.getByRole('dialog').ariaSnapshot());

输出类似:

- dialog "登录" - button "关闭" - textbox "用户名" - textbox "密码" - button "提交"

这种形式一眼就能看出当前界面在无障碍层面是什么结构,很多定位问题其实是 DOM 语义和视觉结构错位导致的,快照能直接暴露出来。

5.5 一套可落地的选择器规范

最后分享我在团队里推的一套约定,实测下来能让脚本维护成本至少降一半:

  1. 前端组件里统一加>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询