前端面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

前端阅读 05月28日 07:18

什么是 Puppeteer?核心原理和实战场景有哪些?

什么是 Puppeteer?Puppeteer 是 Google Chrome 团队开发的 Node.js 库,通过 Chrome DevTools 协议(CDP)提供高级 API 来控制无头或有头 Chrome/Chromium 浏览器。简单说,它让你用代码驱动浏览器完成截图、爬虫、自动化测试等操作,是前端工程师最常接触的浏览器自动化工具之一。核心架构Puppeteer 的 API 围绕几个核心对象组织,理解它们的层级关系是掌握 Puppeteer 的基础:Browser:浏览器实例,通过 puppeteer.launch() 创建,是所有操作的入口BrowserContext:隔离的浏览器会话,类似隐身模式,多个 Context 之间 Cookie、localStorage、缓存互不干扰,适合多账号并行场景Page:一个标签页,绝大部分操作(导航、点击、截图)都在 Page 上进行Frame:页面中的 iframe,每个 Page 有一个主 Frame,通过 page.frames() 访问子 Frameconst browser = await puppeteer.launch({ headless: true });const context = await browser.createIncognitoBrowserContext();const page = await context.newPage();await page.goto('https://example.com');// 操作完成后await browser.close();面试要点:Browser 和 BrowserContext 的区别在于——一个 Browser 可以有多个 BrowserContext,它们之间完全隔离,这在爬虫需要多账号并行或测试需要干净环境时非常关键。主要特性1. 无头浏览器控制默认以 headless 模式运行,不显示浏览器界面但功能完整。Puppeteer 从 v20 起默认使用新的 Headless 模式(headless: 'new'),性能更接近有头模式。设置 headless: false 可打开可视化窗口调试脚本执行过程。2. 页面交互与等待机制Puppeteer 的等待机制是面试高频考点,它决定了脚本的稳定性和效率:page.waitForSelector(selector) — 等待元素出现在 DOM 中page.waitForFunction(fn) — 等待自定义 JS 函数返回 truthypage.waitForNavigation() — 等待页面跳转完成page.waitForResponse(urlOrPredicate) — 等待特定网络响应await page.click('#submit-btn');await page.waitForSelector('.result', { visible: true });const text = await page.$eval('.result', el => el.textContent);面试常问:为什么不推荐用 setTimeout 硬等待?因为网络延迟不可控,硬等待要么浪费时间要么不够等导致报错。Puppeteer 的 waitFor 系列基于轮询 + 事件监听,条件满足时立即继续执行,既可靠又高效。另一个高频问题:点击后等待导航应该怎么写?// 错误写法:click 和 waitForNavigation 竞态await page.click('#link');await page.waitForNavigation();// 正确写法:用 Promise.all 并行等待await Promise.all([ page.waitForNavigation(), page.click('#link')]);3. 网络拦截与请求控制通过 page.setRequestInterception(true) 可以拦截、修改或 abort 请求,这是爬虫和测试场景的核心能力:await page.setRequestInterception(true);page.on('request', request => { if (request.resourceType() === 'image') { request.abort(); // 屏蔽图片,加速爬虫 } else if (request.url().includes('/api/data')) { request.continue({ headers: { ...request.headers(), 'X-Custom': 'value' } }); // 修改请求头 } else { request.continue(); }});实际应用:屏蔽无用资源提升页面加载速度(图片、字体、CSS)、mock 接口返回进行前端测试、修改请求头绕过反爬检测。4. 截图与 PDF 生成// 整页截图await page.screenshot({ path: 'full.png', fullPage: true });// 指定元素截图const element = await page.$('.chart');await element.screenshot({ path: 'chart.png' });// 生成 PDFawait page.pdf({ path: 'output.pdf', format: 'A4', printBackground: true });注意:PDF 生成仅在无头模式下支持,有头模式调用会报错。5. 执行上下文与 page.evaluatepage.evaluate() 在浏览器环境中执行 JS,可以访问 DOM 和 window 对象。这是一个容易踩坑的点:// 正确:通过参数传入const title = await page.evaluate((sel) => { return document.querySelector(sel)?.textContent;}, 'h1');// 错误:闭包变量无法访问const sel = 'h1';const title = await page.evaluate(() => { return document.querySelector(sel)?.textContent; // sel 未定义!});原因:page.evaluate 的回调函数会被序列化后发送到浏览器环境执行,Node.js 侧的闭包变量不会跟随过去。需要传参的变量必须是可以被结构化克隆算法处理的类型(基本类型、普通对象、数组等),函数和 DOM 元素不行。如果需要传递复杂对象,可以用 page.exposeFunction(name, callback) 把 Node.js 函数暴露到浏览器环境中。主要应用场景| 场景 | 说明 | 关键 API ||------|------|----------|| SPA 爬虫 | 抓取 Vue/React 等单页应用的动态渲染内容 | page.goto + waitForSelector || E2E 自动化测试 | 模拟用户操作流程,验证功能正确性 | page.click + page.type + 断言 || PDF/截图服务 | 将网页批量转成 PDF 或截图 | page.pdf + page.screenshot || 性能监控 | 录制性能轨迹分析加载瓶颈 | page.tracing.start/stop || Chrome 扩展测试 | 加载扩展并测试交互 | launch({ args: ['--load-extension=...'] }) || 预渲染(SSR 替代) | 构建时生成静态 HTML,提升 SEO | rendertron / puppeteer-renderer |与 Selenium、Playwright 的对比| 维度 | Puppeteer | Selenium | Playwright ||------|-----------|----------|------------|| 底层协议 | Chrome DevTools Protocol | WebDriver 协议 | CDP + 自有协议 || 浏览器支持 | 仅 Chrome/Chromium | Chrome/Firefox/Safari/Edge | Chromium/Firefox/WebKit || 自动等待 | waitFor 系列需手动调用 | 需显式等待(WebDriverWait) | 内置 auto-waiting || 测试框架 | 无内置,常搭配 Jest/Mocha | 无内置 | 内置 test runner || 多标签/多上下文 | 支持 BrowserContext | 支持 Window handles | 原生支持,API 更完善 || 维护方 | Google | 社区(Selenium 4 由 W3C 标准驱动) | Microsoft || 学习曲线 | 低,API 直观 | 中,需要理解 WebDriver 概念 | 中低,API 设计更现代 |面试高频追问:2026 年还要学 Puppeteer 吗?Playwright 由原 Puppeteer 团队打造,功能更全面,跨浏览器支持好,新项目优先推荐。但 Puppeteer 在 Chrome 专属场景(扩展测试、CDP 深度调试、Chrome 特性验证)仍有优势,且生态成熟、Stack Overflow 上的资料更多。理解 Puppeteer 的 CDP 原理后迁移到 Playwright 成本很低,两者核心概念一致。反爬处理常见策略实际用 Puppeteer 做爬虫时,网站的反爬检测是绕不开的问题:设置 User-Agent:page.setUserAgent('Mozilla/5.0 ...') 模拟真实浏览器隐藏 WebDriver 特征:page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, 'webdriver', { get: () => false }); }) 去除自动控制标识使用 puppeteer-extra-plugin-stealth:社区插件,自动注入十余项反检测脚本,最省事的方案代理轮换:puppeteer.launch({ args: ['--proxy-server=...'] }) 配合代理池避免 IP 封禁模拟人类行为:用 page.type(selector, text, { delay: 100 }) 模拟逐字输入,避免瞬间填写触发风控
前端阅读 05月28日 07:16

Puppeteer 如何实现设备模拟和移动端测试?

