Puppeteer 多屏模拟实战:Browser.screens、addScreen 与 removeScreen 接口深度解析
Puppeteer 的 Browser 类通过 screens()、addScreen()、removeScreen() 三个方法,暴露了 Chromium 底层 Emulation 域的多屏模拟能力:查询当前屏幕列表、动态增删虚拟屏幕。在 headless 模式下,开发者可以构造出精确可控的多显示器环境(分辨率、DPI、色深、工作区缩进、屏幕朝向),用于验证窗口最大化、跨屏拖拽、window.screen API 行为等与多屏布局相关的 Web 应用功能。读完本文,你将掌握这三个方法的完整签名、参数与返回值结构、底层 CDP 调用链,以及一套来自官方测试套件的真实多屏构建与清理流程。
核心 API:Browser.screens()
Browser.screens() 用于获取当前浏览器实例下的屏幕信息对象列表,方法签名为:
class Browser {
abstract screens(): Promise<ScreenInfo[]>;
}
- 返回值:
Promise<ScreenInfo[]>,即一组 ScreenInfo 对象; - 无参数:屏幕列表反映的是浏览器进程当前"看到"的屏幕布局,headless 模式下即为虚拟屏幕,headful 模式下则是平台真实屏幕。
抽象方法定义位于 Browser.ts 第 830–833 行,其注释与 API 文档一致:
Gets a list of screen information objects.(获取屏幕信息对象列表)
需要特别注意的是测试环境的差异:官方测试 browser.test.ts 明确标注,headful 模式下 screens() 返回的是真实平台屏幕信息,"not stable enough for matching"(不够稳定,无法用于断言匹配),因此该用例在 headful 模式下直接跳过。这说明:screens() 的断言式验证只在 headless 模式具有确定性,headful 下结果取决于运行机器的实际显示环境。
ScreenInfo 返回结构
ScreenInfo 接口在 Browser.ts 中定义,字段与 ScreenInfo API 文档一一对应:
| 属性 | 类型 | 含义 |
|---|---|---|
id |
string |
屏幕唯一标识,供 removeScreen() 使用 |
label |
string |
屏幕标签名,可为空字符串 |
left / top |
number |
屏幕在全局坐标系中的左上角位置 |
width / height |
number |
屏幕物理分辨率 |
availLeft / availTop |
number |
可用区域(扣除任务栏等)左上角位置 |
availWidth / availHeight |
number |
可用区域尺寸 |
devicePixelRatio |
number |
设备像素比 |
colorDepth |
number |
色深(位/像素) |
orientation |
ScreenOrientation |
朝向,含 angle 与 type 两个字段 |
isPrimary |
boolean |
是否主屏 |
isExtended |
boolean |
是否扩展屏 |
isInternal |
boolean |
是否为内部(设备自带)屏幕 |
其中 ScreenOrientation 结构(Browser.ts)为:
export interface ScreenOrientation {
angle: number;
type: string;
}
在 headless 默认配置下,官方测试断言了完整的默认主屏形态(browser.test.ts):
const screenInfos = await browser.screens();
expect(screenInfos).toMatchObject([
{
availHeight: 600,
availLeft: 0,
availTop: 0,
availWidth: 800,
colorDepth: 24,
devicePixelRatio: 1,
height: 600,
id: expect.any(String),
isExtended: false,
isInternal: false,
isPrimary: true,
label: '',
left: 0,
orientation: {angle: 0, type: 'landscapePrimary'},
top: 0,
width: 800,
},
]);
这给出了一个可直接复用的基准事实:headless Chrome 的默认主屏为 800x600、色深 24、DPR 1、横向主屏朝向,后续所有 addScreen() 的坐标计算(例如把副屏摆到主屏右侧 left: 800)都以此为参照系。
添加虚拟屏幕:Browser.addScreen()
addScreen(params) 向当前浏览器实例添加一块新屏幕,并返回添加后的 ScreenInfo 对象:
class Browser {
abstract addScreen(params: AddScreenParams): Promise<ScreenInfo>;
}
Remarks(文档原文):Only supported in headless mode.(仅在 headless 模式下支持)
AddScreenParams 参数表
参数类型 AddScreenParams 在 Browser.ts 中的完整定义为:
export interface AddScreenParams {
left: number;
top: number;
width: number;
height: number;
workAreaInsets?: WorkAreaInsets;
devicePixelRatio?: number;
rotation?: number;
colorDepth?: number;
label?: string;
isInternal?: boolean;
}
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
left |
number |
是 | 新屏幕在全局坐标系中的水平位置 |
top |
number |
是 | 新屏幕在全局坐标系中的垂直位置 |
width |
number |
是 | 屏幕宽度 |
height |
number |
是 | 屏幕高度 |
workAreaInsets |
WorkAreaInsets |
否 | 可用区域相对整屏的边距(top/left/bottom/right,均为可选 number) |
devicePixelRatio |
number |
否 | 设备像素比,未指定时默认 1 |
rotation |
number |
否 | 屏幕旋转角度 |
colorDepth |
number |
否 | 色深,不传则沿用浏览器默认(headless 下为 24) |
label |
string |
否 | 屏幕标签 |
isInternal |
boolean |
否 | 是否标记为内部屏幕 |
其中 WorkAreaInsets 定义(Browser.ts):
export interface WorkAreaInsets {
top?: number;
left?: number;
bottom?: number;
right?: number;
}
workAreaInsets 的直接影响体现在返回的 avail* 字段上:可用区域 = 整屏尺寸减去各方向 inset。这一语义在官方测试中有精确验证(browser.test.ts):
describe('Browser.add|removeScreen', function () {
it('should add and remove a screen', async () => {
const {browser} = await getTestState();
const screenInfo = await browser.addScreen({
left: 800,
top: 0,
width: 1600,
height: 1200,
colorDepth: 32,
workAreaInsets: {bottom: 80},
label: 'secondary',
});
expect(screenInfo).toMatchObject({
availHeight: 1120, // 1200 - 80:底部 inset 生效
availLeft: 800,
availTop: 0,
availWidth: 1600,
colorDepth: 32,
devicePixelRatio: 1, // 未指定,默认 1
height: 1200,
id: expect.any(String),
isExtended: true, // 自动标记为扩展屏
isInternal: false,
isPrimary: false,
label: 'secondary',
left: 800,
orientation: {angle: 0, type: 'landscapePrimary'},
top: 0,
width: 1600,
});
expect((await browser.screens()).length).toBe(2);
await browser.removeScreen(screenInfo.id);
expect((await browser.screens()).length).toBe(1);
});
});
从这个真实用例可以读出几条确定的运行时行为:
- 新增屏幕的
isPrimary固定为false,isExtended为true——主屏地位始终属于浏览器自带的默认屏幕; workAreaInsets: {bottom: 80}使availHeight从 1200 缩减为 1120,而availWidth不受影响;label、colorDepth等可选字段原样回显在返回的ScreenInfo中;- 添加成功后
screens()列表长度从 1 变为 2,验证了"新增屏幕会立即进入屏幕列表"。
移除虚拟屏幕:Browser.removeScreen()
removeScreen(screenId) 按 id 移除一块已添加的屏幕:
class Browser {
abstract removeScreen(screenId: string): Promise<void>;
}
- 参数:
screenId: string,取自screens()或addScreen()返回的ScreenInfo.id; - 返回值:
Promise<void>; - Remarks(文档原文):Only supported in headless mode. Fails if the primary screen id is specified.(仅支持 headless 模式;传入主屏 id 会失败)
第二条限制在构造测试环境时非常关键:默认主屏(isPrimary: true 的那块)不可移除。这保证了多屏测试场景下始终存在一个稳定的主屏参照系,副屏可以按需增删。上面测试用例最后的 await browser.removeScreen(screenInfo.id) 之后,screens() 长度回到 1,正是这一"增—查—删—查"闭环的验证方式。
底层实现:CDP 调用链与协议支持边界
从源码结构看,这三个方法在 CDP 实现中是一组非常薄的透传封装。cdp/Browser.ts:
override async screens(): Promise<ScreenInfo[]> {
const {screenInfos} = await this.#connection.send(
'Emulation.getScreenInfos',
);
return screenInfos;
}
override async addScreen(params: AddScreenParams): Promise<ScreenInfo> {
const {screenInfo} = await this.#connection.send(
'Emulation.addScreen',
params,
);
return screenInfo;
}
override async removeScreen(screenId: string): Promise<void> {
return await this.#connection.send('Emulation.removeScreen', {screenId});
}
调用链清晰:
| Puppeteer 方法 | CDP 命令 | 响应字段 |
|---|---|---|
browser.screens() |
Emulation.getScreenInfos |
screenInfos |
browser.addScreen(params) |
Emulation.addScreen |
screenInfo |
browser.removeScreen(screenId) |
Emulation.removeScreen |
无 |
也就是说,参数没有经过任何本地转换,直接作为 Emulation.addScreen 的参数对象下发,返回的 ScreenInfo 也是浏览器原样回传的结构。调试时如果参数未生效,问题基本可以定位在 Chromium 侧的校验(例如非 headless 模式拒绝 addScreen/removeScreen)。
适用前提:仅 CDP + headless
有两层硬性限制,写进代码前必须确认:
- 模式限制:
addScreen与removeScreen仅支持 headless 模式(两个方法的@remarks均如此声明)。screens()在 headful 下也能调用,但返回的是真实平台屏幕,结果不可控; - 协议限制:WebDriver BiDi 实现中这三个方法全部抛出不支持异常。bidi/Browser.ts:
override screens(): Promise<ScreenInfo[]> {
throw new UnsupportedOperation();
}
override addScreen(_params: AddScreenParams): Promise<ScreenInfo> {
throw new UnsupportedOperation();
}
override removeScreen(_screenId: string): Promise<void> {
throw new UnsupportedOperation();
}
因此本文所有用法都要求:Chromium 系浏览器 + CDP 连接 + headless 模式(puppeteer.launch() 默认即满足)。
实战:构建双屏环境并放置窗口
把上面三个方法串起来,就得到一个完整的多屏测试套路。官方测试中"最大化窗口"用例(browser.test.ts)展示了典型用法:先在主屏右侧添加副屏,再把窗口开到副屏上,最后调用 setWindowBounds 将其最大化:
// 1. 添加一块 1600x1200 的副屏,紧贴主屏(800 宽)右侧
const screenInfo = await browser.addScreen({
left: 800,
top: 0,
width: 1600,
height: 1200,
});
// 2. 以副屏的可用区域为基准,在副屏上开窗
const page = await context.newPage({
type: 'window',
windowBounds: {
left: screenInfo.availLeft + 50,
top: screenInfo.availTop + 50,
width: screenInfo.availWidth - 100,
height: screenInfo.availHeight - 100,
},
});
// 3. 通过 windowId 查询/设置窗口状态,验证跨屏最大化行为
const windowId = await page.windowId();
await browser.setWindowBounds(windowId, {windowState: 'maximized'});
这个流程的价值在于:screenInfo.availLeft / availTop / availWidth / availHeight 让窗口坐标的计算与具体屏幕几何解耦——无论副屏放在哪里、带什么 workAreaInsets,窗口都能精确落在副屏可用区域内。配合 browser.screens() 随时可以断言屏幕布局是否符合预期,测试结束后用 removeScreen(screenInfo.id) 清理,互不污染。
小结
Browser.screens()(文档):返回ScreenInfo[],headless 下结果确定(默认 800x600 主屏),适合做布局断言;Browser.addScreen(params)(文档):必填left/top/width/height,可选workAreaInsets/devicePixelRatio/rotation/colorDepth/label/isInternal;新增屏幕自动标记为isExtended: true, isPrimary: false;仅 headless 支持;Browser.removeScreen(screenId)(文档):按id删除屏幕,主屏 id 不可删;仅 headless 支持;- 三者均依赖 CDP
Emulation域命令(getScreenInfos/addScreen/removeScreen),在 WebDriver BiDi 模式下抛出UnsupportedOperation,使用前请确认运行在 Chromium + CDP + headless 的组合下。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00