首页
/ Puppeteer Frame 详解:深入解析 DOM Frame 树、跨 iframe 导航与自动化实操

Puppeteer Frame 详解:深入解析 DOM Frame 树、跨 iframe 导航与自动化实操

2026-09-06 18:41:47作者:郁楠烈Hubert

本文以 Puppeteer 官方 API 文档 puppeteer.frame.md 为主体,结合 puppeteer-core 中 Frame 的完整实现,系统讲解 Puppeteer 中 Frame 类的数据模型、生命周期事件、遍历手段与全部操作方法,并给出可运行的实战代码。读完本文,你将掌握:如何遍历页面的 frame 树、如何在指定 iframe 内执行求值与元素操作、如何在点击/注入脚本等间接触发导航的场景下安全等待导航完成,以及 frame 被分离(detach)后应如何处理。

Frame 是什么:把每一个 <iframe> 当作独立的自动化上下文

在 Puppeteer 中,Frame 类class Frame)表示浏览器中的一个 DOM frame。要理解 Frame,最直观的方式是把它想象成一个 <iframe> 元素:

  • 正如 iframe 可以无限嵌套,Frame 也可以嵌套,从而构成一棵 frame 树(frame tree);
  • 当 JavaScript 在某个 Frame 中执行时,它不会影响到"外层"包含它的 Frame 中的 JavaScript——每个 Frame 拥有独立的执行上下文,这与浏览器原生的隔离语义一致。

在源码中,该抽象被定义为泛型事件发射器:

export declare abstract class Frame extends EventEmitter<FrameEvents>

对应实现见 packages/puppeteer-core/src/api/Frame.ts#L299abstract class Frame extends EventEmitter<FrameEvents>。它的基类是 EventEmitter,事件表由 FrameEvents 定义。

需要注意:Frame 的构造函数被标记为 internal,第三方代码不应直接调用构造函数,也不应创建继承 Frame 的子类。所有 Frame 实例都由 Puppeteer 内部根据浏览器协议事件创建并维护。

Frame 生命周期:由页面上的三个事件驱动

Frame 的整个生命周期由三个事件控制,它们全部派发在 Frame 所属的父级 Page 上(参见原文档 Remarks 与 PageEvent):

事件 含义
PageEvent.FrameAttached 一个新的 Frame 被挂载到页面上
PageEvent.FrameNavigated Frame 发生了(整页级)导航,对应新的文档加载
PageEvent.FrameDetached Frame 从页面中被移除(分离)

packages/puppeteer-core/src/api/Page.ts#L534-L541 中可以确认这些事件的实际字符串值:frameattachedframedetachedframenavigated

源码侧的完整链路:浏览器通过 CDP 协议推送 Page.frameAttached / Page.frameNavigated / Page.frameDetached 事件,FrameManagerL187-L204 附近分别将其路由到 #onFrameAttached#onFrameNavigated#onFrameDetached;其中 #onFrameAttachedL458 起)会把新 Frame 通过 this._frameTree.addFrame(frame) 加入帧树(L476),并向外发出 FrameManagerEvent.FrameAttached