核心概念Puppeteer 的设备模拟通过 page.emulate() 方法实现,它一次性设置视口(viewport)、用户代理(User-Agent)、设备像素比、触摸支持等属性,让无头浏览器完整模拟目标设备的浏览器环境。从 Puppeteer v21 开始,设备预设从 puppeteer.devices 迁移到了 KnownDevices,这是面试中容易踩的坑。内置设备与 KnownDevices使用内置设备预设const 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)单个设备的配置结构{ 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 行为,不仅是视口大小的变化。自定义设备配置当内置预设不满足需求时(比如测试未上市的新机型),可以手动构造设备描述符:const 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():await page.setViewport({ width: 375, height: 812, deviceScaleFactor: 3, isMobile: true, hasTouch: true});单独设置用户代理:await page.setUserAgent( 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) ...');emulate() 与分开设置的区别:emulate() 是原子操作,保证视口和 UA 同时生效;分开设置可能在两次调用之间页面触发重排,导致布局闪烁。地理位置与权限模拟移动端测试经常需要模拟位置信息:const 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 节流这是移动端测试的核心但常被忽略的环节。真实移动网络的延迟和带宽与桌面完全不同。网络节流const 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 节流// CPU 减速 4 倍,模拟移动端性能await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });结合网络节流和 CPU 节流,才能真实还原移动端用户的使用体验。仅模拟视口大小而忽略网络和性能条件,测试结果往往偏乐观。时区与语言环境国际化测试需要模拟不同地区的时区和语言:// 设置时区await page.emulateTimezone('Asia/Tokyo');// 设置语言偏好await page.setExtraHTTPHeaders({ 'Accept-Language': 'ja-JP,ja;q=0.9,en;q=0.8'});触摸事件移动端的核心交互是触摸而非鼠标点击:await 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 事件的组件不会响应。实战:响应式设计批量测试将上述能力组合成一个实用的测试流程:const { 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 更简洁:// 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。
前端阅读 05月28日 07:15

Puppeteer 如何管理 Cookie 与存储实现会话持久化?

核心回答Puppeteer 通过 page.cookies() / page.setCookie() 管理 Cookie,通过 page.evaluate() 操作 LocalStorage、SessionStorage 和 IndexedDB,结合 userDataDir 或手动序列化实现会话持久化,利用 browser.createIncognitoBrowserContext() 实现多账户隔离。三种会话持久化方案的对比:| 方案 | 适用场景 | 优点 | 缺点 ||------|---------|------|------|| userDataDir | 长期保持登录态 | 最简单,自动持久化所有数据 | 数据量大,不易清理 || 手动序列化 Cookie + Storage | 精确控制需要持久化的数据 | 灵活可控,文件小 | 需要手动处理每种存储 || Incognito Context + 手动保存 | 多账户并行 | 完全隔离,互不干扰 | 上下文关闭后数据丢失 |Cookie 管理读取与设置 CookiePuppeteer 提供了简洁的 Cookie API:// 获取当前页面所有 Cookieconst cookies = await page.cookies();// 获取指定 URL 的 Cookie(可跨域获取第三方 Cookie)const cookies = await page.cookies('https://api.example.com');// 设置单个 Cookieawait page.setCookie({ name: 'session_id', value: 'abc123', domain: '.example.com', path: '/', expires: Math.floor(Date.now() / 1000) + 3600, httpOnly: true, secure: true, sameSite: 'Lax'});// 批量设置await page.setCookie( { name: 'token', value: 'xxx', domain: '.example.com' }, { name: 'lang', value: 'zh', domain: '.example.com' });删除与清除 Cookie// 删除指定 Cookie(需匹配 name 和 domain)await page.deleteCookie({ name: 'session_id', domain: '.example.com' });// 清除所有 Cookieconst allCookies = await page.cookies();await page.deleteCookie(...allCookies);Cookie 的 SameSite 策略Chrome 80+ 默认将未声明 SameSite 的 Cookie 视为 Lax,这对跨域场景影响显著:Strict:仅同站请求携带,最安全但体验差(从外部链接跳入不带 Cookie)Lax:同站请求 + 顶级导航的 GET 请求携带(默认值)None:跨站也携带,但必须同时设置 secure: true在 Puppeteer 中模拟跨站场景时,如果目标站点依赖第三方 Cookie,需要显式设置 sameSite: 'None' 并确保 secure: true,否则请求可能不带 Cookie 导致鉴权失败。浏览器存储管理LocalStorageLocalStorage 以键值对形式持久化数据,同源共享,无过期时间:// 读取全部const lsData = await page.evaluate(() => { return Object.fromEntries( Array.from({ length: localStorage.length }, (_, i) => { const key = localStorage.key(i); return [key, localStorage.getItem(key)]; }) );});// 写入await page.evaluate(() => { localStorage.setItem('user_id', '12345'); localStorage.setItem('prefs', JSON.stringify({ theme: 'dark' }));});// 删除指定项 / 清空await page.evaluate(() => localStorage.removeItem('user_id'));await page.evaluate(() => localStorage.clear());SessionStorageSessionStorage 与 LocalStorage API 相同,但数据仅在当前标签页生命周期内有效,关闭标签页即清除:await page.evaluate(() => { sessionStorage.setItem('temp_key', 'temp_value');});const data = await page.evaluate(() => { return Object.fromEntries( Array.from({ length: sessionStorage.length }, (_, i) => { const key = sessionStorage.key(i); return [key, sessionStorage.getItem(key)]; }) );});IndexedDBIndexedDB 适合存储结构化数据,操作较为复杂,Puppeteer 中需要通过 page.evaluate 异步操作:const dbData = await page.evaluate(async () => { return new Promise((resolve, reject) => { const req = indexedDB.open('myDB', 1); req.onsuccess = (e) => { const db = e.target.result; const tx = db.transaction(['store1'], 'readonly'); const store = tx.objectStore('store1'); const getAll = store.getAll(); getAll.onsuccess = () => resolve(getAll.result); getAll.onerror = () => reject(getAll.error); }; req.onerror = () => reject(req.error); });});会话持久化方案一:userDataDir(推荐用于长期持久化)启动浏览器时指定 userDataDir,Chrome 会将 Cookie、LocalStorage、SessionStorage、IndexedDB 等所有用户数据保存到该目录,下次启动自动恢复:const browser = await puppeteer.launch({ userDataDir: './user_data/session1' // 指定持久化目录});const page = await browser.newPage();await page.goto('https://example.com');// 所有登录状态、Cookie、Storage 自动持久化到磁盘这种方式最简单,但要注意:目录会随使用逐渐增大,长期运行需要定期清理。方案二:手动序列化 Cookie + Storage当只需要保存部分数据时,手动序列化更精确:async function saveSession(page, filePath) { const fs = require('fs'); const cookies = await page.cookies(); const localStorage = await page.evaluate(() => { return Object.fromEntries( Array.from({ length: localStorage.length }, (_, i) => { const key = localStorage.key(i); return [key, localStorage.getItem(key)]; }) ); }); fs.writeFileSync(filePath, JSON.stringify({ cookies, localStorage }));}async function restoreSession(page, filePath) { const fs = require('fs'); const { cookies, localStorage } = JSON.parse(fs.readFileSync(filePath, 'utf8')); // 先设置 Cookie,再导航(确保域名匹配) await page.setCookie(...cookies); await page.evaluate((data) => { for (const [k, v] of Object.entries(data)) { localStorage.setItem(k, v); } }, localStorage);}恢复顺序很重要:先 setCookie 再导航到目标页面,这样页面加载时就能携带正确的 Cookie。多账户管理使用 Incognito Browser Context每个 Incognito 上下文拥有独立的 Cookie 和 Storage,互不干扰:const browser = await puppeteer.launch();// 账户 Aconst ctxA = await browser.createIncognitoBrowserContext();const pageA = await ctxA.newPage();await pageA.goto('https://example.com/login');// ... 登录账户 A// 账户 B(完全隔离)const ctxB = await browser.createIncognitoBrowserContext();const pageB = await ctxB.newPage();await pageB.goto('https://example.com/login');// ... 登录账户 B// 操作完成后关闭上下文await ctxA.close();await ctxB.close();多账户持久化方案如果需要在不同运行间恢复多个账户的会话,可以结合 userDataDir 和手动序列化:async function loginAndSave(account, sessionDir) { const browser = await puppeteer.launch({ userDataDir: sessionDir // 每个账户独立目录 }); const page = await browser.newPage(); await page.goto('https://example.com/login'); await page.type('#username', account.username); await page.type('#password', account.password); await page.click('#login-button'); await page.waitForNavigation(); await browser.close(); // 数据自动保存到 sessionDir}安全注意事项敏感数据保护:不要在代码中硬编码密码,使用环境变量 process.env.PASSWORD;将包含会话信息的文件加入 .gitignoreCookie 安全属性:设置 httpOnly: true 防 XSS、secure: true 限 HTTPS 传输、sameSite: 'Strict' 防 CSRF会话过期处理:检查 Cookie 的 expires 字段,过期后重新登录,避免用失效会话发请求第三方 Cookie 限制:Chrome 逐步限制第三方 Cookie,跨域场景需使用 sameSite: 'None' 并配合 secure: true追问Q: userDataDir 和手动序列化如何选择?简单场景(只需保持登录)用 userDataDir,复杂场景(需要跨环境迁移、选择性恢复)用手动序列化。手动序列化的优势在于文件小、可审计、可跨机器使用;userDataDir 的优势是零配置、自动覆盖所有存储类型。Q: 如何检测 Cookie 是否生效?设置 Cookie 后,通过 page.cookies(url) 验证返回的 Cookie 列表中是否包含目标项,或在导航后检查页面行为(如是否仍处于登录态)。注意 Cookie 的 domain 和 path 必须匹配目标 URL,否则不会被发送。Q: Incognito Context 和 CDP Session 有什么区别?Incognito Context 是浏览器层面的隔离,拥有独立的 Cookie 和 Storage;CDP Session 是 DevTools 协议层面的隔离,允许多个客户端独立与页面交互但不隔离存储数据。多账户场景应使用 Incognito Context。
前端阅读 05月28日 07:12

Puppeteer 如何实现页面交互和表单操作?

Puppeteer 是 Google 维护的 Node.js 浏览器自动化库,通过 DevTools 协议控制 Chrome/Chromium,核心能力就是模拟用户在页面上的真实操作——导航、点击、输入、拖拽、截图等。前端面试中,Puppeteer 的页面交互与表单操作是高频考点,本文将系统梳理常用 API 和实际场景中的最佳实践。页面导航与基础操作Puppeteer 的一切操作都围绕 page 对象展开。最基础的交互就是导航:const browser = await puppeteer.launch();const page = await browser.newPage();// 基本导航await page.goto('https://example.com');// 等待网络空闲后再继续,适合需要等待异步资源的页面await page.goto('https://example.com', { waitUntil: 'networkidle2' });// 设置超时避免无限等待await page.goto('https://example.com', { timeout: 30000 });waitUntil 参数常用取值:load(默认,window.onload 触发)、domcontentloaded(DOM 解析完成)、networkidle0(500ms 内无网络请求)、networkidle2(500ms 内不超过2个网络请求)。实际项目中 networkidle2 最常用,因为它能容忍长连接(如 WebSocket)同时确保页面主体资源加载完成。页面刷新、前进后退:await page.reload({ waitUntil: 'networkidle2' });await page.goBack();await page.goForward();元素选择与定位Puppeteer 提供三种选择器策略:CSS 选择器、XPath 和文本选择器。CSS 选择器(最常用):// 选择单个元素const el = await page.$('#submit-btn');// 选择多个元素const items = await page.$$('.list-item');// 批量获取数据(比逐个 evaluate 更高效)const texts = await page.$$eval('.item', els => els.map(e => e.textContent));XPath(适合按文本内容定位):const [el] = await page.$x('//button[contains(text(), "提交")]');文本选择器(Puppeteer 较新版本支持):await page.click('text/登录');面试中常问:page.$ 和 page.evaluate(querySelector) 的区别?前者返回 ElementHandle 对象(可继续调用 Puppeteer API),后者直接在浏览器上下文执行并返回序列化结果。理解这个区别是正确使用 Puppeteer 的关键。点击与输入操作点击和输入是最核心的交互 API,也是面试必考项。点击操作:// 基本点击await page.click('#button');// 右键、双击await page.click('#btn', { button: 'right' });await page.click('#btn', { clickCount: 2 });// 点击延迟,模拟真实用户await page.click('#btn', { delay: 100 });文本输入:// 逐字符输入,触发 keydown/keypress/keyup 事件await page.type('#search', 'Puppeteer', { delay: 50 });// 输入前先清空(v21.1+ 支持 clear 选项)await page.type('#input', 'new text', { clear: true });// 旧版本清空方式await page.click('#input', { clickCount: 3 });await page.keyboard.press('Backspace');面试追问:page.type 和 page.evaluate 直接设置 value 有什么区别?page.type 逐字符输入,会触发完整的键盘事件链(keydown → keypress → input → keyup),对依赖事件监听的框架(React、Vue)有效;直接设置 input.value 不触发事件,可能导致框架状态不同步。所以表单自动化场景应优先使用 page.type。键盘与鼠标高级操作当 CSS 选择器无法定位元素时,键盘和鼠标 API 是重要补充。键盘组合键:// Ctrl+A 全选await page.keyboard.down('Control');await page.keyboard.press('A');await page.keyboard.up('Control');// 常用按键await page.keyboard.press('Enter');await page.keyboard.press('Escape');await page.keyboard.press('Tab');鼠标拖拽与精确操作:// 拖拽操作await page.mouse.move(100, 100);await page.mouse.down();await page.mouse.move(300, 300, { steps: 10 }); // 平滑移动await page.mouse.up();// 滚轮await page.mouse.wheel({ deltaY: 300 });鼠标 API 的坐标是相对视口左上角的 CSS 像素,steps 参数控制中间插值点数,值越大移动越平滑,在需要模拟真实用户行为(避免被反爬检测)时很有用。表单操作全场景面试中表单操作是重点,需要掌握各种控件类型的处理方式。文本与文本域:await page.type('#username', 'admin');await page.type('#bio', '前端工程师');下拉选择框:// 单选await page.select('#country', 'CN');// 多选await page.select('#languages', ['zh', 'en']);// 获取当前选中值const value = await page.$eval('#country', el => el.value);复选框与单选框:// 复选框——先检查再点击,避免取消选中const checked = await page.$eval('#agree', el => el.checked);if (!checked) await page.click('#agree');// 单选框await page.click('input[value="male"]');文件上传:// 单文件await page.setInputFiles('#avatar', '/path/to/photo.jpg');// 多文件await page.setInputFiles('#docs', ['/path/to/a.pdf', '/path/to/b.pdf']);// 移除已选文件await page.setInputFiles('#avatar', []);表单提交:// 方式一:点击提交按钮(最常用)await Promise.all([ page.waitForNavigation(), page.click('#submit')]);// 方式二:通过 JavaScript 提交await page.$eval('form', form => form.submit());// 方式三:回车提交await page.keyboard.press('Enter');注意提交时用 Promise.all 包裹 waitForNavigation 和点击操作,否则导航可能在等待之前就完成了,导致后续操作失败。这是面试中经常考察的细节。等待策略等待策略直接决定自动化脚本的稳定性,也是面试高频考点。// 等待元素出现await page.waitForSelector('.result', { visible: true });// 等待元素消失(如 loading 遮罩)await page.waitForSelector('.loading', { hidden: true });// 等待 XPathawait page.waitForXPath('//div[contains(@class, "result")]');// 等待自定义条件await page.waitForFunction(() => { return document.querySelectorAll('.item').length >= 10;});// 等待特定请求完成await page.waitForResponse(resp => resp.url().includes('/api/data'));面试追问:为什么不能直接用 setTimeout 代替 waitForSelector?setTimeout 是固定等待,太短会失败、太长浪费时间;waitForSelector 是条件等待,元素出现立即继续,兼顾可靠性和效率。在实际项目中,硬编码等待时间是脚本不稳定的常见原因。iframe 与弹窗处理这两个场景在实际项目中非常常见,但很多开发者容易忽略。iframe 内操作:// 获取 iframe 的 frame 对象const frame = await page.frames().find(f => f.name() === 'myiframe');// 也可以通过选择器获取const frameEl = await page.$('iframe');const frame = await frameEl.contentFrame();// 在 iframe 内操作,API 与 page 相同await frame.type('#input', 'hello');await frame.click('#btn');Dialog 弹窗:// 监听并自动处理 alert/confirm/promptpage.on('dialog', async dialog => { console.log(dialog.type(), dialog.message()); await dialog.accept(); // 或 dialog.dismiss()});// prompt 输入值page.on('dialog', async dialog => { await dialog.accept('my input');});Puppeteer 默认会自动 dismiss dialog,如果不手动监听处理,所有 confirm/prompt 都会被取消,导致表单提交行为异常。网络拦截与 Cookie 管理这两个能力让 Puppeteer 不仅能操作页面,还能控制网络层和状态管理。请求拦截(性能优化与 Mock 数据):await page.setRequestInterception(true);page.on('request', request => { // 屏蔽图片和字体,加速页面加载 if (['image', 'font', 'media'].includes(request.resourceType())) { request.abort(); } // Mock API 响应 else if (request.url().includes('/api/user')) { request.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ name: 'mock user' }) }); } else { request.continue(); }});Cookie 操作:// 设置 Cookie(常用于免登录)await page.setCookie({ name: 'token', value: 'abc123', domain: 'example.com'});// 获取所有 Cookieconst cookies = await page.cookies();// 删除 Cookieawait page.deleteCookie({ name: 'token' });Cookie 管理在爬虫和自动化测试中非常实用——通过预设登录态 Cookie 可以跳过登录流程,大幅简化脚本。设备模拟与截图设备模拟(移动端测试必备):const iPhone = puppeteer.devices['iPhone 13'];await page.emulate(iPhone);// 单独设置视口await page.setViewport({ width: 375, height: 812, isMobile: true });// 模拟地理位置await page.setGeolocation({ latitude: 39.9, longitude: 116.4 });截图与 PDF:// 页面截图await page.screenshot({ path: 'home.png', fullPage: true });// 指定元素截图const el = await page.$('.chart');await el.screenshot({ path: 'chart.png' });// 生成 PDFawait page.pdf({ path: 'report.pdf', format: 'A4' });实际应用场景场景一:登录流程自动化:async function login(url, username, password) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url); await page.type('#username', username); await page.type('#password', password); // 提交并等待导航完成 await Promise.all([ page.waitForNavigation({ waitUntil: 'networkidle2' }), page.click('#login-btn') ]); // 验证登录成功 const success = await page.$('.user-avatar') !== null; await browser.close(); return success;}场景二:滚动加载与数据采集:async function scrapeInfiniteScroll(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'networkidle2' }); let prevHeight = 0; while (true) { const currHeight = await page.evaluate(() => document.body.scrollHeight); if (currHeight === prevHeight) break; prevHeight = currHeight; await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await page.waitForTimeout(1500); } const items = await page.$$eval('.item', els => els.map(e => ({ title: e.querySelector('.title')?.textContent, url: e.querySelector('a')?.href })) ); await browser.close(); return items;}场景三:网络 Mock 与接口测试:async function testWithMock(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.setRequestInterception(true); page.on('request', req => { if (req.url().includes('/api/config')) { req.respond({ status: 200, contentType: 'application/json', body: '{"theme":"dark"}' }); } else { req.continue(); } }); await page.goto(url); const theme = await page.$eval('.theme-label', el => el.textContent); await browser.close(); return theme;}最佳实践总结1. 优先使用 Locator API(Puppeteer v22+):自动等待、自动重试,比手动 waitForSelector + click 更可靠。2. 始终处理异步等待:不要假设页面已经加载完成,显式等待目标元素或网络状态。3. 拦截无关资源:测试和爬虫场景中屏蔽图片、字体、媒体,可显著提升速度。4. 资源释放:browser.close() 放在 finally 块中确保执行,避免 Chromium 进程残留。5. 反检测意识:使用 puppeteer-extra + stealth 插件规避反爬检测;模拟真实用户行为(随机延迟、自然鼠标轨迹);避免使用 WebDriver 等可被检测的标识。6. 错误重试机制:网络波动和动态内容加载不可控,关键操作应有 try-catch 和重试逻辑。7. 与 Playwright 的选择:新项目可考虑 Playwright,它由原 Puppeteer 团队打造,支持多浏览器、内置 auto-waiting、API 更现代。但 Puppeteer 生态更成熟、Chrome 支持最深,两者各有优势。Puppeteer 的页面交互能力覆盖了从基础点击到网络拦截的完整链路。掌握核心 API(导航、选择器、输入、等待)、理解 ElementHandle 与浏览器上下文的区别、善用网络拦截和 Cookie 管理应对复杂场景,是面试和实际项目中的关键。面试回答时,先说核心 API 用法,再补充等待策略和最佳实践,最后提一下与 Playwright 的对比,基本就能覆盖大部分考察点。
前端阅读 05月28日 07:11

Puppeteer 如何进行错误处理和调试?

Puppeteer 在浏览器自动化场景下,错误类型多、调试链路长,从脚本层到浏览器层再到网络层都可能出问题。掌握系统的错误处理策略和调试手段,是写出可靠自动化脚本的关键。Puppeteer 常见的错误类型有哪些?Puppeteer 脚本运行时主要会遇到三类错误:脚本层错误——语法错误、逻辑错误,这类错误 Node.js 会直接抛出栈信息,属于常规调试范畴。协议层错误——Puppeteer 通过 CDP(Chrome DevTools Protocol)与浏览器通信,协议调用失败时会抛出特定错误类:const { TimeoutError } = require('puppeteer').errors;try { await page.waitForSelector('.dynamic-content', { timeout: 5000 });} catch (error) { if (error instanceof TimeoutError) { console.error('等待元素超时,可能页面未加载完成'); }}浏览器层错误——页面内的 JS 运行时错误、资源加载失败、网络请求异常,需要通过事件监听捕获:// 捕获页面未处理的 JS 错误page.on('pageerror', error => { console.error('[页面错误]', error.message);});// 捕获资源加载失败page.on('requestfailed', request => { console.error('[请求失败]', request.url(), request.failure().errorText);});怎样构建健壮的错误处理机制?try-catch 配合 finally 管理生命周期每个 Puppeteer 脚本都应确保浏览器实例被正确关闭,finally 块是关键:async function runTask() { const browser = await puppeteer.launch(); const page = await browser.newPage(); try { await page.goto('https://example.com', { waitUntil: 'networkidle2' }); await page.click('#submit'); } catch (error) { // 区分超时和其他错误 if (error.name === 'TimeoutError') { console.error('操作超时:', error.message); } else { console.error('执行失败:', error.message); } // 出错时截图保存现场 await page.screenshot({ path: `error-${Date.now()}.png`, fullPage: true }); } finally { await browser.close(); }}重试策略处理临时性故障网络波动、页面加载慢等临时性问题,适合用重试机制解决:async function withRetry(fn, maxRetries = 3, delay = 2000) { for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (error) { console.warn(`第 ${i + 1} 次尝试失败: ${error.message}`); if (i < maxRetries - 1) { await new Promise(r => setTimeout(r, delay * (i + 1))); } else { throw error; } } }}// 使用:自动重试页面导航const page = await withRetry(() => browser.newPage().then(p => p.goto(url).then(() => p)));全局错误事件监听在 page 级别设置错误监听,防止未捕获的异常导致脚本静默崩溃:page.on('error', err => { console.error('[Page crash]', err.message);});page.on('console', msg => { if (msg.type() === 'error') { console.error('[Console Error]', msg.text()); }});有哪些实用的调试手段?headless: false + slowMo 可视化调试最直接的方式是关掉无头模式,肉眼观察浏览器行为:const browser = await puppeteer.launch({ headless: false, slowMo: 100, // 每步操作放慢 100ms devtools: true // 自动打开 DevTools});slowMo 的值根据脚本复杂度调整,一般 50-250ms 之间。值太大会导致超时,太小来不及观察。DEBUG 环境变量追踪协议通信Puppeteer 内部基于 CDP 协议通信,通过 DEBUG 环境变量可以看到所有协议交互:# 查看所有 Puppeteer 内部通信DEBUG="puppeteer:*" node script.js# 只看 CDP 协议请求DEBUG="puppeteer:protocol" node script.js# 只看 API 调用DEBUG="puppeteer:api" node script.js这在排查"为什么操作没生效"时非常有效,能看到 Puppeteer 到底发送了什么指令、浏览器返回了什么。截图和 HTML 快照保留现场在关键步骤截图,配合 HTML 快照,可以还原出错时的完整页面状态:async function debugCheckpoint(page, name) { const ts = new Date().toISOString().replace(/[:.]/g, '-'); await page.screenshot({ path: `debug-${name}-${ts}.png`, fullPage: true }); const html = await page.content(); require('fs').writeFileSync(`debug-${name}-${ts}.html`, html); console.log(`[调试快照] ${name} 已保存`);}// 在关键步骤之间插入await debugCheckpoint(page, 'after-login');await page.click('#next-step');await debugCheckpoint(page, 'after-click');网络请求拦截与监控拦截和记录网络请求,能定位接口异常、资源加载失败等问题:// 监控所有请求的状态page.on('response', response => { if (response.status() >= 400) { console.warn(`[HTTP ${response.status()}] ${response.url()}`); }});// 拦截并修改请求(模拟接口异常场景)await page.setRequestInterception(true);page.on('request', request => { if (request.url().includes('/api/user')) { // 模拟接口 500 错误 request.abort(); } else { request.continue(); }});如何用 CDP Session 做高级调试?Puppeteer 提供的 API 覆盖了大部分场景,但有些高级调试功能需要直接使用 CDP Session:// 创建 CDP 会话const client = await page.target().createCDPSession();// 性能指标采集await client.send('Performance.enable');const { metrics } = await client.send('Performance.getMetrics');console.log('性能指标:', metrics.filter(m => m.name === 'FirstMeaningfulPaint'));// 追踪页面加载时间线await page.tracing.start({ path: 'trace.json' });await page.goto('https://example.com');await page.tracing.stop();// trace.json 可在 Chrome DevTools → Performance 面板中打开分析// 模拟网络条件(测试弱网场景)await client.send('Network.emulateNetworkConditions', { offline: false, latency: 200, // 额外延迟 200ms downloadThroughput: 500 * 1024, // 下载 500KB/s uploadThroughput: 250 * 1024, // 上传 250KB/s});面试高频追问:常见踩坑与解决方案导航超时怎么处理? 默认超时 30 秒,可以用 waitUntil 参数降低等待条件,或针对性增加超时时间:// 方案1:降低等待条件await page.goto(url, { waitUntil: 'domcontentloaded' });// 方案2:单独设置超时await page.goto(url, { timeout: 60000, waitUntil: 'networkidle2' });// 方案3:手动等待关键元素await page.goto(url, { waitUntil: 'domcontentloaded' });await page.waitForSelector('.main-content');元素找不到或不可点击怎么办? 大部分情况是元素还没渲染完成或被遮挡,按以下顺序排查:// 1. 确认元素存在const element = await page.$(selector);if (!element) throw new Error(`元素不存在: ${selector}`);// 2. 确认元素可见const visible = await element.isIntersectingViewport();if (!visible) { await element.scrollIntoView();}// 3. 等待元素可交互await page.waitForSelector(selector, { visible: true });await element.click();内存泄漏怎么排查? Puppeteer 脚本中最常见的泄漏是浏览器实例未关闭和事件监听器未移除:// 始终用 finally 保证关闭let browser;try { browser = await puppeteer.launch(); const page = await browser.newPage(); // ... 操作} finally { if (browser) await browser.close();}// 长时间运行的脚本,用完后移除监听const handler = msg => console.log(msg.text());page.on('console', handler);// 用完后page.off('console', handler);如何调试 headless 模式下的脚本? headless 环境无法可视化,靠截图和日志定位:// 开启详细日志process.env.DEBUG = 'puppeteer:*';// 在出错时自动保存完整上下文page.on('pageerror', async error => { const debugInfo = { url: page.url(), error: error.message, html: await page.content().catch(() => '获取失败'), screenshot: await page.screenshot({ encoding: 'base64' }).catch(() => null) }; require('fs').writeFileSync('crash-debug.json', JSON.stringify(debugInfo, null, 2));});掌握以上错误处理和调试方法,可以在实际项目中快速定位 Puppeteer 脚本问题,写出更稳定的自动化流程。
前端阅读 05月28日 07:11

Puppeteer 如何使用 Chrome DevTools Protocol (CDP) 进行高级调试和性能分析?

Puppeteer 通过 page.target().createCDPSession() 创建 CDP 会话,直接与 Chrome DevTools Protocol 通信,访问 Performance、Network、Runtime、DOM、HeapProfiler 等底层域,实现性能指标采集、网络请求拦截、运行时异常捕获、内存堆快照等高级调试能力。CDP 会话的创建与基本用法CDP 会话是所有操作的起点。每个 CDP 命令和事件监听都依赖这个会话对象:const client = await page.target().createCDPSession();创建会话后,需要显式启用对应的域才能使用该域的命令和事件。各域之间相互独立,未启用的域调用会报错:await client.send('Performance.enable');await client.send('Network.enable');await client.send('Runtime.enable');发送命令使用 client.send(method, params),监听事件使用 client.on(event, handler)。这两个方法覆盖了 CDP 的全部能力,Puppeteer 高层 API 未暴露的功能都可以通过它们实现。一个关键细节:多个 CDP 会话可以同时存在,但同一个域在不同会话中重复启用不会出错,只是会增加开销。最佳做法是复用同一个 client 实例,按需启用和禁用域:try { await client.send('Performance.enable'); const { metrics } = await client.send('Performance.getMetrics'); // 处理指标数据...} finally { await client.send('Performance.disable');}性能指标采集与追踪获取 Performance Metrics启用 Performance 域后,可以获取浏览器内部的性能指标。这些指标与 Chrome DevTools Performance 面板中的数据一致:await client.send('Performance.enable');const { metrics } = await client.send('Performance.getMetrics');const map = Object.fromEntries(metrics.map(m => [m.name, m.value]));核心指标含义:LayoutDuration — 布局耗时(秒),频繁变动说明存在布局抖动RecalcStyleDuration — 样式重算耗时,CSS 选择器复杂或 DOM 节点过多时会升高ScriptDuration — JS 执行耗时,异常升高通常指向长任务或主线程阻塞TaskDuration — 总任务耗时,包含微任务JSEventListeners — 当前注册的事件监听器数量,持续增长预示内存泄漏Nodes — DOM 节点数,超过 1500 会影响渲染性能这些指标的解读需要结合场景。单独一个指标偏高不一定是问题——比如 ScriptDuration 高可能只是因为页面有大量业务逻辑。关键是在相同场景下对比变化趋势,或者在性能优化前后做对照。性能追踪(Tracing)Tracing 域能生成与 Chrome DevTools 相同格式的 trace 文件,可在 chrome://tracing 中可视化分析:await client.send('Tracing.start', { traceConfig: { includedCategories: ['devtools.timeline', 'blink.user_timing', 'v8.execute'] }});await page.goto('https://example.com');const { value: traceData } = await client.send('Tracing.end');追踪数据可以写入文件后用 DevTools 的 Performance 面板加载,精确定位函数调用耗时和渲染瓶颈。需要注意的是,Tracing.end 返回的数据量可能很大(几十 MB),处理时要留意内存占用。监控长任务与布局偏移CDP 的 Performance.metrics 事件会持续推送指标变化,可用于实时监控:client.on('Performance.metrics', ({ metrics }) => { const map = Object.fromEntries(metrics.map(m => [m.name, m.value])); if (map.TaskDuration > 50) { console.warn('长任务:', map.TaskDuration, 'ms'); }});布局偏移(CLS)无法直接从 Performance.getMetrics 获取,需要通过 PerformanceObserver 在页面内注入监听,或使用 Tracing 追踪 LayoutShift 事件。Puppeteer 中注入页面脚本的做法:const cls = await page.evaluate(() => { return new Promise(resolve => { let cumulativeShift = 0; new PerformanceObserver(list => { for (const entry of list.getEntries()) { if (!entry.hadRecentInput) cumulativeShift += entry.value; } }).observe({ type: 'layout-shift', buffered: true }); setTimeout(() => resolve(cumulativeShift), 5000); });});网络请求监控与拦截请求与响应监控启用 Network 域后,可以监听完整的请求生命周期:await client.send('Network.enable');client.on('Network.requestWillBeSent', ({ requestId, request, type }) => { console.log(`→ ${request.method} ${request.url} [${type}]`);});client.on('Network.responseReceived', ({ requestId, response }) => { console.log(`← ${response.status} ${response.url} ${response.mimeType}`);});获取响应体需要在响应完成后单独请求——这是初学者常踩的坑:client.on('Network.loadingFinished', async ({ requestId }) => { const { body, base64Encoded } = await client.send( 'Network.getResponseBody', { requestId } ); // body 为响应内容,base64Encoded 标识是否需要 atob 解码});注意:响应体必须在 Network.loadingFinished 事件后获取,不能在 responseReceived 时获取,此时数据可能尚未传输完毕。在 responseReceived 中调用 getResponseBody 会抛异常。请求拦截与修改CDP 提供了 Fetch 域(不是 Network 域)来实现请求拦截,功能比 Puppeteer 的 page.setRequestInterception 更灵活:await client.send('Fetch.enable', { patterns: [ { urlPattern: '*api.example.com*', requestStage: 'Request' } ]});client.on('Fetch.requestPaused', async ({ requestId, request }) => { // 修改请求头 const headers = { ...request.headers, 'X-Custom': 'value' }; await client.send('Fetch.continueRequest', { requestId, headers: Object.entries(headers).map(([name, value]) => ({ name, value })) });});Fetch 域支持在 Request 和 Response 阶段分别拦截,可以修改请求头、请求体、响应内容,甚至直接模拟响应。相比 Puppeteer 的 setRequestInterception,Fetch 域不会阻塞所有请求,只拦截匹配 pattern 的请求,性能更好。网络限速模拟CDP 可以模拟不同的网络条件,测试弱网表现:await client.send('Network.emulateNetworkConditions', { offline: false, latency: 100, // 额外延迟 ms downloadThroughput: 500 * 1024, // 下载速度 500KB/s uploadThroughput: 250 * 1024, // 上传速度 250KB/s});结合 Performance 指标采集,可以量化不同网络条件下页面的性能退化程度。运行时调试与异常捕获JavaScript 执行与控制台监听Runtime 域提供了比 page.evaluate 更底层的执行能力:await client.send('Runtime.enable');// 执行表达式并获取返回值const { result } = await client.send('Runtime.evaluate', { expression: 'document.querySelectorAll("div").length', returnByValue: true});console.log('DIV 数量:', result.value);Runtime.evaluate 和 page.evaluate 的区别在于:前者可以指定执行上下文(contextId)、超时时间(timeout)、是否 await Promise(awaitPromise),控制粒度更细。控制台输出和异常通过事件获取:client.on('Runtime.consoleAPICalled', ({ type, args }) => { const values = args.map(a => a.value ?? a.description).join(' '); console.log(`[Console.${type}]`, values);});client.on('Runtime.exceptionThrown', ({ exceptionDetails }) => { const desc = exceptionDetails.exception?.description ?? exceptionDetails.text; console.error('运行时异常:', desc);});调试器(Debugger)启用 Debugger 域可以设置断点、单步执行,实现真正的源码级调试:await client.send('Debugger.enable');// 按 URL 和行号设置断点await client.send('Debugger.setBreakpointByUrl', { urlRegex: 'app\\.js', lineNumber: 42});client.on('Debugger.paused', async ({ reason, callFrames }) => { const top = callFrames[0]; console.log(`断点命中: ${top.url}:${top.location.lineNumber}`); console.log('作用域变量:', top.scopeChain[0]?.object); await client.send('Debugger.resume');});这在排查生产环境偶发问题时非常有用——可以远程附加到运行中的浏览器实例,设置条件断点而不影响正常请求:await client.send('Debugger.setBreakpointByUrl', { urlRegex: 'checkout\\.js', lineNumber: 100, condition: 'amount > 10000' // 只在金额大于 10000 时命中});内存分析与泄漏检测堆快照与内存使用HeapProfiler 域能生成与 DevTools Memory 面板相同的堆快照:await client.send('HeapProfiler.enable');const { totalSize, usedSize } = await client.send('Runtime.getHeapUsage');console.log(`堆内存: ${(usedSize / 1024 / 1024).toFixed(2)}MB / ${(totalSize / 1024 / 1024).toFixed(2)}MB`);对比两个时间点的堆快照可以定位泄漏对象:// 第一次快照await page.goto('https://example.com');const { usedSize: used1 } = await client.send('Runtime.getHeapUsage');// 执行操作(如反复打开/关闭弹窗)for (let i = 0; i < 10; i++) { await page.click('#open-modal'); await page.click('#close-modal');}// 第二次快照const { usedSize: used2 } = await client.send('Runtime.getHeapUsage');const growth = ((used2 - used1) / 1024 / 1024).toFixed(2);console.log(`内存增长: ${growth}MB`);如果 usedSize 持续增长且不回落,基本可以确认存在内存泄漏。进一步可以用 DevTools 加载堆快照,通过"比较"视图查看新增对象。分配时间线(Allocation Timeline)HeapProfiler 还支持记录内存分配过程:await client.send('HeapProfiler.startSampling', { samplingInterval: 32768 });// ... 执行操作 ...const { profile } = await client.send('HeapProfiler.stopSampling');采样数据可以导入 DevTools 的 Memory 面板,以时间线形式查看内存分配的热点函数。DOM 与 CSS 监控DOM 节点操作DOM 域提供了脱离 page.$() 的底层 DOM 操作能力,可以查询节点、获取属性、修改属性:await client.send('DOM.enable');const { root } = await client.send('DOM.getDocument');// 查询节点const { nodeId } = await client.send('DOM.querySelector', { nodeId: root.nodeId, selector: '#main-content'});// 获取属性const { attributes } = await client.send('DOM.getAttributes', { nodeId });// attributes 是扁平数组: [name1, value1, name2, value2, ...]const attrMap = {};for (let i = 0; i < attributes.length; i += 2) { attrMap[attributes[i]] = attributes[i + 1];}// 设置属性值await client.send('DOM.setAttributeValue', { nodeId, name: 'data-loaded', value: 'true'});CSS 覆盖CSS 域可以在不修改源文件的情况下覆盖页面样式,常用于测试不同视觉方案或排查样式问题:await client.send('CSS.enable');await client.send('DOM.enable');// 获取节点的匹配样式规则const { matchedCSSRules } = await client.send('CSS.getMatchedStylesForNode', { nodeId });// 强制设置元素伪类状态(如 :hover)await client.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: ['hover']});代码覆盖率分析Profiler 域可以采集 JS 和 CSS 的代码覆盖率,量化未使用代码的比例。这是优化首屏加载性能的重要手段:await client.send('Profiler.enable');await client.send('Profiler.startPreciseCoverage', { callCount: true, detailed: true});await page.goto('https://example.com');await page.click('#navigate');const { result } = await client.send('Profiler.takePreciseCoverage');result.forEach(script => { const total = script.functions.reduce((s, f) => s + f.ranges[0].endOffset - f.ranges[0].startOffset, 0); const used = script.functions .filter(f => f.ranges.some(r => r.count > 0)) .reduce((s, f) => s + f.ranges.filter(r => r.count > 0).reduce((ss, r) => ss + r.endOffset - r.startOffset, 0), 0); console.log(`${script.url}: 使用率 ${((used / total) * 100).toFixed(1)}%`);});Puppeteer 也提供了 page.coverage 高层 API(page.coverage.startJSCoverage()),但 CDP 方式能获取更细粒度的调用次数信息,且可以同时采集 JS 和 CSS 覆盖率。Page 域与页面生命周期Page 域提供页面级别的生命周期事件,用于监控导航、加载状态和资源树:await client.send('Page.enable');client.on('Page.loadEventFired', () => { console.log('页面 load 事件触发');});client.on('Page.frameNavigated', ({ frame }) => { console.log('导航至:', frame.url);});client.on('Page.domContentEventFired', () => { console.log('DOM 解析完成');});// 获取页面资源树const { frameTree } = await client.send('Page.getResourceTree');console.log('主框架:', frameTree.frame.url);console.log('子框架数量:', frameTree.childFrames?.length ?? 0);Page 域在多 iframe 场景下特别有用,可以追踪每个子框架的导航状态和资源加载情况。完整实战:自动化性能诊断工具将上述能力组合起来,可以构建一个自动化的页面性能诊断工具:const puppeteer = require('puppeteer');async function diagnose(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); const client = await page.target().createCDPSession(); // 启用所有需要的域 await client.send('Performance.enable'); await client.send('Network.enable'); await client.send('Runtime.enable'); const report = { url, performance: {}, network: { requests: [], summary: {} }, errors: [], memory: {} }; // 1. 采集网络数据 client.on('Network.requestWillBeSent', ({ request, type }) => { report.network.requests.push({ url: request.url, method: request.method, type }); }); client.on('Network.responseReceived', ({ response }) => { if (response.status >= 400) { report.errors.push({ type: 'HTTP', status: response.status, url: response.url }); } }); // 2. 捕获运行时异常 client.on('Runtime.exceptionThrown', ({ exceptionDetails }) => { report.errors.push({ type: 'JS', message: exceptionDetails.exception?.description ?? exceptionDetails.text }); }); // 3. 加载页面 const start = Date.now(); await page.goto(url, { waitUntil: 'networkidle2' }); report.loadTime = Date.now() - start; // 4. 性能指标 const { metrics } = await client.send('Performance.getMetrics'); const map = Object.fromEntries(metrics.map(m => [m.name, m.value])); report.performance = { layoutDuration: map.LayoutDuration, scriptDuration: map.ScriptDuration, domNodes: map.Nodes, jsListeners: map.JSEventListeners }; // 5. 内存使用 const heap = await client.send('Runtime.getHeapUsage'); report.memory = { usedMB: (heap.usedSize / 1024 / 1024).toFixed(2), totalMB: (heap.totalSize / 1024 / 1024).toFixed(2) }; // 6. 网络汇总 report.network.summary = { total: report.network.requests.length, byType: report.network.requests.reduce((acc, r) => { acc[r.type] = (acc[r.type] || 0) + 1; return acc; }, {}) }; // 7. 诊断建议 report.recommendations = []; if (map.LayoutDuration > 0.5) { report.recommendations.push('布局耗时过长,检查是否存在强制同步布局或布局抖动'); } if (map.Nodes > 1500) { report.recommendations.push('DOM 节点数量偏多,考虑虚拟滚动或懒加载'); } if (map.JSEventListeners > 200) { report.recommendations.push('事件监听器数量异常,可能存在未清理的监听器导致内存泄漏'); } if (report.errors.length > 0) { report.recommendations.push(`发现 ${report.errors.length} 个错误,需优先修复`); } await client.send('Performance.disable'); await client.send('Network.disable'); await client.send('Runtime.disable'); await browser.close(); return report;}diagnose('https://example.com').then(r => console.log(JSON.stringify(r, null, 2)));最佳实践与注意事项及时禁用不再使用的域。 每个启用的域都会产生事件流开销,尤其是 Network 和 Runtime 域数据量很大。用完后调用 client.send('XXX.disable') 释放资源。事件监听器需要手动清理。 client.on() 注册的监听器不会随页面导航自动移除,在循环场景下会导致重复监听。使用 client.off() 或 client.removeAllListeners() 清理:const handler = ({ request }) => { /* ... */ };client.on('Network.requestWillBeSent', handler);// 操作完成后client.off('Network.requestWillBeSent', handler);CDP 命令可能抛异常。 浏览器版本差异可能导致某些 CDP 方法不可用,务必 try-catch 包裹并做降级处理。避免在事件回调中发送 CDP 命令。 这可能导致命令顺序错乱。应该将数据收集到队列中,在主流程中批量处理。多页面共享 CDP 会话不可行。 每个 CDP 会话绑定到特定的页面目标,跨页面操作需要为每个页面创建独立的会话。对于多 Tab 场景,可以为每个 browser.newPage() 创建对应的 CDP 会话。生产环境慎用 HeapProfiler.takeHeapSnapshot。 堆快照会暂停主线程,在用户访问期间执行会直接造成页面卡顿。建议在无头模式下的自动化测试中使用,或选择 startSampling 采样方式以减少性能影响。
前端阅读 05月28日 07:10

Puppeteer 如何处理动态网页和单页应用(SPA)?

Puppeteer 在处理动态网页和单页应用(SPA)时拥有天然优势——它运行完整的 Chromium 浏览器,能够执行 JavaScript、等待异步加载完成、捕获路由变化,这些都是传统 HTTP 爬虫无法做到的。但真正写出健壮的 SPA 爬虫,关键在于选择正确的等待策略、合理拦截网络请求、以及处理各种边界情况。等待动态内容加载的正确方式SPA 的核心特征是页面内容由 JavaScript 动态渲染,因此"等待"是 Puppeteer 爬虫的第一要务。三种等待策略各有适用场景:waitForSelector — 等待元素出现最常用的等待方式,适合目标元素有明确选择器的场景:const puppeteer = require('puppeteer');async function scrapeDynamicContent() { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto('https://example.com'); // visible: true 确保元素不仅存在于 DOM,而且可见 await page.waitForSelector('.dynamic-content', { visible: true }); const content = await page.$eval('.dynamic-content', el => el.textContent); await browser.close(); return content;}waitForFunction — 等待自定义条件当等待条件无法用单一选择器表达时,用 waitForFunction 编写判断逻辑:// 等待列表项数量超过阈值await page.waitForFunction(() => { return document.querySelectorAll('.item').length > 10;});// 等待全局状态就绪await page.waitForFunction(() => { return window.__APP_READY__ === true;});waitUntil 选项 — 等待网络状态page.goto 的 waitUntil 参数控制何时认为页面加载完成:domcontentloaded:DOM 解析完成,不等样式和图片load:所有资源加载完毕networkidle0:500ms 内无网络请求(适合纯 API 驱动的页面)networkidle2:500ms 内不超过 2 个网络请求(适合有长连接或分析脚本的页面)// SPA 最常用的加载策略await page.goto('https://example.com', { waitUntil: 'networkidle2' });处理无限滚动与懒加载无限滚动是 SPA 中最常见的加载模式,核心思路是循环滚动并检测新内容是否出现。基础版:检测页面高度变化async function scrapeInfiniteScroll(page, maxItems = 100) { const items = []; let previousHeight = 0; while (items.length < maxItems) { await page.evaluate(() => { window.scrollTo(0, document.body.scrollHeight); }); // 等待新内容渲染,优先等待选择器而非固定时间 try { await page.waitForSelector('.item:last-child', { timeout: 3000 }); } catch { break; // 超时说明没有更多内容 } const currentHeight = await page.evaluate(() => document.body.scrollHeight); if (currentHeight === previousHeight) break; previousHeight = currentHeight; const newItems = await page.$$eval('.item', els => els.map(el => el.textContent.trim()) ); items.push(...newItems); } return [...new Set(items)]; // 去重}进阶版:等待加载指示器消失更可靠的方式是观察"加载中"指示器的出现和消失:async function scrapeInfiniteScrollRobust(page) { const items = []; let noNewItemsCount = 0; while (noNewItemsCount < 3) { const countBefore = items.length; await page.evaluate(() => { window.scrollTo(0, document.body.scrollHeight); }); // 等加载指示器消失 try { await page.waitForSelector('.loading-spinner', { hidden: true, timeout: 5000 }); } catch { noNewItemsCount++; continue; } const currentItems = await page.$$eval('.item', els => els.map(el => ({ id: el.dataset.id, text: el.textContent.trim() })) ); // 只添加新项目 const existingIds = new Set(items.map(i => i.id)); const freshItems = currentItems.filter(i => !existingIds.has(i.id)); if (freshItems.length === 0) { noNewItemsCount++; } else { noNewItemsCount = 0; items.push(...freshItems); } } return items;}处理 SPA 路由变化SPA 的路由切换不会触发页面刷新,URL 变了但浏览器不会重新加载,因此需要主动监听和等待。等待 URL 变化到目标路径async function waitForRoute(page, targetPath, timeout = 10000) { await page.waitForFunction( (path) => window.location.pathname === path, { timeout }, targetPath );}// 使用await page.click('#about-link');await waitForRoute(page, '/about');监听所有路由变化通过 framenavigated 事件捕获 SPA 内的导航:page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) { console.log('路由变化:', frame.url()); }});// 触发导航await page.click('#nav-link');等待 SPA 渲染完成再提取数据路由切换后,新页面的 DOM 还没渲染出来,直接提取会拿到空数据:async function navigateAndExtract(page, linkSelector, contentSelector) { await Promise.all([ page.waitForNavigation({ waitUntil: 'networkidle2' }), page.click(linkSelector), ]); // 路由已切换,等待新内容渲染 await page.waitForSelector(contentSelector, { visible: true }); return page.$eval(contentSelector, el => el.textContent);}拦截和监控网络请求掌握 SPA 的网络请求是高效爬取的关键——你可以直接拿到 API 返回的 JSON 数据,无需解析 DOM。等待特定 API 响应async function waitForAPIResponse(page, urlPattern) { return page.waitForResponse( response => response.url().includes(urlPattern) && response.status() === 200 );}// 点击触发请求,同时等待响应const [response] = await Promise.all([ waitForAPIResponse(page, '/api/data'), page.click('#load-data'),]);const data = await response.json();console.log(data);拦截请求:屏蔽不需要的资源减少不必要的网络请求能显著提升爬取速度:await page.setRequestInterception(true);page.on('request', (request) => { const blockedTypes = ['image', 'font', 'media', 'stylesheet']; if (blockedTypes.includes(request.resourceType())) { request.abort(); } else { request.continue(); }});修改请求:注入认证信息await page.setRequestInterception(true);page.on('request', (request) => { if (request.url().includes('/api/')) { request.continue({ headers: { ...request.headers(), 'Authorization': 'Bearer your-token', }, }); } else { request.continue(); }});注意: setRequestInterception 开启后,所有请求都必须手动调用 continue() 或 abort(),否则请求会挂起。处理 WebSocket 实时数据SPA 中的实时功能(聊天、行情、通知)通常依赖 WebSocket,Puppeteer 可以通过 Chrome DevTools Protocol 监听 WebSocket 消息。const client = await page.createCDPSession();await client.send('Network.enable');// 接收 WebSocket 消息client.on('Network.webSocketFrameReceived', (params) => { console.log('收到:', params.response.payloadData);});// 发送 WebSocket 消息client.on('Network.webSocketFrameSent', (params) => { console.log('发送:', params.response.payloadData);});// WebSocket 关闭client.on('Network.webSocketClosed', () => { console.log('WebSocket 连接已关闭');});这种方式适合监听实时推送数据,比轮询 DOM 更高效。处理 React/Vue 等 SPA 框架不同框架的渲染机制略有差异,但核心思路一致:等待框架渲染完成标志。React 应用async function scrapeReactApp(page, url) { await page.goto(url, { waitUntil: 'networkidle2' }); // React 18+ 使用 createRoot,应用挂载到 root 节点 await page.waitForSelector('#root'); // 等待数据加载(如果框架暴露了全局状态) await page.waitForFunction(() => { return window.__INITIAL_STATE__?.loaded === true; }); // 或直接等待目标元素 await page.waitForSelector('[data-loaded="true"]'); return page.$$eval('.data-item', els => els.map(el => el.textContent.trim()) );}Vue 应用async function scrapeVueApp(page, url) { await page.goto(url, { waitUntil: 'networkidle2' }); // Vue 3 应用挂载到 app 节点 await page.waitForSelector('#app'); // 等待 Vue 组件渲染 await page.waitForFunction(() => { return document.querySelector('.v-cloak') === null; }); return page.content();}通用方案:等待 DOM 稳定如果无法判断框架类型,可以等待 DOM 变化趋于稳定:async function waitForDOMStable(page, checkInterval = 500, stableThreshold = 3) { let lastHTML = ''; let stableCount = 0; while (stableCount < stableThreshold) { const currentHTML = await page.evaluate(() => document.body.innerHTML.length); if (currentHTML === lastHTML) { stableCount++; } else { stableCount = 0; lastHTML = currentHTML; } await new Promise(r => setTimeout(r, checkInterval)); }}实战场景:完整的 SPA 爬虫把上述技巧组合起来,写一个能应对真实 SPA 的爬虫:async function scrapeSPA(url) { const browser = await puppeteer.launch({ headless: 'new' }); const page = await browser.newPage(); // 屏蔽无关资源,加速加载 await page.setRequestInterception(true); page.on('request', (req) => { ['image', 'font', 'media'].includes(req.resourceType()) ? req.abort() : req.continue(); }); await page.goto(url, { waitUntil: 'networkidle2' }); // 收集 API 数据(比解析 DOM 更可靠) const apiData = []; page.on('response', async (response) => { if (response.url().includes('/api/') && response.status() === 200) { try { apiData.push(await response.json()); } catch {} } }); // 处理无限滚动 const allItems = await scrapeInfiniteScroll(page, 50); await browser.close(); return { allItems, apiData };}关键陷阱与应对1. waitForTimeout 已废弃page.waitForTimeout() 在 Puppeteer 21+ 中已移除,用原生 setTimeout 替代:// 旧写法(已废弃)await page.waitForTimeout(2000);// 新写法await new Promise(resolve => setTimeout(resolve, 2000));2. 超时与错误处理SPA 加载时间不确定,所有等待操作都应设置超时并提供降级方案:try { await page.waitForSelector('.content', { timeout: 10000 });} catch { // 降级:尝试其他选择器或返回默认值 const content = await page.evaluate(() => document.querySelector('.fallback-content')?.textContent || '' );}3. SPA 中的内存泄漏长时间运行的爬虫中,事件监听器会累积:// 用完即移除const handler = (response) => { /* ... */ };page.on('response', handler);// 完成后page.off('response', handler);4. 反爬虫检测SPA 站点通常有更复杂的反爬机制:// 伪装浏览器指纹await page.setViewport({ width: 1920, height: 1080 });await page.setUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36');// 注入 stealth 插件const StealthPlugin = require('puppeteer-extra-plugin-stealth');const puppeteerExtra = require('puppeteer-extra');puppeteerExtra.use(StealthPlugin());
前端阅读 05月28日 07:08

Puppeteer 中有哪些等待机制?如何正确使用它们来处理异步操作?

核心答案Puppeteer 提供了 8 种等待机制,按场景分为四类:导航等待:waitForNavigation() — 等待页面跳转完成元素等待:waitForSelector()、waitForXPath() — 等待 DOM 元素出现网络等待:waitForResponse()、waitForRequest() — 等待网络请求或响应自定义等待:waitForFunction()、waitForFrame()、waitForTimeout()(已废弃)选择原则:导航操作用 waitForNavigation,元素操作用 waitForSelector,API 调试用 waitForResponse,复杂条件用 waitForFunction。永远不要用 waitForTimeout 做硬等待,它已被废弃。追问:waitForNavigation 和 click 为什么要用 Promise.all 包裹?因为 click 触发的导航是异步的,如果先 click 再 await waitForNavigation,导航可能在 click 返回前就已经完成了,导致 waitForNavigation 永远等不到。用 Promise.all 让两者同时开始监听,才能确保不丢失导航事件。导航等待:waitForNavigationpage.waitForNavigation() 等待页面发生导航并完成加载,典型场景是点击链接、提交表单。// 正确写法:用 Promise.all 并行等待await Promise.all([ page.waitForNavigation(), page.click('#submit-button')]);waitUntil 参数决定"加载完成"的标准,四个选项适用场景不同:| 选项 | 触发条件 | 适用场景 ||------|---------|---------|| load | window.onload 触发 | 静态页面 || domcontentloaded | DOM 解析完毕 | 只需 DOM 不等资源 || networkidle0 | 500ms 内无网络请求 | SPA 应用,等全部接口返回 || networkidle2 | 500ms 内 ≤2 个网络请求 | 有长连接或轮询的页面 |networkidle2 是实际项目中最常用的选项。很多页面有 WebSocket 长连接或统计上报,用 networkidle0 会永远等不到空闲,networkidle2 允许最多 2 个连接,正好覆盖这种情况。超时时间通过 timeout 参数设置,默认 30 秒。如果页面加载慢,可以调大:await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 60000});元素等待:waitForSelector 与 waitForXPathwaitForSelectorpage.waitForSelector(selector) 等待匹配选择器的元素出现在 DOM 中。这是最常用的等待方法,大多数场景下用它就够了。// 等待元素出现await page.waitForSelector('.result-item');// 等待元素可见(不仅存在于 DOM,还要有尺寸)await page.waitForSelector('.modal', { visible: true });// 等待元素隐藏或消失await page.waitForSelector('.loading-spinner', { hidden: true });visible 和 hidden 的区别需要留意:不用这两个选项时,只要元素在 DOM 中就算满足条件,哪怕 display: none。加了 visible: true 才要求元素实际可见(有非零尺寸)。waitForXPathpage.waitForXPath(xpath) 是 XPath 版本的元素等待,在需要按文本内容或复杂层级关系定位时有用:// 按文本内容定位await page.waitForXPath('//button[contains(text(), "提交")]');// 复杂层级关系await page.waitForXPath('//div[@class="form"]/following-sibling::button');实际项目中 CSS 选择器能覆盖 90% 的场景,waitForXPath 主要用于文本匹配这类选择器不好写的情况。网络等待:waitForResponse 与 waitForRequestwaitForResponsepage.waitForResponse() 等待特定的网络响应返回,在调试接口或等待异步数据加载时非常实用。// 等待特定 URL 的响应const response = await page.waitForResponse( 'https://api.example.com/data');// 用谓词函数做更精确的匹配const response = await page.waitForResponse( res => res.url().includes('/api/users') && res.status() === 200);const data = await response.json();一个常见的使用模式是:触发操作的同时等待对应的接口响应,确保数据已经返回:const [response] = await Promise.all([ page.waitForResponse(res => res.url().includes('/api/search')), page.type('#search-input', 'puppeteer')]);waitForRequestpage.waitForRequest() 等待特定的网络请求发出。和 waitForResponse 的区别是:一个等请求发出,一个等响应回来。// 验证点击按钮后是否发出了正确的请求const request = await page.waitForRequest( req => req.url().includes('/api/track') && req.method() === 'POST');waitForRequest 在验证请求参数、检查埋点是否正确上报时比较常用。自定义等待:waitForFunctionpage.waitForFunction(pageFunction, ...args) 是最灵活的等待方式,可以等待任意 JavaScript 表达式为真。// 等待列表加载超过 5 项await page.waitForFunction( () => document.querySelectorAll('.item').length > 5);// 带参数await page.waitForFunction( (count) => document.querySelectorAll('.item').length >= count, {}, 10);// 等待某个全局变量赋值await page.waitForFunction( () => window.__APP_READY__ === true);// 等待 SPA 路由切换await page.waitForFunction( () => window.location.pathname === '/dashboard');当 waitForSelector 和 waitForResponse 都无法满足需求时(比如等待元素数量变化、等待某个 JS 变量、等待 URL 变化),就用 waitForFunction。waitForFunction 的第二个参数可以传入轮询策略:await page.waitForFunction( () => document.querySelector('.price')?.textContent !== '', { polling: 'mutation' } // DOM 变化时检查,比定时轮询高效);polling 支持 'raf'(每帧检查)、'mutation'(DOM 变化时检查)、数字(毫秒间隔)。DOM 相关等待用 mutation 最合理。waitForFrame:等待 iframe 加载page.waitForFrame() 等待指定的 iframe 加载完成,处理嵌入页面时使用:const frame = await page.waitForFrame('iframe-name');const button = await frame.waitForSelector('.btn');await button.click();多 iframe 场景下,操作前一定要先拿到正确的 frame 对象,再通过 frame 调用 waitForSelector,而不是直接用 page 调用,否则会找不到元素。waitForTimeout:已废弃的硬等待page.waitForTimeout(milliseconds) 等待固定时间,已被官方废弃。如果确实需要延时,用原生方式替代:// 已废弃await page.waitForTimeout(1000);// 替代方案await new Promise(resolve => setTimeout(resolve, 1000));硬等待的问题在于:时间设短了不够等,设长了浪费时间,而且无法适应网络波动。应该尽量用条件等待替代,只有在完全没有条件可判断的极端场景下才考虑延时。常见坑与解决方案坑 1:waitForNavigation 和 click 的竞态这是 Puppeteer 新手最常见的 bug。先 click 再 waitForNavigation,导航可能在 click 返回前就完成了:// 错误写法:可能永远等不到导航await page.click('#link');await page.waitForNavigation();// 正确写法:并行等待await Promise.all([ page.waitForNavigation(), page.click('#link')]);坑 2:元素在 DOM 中但不可见waitForSelector 默认只检查元素是否在 DOM 中,不关心是否可见。如果页面有 display: none 或 visibility: hidden 的元素,不加 visible: true 可能拿到不可操作的元素:// 可能拿到隐藏元素await page.waitForSelector('.dropdown-menu');// 确保元素可见await page.waitForSelector('.dropdown-menu', { visible: true });坑 3:SPA 页面导航不会触发 waitForNavigationSPA 内部的路由切换(比如 React Router 或 Vue Router)不会触发浏览器级别的导航事件,waitForNavigation 不会触发。这种场景要用 waitForFunction 等待 URL 变化或特定元素出现:// SPA 路由切换不能用 waitForNavigationawait Promise.all([ page.waitForFunction(() => window.location.pathname === '/profile'), page.click('.nav-profile')]);坑 4:networkidle0 在有长连接的页面上永远等不到如果页面有 WebSocket 或 SSE 连接,网络请求永远不会归零,networkidle0 会超时。改用 networkidle2:// 有长连接的页面await page.goto(url, { waitUntil: 'networkidle2' });超时处理所有等待方法都支持 timeout 参数,默认 30 秒。可以在页面级别设置默认超时:page.setDefaultTimeout(10000); // 全局默认 10 秒// 也可以在单次调用中覆盖await page.waitForSelector('.element', { timeout: 5000 });超时会抛出 TimeoutError,用 try/catch 捕获后可以做降级处理:try { await page.waitForSelector('.optional-banner', { timeout: 3000 }); // banner 出现了,关闭它 await page.click('.banner-close');} catch (error) { // banner 没出现,继续执行}这种"可选元素"的等待模式在实际项目中很常用:元素可能出现也可能不出现,出现了就处理,没出现也不影响主流程。方法选择速查| 场景 | 方法 | 示例 ||------|------|------|| 点击后页面跳转 | waitForNavigation | await Promise.all([page.waitForNavigation(), page.click('#link')]) || 等待动态元素出现 | waitForSelector | await page.waitForSelector('.item', { visible: true }) || 等待接口返回数据 | waitForResponse | await page.waitForResponse(res => res.url().includes('/api/data')) || 等待复杂条件满足 | waitForFunction | await page.waitForFunction(() => document.querySelectorAll('.item').length > 5) || 等待 iframe 加载 | waitForFrame | await page.waitForFrame('iframe-name') || SPA 路由切换 | waitForFunction | await page.waitForFunction(() => location.pathname === '/new') || 按文本定位元素 | waitForXPath | await page.waitForXPath('//button[contains(text(),"确认")]') || 验证请求发出 | waitForRequest | await page.waitForRequest(req => req.url().includes('/track')) |Puppeteer 的等待机制核心思路就是用条件等待替代硬编码延时。选对方法、处理好事物的并行和竞态关系,脚本才能既稳定又高效。遇到问题先判断是导航、元素、网络还是自定义条件,然后对号入座选方法,大部分不稳定用例都能解决。
前端阅读 05月28日 07:08

Puppeteer 和 Selenium 有什么区别?

核心结论Puppeteer 和 Selenium 的根本区别在于通信协议:Puppeteer 基于 Chrome DevTools Protocol (CDP) 直接与浏览器内核通信,而 Selenium 基于 WebDriver 协议通过中间驱动层间接控制浏览器。这决定了两者在性能、能力和适用场景上的所有差异。简单选择标准: 只需要操作 Chrome 且追求性能 → Puppeteer;需要跨浏览器或企业级测试 → Selenium。通信协议的本质差异这是理解两者所有区别的钥匙。CDP(Puppeteer): 通过 WebSocket 直接连接浏览器的调试端口,指令直达渲染进程。没有中间层翻译,所以快。代价是只能控制实现了 CDP 的浏览器,实际上就是 Chrome/Chromium。WebDriver(Selenium): 测试脚本 → WebDriver 客户端 → WebDriver 服务器(如 chromedriver)→ 浏览器。每一层都是一次进程间通信,不可避免地引入延迟。好处是 WebDriver 是 W3C 标准协议,任何浏览器只要实现 WebDriver 接口就能被 Selenium 控制。// Puppeteer:直接通信,一步到位const browser = await puppeteer.launch();const page = await browser.newPage();await page.goto('https://example.com');// Selenium:经过驱动层中转const driver = await new Builder().forBrowser('chrome').build();await driver.get('https://example.com'); // 命令经 chromedriver 转发什么时候选择 Puppeteer场景一:网页爬虫和数据抓取Puppeteer 的网络拦截能力是爬虫场景的核心武器。可以在请求层面直接屏蔽图片、字体等无关资源,大幅提升抓取速度。Selenium 没有原生的请求拦截能力,只能依赖第三方代理。await page.setRequestInterception(true);page.on('request', request => { const blocked = ['image', 'font', 'stylesheet']; blocked.includes(request.resourceType()) ? request.abort() : request.continue();});场景二:性能监控和页面指标采集通过 CDP 可以直接读取浏览器内核的性能数据(LCP、FID、CLS 等),这是 Selenium 无法做到的。Chrome DevTools 的 Performance 面板能看到的指标,Puppeteer 都能程序化获取。场景三:截图和 PDF 生成Puppeteer 的截图 API 支持全页截图、指定元素截图、自定义视口。PDF 生成直接调用 Chrome 的打印引擎,排版效果与浏览器打印预览一致。Selenium 的截图功能相对基础,不支持 PDF 生成。场景四:设备模拟和地理位置// 一行代码模拟 iPhone 12await page.emulate(puppeteer.devices['iPhone 12']);// 设置地理位置await page.setGeolocation({ latitude: 35.6895, longitude: 139.6917 });什么时候选择 Selenium场景一:跨浏览器兼容性测试这是 Selenium 最不可替代的能力。如果你的产品需要保证在 Safari、Firefox、Edge 上都能正常运行,Selenium 是唯一成熟的选择。Puppeteer 对非 Chromium 浏览器的支持非常有限。场景二:多语言技术栈Selenium 支持 Java、Python、C#、Ruby、JavaScript 等主流语言。后端团队用 Java 写测试、数据团队用 Python 写测试、前端团队用 JavaScript 写测试,都能统一在 Selenium 体系下。Puppeteer 只支持 Node.js。场景三:企业级分布式测试Selenium Grid 支持在多台机器上并行运行测试,结合 Docker 可以快速搭建大规模测试集群。Puppeteer 本身没有分布式能力,需要借助第三方工具。场景四:移动端测试通过 Appium(基于 WebDriver 协议),Selenium 生态可以覆盖原生移动应用测试。Puppeteer 只能测试移动端网页,无法触及原生层。性能对比的根因分析不是"Puppeteer 更快"这么简单。快在哪里?启动速度: Puppeteer 自带 Chromium,无需额外下载驱动;Selenium 需要匹配浏览器版本下载对应驱动,版本不匹配是常见报错来源指令执行: CDP 单次指令延迟 <10ms,WebDriver 经驱动中转延迟 50-200ms内存占用: Puppeteer 可通过 page.setRequestInterception 屏蔽无关资源,减少内存消耗;Selenium 无法在请求层做控制但 Selenium 在 4.x 版本引入了 CDP 支持(SeV4CDP),部分缩小了性能差距。不过 CDP 功能在 Selenium 中属于实验性特性,稳定性和 API 完整度不如 Puppeteer。2026 年的新变量:Playwright讨论 Puppeteer vs Selenium 不能忽略 Playwright。微软推出的 Playwright 同时支持 Chromium、Firefox 和 WebKit,API 设计比 Puppeteer 更现代(自动等待、多页面上下文),性能接近 Puppeteer。如果你正在做技术选型,决策逻辑应该是:只需要 Chrome → Puppeteer需要多浏览器 + 全新项目 → 优先考虑 Playwright已有 Selenium 基础设施 / 需要 Java 等非 JS 语言 → Selenium追问:Puppeteer 能用来做自动化测试吗?可以,但要认清局限。Puppeteer 本质是浏览器控制库,不是测试框架。它没有内置的断言库、测试运行器、用例管理。实际项目中通常配合 Jest 或 Mocha 使用。与 Selenium 作为测试框架的定位不同,Puppeteer 更适合作为工具链中的一环——爬虫用它抓数据,CI 用它做冒烟测试,监控系统用它采集性能指标。如果你需要的是一套完整的端到端测试方案,Selenium + 测试框架的组合更成熟;如果只需要轻量级的浏览器控制能力,Puppeteer 更灵活。反过来,如果你的爬虫需要绕过反爬检测,Puppeteer 需要配合 stealth 插件隐藏自动化特征,而 Selenium 同样需要类似的反检测处理。两个工具在反爬场景下的表现差异不大,关键在于如何模拟真实用户行为。
前端阅读 05月28日 07:07

Puppeteer 在实际项目中怎么用?

Puppeteer 是 Google 维护的 Node.js 浏览器自动化库,通过 Chrome DevTools Protocol 控制无头浏览器。它的实际应用远不止"跑个脚本打开网页",在爬虫、测试、文档生成、性能监控等场景中都是生产级方案。核心应用场景一览| 场景 | 典型用途 | 复杂度 ||------|---------|--------|| 网页爬虫 | SPA 数据采集、价格监控 | 中 || 自动化测试 | E2E 测试、视觉回归 | 中高 || PDF 生成 | 报表、发票批量输出 | 低 || 性能监控 | 页面加载分析、Core Web Vitals | 中 || SEO 审计 | 页面结构检查、可访问性扫描 | 低 || 自动化运维 | 表单批量填写、数据录入 | 中 |下面逐个场景拆解关键实现和踩坑要点。网页爬虫:SPA 和动态内容的克星传统爬虫(requests/axios)面对 Vue、React 渲染的页面基本无能为力,因为拿到的 HTML 只是空壳。Puppeteer 的优势在于它能等 JavaScript 执行完毕再提取数据。价格监控是最常见的爬虫场景。核心逻辑:启动浏览器 → 设置 User-Agent 伪装 → 等待目标元素出现 → 提取数据。一段精简实现:const puppeteer = require('puppeteer');async function monitorPrice(url) { const browser = await puppeteer.launch({ headless: 'new' }); const page = await browser.newPage(); await page.setUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' ); await page.goto(url, { waitUntil: 'networkidle2' }); await page.waitForSelector('.price', { timeout: 10000 }); const data = await page.evaluate(() => ({ title: document.querySelector('.product-title')?.textContent, price: document.querySelector('.price')?.textContent, })); await browser.close(); return data;}踩坑经验: networkidle2 不等于页面完全加载。如果目标元素是懒加载的,建议用 waitForSelector 配合超时做二次保障。另外,大批量采集时务必控制并发数,同时打开 20 个标签页会直接把内存撑爆。反爬虫要点: 裸跑 Puppeteer 会被大多数反爬系统识别——navigator.webdriver 属性默认为 true,WebGL 指纹也暴露无头浏览器特征。生产环境中需要配合 puppeteer-extra-plugin-stealth 插件修补这些泄露点,或者使用代理池轮换 IP。自动化测试:E2E 与视觉回归Puppeteer 在测试领域有两个典型用法:端到端流程测试——模拟用户完整操作路径,验证业务逻辑正确性。比如注册-登录-下单流程,每个步骤的页面跳转和状态变化都能断言。关键技巧是用 Promise.all 包裹点击和等待导航,避免竞态条件:await Promise.all([ page.waitForNavigation(), page.click('#submit-button'),]);视觉回归测试——截取页面快照与基线图对比,像素级检测 UI 变更。核心依赖 pixelmatch 库做图片 diff,差异超过阈值(通常 0.5%)即判定为回归。实际项目中建议把视觉回归集成到 CI 流程,每次提交自动跑一遍。注意截图的稳定性:字体渲染、动画状态、抗锯齿差异都可能产生误报。解决方法是截图前等动画完成,并用固定视口宽度。PDF 生成:报表和发票的批量引擎服务端生成 PDF 是个老大难问题。用 PDFKit 手动排版太痛苦,用 wkhtmltopdf 中文渲染经常出问题。Puppeteer 的方案最直接:渲染 HTML → 调用 page.pdf() 输出。await page.setContent(htmlContent);await page.pdf({ path: 'report.pdf', format: 'A4', printBackground: true, margin: { top: '20px', bottom: '20px', left: '20px', right: '20px' },});批量生成发票时,不要每个发票都启停浏览器。用一个浏览器实例复用 Page,速度能提升 5-10 倍。但要注意内存泄漏——每次 setContent 后如果页面越来越慢,说明需要定期 page.close() 再开新 Page。性能监控:比 Lighthouse 更灵活Lighthouse 适合一次性审计,但线上持续监控需要自定义方案。Puppeteer 可以精确采集每个页面的 FCP、LCP、DOM 节点数等指标,写入时序数据库做趋势分析。const client = await page.target().createCDPSession();await client.send('Performance.enable');await page.goto(url, { waitUntil: 'networkidle2' });const fcp = await page.evaluate(() => performance.getEntriesByType('paint') .find(e => e.name === 'first-contentful-paint')?.startTime);通过 CDP Session 还能拦截网络请求、监控 JS 堆内存变化,这些是 Lighthouse 做不到的细粒度采集。SEO 审计:自动化页面健康检查Puppeteer 可以批量扫描网站的 SEO 问题:缺少 title 标签、meta description 过长、H1 缺失或重复、图片缺少 alt 属性等。核心是 page.evaluate 在页面上下文中执行 DOM 查询,把结果结构化返回。相比纯 HTTP 请求的方式,Puppeteer 能检查 JS 渲染后的真实 DOM,对 SPA 应用尤其重要——很多 SPA 的 SEO 问题只有运行后才能发现。请求拦截与资源优化这是一个跨场景的通用技巧。通过拦截请求可以大幅降低资源消耗:await page.setRequestInterception(true);page.on('request', (req) => { const blocked = ['image', 'font', 'stylesheet']; blocked.includes(req.resourceType()) ? req.abort() : req.continue();});爬虫场景下屏蔽图片和字体能提速 40% 以上;测试场景下可以 mock 接口返回,实现更可控的测试环境。面试高频追问Q: Puppeteer 和 Playwright 怎么选?Puppeteer 只支持 Chromium,API 简洁,适合 Chrome 专属场景。Playwright 支持三浏览器(Chromium/Firefox/WebKit),自动等待机制更智能,新增项目推荐 Playwright。但 Puppeteer 生态更成熟,puppeteer-extra 插件体系(stealth、recaptcha)在爬虫场景无可替代。选型看需求:爬虫偏 Puppeteer,跨浏览器测试偏 Playwright。Q: 无头浏览器如何降低被检测概率?三层防护:第一层用 stealth 插件修补 navigator.webdriver、Chrome 对象等指纹;第二层用 ghost-cursor 模拟真人鼠标轨迹,避免点击坐标过于精确;第三层用代理池轮换 IP 和 User-Agent,避免单 IP 高频请求触发风控。没有银弹,三层全上才能通过中高级反爬。Q: Puppeteer 采集任务如何稳定运行在生产环境?三个关键点:一是进程管理,用 puppeteer.connect 连接常驻浏览器实例而非每次启动,配合 pm2 做进程守护;二是内存控制,每处理 50 个页面重启一次浏览器,防止内存泄漏积累;三是错误恢复,page.on('error') 监听页面崩溃,browser.on('disconnected') 监听浏览器断连,两者都要有自动重连逻辑。Puppeteer 的应用边界还在扩展——AI Agent 的浏览器操作层、RPA 流程自动化、竞品数据监控,都是 2026 年依然活跃的场景。掌握核心 API 再结合上述实战经验,基本能覆盖日常开发中 90% 的浏览器自动化需求。
前端阅读 05月28日 07:00

如何在 Jest 中测试 React 组件?常用的测试工具和查询方法有哪些?

在 Jest 中测试 React 组件,核心思路是:渲染组件 → 查询元素 → 断言行为。React 官方推荐的测试方案是 Jest + React Testing Library(RTL),本文聚焦面试中高频考察的知识点。React 组件测试的基本流程是什么?测试 React 组件通常分三步:渲染:使用 RTL 的 render 方法将组件挂载到虚拟 DOM查询:通过 screen 对象提供的方法定位页面元素断言:使用 Jest 的 expect 验证元素状态或行为import { render, screen } from '@testing-library/react';import Counter from './Counter';test('counter displays initial value', () => { render(<Counter initialCount={0} />); expect(screen.getByText('Count: 0')).toBeInTheDocument();});查询方法的优先级怎么选?RTL 的查询方法有三个前缀,区别在于元素不存在时的行为:| 前缀 | 元素存在 | 元素不存在 | 适用场景 ||------|---------|-----------|---------|| getBy* | 返回元素 | 抛出错误 | 断言元素一定存在 || queryBy* | 返回元素 | 返回 null | 断言元素不存在 || findBy* | 返回 Promise | Promise reject | 异步元素出现 |具体查询方法的推荐优先级:getByRole — 最优先,基于 ARIA 角色,如 button、textbox、headinggetByLabelText — 表单元素优先用,关联 label 文本getByPlaceholderText — 没有 label 时使用getByText — 非表单元素(按钮、链接、段落)常用getByTestId — 最后手段,需要手动添加 data-testid 属性// 推荐:通过角色查询screen.getByRole('button', { name: /submit/i });// 不推荐但有时必要:通过 testId 查询screen.getByTestId('submit-btn');面试关键点:优先使用 getByRole 是因为它验证了组件的可访问性,这与 RTL "测试用户视角" 的核心理念一致。如何测试用户交互?使用 fireEvent 或 userEvent 模拟用户操作。userEvent 更接近真实用户行为,推荐优先使用。import { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';test('clicking button increments counter', async () => { const user = userEvent.setup(); render(<Counter initialCount={0} />); await user.click(screen.getByRole('button', { name: /increment/i })); expect(screen.getByText('Count: 1')).toBeInTheDocument();});fireEvent 与 userEvent 的区别:fireEvent.click() 只触发 click 事件userEvent.click() 会依次触发 mousedown → mouseup → focus → click,更贴近真实操作userEvent.type() 会逐字符触发键盘事件,而 fireEvent.change() 直接修改值异步组件怎么测试?异步场景(接口请求、定时器、状态延迟更新)使用 waitFor 或 findBy* 处理。import { render, screen, waitFor } from '@testing-library/react';test('displays user data after loading', async () => { render(<UserProfile userId={1} />); // 方式一:findBy(推荐,更简洁) expect(await screen.findByText('John')).toBeInTheDocument(); // 方式二:waitFor(更灵活,可组合多个断言) await waitFor(() => { expect(screen.getByText('John')).toBeInTheDocument(); expect(screen.getByText('john@example.com')).toBeInTheDocument(); });});常见坑:waitFor 中不要用 queryBy*,因为它不抛错,断言不会失败,导致测试误通过。应使用 getBy*。如何 Mock 模块和 API 请求?面试中常考的 Mock 手段分两种:Jest.fn() — Mock 函数test('calls onSubmit with form data', async () => { const onSubmit = jest.fn(); const user = userEvent.setup(); render(<LoginForm onSubmit={onSubmit} />); await user.type(screen.getByLabelText(/email/i), 'test@example.com'); await user.click(screen.getByRole('button', { name: /login/i })); expect(onSubmit).toHaveBeenCalledWith({ email: 'test@example.com' });});jest.mock — Mock 模块// Mock API 请求模块jest.mock('../api', () => ({ fetchUser: jest.fn().mockResolvedValue({ name: 'John' })}));test('renders fetched user name', async () => { render(<UserProfile />); expect(await screen.findByText('John')).toBeInTheDocument();});对于更复杂的 API Mock 场景,可以使用 Mock Service Worker(MSW),它在 Service Worker 层拦截请求,不需要修改业务代码。React Hooks 怎么测试?自定义 Hook 使用 renderHook 进行测试:import { renderHook, act } from '@testing-library/react';import { useCounter } from './useCounter';test('useCounter increments and decrements', () => { const { result } = renderHook(() => useCounter(0)); expect(result.current.count).toBe(0); act(() => { result.current.increment(); }); expect(result.current.count).toBe(1); act(() => { result.current.decrement(); }); expect(result.current.count).toBe(0);});注意:状态更新必须包裹在 act() 中,否则 Jest 会报警告。renderHook 已从 RTL v13 起内置,不再需要 @testing-library/react-hooks 包。快照测试怎么用?什么场景下用?import renderer from 'react-test-renderer';test('Button matches snapshot', () => { const tree = renderer.create(<Button>Click</Button>).toJSON(); expect(tree).toMatchSnapshot();});快照测试的适用与不适用:适合:配置型组件(Theme、Layout),结构稳定的纯展示组件不适合:频繁变动的业务组件,否则每次改动都要更新快照,失去测试价值面试加分点:快照测试只是确认结构没变,并不验证行为是否正确,所以不能替代行为测试。测试 React 组件有哪些最佳实践?测试行为,不测实现 — 不测内部 state 的值,测用户看到的结果避免过度 Mock — Mock 越多,测试离真实场景越远查询方法按优先级选 — getByRole > getByLabelText > getByText > getByTestId异步用 findBy 优于 waitFor + getBy — 更简洁,语义更清晰使用 screen 而非 render 返回值 — 避免反复解构,代码更干净一个测试只验证一个行为 — 方便定位失败原因面试追问方向:如何测试 Context Provider 包裹的组件?如何处理第三方库的渲染行为?如何在 CI 中提升测试执行速度?这些是区分中级与高级的关键问题。
前端阅读 05月28日 06:59

如何在 Jest 中 Mock fetch 和 Axios 测试 API 调用?

核心思路测试 API 调用的关键原则是隔离外部依赖——不发出真实网络请求,用 Mock 替代,验证的是"你的代码如何调用 API、如何处理响应",而非 API 本身的行为。Jest 提供了三种主要 Mock 手段:jest.mock() 模块级替换、jest.spyOn() 方法级监听、jest.fn() 手动创建假函数。理解三者的区别和适用场景,是这道题的答题主线。Mock Axios 的两种方式方式一:jest.mock() 替换整个模块jest.mock('axios') 会将 axios 模块中所有导出替换为 jest.fn(),适合需要完全控制模块行为的场景:import axios from 'axios';import { getUser } from './api';jest.mock('axios');test('getUser 应返回用户数据', async () => { const mockData = { id: 1, name: 'Tom' }; axios.get.mockResolvedValue({ data: mockData }); const result = await getUser(1); expect(result).toEqual(mockData); expect(axios.get).toHaveBeenCalledWith('/users/1');});mockResolvedValue 让 axios.get 返回一个 resolved Promise,模拟成功响应。toHaveBeenCalledWith 断言调用参数,确保请求地址正确。方式二:jest.spyOn() 监听原方法jest.spyOn 不替换模块,而是包装原方法,可以追踪调用并控制返回值,还能通过 mockRestore() 恢复原实现:import axios from 'axios';import { getUser } from './api';test('getUser 应返回用户数据', async () => { const spy = jest.spyOn(axios, 'get').mockResolvedValue({ data: { id: 1, name: 'Tom' } }); const result = await getUser(1); expect(result).toEqual({ id: 1, name: 'Tom' }); spy.mockRestore(); // 恢复 axios.get 原实现});何时选哪个? jest.mock() 适合整个测试文件都需要 mock 的场景;jest.spyOn() 适合只想在单个测试中临时 mock、其余测试保留真实行为的场景。Mock fetch 的两种方式方式一:jest.fn() 替换全局 fetchfetch 是全局对象上的方法,直接赋值即可替换:import { fetchPosts } from './api';beforeEach(() => { global.fetch = jest.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve([{ id: 1, title: 'Hello' }]), }) );});afterEach(() => { jest.restoreAllMocks();});test('fetchPosts 应返回帖子列表', async () => { const posts = await fetchPosts(); expect(posts).toEqual([{ id: 1, title: 'Hello' }]); expect(global.fetch).toHaveBeenCalledWith('/api/posts');});这里用 beforeEach / afterEach 管理 Mock 生命周期,避免测试间互相污染——这是面试中经常追问的考点。方式二:jest.spyOn() 监听全局 fetchtest('fetchPosts 处理响应数据', async () => { jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, json: () => Promise.resolve([{ id: 1 }]), }); const posts = await fetchPosts(); expect(posts).toEqual([{ id: 1 }]);});测试错误场景只测成功路径是不够的,面试官一定会问"网络请求失败了怎么办":test('getUser 应抛出网络错误', async () => { axios.get.mockRejectedValue(new Error('Network Error')); await expect(getUser(1)).rejects.toThrow('Network Error');});test('getUser 应处理 404 响应', async () => { axios.get.mockRejectedValue({ response: { status: 404, data: { message: 'Not Found' } }, }); await expect(getUser(999)).rejects.toMatchObject({ response: { status: 404 }, });});mockRejectedValue 模拟 Promise reject,覆盖网络异常和服务端错误两种情况。使用 MSW 做更真实的拦截当项目有大量 API 需要测试时,逐个 jest.mock 维护成本高。MSW(Mock Service Worker)在网络层拦截请求,不需要修改业务代码:import { rest } from 'msw';import { setupServer } from 'msw/node';const server = setupServer( rest.get('/api/users/:id', (req, res, ctx) => { return res(ctx.json({ id: req.params.id, name: 'Tom' })); }));beforeAll(() => server.listen());afterEach(() => server.resetHandlers());afterAll(() => server.close());test('getUser 通过 MSW 返回数据', async () => { const user = await getUser(1); expect(user).toEqual({ id: '1', name: 'Tom' });});MSW 的优势:可以在运行时动态修改响应(server.use()),测试超时、限流等边界场景;同一套 handler 可复用于单元测试和集成测试。关键差异速查| 场景 | 推荐方案 | 原因 ||------|---------|------|| Mock 整个第三方库 | jest.mock() | 一键替换所有导出 || 单个测试临时 Mock | jest.spyOn() | 可恢复,不影响其他测试 || Mock 全局 API(fetch) | jest.fn() / spyOn | fetch 是全局变量,需手动处理 || 大量 API 集成测试 | MSW | 网络层拦截,维护成本低 |面试追问方向jest.mock 和 jest.spyOn 的本质区别? mock 是替换,spyOn 是包装。mock 后原实现丢失,spyOn 可恢复。为什么要避免测试中发出真实请求? 网络不稳定、速度慢、可能产生脏数据、依赖外部服务可用性。Mock 污染怎么解决? beforeEach 重置、afterEach 调用 jest.restoreAllMocks()、每个测试独立设置数据。如何测试请求重试逻辑? 用 mockRejectedValueOnce 连续返回失败,最后一次返回成功,模拟重试后恢复。
前端阅读 05月28日 06:53

Puppeteer 如何与测试框架集成实现 E2E 和 CI/CD?

Puppeteer 可以与 Jest、Mocha、Vitest 等主流测试框架深度集成,完成端到端测试、视觉回归测试和性能测试,再通过 GitHub Actions、Docker 等工具接入 CI/CD 流水线。以下是生产环境中经过验证的集成方式和最佳实践。与 Jest 集成Jest 是与 Puppeteer 搭配最多的测试框架,jest-puppeteer 提供了开箱即用的预设配置。安装核心依赖:npm install --save-dev puppeteer jest jest-puppeteer @types/puppeteerjest-puppeteer 的配置文件:// jest-puppeteer.config.jsmodule.exports = { launch: { headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'], }, browserContext: 'incognito', exitOnPageError: true,};Jest 配置文件:// jest.config.jsmodule.exports = { preset: 'jest-puppeteer', testMatch: ['**/e2e/**/*.test.js'], setupFilesAfterEnv: ['./e2e/setup.js'], testTimeout: 30000,};setup 文件中处理每个测试的前置条件:// e2e/setup.jsbeforeEach(async () => { await page.setViewport({ width: 1280, height: 720 }); await page.goto('http://localhost:3000', { waitUntil: 'networkidle0' });});编写一个完整的登录 E2E 测试:// e2e/auth.test.jsdescribe('用户登录流程', () => { test('使用正确的凭据登录成功', async () => { await page.type('[data-testid="username"]', 'testuser'); await page.type('[data-testid="password"]', 'password123'); await page.click('[data-testid="login-btn"]'); await page.waitForSelector('[data-testid="dashboard"]'); const welcome = await page.$eval( '[data-testid="welcome-msg"]', el => el.textContent ); expect(welcome).toContain('欢迎回来'); }); test('使用错误密码登录失败', async () => { await page.type('[data-testid="username"]', 'testuser'); await page.type('[data-testid="password"]', 'wrongpassword'); await page.click('[data-testid="login-btn"]'); await page.waitForSelector('[data-testid="error-msg"]'); const error = await page.$eval( '[data-testid="error-msg"]', el => el.textContent ); expect(error).toContain('用户名或密码错误'); });});注意这里使用 data-testid 选择器而非 CSS 类名或 ID,这能让测试不依赖 UI 样式变更,提升稳定性。与 Mocha 集成Mocha 的灵活性更高,适合需要自定义测试生命周期的团队。Puppeteer 的浏览器生命周期需要手动管理。// test/setup.jsconst puppeteer = require('puppeteer');const { expect } = require('chai');let browser;before(async () => { browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'], });});after(async () => { await browser.close();});beforeEach(async function () { this.page = await browser.newPage(); await this.page.goto('http://localhost:3000');});afterEach(async function () { await this.page.close();});Mocha 测试用例:// test/user.spec.jsdescribe('用户注册功能', function () { this.timeout(15000); it('填写完整信息后注册成功', async function () { const { page } = this; await page.click('[data-testid="register-link"]'); await page.type('[data-testid="reg-username"]', 'newuser'); await page.type('[data-testid="reg-email"]', 'new@example.com'); await page.type('[data-testid="reg-password"]', 'StrongP@ss1'); await page.click('[data-testid="reg-submit"]'); await page.waitForSelector('[data-testid="reg-success"]'); const text = await page.$eval( '[data-testid="reg-success"]', el => el.textContent ); expect(text).to.include('注册成功'); });});Mocha 的 this.timeout() 需要显式设置,Puppeteer 测试通常需要 10-30 秒的超时时间,不要使用箭头函数,否则无法访问 Mocha 的 this 上下文。Page Object 模式无论使用 Jest 还是 Mocha,当测试用例超过 20 个时,必须引入 Page Object 模式。它把页面元素定位和操作封装成独立类,测试用例只关心业务逻辑。// pages/LoginPage.jsclass LoginPage { constructor(page) { this.page = page; this.usernameInput = '[data-testid="username"]'; this.passwordInput = '[data-testid="password"]'; this.submitBtn = '[data-testid="login-btn"]'; this.errorMessage = '[data-testid="error-msg"]'; } async login(username, password) { await this.page.type(this.usernameInput, username); await this.page.type(this.passwordInput, password); await this.page.click(this.submitBtn); } async getErrorMessage() { await this.page.waitForSelector(this.errorMessage); return this.page.$eval(this.errorMessage, el => el.textContent); }}module.exports = LoginPage;测试用例中使用 Page Object:const LoginPage = require('../pages/LoginPage');test('登录失败显示错误提示', async () => { const loginPage = new LoginPage(page); await loginPage.login('testuser', 'wrongpass'); const error = await loginPage.getErrorMessage(); expect(error).toContain('用户名或密码错误');});如果 UI 改了 data-testid 的值,只需修改 LoginPage 一处,所有引用它的测试自动更新。这就是 Page Object 的核心价值。视觉回归测试视觉回归测试能捕获 CSS 改动导致的 UI 偏移,Puppeteer 结合 Percy 或 jest-image-snapshot 可以自动完成截图对比。使用 jest-image-snapshot 的方式:npm install --save-dev jest-image-snapshotconst { toMatchImageSnapshot } = require('jest-image-snapshot');expect.extend({ toMatchImageSnapshot });test('首页视觉一致性', async () => { await page.goto('http://localhost:3000'); await page.waitForSelector('#main-content'); const screenshot = await page.screenshot({ fullPage: true }); expect(screenshot).toMatchImageSnapshot({ failureThreshold: 0.03, failureThresholdType: 'percent', });});failureThreshold 设为 3% 是比较合理的起点,太严格会导致大量误报,太宽松又漏掉真正的 UI 变化。首次运行会生成基准截图,后续运行自动对比。性能测试Puppeteer 可以通过 Chrome DevTools Protocol 采集性能指标,结合 Lighthouse 做更全面的审计。const puppeteer = require('puppeteer');test('首页核心性能指标', async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto('http://localhost:3000', { waitUntil: 'networkidle0' }); // 采集 Core Web Vitals const metrics = await page.evaluate(() => { return new Promise(resolve => { new PerformanceObserver(list => { const entries = list.getEntries(); const lastEntry = entries[entries.length - 1]; resolve({ LCP: lastEntry.startTime, FID: 0, CLS: 0, }); }).observe({ type: 'largest-contentful-paint', buffered: true }); }); }); expect(metrics.LCP).toBeLessThan(2500); await browser.close();});性能测试中 waitUntil: 'networkidle0' 很关键,确保页面资源加载完成后再采集数据。CI/CD 集成CI/CD 是 Puppeteer 测试从本地走向生产的关键环节。主要解决三个问题:浏览器安装、无头模式运行、测试稳定性。GitHub Actions 配置:name: E2E Testson: [push, pull_request]jobs: e2e: runs-on: ubuntu-latest container: image: node:18-slim steps: - uses: actions/checkout@v4 - name: Install Chrome dependencies run: | apt-get update apt-get install -y chromium libx11-xcb1 libxcomposite1 libxdamage1 libxi6 libxtst6 libnss3 libatk1.0-0 - name: Install dependencies run: npm ci - name: Run E2E tests run: npm run test:e2e env: CI: true PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromiumDocker 配置:FROM node:18-slimRUN apt-get update && apt-get install -y \ chromium \ --no-install-recommends && \ rm -rf /var/lib/apt/lists/*ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=trueENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromiumWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .CMD ["npm", "run", "test:e2e"]Docker 环境下必须设置 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true,使用系统安装的 Chromium,避免 Puppeteer 自带浏览器在容器中启动失败。--no-sandbox 参数在 Docker 中也是必需的,因为容器默认以 root 运行,Chrome 要求沙箱模式下不能是 root。测试稳定性实践Puppeteer 测试在 CI 环境中失败率较高,以下是提升稳定性的关键手段。等待策略:永远不要使用 waitForTimeout,改用显式等待。// 错误做法:硬编码等待await page.waitForTimeout(3000);// 正确做法:等待元素可见await page.waitForSelector('[data-testid="result"]', { visible: true });// 等待网络空闲await page.waitForNavigation({ waitUntil: 'networkidle2' });// 等待特定请求完成await page.waitForResponse( resp => resp.url().includes('/api/user') && resp.status() === 200);测试隔离:每个测试用例使用独立的浏览器上下文,避免 Cookie 和 Storage 污染。beforeEach(async () => { context = await browser.createIncognitoBrowserContext(); page = await context.newPage(); await page.goto('http://localhost:3000');});afterEach(async () => { await context.close();});失败截图:测试失败时自动保存截图,方便排查 CI 中的问题。afterEach(async function () { if (this.currentTest.state === 'failed') { const timestamp = Date.now(); const testName = this.currentTest.title.replace(/\s+/g, '_'); await page.screenshot({ path: `screenshots/${testName}_${timestamp}.png`, fullPage: true, }); }});重试机制:CI 环境中网络和资源加载不稳定,给 E2E 测试加一层重试。// jest.config.jsmodule.exports = { preset: 'jest-puppeteer', retryTimes: 2,};测试分层与并行执行当测试规模增长后,需要按速度和稳定性分层运行,并利用并行加速。// jest.config.jsmodule.exports = { preset: 'jest-puppeteer', projects: [ { displayName: 'smoke', testMatch: ['**/e2e/smoke/**/*.test.js'], retryTimes: 1, }, { displayName: 'critical', testMatch: ['**/e2e/critical/**/*.test.js'], retryTimes: 2, }, { displayName: 'full', testMatch: ['**/e2e/full/**/*.test.js'], retryTimes: 2, maxWorkers: 4, }, ],};smoke 测试只覆盖核心路径(登录、关键业务流程),在每次提交时运行;critical 测试覆盖主要功能,在 PR 合并时运行;full 测试覆盖所有场景,在每日构建时运行。并行执行时注意 maxWorkers 不要超过 CPU 核心数,每个 worker 会启动一个浏览器实例,过度并行反而会因资源争抢导致测试超时。从 Puppeteer 迁移到 Playwright 的考虑如果团队需要跨浏览器测试(Firefox、Safari),或者对自动等待、网络拦截有更高要求,Playwright 是更合适的选择。Playwright 由微软维护,API 设计参考了 Puppeteer 并做了大量改进。迁移路径:Puppeteer 的 page 对象与 Playwright 的 page 对象 API 相似但不完全兼容。最稳妥的方式是先保留 Puppeteer 的集成测试,新测试用 Playwright 编写,逐步替换。不要一次性迁移,风险太大。如果你的项目只需要 Chrome 测试,Puppeteer 仍然是最轻量的选择。
前端阅读 05月28日 06:34

Astro 组件的基本结构是什么?如何定义和使用 Props、插槽?

Astro 是近年来增长最快的前端框架之一,其组件系统融合了服务端逻辑与客户端模板的独特设计,让开发者可以用最少的 JavaScript 构建高性能页面。本文将系统讲解 Astro 组件的三大核心结构——前置脚本、模板区域和样式作用域,以及 Props 传参与 Slots 插槽的完整用法。Astro 组件的三大结构每个 .astro 文件都由三个可选部分组成:前置脚本(Frontmatter)、HTML 模板和 <style> 样式块。理解这三部分的执行时机和作用域,是掌握 Astro 组件的基础。1. 前置脚本(Frontmatter)用 --- 分隔符包裹的顶部区域,是组件的"服务端大脑":---// 这里的代码在构建时(或 SSR 请求时)执行,不会发送到浏览器const title = "我的博客文章";const date = new Date().toLocaleDateString();// 支持导入其他组件import Card from './Card.astro';// 支持异步操作,如数据获取const posts = await fetch('/api/posts').then(r => r.json());---关键要点:前置脚本中的代码仅在服务端执行,永远不会出现在客户端 bundle 中可以使用完整的 JavaScript/TypeScript 语法,包括顶层 await这里定义的变量可以在下方模板中直接使用无法访问浏览器 API(如 window、document)2. 模板区域紧跟在前置脚本之后的 HTML 区域,支持类 JSX 语法:<h1>{title}</h1><p>发布于 {date}</p><div class="posts"> {posts.map(post => ( <Card title={post.title} /> ))}</div>模板支持的表达式:| 语法 | 用途 | 示例 ||------|------|------|| {variable} | 变量插值 | <h1>{title}</h1> || {condition && <Comp />} | 条件渲染 | {isAdmin && <AdminPanel />} || {a ? <A /> : <B />} | 三元条件 | {loggedIn ? <Dashboard /> : <Login />} || {items.map(...)} | 列表渲染 | {posts.map(p => <Card {...p} />)} || set:html={raw} | 原始 HTML 注入 | <div set:html={content} /> |3. 样式作用域<style> /* 默认 scoped,不会影响其他组件 */ h1 { color: #333; } /* 需要全局样式时使用 :global() */ :global(.markdown-body p) { line-height: 1.8; }</style>Astro 的样式默认是作用域隔离的——每个组件的样式会自动添加唯一属性选择器,杜绝样式泄漏。如果需要影响子组件或全局,使用 :global() 选择器。Props:组件间的数据传递Props 是 Astro 组件接收外部数据的标准方式,通过 Astro.props 对象访问。基本用法---// Card.astroconst { title, description } = Astro.props;---<div class="card"> <h2>{title}</h2> <p>{description}</p></div>使用组件时传入 Props:---import Card from './Card.astro';---<Card title="文章标题" description="文章描述" />TypeScript 类型约束为 Props 添加类型定义,可以在构建时捕获错误:---interface Props { title: string; description?: string; // 可选属性 count?: number;}const { title, description = '暂无描述', count = 0 } = Astro.props satisfies Props;---<h1>{title}</h1><p>{description}</p><span>数量: {count}</span>使用 satisfies 操作符既能获得类型检查,又能保留解构时的默认值推断。Props 传递的最佳实践保持 Props 简单:Props 应该是序列化安全的原始数据(字符串、数字、布尔值、简单对象),避免传递函数或复杂类实例提供默认值:通过解构默认值为可选 Props 设定合理的 fallback使用 ...rest 透传:当包装组件时,用 const { class: className, ...rest } = Astro.props 收集并透传属性---// 包装组件的最佳实践interface Props { class?: string; variant?: 'primary' | 'secondary';}const { class: className = '', variant = 'primary', ...rest } = Astro.props satisfies Props;---<div class={`btn btn-${variant} ${className}`} {...rest}> <slot /></div>Slots:组件的内容分发如果说 Props 传递的是"数据",那么 Slots 传递的就是"内容"。Slots 让组件成为可复用的布局容器。默认插槽---// Layout.astroconst { title } = Astro.props;---<html> <head><title>{title}</title></head> <body> <main> <slot /> <!-- 所有子内容将渲染在这里 --> </main> </body></html>使用时直接在组件标签内放入内容:---import Layout from './Layout.astro';---<Layout title="我的页面"> <h1>页面标题</h1> <p>这些内容会出现在 <slot /> 的位置</p></Layout>命名插槽当组件需要多个内容入口时,使用命名插槽:---// PageLayout.astroconst { title } = Astro.props;---<div class="page"> <header> <slot name="header" /> <!-- 命名插槽 --> </header> <main> <slot /> <!-- 默认插槽 --> </main> <footer> <slot name="footer" /> <!-- 命名插槽 --> </footer></div>使用命名插槽:---import PageLayout from './PageLayout.astro';---<PageLayout title="首页"> <nav slot="header"> <a href="/">首页</a> <a href="/about">关于</a> </nav> <!-- 没有 slot 属性的内容进入默认插槽 --> <h1>欢迎</h1> <p>这是主要内容</p> <p slot="footer">版权信息</p></PageLayout>插槽的 Fallback 内容插槽可以设置默认内容,当没有传入对应内容时自动显示:---// Card.astroconst { title } = Astro.props;---<div class="card"> <h2>{title}</h2> <div class="body"> <slot> <p>暂无内容</p> <!-- Fallback:未传入内容时显示 --> </slot> </div></div>插槽传递(Slot Forwarding)在嵌套布局中,子布局可以将插槽"透传"给父布局:---// BaseLayout.astro---<html> <body> <slot name="head" /> <slot /> </body></html>---// HomeLayout.astroimport BaseLayout from './BaseLayout.astro';---<BaseLayout> <slot name="head" slot="head" /> <slot /></BaseLayout>这样最终页面使用 <HomeLayout> 时,内容会正确传递到 <BaseLayout> 的对应插槽位置。框架组件中的 SlotsAstro 支持在 React、Vue、Svelte 等框架组件中使用插槽,但各框架的接收方式不同:| 框架 | 默认插槽 | 命名插槽 ||------|---------|---------|| React / Preact / Solid | children prop | slotName 顶级 prop || Vue | <slot /> | <slot name="xxx" /> || Svelte | <slot /> | <slot name="xxx" /> |注意:传给框架组件的命名插槽名会从 kebab-case 转为 camelCase(如 slot="my-header" 在 React 中变为 myHeader prop)。常见陷阱与注意事项前置脚本不等于客户端脚本:--- 中的代码在服务端执行,需要交互逻辑时应使用 <script> 标签或 client:* 指令模板表达式是静态的:{variable} 在构建时求值,不是响应式绑定Props 无法传递函数:Astro 组件的 Props 是序列化传递的,函数和类实例无法通过 Props 传递样式隔离是默认行为:不要假设子组件能继承父组件的 class 样式组件默认是静态的:需要客户端交互时,必须使用 client:load、client:visible 等水合指令---// 静态组件 vs 交互组件import StaticCard from './StaticCard.astro'; // 始终静态import InteractiveCounter from './Counter.jsx'; // 需要水合指令---<StaticCard title="静态内容" /><!-- client:load = 页面加载时立即水合 --><InteractiveCounter client:load /><!-- client:visible = 进入视口时才水合,节省资源 --><InteractiveCounter client:visible />总结Astro 组件的设计哲学是默认静态、按需交互:三大结构:前置脚本处理服务端逻辑,模板渲染 HTML,样式自动隔离Props:通过 Astro.props 传递数据,配合 TypeScript 类型约束确保安全Slots:通过默认插槽和命名插槽实现内容分发,支持嵌套透传和跨框架使用核心原则:能静态就不动态,需要交互时使用 client:* 水合指令掌握这三个核心概念,就能构建出结构清晰、性能优秀的 Astro 应用。
前端阅读 05月28日 06:28

如何在 Astro 中创建和使用 API 路由?如何处理请求和响应?

Astro 的 API 路由(Server Endpoints)允许你在项目中创建服务端接口,处理 HTTP 请求并返回响应。这是 Astro 构建全栈应用的核心能力之一,面试中常考请求处理方式、SSR/SSG 模式差异、类型安全等知识点。API 路由的基本原理API 路由文件放在 src/pages/ 目录下,文件路径即接口路径。与页面组件不同,API 路由文件使用 .ts 或 .js 扩展名,导出的是 HTTP 方法函数而非 Astro 组件。Astro 使用 Web 标准的 Request 和 Response 对象,与 Cloudflare Workers、Deno 等运行时保持一致,这意味着你不需要学习 Express 那样的 req/res 专属 API,掌握 Fetch API 标准即可上手。关键前提:API 路由需要服务端渲染(SSR)模式才能在请求时动态执行。如果你的项目是纯静态站点(SSG),API 路由只会在构建时执行一次。需要在 astro.config.mjs 中配置适配器:import { defineConfig } from 'astro/config';import node from '@astrojs/node';export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone' }),});创建第一个 API 路由使用 APIRoute 类型可以获得完整的类型提示,这是推荐的做法:// src/pages/api/hello.tsimport type { APIRoute } from 'astro';export const GET: APIRoute = async ({ request }) => { return new Response( JSON.stringify({ message: 'Hello, World!', timestamp: Date.now() }), { status: 200, headers: { 'Content-Type': 'application/json' }, } );};访问 /api/hello 即可得到 JSON 响应。APIRoute 类型会自动推断 params、request、cookies 等参数的类型,避免手写类型声明。支持哪些 HTTP 方法每个 API 路由文件可以导出多个 HTTP 方法函数,Astro 根据请求方法自动路由到对应函数:// src/pages/api/users.tsimport type { APIRoute } from 'astro';export const GET: APIRoute = async ({ request, url }) => { const users = await fetchUsers(); return new Response(JSON.stringify(users), { headers: { 'Content-Type': 'application/json' }, });};export const POST: APIRoute = async ({ request }) => { const body = await request.json(); const newUser = await createUser(body); return new Response(JSON.stringify(newUser), { status: 201, headers: { 'Content-Type': 'application/json' }, });};export const DELETE: APIRoute = async ({ request }) => { const body = await request.json(); await deleteUser(body.id); return new Response(null, { status: 204 });};支持的导出函数名包括 GET、POST、PUT、PATCH、DELETE、OPTIONS 和 ALL。ALL 函数会在请求方法没有对应导出函数时被调用,适合做兜底处理或方法校验。动态路由参数使用方括号语法定义动态路由参数,与页面路由的规则一致:// src/pages/api/users/[id].tsimport type { APIRoute } from 'astro';export const GET: APIRoute = async ({ params }) => { const { id } = params; const user = await fetchUserById(id); if (!user) { return new Response( JSON.stringify({ error: 'User not found' }), { status: 404, headers: { 'Content-Type': 'application/json' } } ); } return new Response(JSON.stringify(user), { headers: { 'Content-Type': 'application/json' }, });};如果需要捕获多个路径段,使用剩余参数语法 [...path].ts,params.path 会得到完整的路径数组。静态模式下,动态路由必须导出 getStaticPaths() 来预生成路径。请求处理:获取请求体、查询参数和请求头API 路由函数接收一个上下文对象,从中可以提取请求的所有信息:// src/pages/api/search.tsimport type { APIRoute } from 'astro';export const POST: APIRoute = async ({ request, url, cookies }) => { try { // 请求体:根据 Content-Type 选择解析方式 const body = await request.json(); // JSON 请求体 // const formData = await request.formData(); // 表单数据 // const text = await request.text(); // 纯文本 // 查询参数 const limit = parseInt(url.searchParams.get('limit') || '10'); const page = parseInt(url.searchParams.get('page') || '1'); // 请求头 const authHeader = request.headers.get('Authorization'); const contentType = request.headers.get('Content-Type'); // Cookie const sessionToken = cookies.get('session')?.value; const results = await search(body.query, { limit, page }); return new Response(JSON.stringify({ results, page, limit }), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300', }, }); } catch (error) { return new Response( JSON.stringify({ error: 'Invalid request body' }), { status: 400, headers: { 'Content-Type': 'application/json' } } ); }};注意 request.json() 只能调用一次,因为 Request.body 是 ReadableStream,消费后不可重读。如果需要多次读取,先 clone() 再解析。响应构建:状态码、头信息和重定向Astro 返回的是标准 Response 对象,你可以完全控制状态码、头信息和响应体:// 成功响应return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', },});// 创建资源return new Response(JSON.stringify(newItem), { status: 201, headers: { 'Location': `/api/items/${newItem.id}` },});// 重定向return Response.redirect(new URL('/api/new-path', request.url), 301);// 无内容return new Response(null, { status: 204 });面试中容易被问到:Astro 4+ 使用的是原生 Response 构造函数,不再返回 Astro 自定义的响应对象。如果你看到教程中使用 ({ body, status }) 的写法,那是 Astro 3 及更早版本的旧语法,已经废弃。身份验证与授权API 路由中实现鉴权通常从请求头或 Cookie 中提取凭证:// src/pages/api/admin/stats.tsimport type { APIRoute } from 'astro';export const GET: APIRoute = async ({ request, cookies }) => { // 方式一:Bearer Token const token = request.headers.get('Authorization')?.replace('Bearer ', ''); if (!token) { return new Response( JSON.stringify({ error: 'Missing authorization token' }), { status: 401, headers: { 'Content-Type': 'application/json' } } ); } const user = await verifyToken(token); if (!user) { return new Response( JSON.stringify({ error: 'Invalid or expired token' }), { status: 403, headers: { 'Content-Type': 'application/json' } } ); } // 方式二:Session Cookie(配合中间件更方便) const sessionId = cookies.get('session_id')?.value; const stats = await fetchAdminStats(user.id); return new Response(JSON.stringify(stats), { headers: { 'Content-Type': 'application/json' }, });};更推荐的做法是将鉴权逻辑提取到中间件(middleware)中,避免每个路由重复编写。中间件在 API 路由执行前运行,可以在 locals 上挂载用户信息:// src/middleware.tsimport { defineMiddleware } from 'astro:middleware';export const onRequest = defineMiddleware(async (context, next) => { const token = context.request.headers.get('Authorization')?.replace('Bearer ', ''); if (token) { const user = await verifyToken(token); if (user) { context.locals.user = user; } } return next();});在 API 路由中直接读取 context.locals.user 即可判断身份。错误处理策略推荐封装统一的错误处理工具,让 API 路由保持简洁:// src/lib/api-error.tsexport class ApiError extends Error { constructor( public statusCode: number, message: string, public code?: string ) { super(message); this.name = 'ApiError'; }}export function handleApiError(error: unknown): Response { if (error instanceof ApiError) { return new Response( JSON.stringify({ error: error.message, code: error.code }), { status: error.statusCode, headers: { 'Content-Type': 'application/json' } } ); } console.error('Unexpected error:', error); return new Response( JSON.stringify({ error: 'Internal Server Error' }), { status: 500, headers: { 'Content-Type': 'application/json' } } );}在路由中使用:// src/pages/api/data.tsimport type { APIRoute } from 'astro';import { ApiError, handleApiError } from '../../lib/api-error';export const GET: APIRoute = async ({ params }) => { try { const data = await fetchData(params.id); if (!data) throw new ApiError(404, 'Data not found', 'NOT_FOUND'); return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { return handleApiError(error); }};CORS 跨域配置如果你的 API 需要被其他域名的前端调用,必须处理 CORS。可以通过 OPTIONS 预检和响应头来解决:// src/pages/api/public-data.tsimport type { APIRoute } from 'astro';const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Max-Age': '86400',};export const OPTIONS: APIRoute = async () => { return new Response(null, { status: 204, headers: corsHeaders });};export const GET: APIRoute = async ({ request }) => { const data = await fetchPublicData(); return new Response(JSON.stringify(data), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, });};更优雅的做法是在中间件中统一添加 CORS 头,避免每个路由重复定义。API 路由与 Astro Actions 的区别Astro 4.9+ 引入了 Actions,这是处理服务端逻辑的新方式。面试中经常考察两者的适用场景:API 路由适合:对外提供 REST 接口,供第三方或前端 SPA 调用需要处理多种 HTTP 方法的场景Webhook 回调接收需要自定义响应格式(非 JSON)的场景Actions 适合:表单提交和数据变更需要输入验证(Zod schema)和类型安全的场景渐进增强需求——即使 JavaScript 禁用也能工作组件内部的服务端调用// Actions 示例:带验证的表单处理import { defineAction } from 'astro:actions';import { z } from 'astro:schema';export const server = { createPost: defineAction({ input: z.object({ title: z.string().min(1).max(200), content: z.string().min(1), }), handler: async (input) => { const post = await db.post.create({ data: input }); return post; }, }),};面试要点:Actions 底层仍基于 API 路由实现,但它封装了验证、序列化和错误处理,适合大多数表单交互场景。如果你不需要 REST 语义或对外暴露接口,优先用 Actions。文件上传处理处理文件上传需要从 formData 中提取文件对象,并进行类型和大小校验:// src/pages/api/upload.tsimport type { APIRoute } from 'astro';export const POST: APIRoute = async ({ request }) => { try { const formData = await request.formData(); const file = formData.get('file') as File | null; if (!file) { return new Response( JSON.stringify({ error: 'No file provided' }), { status: 400, headers: { 'Content-Type': 'application/json' } } ); } const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; if (!allowedTypes.includes(file.type)) { return new Response( JSON.stringify({ error: 'Unsupported file type' }), { status: 400, headers: { 'Content-Type': 'application/json' } } ); } const maxSize = 5 * 1024 * 1024; // 5MB if (file.size > maxSize) { return new Response( JSON.stringify({ error: 'File exceeds 5MB limit' }), { status: 400, headers: { 'Content-Type': 'application/json' } } ); } const url = await uploadToStorage(file); return new Response(JSON.stringify({ url }), { status: 201, headers: { 'Content-Type': 'application/json' }, }); } catch (error) { return new Response( JSON.stringify({ error: 'Upload failed' }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); }};大文件上传建议使用流式处理(request.body 是 ReadableStream),避免将整个文件加载到内存。数据库集成与分页查询API 路由连接数据库时,分页是最常见的需求之一:// src/pages/api/posts.tsimport type { APIRoute } from 'astro';import { db } from '../../lib/db';export const GET: APIRoute = async ({ url }) => { const page = Math.max(1, parseInt(url.searchParams.get('page') || '1')); const limit = Math.min(50, Math.max(1, parseInt(url.searchParams.get('limit') || '10'))); const offset = (page - 1) * limit; const [posts, total] = await Promise.all([ db.post.findMany({ take: limit, skip: offset, orderBy: { createdAt: 'desc' }, }), db.post.count(), ]); return new Response( JSON.stringify({ posts, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, }), { headers: { 'Content-Type': 'application/json' } } );};注意对 page 和 limit 做了边界处理,防止负数或过大值导致的异常查询。SSG 模式下的 API 路由静态站点生成模式下,API 路由在构建时执行,产出的 JSON 文件会被当作静态资源。这意味着动态路由需要通过 getStaticPaths() 声明所有可能的路径:// src/pages/api/tags/[tag].tsimport type { APIRoute } from 'astro';export async function getStaticPaths() { const tags = await fetchAllTags(); return tags.map(tag => ({ params: { tag } }));}export const GET: APIRoute = async ({ params }) => { const posts = await fetchPostsByTag(params.tag); return new Response(JSON.stringify(posts), { headers: { 'Content-Type': 'application/json' }, });};如果需要运行时动态响应,必须将路由标记为按需渲染:export const prerender = false;面试常问:SSG 模式的 API 路由本质上是构建时的数据预生成,适合数据不频繁变化的场景;SSR 模式才是真正的服务端接口,适合实时数据。搞混这两种模式是常见的错误。实战中的常见问题请求体解析失败怎么办? request.json() 在非法 JSON 时会抛异常,必须用 try/catch 包裹。同理 request.formData() 在非表单请求时也会报错。如何实现速率限制? Astro 本身不提供速率限制,需要自行实现或使用中间件。简单的做法是基于 IP 和时间窗口做计数:// src/lib/rate-limit.tsconst requests = new Map<string, { count: number; resetAt: number }>();export function rateLimit(ip: string, limit = 100, windowMs = 60000): boolean { const now = Date.now(); const record = requests.get(ip); if (!record || now > record.resetAt) { requests.set(ip, { count: 1, resetAt: now + windowMs }); return true; } record.count++; return record.count <= limit;}生产环境建议用 Redis 存储计数,避免内存泄漏和分布式场景下的不一致问题。如何做输入验证? 除了 Actions 内置的 Zod 验证,API 路由中也可以直接用 Zod:import { z } from 'zod';const CreatePostSchema = z.object({ title: z.string().min(1).max(200), content: z.string().min(1), tags: z.array(z.string()).optional(),});export const POST: APIRoute = async ({ request }) => { const body = await request.json(); const result = CreatePostSchema.safeParse(body); if (!result.success) { return new Response( JSON.stringify({ error: 'Validation failed', details: result.error.flatten() }), { status: 422, headers: { 'Content-Type': 'application/json' } } ); } const post = await createPost(result.data); return new Response(JSON.stringify(post), { status: 201, headers: { 'Content-Type': 'application/json' }, });};这样做的好处是类型从验证结果中推断,不需要手动声明 body 的类型。掌握 Astro API 路由的关键在于理解它是基于 Web 标准的请求响应模型,与 Express 等框架的专有 API 不同。核心知识点包括:SSR/SSG 模式选择、APIRoute 类型标注、中间件集成鉴权、Actions 与 API 路由的适用场景区分,以及输入验证和错误处理的最佳实践。
前端阅读 05月28日 05:32

如何在 JavaScript 中操作 SVG?核心方法与常见坑

用 JavaScript 操控 SVG,本质就是操作 DOM——只不过多了个命名空间的坑。SVG 元素挂在 DOM 树上,所以 querySelector、addEventListener 这些老朋友都能用,但创建元素时必须用 createElementNS,这是新手最容易栽的地方。本文覆盖 SVG 元素选择、属性修改、事件绑定、动画实现、坐标换算、拖拽交互这些核心操作,顺带聊几个实际开发中踩过的坑。命名空间:第一个坑HTML 元素用 document.createElement('div') 就行,SVG 不行——你必须指定命名空间:const SVG_NS = 'http://www.w3.org/2000/svg';const circle = document.createElementNS(SVG_NS, 'circle');忘掉 NS 后缀会怎样?浏览器不会报错,但创建出来的元素不属于 SVG 命名空间,渲染不出来,调试半天才发现是这个原因。这类 bug 特征是:元素确实被插入了 DOM,但页面上什么都看不到。另一个容易忽略的是 xlink:href 属性。SVG 的 <use>、<image> 等元素引用外部资源时用的是 xlink:href,它有自己的命名空间:const XLINK_NS = 'http://www.w3.org/1999/xlink';useEl.setAttributeNS(XLINK_NS, 'xlink:href', '#icon');新规范中 href 已经可以直接用 setAttribute 设置,但兼容旧浏览器时还是得走 xlink。选择元素:和 HTML 一样SVG 元素的选择没有特殊之处,标准 DOM API 直接用:const circle = document.getElementById('myCircle');const allCircles = document.querySelectorAll('svg circle');const filledElements = document.querySelectorAll('[fill="red"]');需要注意的是,如果你用 <img> 标签引入 SVG,JavaScript 是无法访问内部元素的。必须用内联 SVG(直接写在 HTML 中)或 <object> / <iframe> 加载,才能用 JS 操作。修改属性和样式SVG 元素的属性分为两类:呈现属性(如 fill、stroke、r)和 样式属性。两者都可以改,但走不同的路:// 方式一:setAttribute 修改呈现属性circle.setAttribute('fill', 'red');circle.setAttribute('r', '60');// 方式二:style 对象修改样式circle.style.fill = 'green';circle.style.opacity = '0.5';一个常见的困惑是:setAttribute('fill', 'red') 和 style.fill = 'red' 有什么区别?CSS 样式的优先级高于呈现属性,所以 style.fill 会覆盖 setAttribute('fill', ...)。这和 CSS 层叠规则一致。带连字符的属性(如 stroke-width、font-size)不能用点语法赋值,circle.stroke-width = 4 会报错。必须用 setAttribute 或驼峰写法 style.strokeWidth。事件绑定SVG 元素天然支持 DOM 事件,点击、悬停、拖拽都能绑定。唯一要注意的是键盘事件——SVG 元素默认不可聚焦,需要手动加 tabindex:circle.setAttribute('tabindex', '0');circle.addEventListener('keydown', (e) => { if (e.key === 'Enter') { circle.setAttribute('fill', 'red'); }});对于大量同类元素(比如数据可视化中的几十个柱子),逐个绑定事件很浪费内存,用事件委托更合理:svg.addEventListener('click', (e) => { const bar = e.target.closest('rect.bar'); if (bar) { highlightBar(bar); }});动画:CSS 过渡 vs requestAnimationFrame简单动画用 CSS 过渡就够了,改个属性值浏览器自动补间:circle.style.transition = 'all 0.3s ease';circle.setAttribute('r', '80');需要精确控制的动画(比如沿路径运动、物理模拟)则要用 requestAnimationFrame。一个容易犯的错是用 setInterval——它不跟浏览器刷新率同步,动画会卡顿。requestAnimationFrame 的回调在浏览器下一次重绘前执行,能保证流畅:let angle = 0;function animate() { angle += 0.02; const x = 150 + Math.cos(angle) * 100; const y = 150 + Math.sin(angle) * 100; circle.setAttribute('cx', x); circle.setAttribute('cy', y); requestAnimationFrame(animate);}requestAnimationFrame(animate);性能优化有一条核心原则:尽量动画 transform 和 opacity,别动布局属性。transform: translate() 走 GPU 合成,不触发重排;改 cx、cy 则会触发重排。当元素数量多时差距明显。获取鼠标在 SVG 中的坐标鼠标的 clientX/clientY 是页面坐标,要换算成 SVG 内部坐标需要做矩阵变换:function getSVGPoint(svg, event) { const pt = svg.createSVGPoint(); pt.x = event.clientX; pt.y = event.clientY; return pt.matrixTransform(svg.getScreenCTM().inverse());}svg.addEventListener('click', (e) => { const { x, y } = getSVGPoint(svg, e); console.log(`SVG 坐标: ${x}, ${y}`);});getScreenCTM() 返回 SVG 坐标系到屏幕坐标系的变换矩阵,.inverse() 取逆矩阵,就能从屏幕坐标映射回 SVG 坐标。如果 SVG 做过 viewBox 缩放或 transform,这一步是必须的。拖拽实现拖拽是把鼠标坐标换算和事件监听组合起来的典型场景:let dragging = null;let offset = { x: 0, y: 0 };function getMousePos(svg, e) { const CTM = svg.getScreenCTM(); return { x: (e.clientX - CTM.e) / CTM.a, y: (e.clientY - CTM.f) / CTM.d };}svg.addEventListener('mousedown', (e) => { dragging = e.target; const pos = getMousePos(svg, e); offset.x = pos.x - parseFloat(dragging.getAttribute('cx')); offset.y = pos.y - parseFloat(dragging.getAttribute('cy'));});svg.addEventListener('mousemove', (e) => { if (!dragging) return; const pos = getMousePos(svg, e); dragging.setAttribute('cx', pos.x - offset.x); dragging.setAttribute('cy', pos.y - offset.y);});svg.addEventListener('mouseup', () => { dragging = null; });触摸设备上要把 mousedown/mousemove/mouseup 换成 touchstart/touchmove/touchend,或者用 Pointer Events 统一处理。什么时候该用 SVG,什么时候该用 Canvas?这不是本文主题,但做 SVG 开发迟早会遇到这个问题,简单说下判断依据:用 SVG:需要交互(每个元素可点击/悬停)、需要无障碍访问、图形数量在几千以内、需要 CSS 动画用 Canvas:大量元素(超过 3000 个)、像素级操作、实时游戏渲染、不需要单个元素的交互实际项目中,图表用 SVG(D3.js / ECharts 的 SVG 模式),游戏用 Canvas,这是比较成熟的选型。几个实际开发中的坑innerHTML 可以用但别滥用。svg.innerHTML = '<circle cx="50" cy="50" r="40"/>' 在现代浏览器中能工作,但它不经过命名空间检查,序列化时可能出问题。动态创建元素还是老老实实用 createElementNS。getBBox() 获取元素边界。想知道一个 SVG 元素实际占了多大空间,用 getBBox() 返回 { x, y, width, height },这个值不受 transform 影响,是元素自身的原始尺寸。SMIL 动画(<animate> 标签)正在被边缘化。Chrome 曾一度要移除 SMIL 支持,虽然后来撤回了,但趋势是尽量用 CSS 动画或 JavaScript 替代 SMIL。SVGO 压缩 SVG。从设计工具导出的 SVG 通常包含大量冗余属性(编辑器元数据、无用空白等),用 SVGO 压缩可以减小 30%-70% 的体积,线上必须走一遍。
前端阅读 05月28日 05:25

Expo 应用如何实现国际化?i18next 配置与 RTL 处理

Expo 国际化用 i18next + expo-localization,不要选 react-native-localize——它在 Expo Go 里直接报错,必须 eject 才能用。i18next 管翻译引擎(资源加载、变量插值、复数、语言切换),expo-localization 读设备语言和时区,两个配合才是正解。核心流程:getLocales() 拿设备语言 → i18next 加载翻译资源 → useTranslation() 的 t() 渲染文本。切换语言调 i18n.changeLanguage(),AsyncStorage 持久化偏好。i18next 的 init 必须在根组件渲染前执行——入口文件顶部 import 配置即可,否则子组件拿不到翻译,这是新手最常见的坑。追问i18next 和 expo-localization 分工是什么?能只用一个吗?不能替代。expo-localization 是只读工具——告诉你设备语言是 zh-Hans、时区是 Asia/Shanghai,不碰翻译。i18next 才是翻译引擎:翻译资源管理、{{变量}} 插值、单复数(one item / {{count}} items)、命名空间拆分、运行时语言切换全归它管。一个读信息,一个做翻译,职责不重叠。react-native-localize 比 expo-localization 好在哪?为什么不推荐?react-native-localize 能拿更多信息:日历类型、温度单位、24 小时制开关、度量衡。但代价是依赖原生模块——Expo Go 拒绝加载自定义原生代码,import 就报错,必须 npx expo run:ios 跑开发构建或 eject 到 bare workflow。还在 Expo Go 阶段的项目别碰它;bare workflow 项目两个随便选。Expo Router 里怎么做国际化?根 layout 用 useTranslation,t() 放在 options.tabBarLabel 和 options.title 里,切换语言后组件重渲染、标签名自动变。关键约束:Expo Router 基于文件系统路由,路径名不能动态改,所以路由文件名保持英文(app/settings.tsx),展示文本走 t() 翻译。useSegments() 拿当前路由做翻译 key 映射也是常见做法。RTL 语言(阿拉伯语、希伯来语)怎么办?I18nManager.forceRTL(true) 开启 RTL,但要重启才生效——调 Updates.reloadAsync() 即可。样式必须用逻辑属性:marginStart/marginEnd 替代 marginLeft/marginRight,paddingStart/paddingEnd 替代左右内边距,textAlign 用 'start' 不用 'left'。RTL 模式下布局自动翻转,零额外代码。忘了用逻辑属性的后果:文本翻了布局没翻,界面乱套。翻译资源多了怎么组织?5 种语言以内 JSON 文件放 locales/en.json,按功能分 key(auth.login、settings.theme)。语言多了用 i18next 命名空间拆分:common、auth、settings 各一个 namespace,懒加载减少首屏体积。改文案不发版的场景上 i18next-http-backend 从 CDN 拉翻译 JSON,AsyncStorage 缓存离线兜底。翻译量大需要协作时,Lokalise 或 Crowdin 配合 i18next 官方同步插件。开发阶段开 saveMissing: true,缺失 key 自动打 console 警告;上线后 i18next-scanner 扫代码提取 key,和翻译文件做 diff 排查遗漏。写段代码// i18n.ts — 入口文件顶部 importimport i18n from 'i18next';import { initReactI18next } from 'react-i18next';import { getLocales } from 'expo-localization';i18n.use(initReactI18next).init({ resources: { en: { translation: { welcome: 'Welcome', hello: 'Hello, {{name}}!' } }, zh: { translation: { welcome: '欢迎', hello: '你好,{{name}}!' } }, }, lng: getLocales()[0]?.languageCode ?? 'en', fallbackLng: 'en', saveMissing: __DEV__, interpolation: { escapeValue: false },});// 组件const { t, i18n } = useTranslation();<Text>{t('hello', { name: '用户' })}</Text><Button onPress={() => { i18n.changeLanguage('zh'); AsyncStorage.setItem('lang', 'zh');}} title="中文" />
前端阅读 05月28日 05:24

Expo OTA更新怎么工作?EAS Update怎么用?

Expo的OTA(Over-the-Air)更新让开发者绕过应用商店审核流程,直接向用户推送JavaScript和资源文件的更新。这项能力来自EAS Update服务,配合expo-updates原生模块在客户端完成检查、下载和应用更新的全流程。OTA更新的底层机制一次OTA更新涉及三个核心概念:分支(Branch)、通道(Channel)和运行时版本(Runtime Version)。分支是更新在服务端的组织方式。每次执行eas update,Expo会将打包后的JavaScript bundle和资源文件上传到指定分支,分支上的最新更新即为活跃更新。通道则是客户端与分支之间的桥梁——客户端通过app.json中配置的通道名称连接到对应分支,从而获取更新。运行时版本是兼容性的守门员:只有运行时版本匹配的更新才会被下载,防止含原生依赖变更的更新在不兼容的二进制上运行导致崩溃。更新下载分两个阶段进行。应用启动时,expo-updates先请求最新的更新清单(manifest),清单包含更新元数据和所需资源列表。接着只下载当前缓存中缺失的资源文件,已缓存的部分直接复用。SDK 55引入的bundle diffing机制进一步将更新体积缩小60%到80%——客户端只需下载新旧bundle之间的差异部分,而非整个bundle。EAS Update的完整配置流程1. 安装依赖并初始化npx expo install expo-updatesnpm install -g eas-clieas logineas initeas init会在app.json中写入EAS项目ID,expo-updates则是客户端检查和下载更新所依赖的原生模块。2. 配置app.json{ "expo": { "runtimeVersion": { "policy": "appVersion" }, "updates": { "url": "https://u.expo.dev/your-project-id", "enabled": true, "fallbackToCacheTimeout": 0, "checkAutomatically": "ON_LOAD" } }}checkAutomatically控制更新检查时机,ON_LOAD表示应用启动时自动检查,WIFI_ONLY仅在WiFi下检查。fallbackToCacheTimeout设为0表示不等待更新下载完成,直接加载缓存版本。3. 构建支持更新的二进制OTA更新只在production或preview构建中生效,Expo Go不支持:eas build --platform all --profile production构建时expo-updates被编译进二进制,并嵌入了构建时的初始更新作为回退版本。4. 发布更新# 向production通道发布eas update --branch production --message "修复登录按钮样式"# 指定运行时版本eas update --branch production --runtime-version 1.0.0# 向preview通道发布用于测试eas update --branch preview --message "测试新功能"5. 查看和回滚更新# 列出所有更新eas update:list# 查看指定分支的更新eas update:list --branch production# 回滚到上一版本eas update:rollback --channel production# 回滚到特定版本eas update:rollback --channel production --target-message "上一个稳定版本"运行时版本策略选择三种策略各有适用场景:appVersion(推荐):运行时版本跟随app.json中的version字段。每次改了原生依赖就升version,简单可靠,适合大多数团队。nativeVersion:跟随原生构建号,比appVersion更细粒度,适合频繁发版但原生变更不多的场景。自定义版本字符串:完全手动控制,灵活性最高但需要团队约定版本命名规范。关键原则:只要添加、删除或升级了原生依赖,就必须同步更新运行时版本,否则OTA更新可能导致原生模块找不到而崩溃。客户端程序化控制更新手动控制更新检查和应用的场景很常见,比如在设置页提供"检查更新"按钮,或在关键操作前确保代码是最新的:import * as Updates from 'expo-updates';async function checkAndApplyUpdate() { if (__DEV__) return; // 开发模式不支持OTA try { const update = await Updates.checkForUpdateAsync(); if (update.isAvailable) { const result = await Updates.fetchUpdateAsync(); if (result.isNew) { // 立即重载应用新版本 await Updates.reloadAsync(); } } } catch (error) { // 更新失败不影响正常使用,静默处理或上报 console.warn('更新检查失败:', error.message); }}监听更新事件可以在后台下载完成时通知用户:useEffect(() => { if (__DEV__) return; const subscription = Updates.addListener((event) => { if (event.type === Updates.UpdateEventType.DOWNLOAD_FINISHED) { // 提示用户下次启动将使用新版本 Alert.alert('更新已就绪', '重启应用以使用最新版本', [ { text: '稍后', style: 'cancel' }, { text: '立即重启', onPress: () => Updates.reloadAsync() }, ]); } if (event.type === Updates.UpdateEventType.ERROR) { console.warn('更新下载出错:', event.message); } }); return () => subscription.remove();}, []);OTA更新的边界与限制OTA能更新的是JavaScript业务逻辑、React组件树、样式、静态资源和导航结构。以下变更必须发新构建:新增或删除原生依赖、修改app.json中的原生字段(权限、URL Scheme等)、升级Expo SDK大版本、更换应用图标或启动屏。苹果和谷歌对OTA更新的政策有明确要求:更新不得改变应用的核心功能定位,不得绕过应用商店审核引入付费功能或隐私敏感变更。实际操作中,大多数UI修复和小功能调整都符合政策。通道与分支的运维实践通道和分支的典型组合:| 环境 | 分支 | 通道 | 用途 ||------|------|------|------|| 生产 | production | production | 面向所有用户 || 预发 | staging | staging | 内部测试验证 || 预览 | preview | preview | 功能预览 |通道还支持按比例灰度发布。通过eas channel:rollout命令可以逐步将新更新推送给一定比例的用户,观察错误率后再全量发布:# 先向20%用户推送eas channel:rollout production --percent 20# 确认无问题后扩大到50%eas channel:rollout production --percent 50# 全量发布eas channel:rollout production --percent 100错误恢复机制expo-updates内置了自动错误恢复:如果更新后的应用在启动时连续崩溃,模块会自动回退到上一个已缓存的可用版本。这为线上事故提供了兜底,但仍建议在发布前通过preview通道充分测试。手动回滚同样简单。EAS Update保留每个通道的完整更新历史,回滚只是将活跃指针指向上一个版本,客户端会在下次启动时下载并切换。面试追问OTA更新和发新版本各自的适用场景? JavaScript层面的bug修复、UI调整、文案改动适合OTA;新增原生模块、修改权限声明、升级SDK大版本必须发新构建。判断依据是变更是否涉及原生代码。运行时版本不匹配会发生什么? 客户端会忽略不匹配的更新,继续运行当前缓存版本。这保护了应用不会因缺少原生模块而崩溃,但也意味着如果忘记同步运行时版本,用户将收不到更新。如何保证OTA更新的安全性? EAS Update默认通过HTTPS传输更新包,expo-updates在加载前会校验更新签名。自托管更新服务器时需确保同样启用HTTPS和签名验证。
前端阅读 95月28日 03:39

什么是事件代理?原理、优缺点和应用场景是什么?

事件代理(事件委托)是利用事件冒泡机制,将子元素的事件监听器统一绑定到父元素上的一种模式。面试中常从原理、优缺点、边界问题、实战场景四个层面考察。核心原理DOM 事件流经历三个阶段:捕获阶段(从 window 向下传播到目标元素)→ 目标阶段(事件到达目标元素)→ 冒泡阶段(从目标元素向上传播回 window)。事件代理利用的就是冒泡阶段——子元素触发事件后,事件沿 DOM 树逐层向上传播,因此在父元素上可以统一捕获并处理。// 传统方式:每个子元素各自绑定,N 个元素需要 N 个监听器document.querySelectorAll('li').forEach(li => { li.addEventListener('click', handler);});// 事件代理:只在父元素绑定一次,无论多少子元素都只需 1 个监听器document.querySelector('ul').addEventListener('click', (e) => { if (e.target.matches('li')) { handler(e); }});优点减少内存占用:100 个按钮只需 1 个监听器,而非 100 个,显著降低内存消耗动态元素自动响应:新增的子元素无需重新绑定,天然具备事件响应能力,特别适合动态渲染的列表减少 DOM 操作:绑定和解绑只涉及父元素,降低与 DOM 的交互次数代码更易维护:事件处理逻辑集中在父元素,修改时只需改一处缺点不适用于不冒泡的事件:focus、blur、scroll、mouseenter/mouseleave 不冒泡,无法使用事件代理(可改用 focusin/focusout,它们冒泡)嵌套元素干扰判断:子元素内部还有子元素时,e.target 可能不是期望的目标元素非目标点击误触发:父元素区域内非目标元素的点击也会进入回调,需要手动过滤层级过深可能被拦截:冒泡链路中间如果调用了 stopPropagation(),事件无法到达代理层嵌套子元素干扰如何解决用 e.target.closest('li') 替代 e.target.matches('li')。closest 会沿 DOM 树向上查找最近匹配的祖先元素,即使点击的是 li 内部的 span 也能正确定位。而 matches 只检查元素自身,不向上查找。// matches 版本:点击 li 内的 span 会匹配失败ul.addEventListener('click', (e) => { if (e.target.matches('li')) handler(e); // 内部有 span 时失效});// closest 版本:点击 li 内的 span 仍能找到 liul.addEventListener('click', (e) => { const li = e.target.closest('li'); if (li) handler(e);});e.target 与 e.currentTarget 的区别e.target:实际触发事件的最深层元素(用户真正点击的那个元素)e.currentTarget:绑定监听器的元素,在事件代理中就是父元素代理场景下两者始终不同:e.currentTarget 是挂载监听器的父元素,e.target 是用户实际点击的子元素。理解这个区别是掌握事件代理的关键。实际应用场景列表/表格的行点击:导航菜单选中、数据表格行操作动态表单项:可增减的输入行、标签列表的添加与删除React 合成事件体系:React 17 前将所有事件代理到 document,17+ 代理到 root 节点,本质上就是事件代理思想在框架层的工程化实践事件代理 + 防抖:在滚动容器上代理子元素的点击,配合防抖避免误触就近委托是最佳实践:在最近的公共父元素上代理,而非一律挂载到 document 或 body,这样可以减少不必要的事件冒泡路径和回调触发次数。
前端阅读 335月28日 03:36

some、every、find、filter、map、forEach 有什么区别?

这 6 个方法是 JavaScript 数组最常用的迭代方法,面试几乎必考。核心区别在于返回值类型和是否短路,按返回值分三类记忆最清晰。一、遍历类(无返回值)forEach纯遍历,对每个元素执行回调,返回值永远是 undefined。不能中断:return 只跳过当前回调,break 语法不支持,想中途退出只能用 try/catch 抛异常(不推荐)不支持异步:回调里写 async/await 不会等待 Promise,因为 forEach 不关心返回值const list = [1, 2, 3];list.forEach(item => console.log(item)); // 1, 2, 3// return 只跳过当次,不会中断循环二、返回新数组map每个元素经回调映射后返回等长新数组,不改变原数组。const nums = [1, 2, 3];const doubled = nums.map(n => n * 2); // [2, 4, 6]filter返回满足条件的元素组成的新数组,长度可能小于原数组,不改变原数组。const nums = [1, 2, 3, 4, 5];const big = nums.filter(n => n > 3); // [4, 5]三、返回布尔值或单个元素find返回第一个满足条件的元素,找到即停止遍历(短路)。找不到返回 undefined。const users = [{id: 1, name: 'A'}, {id: 2, name: 'B'}];users.find(u => u.id === 2); // {id: 2, name: 'B'}some有任意一个满足条件就返回 true,找到即短路。全不满足返回 false。空数组返回 false。[1, 2, 3].some(n => n > 2); // true[1, 2, 3].some(n => n > 5); // false[].some(n => n > 0); // falseevery所有元素都满足条件才返回 true,遇到不满足即短路。空数组返回 true(空真逻辑 vacuous truth)。[1, 2, 3].every(n => n > 0); // true[1, 2, 3].every(n => n > 1); // false[].every(n => n > 0); // true(空真)四、对比速查表| 方法 | 返回值 | 是否短路 | 空数组返回 | 链式调用 | 修改原数组 ||------|--------|----------|-----------|---------|-----------|| forEach | undefined | 否 | undefined | 否 | 否 || map | 新数组 | 否 | [] | 是 | 否 || filter | 新数组 | 否 | [] | 是 | 否 || find | 单个元素/undefined | 是 | undefined | 否 | 否 || some | boolean | 是 | false | 否 | 否 || every | boolean | 是 | true | 否 | 否 |五、高频追问map 和 forEach 怎么选?需要返回新数组用 map,纯副作用(如 console.log、DOM 操作)用 forEach。关键区别:map 可链式调用,forEach 返回 undefined 不可链式。some 和 includes 有什么区别?includes(val) 判断数组是否包含某个具体值,用严格相等(===)比较some(fn) 判断是否有元素满足自定义条件includes 只能判断值存在性,some 可以写任意判断逻辑[1, 2, 3].includes(2); // true[1, 2, 3].some(n => n > 2); // true[{a: 1}].includes({a: 1}); // false(引用不同)[{a: 1}].some(o => o.a === 1); // true这些方法支持异步回调吗?都不原生支持。forEach 里写 async/await 不会等待 Promise resolve。需要异步迭代用 for...of + await 或 Promise.all + map。// 错误:forEach 不会等待 asyncids.forEach(async id => { const data = await fetch(id); // 并发执行,不会依次等待});// 正确方式1:for...offor (const id of ids) { const data = await fetch(id);}// 正确方式2:Promise.all + map(并行)const results = await Promise.all(ids.map(id => fetch(id)));find 和 filter 怎么选?只需第一个匹配用 find(性能更好,短路),需要所有匹配用 filter。reduce 为什么没列进来?reduce 是这 6 个方法的基础——map、filter、some、every、find 都可以用 reduce 实现。面试中常追问 reduce 的用法,但 reduce 更偏向"累加器"模式,功能更强大也更复杂,属于另一个考点的范畴。