Puppeteer 如何实现设备模拟和移动端测试?
核心概念
Puppeteer 的设备模拟通过 page.emulate() 方法实现,它一次性设置视口(viewport)、用户代理(User-Agent)、设备像素比、触摸支持等属性,让无头浏览器完整模拟目标设备的浏览器环境。
从 Puppeteer v21 开始,设备预设从 puppeteer.devices 迁移到了 KnownDevices,这是面试中容易踩的坑。
内置设备与 KnownDevices
使用内置设备预设
javascriptconst puppeteer = require('puppeteer'); const { KnownDevices } = require('puppeteer'); (async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); // v21+ 使用 KnownDevices,旧版用 puppeteer.devices const iPhone = KnownDevices['iPhone 12']; await page.emulate(iPhone); await page.goto('https://example.com'); await page.screenshot({ path: 'iphone12.png' }); await browser.close(); })();
常用内置设备
Puppeteer 内置了数十种设备预设,覆盖三大平台:
| 平台 | 常用设备 | 典型视口 |
|---|---|---|
| iPhone | iPhone 12/13/14/15, iPhone SE, iPhone X | 375×667 ~ 390×844 |
| iPad | iPad Pro, iPad Mini | 1024×1366, 768×1024 |
| Android | Pixel 5, Pixel 4, Galaxy S5 | 393×815, 360×640 |
查看所有可用设备:Object.keys(KnownDevices)
单个设备的配置结构
javascript{ name: 'iPhone 12', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 ...)', viewport: { width: 390, height: 844, deviceScaleFactor: 3, // Retina 屏幕像素比 isMobile: true, // 启用移动端行为 hasTouch: true, // 启用触摸事件 isLandscape: false } }
isMobile: true 会影响媒体查询 @media (pointer: coarse) 和部分 CSS 行为,不仅是视口大小的变化。
自定义设备配置
当内置预设不满足需求时(比如测试未上市的新机型),可以手动构造设备描述符:
javascriptconst customDevice = { name: 'Custom Foldable', userAgent: 'Mozilla/5.0 (Linux; Android 13; Foldable) ...', viewport: { width: 320, // 折叠态宽度 height: 820, deviceScaleFactor: 3, isMobile: true, hasTouch: true, isLandscape: false } }; await page.emulate(customDevice);
也可以单独设置视口,而不使用完整的 emulate():
javascriptawait page.setViewport({ width: 375, height: 812, deviceScaleFactor: 3, isMobile: true, hasTouch: true });
单独设置用户代理:
javascriptawait page.setUserAgent( 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) ...' );
emulate() 与分开设置的区别:emulate() 是原子操作,保证视口和 UA 同时生效;分开设置可能在两次调用之间页面触发重排,导致布局闪烁。
地理位置与权限模拟
移动端测试经常需要模拟位置信息:
javascriptconst browser = await puppeteer.launch(); const context = await browser.createIncognitoBrowserContext(); const page = await context.newPage(); // 授予地理定位权限(必须在导航前设置) await context.overridePermissions('https://example.com', ['geolocation']); // 设置坐标——东京涩谷 await page.setGeolocation({ latitude: 35.6580, longitude: 139.7016 }); await page.goto('https://example.com');
权限列表还包括 'notifications'、'camera'、'microphone' 等,对应移动端常见的权限弹窗场景。
网络与 CPU 节流
这是移动端测试的核心但常被忽略的环节。真实移动网络的延迟和带宽与桌面完全不同。
网络节流
javascriptconst client = await page.createCDPSession(); // 模拟 3G 网络 await client.send('Network.emulateNetworkConditions', { offline: false, latency: 200, // 往返延迟 200ms downloadThroughput: 750 * 1024 / 8, // 750kbps uploadThroughput: 250 * 1024 / 8, // 250kbps }); // 模拟离线 await client.send('Network.emulateNetworkConditions', { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1, });
CPU 节流
javascript// CPU 减速 4 倍,模拟移动端性能 await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });
结合网络节流和 CPU 节流,才能真实还原移动端用户的使用体验。仅模拟视口大小而忽略网络和性能条件,测试结果往往偏乐观。
时区与语言环境
国际化测试需要模拟不同地区的时区和语言:
javascript// 设置时区 await page.emulateTimezone('Asia/Tokyo'); // 设置语言偏好 await page.setExtraHTTPHeaders({ 'Accept-Language': 'ja-JP,ja;q=0.9,en;q=0.8' });
触摸事件
移动端的核心交互是触摸而非鼠标点击:
javascriptawait page.emulate(KnownDevices['iPhone 12']); // 触摸点击元素 await page.tap('#submit-btn'); // 底层触摸 API:精确坐标点击 await page.touchscreen.tap(200, 400); // 模拟滑动——从 (100, 500) 滑动到 (100, 200) await page.mouse.move(100, 500); await page.mouse.down(); await page.mouse.move(100, 200, { steps: 10 }); await page.mouse.up();
page.tap() 内部会触发 touchstart → touchend → click 事件序列,与真实触屏行为一致。如果用 page.click() 在移动模拟下测试,部分依赖 touch 事件的组件不会响应。
实战:响应式设计批量测试
将上述能力组合成一个实用的测试流程:
javascriptconst { KnownDevices } = require('puppeteer'); const puppeteer = require('puppeteer'); async function testResponsive(url) { const browser = await puppeteer.launch(); const results = []; const profiles = [ { label: 'Desktop', viewport: { width: 1440, height: 900, isMobile: false, hasTouch: false } }, { label: 'Tablet', device: KnownDevices['iPad Pro'] }, { label: 'Mobile', device: KnownDevices['iPhone 12'] }, ]; for (const p of profiles) { const context = await browser.createIncognitoBrowserContext(); const page = await context.newPage(); if (p.device) { await page.emulate(p.device); } else { await page.setViewport(p.viewport); } // 添加 3G 网络节流 const client = await page.createCDPSession(); await client.send('Network.emulateNetworkConditions', { offline: false, latency: 150, downloadThroughput: 750 * 1024 / 8, uploadThroughput: 250 * 1024 / 8, }); await page.goto(url, { waitUntil: 'networkidle2' }); // 检测布局溢出 const overflow = await page.evaluate(() => { return document.documentElement.scrollWidth > document.documentElement.clientWidth; }); // 检测首屏加载时间 const timing = await page.evaluate(() => { const nav = performance.getEntriesByType('navigation')[0]; return nav ? nav.loadEventEnd - nav.startTime : -1; }); results.push({ profile: p.label, overflow, loadTime: timing }); await context.close(); } await browser.close(); return results; }
关键设计:使用 IncognitoBrowserContext 隔离每个设备的 Cookie 和缓存,避免状态污染;加入网络节流让性能数据更真实。
常见陷阱
1. puppeteer.devices 已废弃
Puppeteer v21+ 必须使用 KnownDevices,旧写法会直接报错。
2. emulate() 后切换页面不生效
page.emulate() 只对当前 page 生效。如果通过 browser.newPage() 创建新页面,需要重新调用 emulate()。建议使用 Browser Context 级别的设置来统一管理。
3. 地理位置权限时机
overridePermissions 必须在 page.goto() 之前调用,否则页面会先收到定位拒绝,再获得权限也不会自动重新请求。
4. 触摸事件与 click 的差异
在 isMobile: true 模式下,部分框架(如 React 的 onClick)会响应 click,但原生 touchstart 监听器不会触发。测试时优先用 page.tap() 而非 page.click()。
5. 横屏模式
内置设备默认是竖屏。测试横屏需要手动设置 isLandscape: true 或交换 width/height,emulate() 不会自动旋转。
追问:Puppeteer 与 Playwright 的设备模拟有何差异?
Playwright 内置了类似的设备模拟,但 API 更简洁:
javascript// Playwright 写法 const iphone = devices['iPhone 12']; const browser = await chromium.launch(); const context = await browser.newContext({ ...iphone }); // 设备配置在 context 级别生效,所有 page 自动继承
关键区别:Playwright 在 BrowserContext 级别设置设备,所有页面自动继承;Puppeteer 在 Page 级别设置,需要每个页面单独配置。Playwright 还内置了网络节流 API(context.route()),无需 CDP Session。