帧树的数据结构由 FrameTree 维护(类定义见 L17)。从源码看,它用 frameId -> parentFrameIdframeId -> childFrameIds 两组映射来表达树形关系,并缓存了主 Frame(#mainFrame)与"主 Frame 已失效"标记(#isMainFrameStale),以应对"树结构最终一致"(eventually consistent)的协议特性——即 FrameTree 通过 frame ID 引用 Frame,被引用的 Frame 可能已不在树中。

理解了这一点,很多后续行为就顺理成章了:例如 waitForNavigationwaitForSelector 之所以能"跨导航"工作,是因为底层协议事件(Page.lifecycleEventPage.frameNavigatedWithinDocument 等)会被持续转发到帧树与对应的 Frame 实例上(见 FrameManager.ts#L209-L222 的事件订阅)。

生命周期实践:监听 iframe 的挂载与卸载

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();

page.on('frameattached', frame => console.log('attached:', frame.url()));
page.on('framenavigated', frame => console.log('navigated:', frame.url()));
page.on('framedetached', frame => console.log('detached:', frame.url()));

await page.goto('https://example.com'); // 页面含 iframe 时会触发上述事件
await browser.close();

获取与遍历 Frame 树:mainFrame、childFrames、parentFrame

在任何时刻,页面都会通过 Page.mainFrame()Frame.childFrames() 暴露当前的 frame 树:

经典示例:递归打印整棵 frame 树

原文档(Example 2)给出的"dump frame tree"是理解帧树最直接的代码,此处原样保留并加入类型标注,使其可直接运行:

import puppeteer from 'puppeteer';
import type {Frame} from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://www.google.com/chrome/browser/canary.html');

function dumpFrameTree(frame: Frame, indent: string): void {
  console.log(indent + frame.url());
  for (const child of frame.childFrames()) {
    dumpFrameTree(child, indent + '  ');
  }
}

dumpFrameTree(page.mainFrame(), '');
await browser.close();

注意嵌套深度会反映为打印缩进,主 Frame 的 url() 通常就是当前页面的 URL,而子 Frame 的 URL 是各自 iframe src 解析后的结果。

依据 name 在页面中定位并读取 iframe 文本

原文档(Example 3)演示了从所有 frame 中按 name 属性找到目标 iframe,并读取其内部文本:

const frames = page.frames();
let frame = null;
for (const currentFrame of frames) {
  const frameElement = await currentFrame.frameElement();
  const name = await frameElement.evaluate(el => el.getAttribute('name'));
  if (name === 'myframe') {
    frame = currentFrame;
    break;
  }
}
if (frame) {
  const text = await frame.$eval('.selector', element => element.textContent);
  console.log(text);
} else {
  console.error('Frame with name "myframe" not found.');
}

这里用到的 frameElement() 会返回承载该 Frame 的 <iframe>/<frame> 元素句柄。从实现上看(api/Frame.ts#L454-L471),Puppeteer 会先在父 Frame 的隔离世界中查询所有 iframe, frame 元素,再逐一通过 ElementHandle.contentFrame() 比对内部 frame ID(frame?._id === this._id)来确认对应关系;主 Frame 没有承载元素,因此对主 Frame 调用 frameElement() 会返回 null

Frame 内执行 JavaScript:evaluate 与 evaluateHandle

Frame 提供与页面同名的求值方法,但作用域被限定在该 Frame 自己的文档内:

实现上(api/Frame.ts#L501-L513L480-L492),两个方法最终都会委托给该 Frame 的 主执行世界(main realm)

async evaluate<...>(pageFunction, ...args) {
  pageFunction = withSourcePuppeteerURLIfNone(this.evaluate.name, pageFunction);
  return await this.mainRealm().evaluate(pageFunction, ...args);
}

mainRealm() 属于内部接口(api/Frame.ts#L420),在 Chrome 连接的实现中由 CdpFrame(类定义见 L42)为每个 Frame 建立 MAIN_WORLD(主世界,与页面 JS 共享)与 PUPPETEER_WORLD(Puppeteer 私有隔离世界)两个 IsolatedWorld。这也是 evaluate 能拿到页面真实 DOM、而 Puppeteer 内部注入逻辑不影响页面变量的根本原因。

在指定 iframe 中注入自定义逻辑

const frame = page.frames().find(f => f.url().includes('widget'));
const result = await frame!.evaluate(() => document.title);
console.log(result);

// 也可以传参 + 复杂函数
const sum = await frame!.evaluate(
  (a: number, b: number) => a + b,
  1,
  2,
);
console.log(sum); // 3

在 Frame 内查找元素:$$$$eval$$eval 与 waitForSelector

Frame 支持一整套与 Page 对齐的元素查询 API,查询范围严格限定在该 Frame 的文档内(不会穿透到其子 Frame),这一点与浏览器 DOM API 的边界一致:

方法 作用 返回
$(selector) 查询匹配该选择器的第一个元素 ElementHandlenull
$$(selector, options?) 查询所有匹配元素 ElementHandle[]
$eval(selector, pageFunction, ...args) 对第一个匹配元素运行函数 函数返回值
$$eval(selector, pageFunction, ...args) 对匹配元素数组运行函数 函数返回值
waitForSelector(selector, options?) 等待匹配元素出现 ElementHandlenull(超时)

其中 $eval / $$eval 若传入的函数返回 Promise,则该方法会等待其 resolve(见 docs/api/puppeteer.frame._eval.mddocs/api/puppeteer.frame.__eval.md 的说明,以及 api/Frame.ts#L656L709 起的实现)。

实现细节$$$ 会通过缓存的文档句柄执行查询(api/Frame.ts#L581-L619)——Frame 内部使用私有字段 #_document 缓存当前文档的 ElementHandleapi/Frame.ts#L427-L439),首次使用时在 mainRealm() 中执行 document 求值获得,导航导致旧句柄失效时会通过 clearDocumentHandle() 清空重建(api/Frame.ts#L446-L448)。

waitForSelector 则走的是选择器处理器(QueryHandler)机制(api/Frame.ts#L759-L769):先通过 getQueryHandlerAndSelector 解析出选择器类型与轮询方式,再调用 QueryHandler.waitFor(this, updatedSelector, {polling, ...options})。这也是原文档强调"waitForSelector 跨导航也能工作"(见 docs/api/puppeteer.frame.waitforselector.md)的底层来源。

// 等待 iframe 内的某个元素渲染完成
const frame = page.frames().find(f => f.url().includes('/embed/'));
await frame!.waitForSelector('.video-ready', {timeout: 10_000});
const title = await frame!.$eval('.video-title', el => el.textContent);

Frame 级导航与等待:goto、waitForNavigation 与竞态规避

goto:让某个 Frame 自己跳转

goto(url, options?) 将 Frame 或页面导航到给定 URL(URL 需带协议前缀,如 https://),resolve 为主资源(main resource)的 HTTPResponse;发生多次重定向时,resolve 的是最后一次重定向后的响应。

需要注意的三点语义(原文档与源码 api/Frame.ts#L338-L379 保持一致):

  1. 导航到 about:blank,或导航到"同 URL 但不同 hash",会成功并返回 null
  2. Headless shell(无头外壳)模式不支持导航到 PDF 文档(上游 issue:crbug.com/761295);
  3. 在 headless shell 中,只要远端服务器返回任何合法的 HTTP 状态码(包括 404、500),goto 都不会抛错——这类响应的状态码需要调用 HTTPResponse.status() 去获取。

可能抛错的场景包括:SSL 错误(如自签名证书)、目标 URL 非法、导航超时、远端无响应、主资源加载失败、URL 命中屏蔽规则等。

waitForNavigation:间接导航的安全等待模式

waitForNavigation(options?) 用于"你运行的代码会间接导致该 Frame 导航"的场景。值得注意的是:使用 History API 修改 URL 也被视为一次导航(见 docs/api/puppeteer.frame.waitfornavigation.md,实现上对应 Page.frameNavigatedWithinDocument 事件的处理,见 FrameManager.ts#L195-L196)。

规避竞态的经典模式:原文档在 click 的 Remarks 中专门提醒——如果 click() 触发了导航,而你又单独调用了 page.waitForNavigation(),二者会产生竞态(race condition)。正确姿势是把"等待导航"与"触发导航的动作"放进同一个 Promise.all

const [response] = await Promise.all([
  page.waitForNavigation(waitOptions),
  frame.click(selector, clickOptions),
]);

同样的模式也适用于 Frame 内部间接导航:

// 来自源码注释的等价示例(见 api/Frame.ts#L389-L398)
const [response] = await Promise.all([
  frame.waitForNavigation(),
  // 点击链接会间接引起一次导航
  frame.click('a.my-link'),
]);

waitForFunction:等待某个条件变真

waitForFunction(pageFunction, options, ...args) 会持续对传入的函数求值,直到它返回真值才 resolve;返回的句柄类型为 HandleFor(底层实现委托给 mainRealm().waitForFunction(...),见 api/Frame.ts#L805-L818)。

原文档(源码注释,见 api/Frame.ts#L772-L797)给了两个实用例子——观察视口变化、向断言函数传参:

// 例一:观察窗口宽度变化
const watchDog = page
  .mainFrame()
  .waitForFunction('window.innerWidth < 100');
page.setViewport({width: 50, height: 50});
await watchDog;

// 例二:把 Node.js 侧的参数传给谓词函数
const selector = '.foo';
await frame.waitForFunction(
  selector => !!document.querySelector(selector),
  {}, // 空的 options 对象
  selector,
);

轮询、超时等行为由 FrameWaitForFunctionOptions 控制(支持 pollingtimeoutsignal 等字段)。

Frame 内的用户交互:click、tap、hover、focus、type、select

所有交互都作用在"当前 Frame 文档内第一个匹配选择器的元素"上:

方法 行为
click(selector, options?) 点击第一个匹配元素
tap(selector) 触摸点按第一个匹配元素(移动端场景)
hover(selector) 将指针悬停在第一个匹配元素中心
focus(selector) 聚焦第一个匹配元素
select(selector, ...values) 在第一个匹配的 <select> 上选中若干 value
type(selector, text, options?) 逐字符发出 keydown/keypress(input)/keyup 事件输入文本

关于 type,原文档特别提示:要按下特殊键(如 ControlArrowDown),应使用 Keyboard.press(),而不是 type(见 docs/api/puppeteer.frame.type.md 的 Remarks)。

这些方法在 api/Frame.ts 中均有实现骨架(如 clickL1085focusL1102selectL1141typeL1182),其底层会先在该 Frame 内解析出 ElementHandle,再委托元素句柄/键盘完成真实输入事件的分发。

// 完整交互示例:在 iframe 的表单里填值并提交
const frame = page.frames().find(f => f.url().includes('/login'));
await frame!.waitForSelector('#username');
await frame!.type('#username', 'pptr-user');
await frame!.type('#password', 's3cret');
await frame!.select('#lang', 'zh-CN');
const [response] = await Promise.all([
  frame!.waitForNavigation(),
  frame!.click('#submit'),
]);

注入脚本与样式:addScriptTag、addStyleTag

  • addScriptTag(options):把 <script> 标签注入 Frame,URL 或内容二选一;返回注入的 <script> 元素句柄。urlpathcontent 三者必须恰好指定一个,否则实现会直接抛错(api/Frame.ts#L934-L938 的校验逻辑:+!!options.url + +!!path + +!!content !== 1 即报错)。type 缺省为 text/javascript;脚本经 隔离世界 创建后再传输回主世界(api/Frame.ts#L929-L958)。
  • addStyleTag(options):存在两个重载——注入 <style>(内联 CSS 文本)或注入 <link>(外部 CSS URL),具体类型由 FrameAddStyleTagOptions 决定。
await frame.addScriptTag({url: 'https://cdn.example.com/lib.js'});
await frame.addStyleTag({content: 'body { background: #fafafa; }'});
await frame.addStyleTag({url: 'https://cdn.example.com/site.css'});

读写 Frame 内容:content() 与 setContent()

  • content():返回 Frame 的完整 HTML 内容(含 DOCTYPE)。实现上(api/Frame.ts#L823-L839)它遍历 document.childNodesdocumentElementouterHTML 序列化,其余节点(如 <!DOCTYPE>、注释)用 XMLSerializer().serializeToString 序列化后拼接,因此能真实还原"带 DOCTYPE 的完整源码"。
  • setContent(html, options?):用给定 HTML 替换整个 Frame 内容,等待与超时行为由 SetContentWaitForOptions 控制;内部会通过 document.open() / document.write(html) / document.close() 完成重写(见 api/Frame.ts#L856-L862setFrameContent)。
await page.mainFrame().setContent('<!DOCTYPE html><h1>hello frame</h1>');
console.log(await page.content()); // 输出含 DOCTYPE 的完整 HTML

Frame 的元信息与状态:url、title、name、page、detached

API 说明
url() Frame 当前 URL
title() Frame 的标题
page() 与该 Frame 关联的 Page
frameElement() 承载该 Frame 的 <iframe>/<frame> 句柄;主 Frame 返回 null
name()(已废弃) Frame 的 name 属性(首次创建时计算一次,后续不会随属性修改更新;为空时返回 id,见 api/Frame.ts#L864-L883
isDetached()(已废弃) 是否已分离,返回 detached getter 的代理(api/Frame.ts#L910-L912
detached(只读属性) 当前推荐的分离状态判断入口,true 表示 Frame 已从页面移除

关于 name() 的废弃说明,官方给出的替代写法是(见 docs/api/puppeteer.frame.name.mdapi/Frame.ts#L874-L880):

const element = await frame.frameElement();
const nameOrId = await element.evaluate(frame => frame.name ?? frame.id);

detached 状态在框架内部扮演关键角色:Frame 上的大部分方法(求值、查询、交互等)都标记了 @throwIfDetached 装饰器(如 api/Frame.ts#L453L500L580),一旦 Frame 已分离再调用这些方法,会抛出"已销毁/已分离"错误(对应错误类型见 TimeoutError / PuppeteerError 体系)。该装饰器定义于 api/Frame.ts#L226-L228,在浏览器层即先于任何远程调用做本地拦截。

// 安全判断
if (!frame.detached) {
  const t = await frame.title();
  console.log(t);
} else {
  console.warn('frame 已被移除,忽略本次操作');
}

更高级的查询:Locator 与 Frame 内扩展 Realm

  • locator(selector):为指定选择器创建一个 Locator,可链式调用 wait()click()fill()setVisibilitysetTimeout 等动作与配置,更适合编写"自动重试、等待就绪"的健壮自动化;实现上根据入参类型分别走 NodeLocatorFunctionLocatorapi/Frame.ts#L549-L557)。第二个重载 locator(func) 支持用返回节点的函数定位。
  • 选择器本身支持 Puppeteer 扩展语法:除了原生 CSS,还可以用 text/aria/xpath/ 前缀以及跨 Shadow DOM 的组合查询(见源码 JSDoc,api/Frame.ts#L519-L533)。
  • extensionRealms():返回与当前 Frame 关联的扩展执行世界列表——即浏览器扩展内容脚本注入该 Frame 后创建的世界(对应 CdpFrame 中的 extensionWorlds 记录)。
// Locator 自动等待元素可交互后再点击,天然规避手动 Promise.all 的繁琐
const loginButton = frame.locator('#login').setTimeout(5000);
await loginButton.click();

结语:把 Frame 当作"缩小版的 Page"

Puppeteer 的设计哲学在此体现得非常一致:Frame 几乎复刻了 Page 的核心 API(求值、选择器、导航、等待、交互、内容读写、脚本注入),只是作用域被限定在一个 iframe 文档内。因此,处理含第三方 iframe 的页面(广告、支付、社交登录、地图与播放器嵌入)时,只需三步:

  1. page.frames() / mainFrame().childFrames() 定位目标 Frame(优先按 URL 前缀判断,必要时用 frameElement() 反查 name);
  2. 在该 Frame 上使用 waitForSelector + $eval/交互 API 完成自动化,并在调用前检查 detached
  3. 对会触发导航的动作,务必把 waitForNavigation 与动作本身放进同一个 Promise.all,避免竞态。

如需进一步了解每个方法的参数细节(如 WaitForOptions 中的 waitUntil 取值 load/domcontentloaded/networkidle0 等,或 FrameWaitForFunctionOptionspolling 配置),可直接查阅仓库中对应的独立 API 文档页:以 docs/api/puppeteer.frame.md 为索引,方法级的文档位于同目录,如 gotowaitForNavigationwaitForSelectorwaitForFunctionclick;底层实现与测试则集中在 packages/puppeteer-core/src/api/Frame.tspackages/puppeteer-core/src/cdp/Frame.tspackages/puppeteer-core/src/cdp/FrameManager.tspackages/puppeteer-core/src/cdp/FrameTree.ts

登录后查看全文
热门项目推荐
相关项目推荐