面试题手册

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

前端阅读 05月28日 07:25

如何在 Monorepo 项目中配置和使用 Prettier?

核心答案在 Monorepo 中配置 Prettier,关键是统一配置 + 分包覆盖 + 工具链集成三步走:根目录放一份基础 .prettierrc 作为全局基准,通过共享配置包 @org/prettier-config 让各子项目继承,再用 overrides 按包定制差异规则,最后配合 Husky + lint-staged 在提交时自动格式化、Turborepo/Nx 在 CI 层做缓存检查。根目录统一配置最简单的方式是在 monorepo 根目录创建 .prettierrc:{ "semi": true, "singleQuote": true, "tabWidth": 2, "trailingComma": "es5", "printWidth": 80}Prettier 会从文件所在目录向上查找配置,子包如果自己没有 .prettierrc,就自动继承根目录的规则。这意味着只要根目录配置到位,大部分子包无需额外配置。需要注意的是,如果子包自己也有 .prettierrc,它会完全覆盖根配置而不是合并。所以除非有必要,不要在子包里单独放配置文件。共享配置包当团队规模较大或 monorepo 包含多个独立发布的库时,推荐把 Prettier 配置抽成 npm 包:// packages/prettier-config/index.jsmodule.exports = { semi: true, singleQuote: true, tabWidth: 2, trailingComma: "es5", printWidth: 80, bracketSpacing: true, arrowParens: "always",};// packages/prettier-config/package.json{ "name": "@my-org/prettier-config", "version": "1.0.0", "main": "index.js"}在各子包中引用:{ "prettier": "@my-org/prettier-config"}共享配置包的优势在于版本可控——改一处发布新版本,所有依赖它的子包 npm update 即可同步。对于使用 pnpm workspace 的项目,直接用 workspace:* 协议引用,无需发布到外部 registry。分包差异化配置(overrides)有些子包需要不同的格式化规则,比如 UI 库希望更宽的 printWidth,而后端服务保持 80 列。用 overrides 字段实现:{ "semi": true, "singleQuote": true, "printWidth": 80, "overrides": [ { "files": "packages/ui/**/*", "options": { "printWidth": 100 } }, { "files": "packages/server/**/*", "options": { "printWidth": 80 } }, { "files": "packages/docs/**/*.md", "options": { "proseWrap": "always", "printWidth": 90 } } ]}overrides 是在根配置基础上增量覆盖,不会丢失未显式指定的规则。这比在每个子包单独放 .prettierrc 更容易维护。.prettierignore 配置很多教程忽略了 .prettierignore,但它在 monorepo 中非常关键。典型的忽略规则:node_modulesdistbuildcoverage.next.turbo*.min.js*.min.csspnpm-lock.yamlpackage-lock.json不配 .prettierignore 会导致 prettier --write 扫描 node_modules 和构建产物,既浪费时间又可能报错。尤其在 monorepo 中,子包的 dist 目录层级深,手动排除不现实,需要用通配符一次搞定。与 ESLint 的冲突解决Prettier 和 ESLint 同时存在时,格式化规则会冲突。比如 ESLint 要求尾逗号,Prettier 又删掉尾逗号,来回打架。解决方案分两步:第一步:安装 eslint-config-prettier,它关闭所有与 Prettier 冲突的 ESLint 规则:pnpm add -wD eslint-config-prettier// .eslintrc.jsmodule.exports = { extends: [ "eslint:recommended", // 其他配置... "prettier" // 必须放最后,覆盖前面的格式化规则 ]};第二步(可选):如果想在 ESLint 中实时报告格式问题,安装 eslint-plugin-prettier:pnpm add -wD eslint-plugin-prettiermodule.exports = { plugins: ["prettier"], rules: { "prettier/prettier": "error" }};不过在 monorepo 中,更推荐的做法是分离职责:ESLint 只管代码质量,Prettier 只管格式,不要把 Prettier 嵌入 ESLint。这样运行更快,调试也更清晰。Husky + lint-staged 自动格式化提交时自动格式化是 monorepo 的标配实践:pnpm add -wD husky lint-stagedpnpm exec husky init// package.json{ "lint-staged": { "*.{js,jsx,ts,tsx}": ["prettier --write"], "*.{json,css,md}": ["prettier --write"] }}# .husky/pre-commitpnpm exec lint-staged这样每次 git commit 只会格式化暂存区的文件,而不是整个项目。对于 monorepo 来说,增量处理比全量扫描快得多。如果使用 pnpm workspace,可以把 lint-staged 配置放在根目录,它会自动根据修改文件的路径匹配对应规则。Turborepo 集成Turborepo 的缓存机制能避免重复格式化检查:// turbo.json{ "pipeline": { "format": { "outputs": [] }, "format:check": { "outputs": [] } }}// 根 package.json{ "scripts": { "format": "prettier --write .", "format:check": "prettier --check ." }}outputs 设为空数组是因为格式化不产生构建产物,Turborepo 只需要根据输入文件的变化判断是否需要重新执行。实际项目中,format:check 通常放在 CI 里,而 format 在本地开发时使用。Turborepo 会缓存未变更文件的结果,二次运行几乎零耗时。Nx 集成Nx 对 Prettier 有专门的 executor 支持:{ "targets": { "format": { "executor": "@nx/vite:format", "options": { "write": true } } }}Nx 的优势在于受影响项目检测——只格式化当前提交影响到的子包:nx format:check --projects=tag:scope:uinx format:write --projects=tag:scope:ui这在大型 monorepo 中比 prettier --write . 高效很多。Lerna 集成Lerna 的 --scope 选项可以针对特定子包执行格式化:lerna exec --scope @my-org/ui -- prettier --write "src/**/*.js"lerna exec --scope @my-org/core -- prettier --check "src/**/*.{ts,tsx}"Lerna 7 之后去除了内置的 lerna run 对 Prettier 的特殊处理,推荐直接在子包的 package.json 里加 format 脚本,然后用 lerna run format 批量执行。性能优化增量格式化——只处理 Git 暂存区中的变更文件:git diff --name-only --diff-filter=ACM HEAD | grep -E '\.(js|ts|tsx)$' | xargs prettier --write并行处理——多核同时跑,适合项目文件数过万的场景:find . -name "*.ts" -not -path "*/node_modules/*" | parallel -j 4 prettier --write缓存机制——Prettier 3.0 原生支持缓存:prettier --write --cache --cache-strategy content "src/**/*.ts"--cache-strategy content 基于文件内容哈希判断是否需要重新格式化,比默认的 metadata 策略更准确。首次运行生成缓存,后续未修改的文件直接跳过。真实场景:在一个 200+ 子包的 monorepo 中,全量 prettier --check . 需要 45 秒。加上缓存后,二次运行降至 3 秒以内。配合 lint-staged 只处理暂存文件,提交时的格式化检查几乎无感。CI/CD 集成在 GitHub Actions 中配置格式化检查:name: Format Checkon: [push, pull_request]jobs: format: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - run: npm ci - run: npm run format:check关键点:CI 中必须用 --check(只检查不修改),而不是 --write。如果格式不合格,CI 直接报错,开发者本地 format 后重新提交。如果用 Turborepo,可以配合缓存进一步加速:- uses: actions/cache@v4 with: path: .turbo key: turbo-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}- run: npx turbo format:check常见问题排查问题1:子包格式化规则不生效检查子包目录下是否有自己的 .prettierrc。如果存在,它会完全覆盖根配置。删除子包的 .prettierrc,改用根目录的 overrides 来定制规则。问题2:Prettier 和 ESLint 反复修改同一行确认 eslint-config-prettier 放在了 extends 数组的最后一位。如果放在前面,后续配置会重新开启被关闭的规则。问题3:CI 中格式检查通过但本地不通过(或反过来)通常是 Prettier 版本不一致导致的。在 monorepo 根目录统一安装 Prettier,子包不要单独安装。用 pnpm ls prettier 检查是否有多个版本。问题4:格式化速度过慢按优先级排查:1) 检查 .prettierignore 是否正确排除了 node_modules 和构建产物;2) 启用 --cache;3) 用 lint-staged 只处理变更文件;4) 考虑并行处理。追问为什么推荐共享配置包而不是根目录 .prettierrc?根目录配置对纯内部 monorepo 足够。但如果某些子包会独立发布到 npm,它们脱离 monorepo 上下文后就失去了根配置。共享配置包作为 npm 依赖,无论在不在 monorepo 中都能生效。Prettier 3.0 有哪些影响 monorepo 的变化?最大的变化是原生缓存支持(--cache)和 ESM 配置文件支持(prettier.config.mjs)。缓存对大型 monorepo 的性能提升显著。ESM 配置则允许在配置文件中动态导入其他模块,比如根据环境变量切换规则。
前端阅读 05月28日 07:24

Prettier 与 ESLint 有什么区别?如何协作使用?

Prettier 与 ESLint 有什么区别?如何协作使用?前端项目中,Prettier 和 ESLint 是最常搭配使用的两个工具,但它们的职责完全不同。理解各自的定位,才能正确配置和协作使用。Prettier 和 ESLint 各自负责什么Prettier 是代码格式化工具,只关心代码长什么样:统一缩进、引号、分号、换行等风格解析代码生成 AST 后重新输出,确保格式完全一致配置项很少(约20个),设计理念是"别吵了,就用这个"支持 JS/TS/CSS/HTML/JSON/Markdown 等多种语言ESLint 是代码质量检查工具,关心代码有没有问题:检测未使用变量、潜在错误、不安全的写法执行团队约定的编码规范(如禁用 var、要求 ===)拥有数千条可配置规则和丰富的插件生态仅针对 JavaScript/TypeScript核心区别一句话: Prettier 管"好不好看",ESLint 管"对不对"。为什么不能只用一个ESLint 虽然也有格式化规则(如缩进、引号),但能力有限且配置复杂。Prettier 的格式化效果更一致、覆盖语言更多,且几乎不需要团队争论配置。反过来,Prettier 完全不做代码质量检查,漏掉未使用变量、错误逻辑等问题会埋下隐患。两者结合是当前前端工程的标准做法。协作配置(ESLint Flat Config)从 ESLint v9 开始,官方推荐使用 Flat Config(eslint.config.js)替代旧版 .eslintrc。新配置方式如下:安装依赖:npm install --save-dev eslint prettier eslint-config-prettier配置 eslint.config.js:import js from "@eslint/js";import prettierConfig from "eslint-config-prettier";export default [ js.configs.recommended, prettierConfig, // 必须放在最后,关闭与 Prettier 冲突的规则 { rules: { "no-unused-vars": "warn", "prefer-const": "error", }, },];配置 .prettierrc:{ "semi": true, "singleQuote": true, "tabWidth": 2, "trailingComma": "es5"}关键点: eslint-config-prettier 必须放在配置数组最后,它会关闭所有与 Prettier 冲突的 ESLint 格式化规则,让 Prettier 独占格式化职责。旧版配置方式(.eslintrc)如果项目仍在使用旧版配置,这样设置:// .eslintrc.jsmodule.exports = { extends: [ "eslint:recommended", "prettier", // 放在最后 ],};eslint-plugin-prettier 还需要吗eslint-plugin-prettier 的作用是把 Prettier 的格式化结果作为 ESLint 规则来报告。Prettier 官方现在不再推荐这种方式,原因是:它让 ESLint 承担了格式化职责,导致运行变慢格式化问题被混在 ESLint 报错中,难以区分推荐做法是让两者各自独立运行编辑器集成在 VS Code 中配置自动格式化,保存时同时生效:// .vscode/settings.json{ "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }}保存时先由 Prettier 格式化,再由 ESLint 修复代码质量问题,顺序正确无冲突。Git 提交时自动检查配合 Husky 和 lint-staged,在提交代码时自动运行检查:npm install --save-dev husky lint-stagednpx husky init// package.json{ "lint-staged": { "*.{js,ts}": ["eslint --fix", "prettier --write"], "*.{css,html,md,json}": ["prettier --write"] }}# .husky/pre-commitnpx lint-staged这样只有暂存的文件会被检查,既保证代码质量又不影响提交效率。常见冲突与排查| 问题 | 原因 | 解决方式 ||------|------|----------|| ESLint 报缩进/引号错误 | 格式化规则与 Prettier 冲突 | 确认 eslint-config-prettier 在 extends 最后 || Prettier 格式化后 ESLint 仍报错 | 质量规则报错,非格式问题 | 检查具体规则,质量规则应保留 || 保存时格式化不生效 | 编辑器未配置或扩展未安装 | 检查 VS Code 扩展和 settings.json |可以用以下命令快速排查冲突规则:npx eslint-config-prettier path/to/.eslintrc.js执行顺序总结实际运行时的正确顺序:Prettier 先格式化代码(处理风格)ESLint 再检查代码质量(处理逻辑)两者通过 eslint-config-prettier 隔离职责,互不干扰掌握 Prettier 和 ESLint 的职责边界、正确配置方式以及常见冲突排查,是前端工程化基础设施的基本要求。
服务端阅读 05月28日 07:24

Nginx 如何实现访问控制?有哪些访问控制方法?

Nginx 如何实现访问控制?有哪些访问控制方法?Nginx 的访问控制是后端面试高频考点,核心思路是"在反向代理层拦截非法请求,减轻后端压力"。主要方法有五种:IP 黑白名单、HTTP 基本认证、请求方法限制、基于请求头的鉴权、地理/时间条件控制,实战中往往组合使用。下面逐一拆解原理和配置要点。一、IP 黑白名单:最基础的网络层控制Nginx 通过 allow / deny 指令按 IP 或 CIDR 段做访问控制,规则从上到下依次匹配,命中即生效:location /admin { allow 192.168.1.0/24; # 内网放行 allow 10.0.0.0/8; # VPN 段放行 deny all; # 其余全部拒绝 proxy_pass http://backend;}注意事项:当客户端经过代理时,$remote_addr 拿到的是代理 IP 而非真实客户端 IP,需要配合 $http_x_forwarded_for 或 realip 模块获取真实地址白名单优先于黑名单是安全最佳实践——默认拒绝,显式放行二、HTTP 基本认证:用户名密码验证使用 auth_basic + auth_basic_user_file 实现,密码文件通过 htpasswd 工具生成:location /admin { auth_basic "Admin Area"; auth_basic_user_file /etc/nginx/.htpasswd; proxy_pass http://backend;}生成密码文件:htpasswd -c /etc/nginx/.htpasswd admin_user关键点: Basic Auth 的凭据是 Base64 编码而非加密,生产环境务必搭配 HTTPS 使用,否则密码可被中间人截获。三、请求方法限制:只允许特定 HTTP 方法用 limit_except 指令比 if ($request_method) 更规范,它在 location 级别做方法白名单:location /api { limit_except GET POST { deny all; # 只允许 GET 和 POST,其他方法返回 403 } proxy_pass http://api_backend;}与 if 写法的区别: limit_except 是 Nginx 官方推荐的方式,不会触发 "if is evil" 问题,且与 satisfy 指令配合更好。四、基于请求头的鉴权:API Key、Referer、User-AgentAPI Key 校验:map $http_x_api_key $api_valid { default 0; "sk_prod_abc123" 1; "sk_prod_def456" 1;}location /api { if ($api_valid = 0) { return 401; } proxy_pass http://api_backend;}用 map 比 if 直接比较更灵活,支持多 key 映射且可集中管理。防盗链(Referer 校验):location /images/ { valid_referers none blocked server_names *.example.com; if ($invalid_referer) { return 403; } root /var/www/images;}UA 过滤: 屏蔽恶意爬虫if ($http_user_agent ~* (bot|crawler|spider|scraper)) { return 403;}五、地理与时间条件控制地理位置限制(基于 geo 模块):geo $allowed_country { default no; CN yes; US yes;}server { location / { if ($allowed_country = no) { return 403; } proxy_pass http://backend; }}如需精确到城市级,可用 geoip 模块配合 MaxMind 数据库。时间条件限制:map $time_iso8601 $business_hours { default 0; ~^(\d{4}-\d{2}-\d{2}T(09|1[0-9]|2[0-1])) 1;}location /admin { if ($business_hours = 0) { return 403; } proxy_pass http://backend;}适合管理后台只在工作时段开放的场景。六、组合策略:satisfy 指令与多层防护satisfy any 表示满足任一条件即可访问,satisfy all 表示必须全部满足:location /admin { satisfy any; # IP 白名单 或 密码认证,满足其一即可 allow 192.168.1.0/24; deny all; auth_basic "Admin Area"; auth_basic_user_file /etc/nginx/.htpasswd; proxy_pass http://backend;}实战建议: 管理后台常用 satisfy any——内网 IP 免密码,外网需要认证;API 接口常用 satisfy all——IP + Key 双重验证。七、安全加固:敏感文件与目录防护# 禁止访问隐藏文件(如 .git、.env)location ~ /\. { deny all; access_log off; log_not_found off;}# 禁止访问敏感后缀文件location ~* \.(htaccess|htpasswd|ini|log|sh|sql|bak|swp)$ { deny all; access_log off;}# 禁止目录遍历autoindex off;这些规则应作为 Nginx 配置的基线安全策略,防止信息泄露。面试追问与核心要点Q:Nginx 访问控制的执行顺序是什么?allow/deny 按配置顺序从上到下匹配,先命中先生效。location 内的规则优先于 server 级别,server 级别优先于 http 级别。Q:satisfy any 和 satisfy all 的区别?any 是"或"逻辑——IP 白名单和认证满足其一即可;all 是"与"逻辑——两者都必须通过。默认是 all。Q:代理场景下 IP 限制为什么不生效?因为 $remote_addr 拿到的是上一层代理的 IP。解决方案:使用 ngx_http_realip_module 设置 set_real_ip_from 和 real_ip_header X-Forwarded-For 还原真实客户端 IP。Q:if 指令在 location 中有什么陷阱?Nginx 的 if 在 location 中属于 rewrite 阶段,可能导致非预期行为("if is evil")。能用 map、limit_except、allow/deny 替代的就避免用 if。
服务端阅读 05月28日 07:23

Nginx 如何实现限流?有哪些限流策略?

Nginx 如何实现限流?有哪些限流策略?Nginx 限流的核心思路是控制单位时间内的请求量或并发连接数,防止后端服务被流量打垮。面试中这道题主要考察三个层面:你知道哪些限流模块、你理解底层算法吗、你在生产环境怎么用。限流的两种基本方式Nginx 提供两大限流模块:limit_req:限制请求速率,控制单位时间内允许的请求数limit_conn:限制并发连接数,控制同一时刻的 TCP 连接数两者的区别在于粒度——limit_req 关注的是请求频率(每秒多少个请求),limit_conn 关注的是连接数(同时存在多少个连接)。一个长连接可以承载多个请求,所以实际防护中通常两者配合使用。limit_req:请求速率限制http { limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; server { location /api/ { limit_req zone=api_limit burst=20 nodelay; limit_req_status 429; proxy_pass http://backend; } }}关键参数解读:$binary_remote_addr:以客户端 IP 作为限流键,二进制格式比字符串格式节省内存,10MB 共享内存大约能记录 16 万个 IPzone=api_limit:10m:定义共享内存区域名称和大小rate=10r/s:每秒允许 10 个请求,也可以用 r/m 表示每分钟burst=20:允许 20 个突发请求排队等待nodelay:突发请求不延迟处理,超出 burst 容量则直接拒绝limit_req_status 429:被限流时返回 429 而非默认的 503burst 和 nodelay 到底怎么配合?这是面试的高频追问,很多人配置过但说不清楚原理。只用 limit_req zone=api_limit:严格按 rate 执行,超出的请求直接 503,体验差。加 burst=20:允许 20 个请求排队,Nginx 按 rate 速率逐个处理队列中的请求,多余请求延迟响应。好处是不误杀,坏处是用户感知延迟。再加 nodelay:队列中的请求立即处理,不延迟响应,但队列满了还是拒绝。实际效果是「短时间内允许突发,超出就拒绝」,适合大多数 API 场景。简单记:burst 控制能容忍多少突发,nodelay 决定突发请求是延迟还是立即处理。limit_conn:并发连接数限制http { limit_conn_zone $binary_remote_addr zone=conn_limit:10m; server { limit_conn conn_limit 10; proxy_pass http://backend; }}这里 limit_conn conn_limit 10 表示同一个 IP 最多同时保持 10 个连接。注意这和 limit_req 不同——limit_req 限制的是请求的到达速率,limit_conn 限制的是连接的并发数量。典型场景:防止单个客户端通过大量并发连接耗尽服务器资源(如慢速攻击 Slowloris)。底层算法:漏桶与令牌桶面试中问限流,必然会追问算法原理。漏桶算法(Leaky Bucket)请求像水一样倒入桶中,桶以固定速率漏水。如果桶满了,新请求被丢弃。特点是输出速率恒定,不管输入多猛烈,处理速度始终平稳。limit_req 不加 burst 参数时就是典型的漏桶行为——严格按 rate 处理,超出直接拒绝。令牌桶算法(Token Bucket)系统以固定速率往桶里放令牌,每个请求需要取走一个令牌。桶满了令牌不再增加。与漏桶的区别在于:令牌桶允许突发——桶里攒够了令牌时,可以一次性处理一批请求。limit_req 加上 burst 参数就实现了类似令牌桶的效果,允许一定程度的流量突发。核心区别:漏桶强制匀速输出,令牌桶允许有限突发。Nginx 的 limit_req 实际上是两者的结合——基础速率是漏桶,burst 提供了令牌桶式的突发能力。带宽限制除了请求和连接层面的限流,Nginx 还能限制响应传输速率:location /download/ { limit_rate 1m; limit_rate_after 10m; root /var/www/files;}limit_rate 1m:限速 1MB/slimit_rate_after 10m:前 10MB 不限速,之后才限速适用于大文件下载场景,防止少数大流量用户占满带宽。白名单与动态限流生产环境中通常需要对内部 IP 或特定请求方法豁免限流。基于 geo 的白名单:geo $limit_key { default $binary_remote_addr; 192.168.1.0/24 ""; 10.0.0.0/8 "";}limit_req_zone $limit_key zone=whitelist:10m rate=10r/s;白名单内的 IP 对应空字符串,不参与限流计算。基于请求方法的动态限流:map $request_method $limit_key { default $binary_remote_addr; GET ""; HEAD "";}GET 和 HEAD 请求不限流,其他方法(POST、PUT 等)参与限流,适合写操作需要更严格控制的场景。多层限流实战配置http { # 全局限流 limit_req_zone $binary_remote_addr zone=global:10m rate=50r/s; # API 接口限流 limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; # 登录接口限流 limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; # 连接数限制 limit_conn_zone $binary_remote_addr zone=conn:10m; limit_req_status 429; limit_conn_status 429; server { limit_conn conn 20; location / { limit_req zone=global burst=50 nodelay; proxy_pass http://backend; } location /api/ { limit_req zone=global burst=50 nodelay; limit_req zone=api burst=10 nodelay; proxy_pass http://api_backend; } location /login { limit_req zone=login burst=2 nodelay; proxy_pass http://auth_backend; } }}这套配置的思路是分层防护:全局兜底防 DDoS,API 层控制接口频率,登录接口单独严控防暴力破解。同一个 location 可以叠加多个 limit_req,任一规则触发都会拒绝请求。限流日志与监控log_format limit '$remote_addr - [$time_local] "$request" ' '$status limit=$limit_req_status';limit_req_log_level warn;$limit_req_status 变量记录限流状态,limit_req_log_level 控制限流日志级别。生产环境建议用 warn 级别,避免日志量过大。配合 ELK 或 Prometheus 可以做限流趋势分析和告警。生产环境的几个经验阈值不是拍脑袋定的——先压测后端服务的极限 QPS,限流值设在其 70%-80% 作为安全水位429 响应要友好——返回 JSON 格式的错误提示,带上 Retry-After 头告诉客户端多久后重试zone 内存别省——10MB 约存 16 万 IP,如果用户量大要相应调大,内存耗尽后新请求直接 503burst 要结合业务——API 类场景 burst 可以小一些(5-10),页面访问场景可以大一些(20-50)限流不是万能的——在 Nginx 层限流只能防住从外到内的流量冲击,内部服务间的调用保护需要 Sentinel 或熔断器关注误杀——公司出口 IP 共享场景下,单 IP 限流会误伤同一 NAT 后的多个用户,可考虑基于 token 或租户维度的限流键追问:limitreq 和 limitconn 该选哪个?都要用。limit_req 防高频请求冲击,limit_conn 防连接数耗尽,两者解决不同问题。面试中如果只答一个,会被认为理解不全面。追问:Nginx 限流有什么局限?单机维度的限流,分布式环境下需要 Redis + Lua 或专门的限流服务限流键有限,复杂业务逻辑(如按用户等级限流)需要结合 OpenResty 或网关层limit_req 基于共享内存,重启后状态丢失不支持滑动窗口计数,只有固定时间窗口的速率计算
服务端阅读 05月28日 07:19

Redis 事务、Lua 脚本和分布式锁的实现原理和使用场景是什么?

Redis 事务、Lua 脚本和分布式锁是 Redis 面试中出现频率最高的三个高级特性,很多候选人只能说出命令用法,却讲不清背后的原理和边界,面试官一追问就卡壳。下面逐一拆解。Redis 事务的原理与局限Redis 事务通过 MULTI、EXEC、DISCARD、WATCH 四个命令协作完成。MULTI 开启事务后,后续命令进入队列而非立即执行;EXEC 一次性提交队列中的所有命令;DISCARD 放弃事务;WATCH 实现乐观锁,监控 key 是否在事务提交前被修改。WATCH balanceMULTIDECRBY balance 100INCRBY expense 100EXEC如果 WATCH 监控的 key 在 EXEC 之前被其他客户端修改,整个事务会被丢弃,返回 nil。事务的核心特点:命令按顺序执行,不会被其他客户端插入,这是隔离性的保证。不支持回滚。如果队列中某条命令执行失败(比如对字符串执行 LPUSH),其余命令照常执行。Redis 官方的设计哲学是:命令失败属于编程错误,应在开发阶段发现,不值得为此牺牲性能。无法使用中间结果。事务中的命令不能引用前一条命令的返回值,这极大限制了事务的表达能力。这些局限正是 Lua 脚本存在的理由。追问:Redis 事务为什么不支持回滚?Redis 作者 antirez 的原话是:回滚需要保存命令执行前的状态,这会引入与 AOF 持久化类似的复杂度,而命令失败本质是 bug,不应该出现在生产环境。所以 Redis 选择不支持回滚,换来更简单、更快的实现。Lua 脚本为什么能替代事务Lua 脚本在 Redis 服务端以原子方式执行,执行期间不会处理其他客户端的命令。和事务相比,Lua 脚本的核心优势在于可以读取中间结果并做条件判断。EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey myvalue先用 SCRIPT LOAD 获取脚本 SHA1 校验和,后续用 EVALSHA 执行,避免每次传输完整脚本:SCRIPT LOAD "return redis.call('SET', KEYS[1], ARGV[1])"# 返回 sha1sumEVALSHA <sha> 1 mykey myvalue几个典型的 Lua 脚本应用场景:原子性 CAS 操作——只有当值等于预期时才更新:local current = redis.call('GET', KEYS[1])if current == ARGV[1] then redis.call('SET', KEYS[1], ARGV[2]) return 1else return 0end滑动窗口限流器——一次执行完成过期清理、计数检查、记录添加三个步骤:local key = KEYS[1]local limit = tonumber(ARGV[1])local window = tonumber(ARGV[2])redis.call('ZREMRANGEBYSCORE', key, '-inf', window)local count = redis.call('ZCARD', key)if count < limit then redis.call('ZADD', key, window, ARGV[3]) redis.call('EXPIRE', key, math.ceil((window - tonumber(ARGV[4])) / 1000)) return 1else return 0endLua 脚本需要注意三点:执行时间不能太长,否则阻塞整个 Redis 实例(默认 5 秒超时);不能使用随机函数(如 math.random),否则主从复制结果不一致;不能执行阻塞命令(如 BLPOP)。追问:Lua 脚本出错会怎样?Lua 脚本运行时出错会立即停止,但已执行的 Redis 命令不会被撤销——这点和事务一致,都不满足严格意义上的原子性。从外部看,脚本的执行是不可分割的;从内部看,部分成功部分失败是可能的。分布式锁的三种实现与踩坑分布式锁要解决的核心问题:在多进程、多机器环境下,保证同一时刻只有一个客户端能操作共享资源。SETNX + EXPIRE 的问题最早的做法是先 SETNX 获取锁,再 EXPIRE 设置过期时间。这两步不是原子操作——如果 SETNX 成功后客户端崩溃,锁永远不释放,造成死锁。SET NX EX(推荐的基础方案)Redis 2.6.12 起 SET 命令支持 NX 和 EX 参数,一条命令完成加锁和设置过期时间:public boolean tryLock(String key, String value, int expireSeconds) { String result = jedis.set(key, value, "NX", "EX", expireSeconds); return "OK".equals(result);}释放锁必须用 Lua 脚本保证原子性——先判断值是否为自己持有,再删除:if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1])else return 0end为什么不能直接 DEL?因为锁可能已经过期并被其他客户端获取,直接 DEL 会删掉别人的锁。追问:主从切换导致锁丢失怎么办?考虑这个时序:客户端 A 获取锁 → 主节点写入成功但尚未同步到从节点 → 主节点宕机 → 从节点升为主节点 → 客户端 B 获取同一把锁 → 两个客户端同时持有锁。Redlock 算法就是为解决这个问题设计的。Redlock 算法Redlock 向 N 个(通常 5 个)独立的 Redis 实例获取锁,只要在大多数实例(≥3)上成功,且总耗时未超过锁的有效期,就认为加锁成功。这依赖的是时钟同步和多数派决策,不依赖主从复制。Redlock 的争议:Martin Kleppmann 在《How to do distributed locking》一文中指出 Redlock 依赖系统时钟,当时钟跳变时可能出错,建议使用 fencing token 方案。antirez 专门写了长文反驳。实际工程中,大多数团队选择接受 Redlock 的概率性安全保证,或直接使用单节点锁 + 幂等设计来规避风险。Redisson 的工程级实现Redisson 是 Java 生态最成熟的 Redis 分布式锁实现,提供了可重入锁、公平锁、读写锁、联锁等多种锁类型。核心设计:看门狗机制:默认锁过期时间 30 秒,Redisson 会启动一个后台线程,每 10 秒(过期时间的 1/3)自动续期,直到显式释放或客户端宕机。这解决了长任务执行期间锁过期被其他客户端获取的问题。RLock lock = redisson.getLock("myLock");try { if (lock.tryLock(10, 30, TimeUnit.SECONDS)) { // 执行业务逻辑 }} finally { lock.unlock();}可重入性:Redisson 使用 Hash 结构存储锁信息,field 是客户端 ID + 线程 ID,value 是重入次数,加锁时重入次数 +1,解锁时 -1,减到 0 才真正释放。整个逻辑用 Lua 脚本保证原子性。事务、Lua 脚本与分布式锁的选型| 维度 | 事务 | Lua 脚本 | 分布式锁 ||------|------|----------|---------|| 原子性 | 命令不可插入,但不支持回滚 | 同事务 | 依赖实现方式 || 条件判断 | 不支持 | 完全支持 | 通过 Lua 脚本支持 || 网络开销 | 多次 RTT(除非 pipeline) | 一次 RTT | 多次 RTT || 典型场景 | 简单批量操作 | CAS、限流、复杂业务逻辑 | 跨进程互斥 |选择原则:事务适合不需要中间结果的简单批量操作;Lua 脚本适合需要条件判断、依赖中间结果的场景;分布式锁解决的是跨进程互斥问题,本质上依赖 Lua 脚本保证操作的原子性。面试中容易被追问的几个点Redis 事务和 MySQL 事务有什么区别? Redis 事务不支持回滚,没有隔离级别,不保证持久性——它只是批量执行命令的机制,和 ACID 事务有本质区别。Lua 脚本执行期间 Redis 宕机怎么办? 如果开启了 AOF 持久化,已执行的命令会被记录,重启后重放。如果没有持久化,数据丢失。关键点是 Lua 脚本的"原子性"只保证执行期间不被打断,不保证持久性和严格的原子性。分布式锁的过期时间怎么设置? 太短容易导致任务未完成锁就被释放,太长会导致客户端宕机后其他客户端等待过久。Redisson 的看门狗机制是最佳实践,动态续期比固定过期时间更可靠。
前端阅读 05月28日 07:18

Puppeteer 性能优化有哪些核心策略?

Puppeteer 在爬虫和自动化测试场景下,性能瓶颈主要来自 Chromium 的资源消耗——每次启动一个浏览器实例就要占 50-100MB 内存,每个 Page 再加 30-80MB,而页面加载时的网络 I/O 和 DOM 渲染又是时间上的最大开销。理解哪些环节最耗资源,才能对症下药。核心优化方向有三个:减少浏览器开销、降低页面加载成本、合理管理并发与内存。浏览器启动与实例管理每次 puppeteer.launch() 都会启动一个完整的 Chromium 进程,开销约 50-100MB 内存。批量任务中复用浏览器实例是最基本也最有效的优化:const browser = await puppeteer.launch({ headless: 'new', args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--disable-gpu', '--window-size=1920,1080' ]});// 复用同一个 browser,每次任务只开新 pagefor (const url of urls) { const page = await browser.newPage(); await page.goto(url); // ... 执行任务 await page.close();}await browser.close();启动参数中几个关键项的作用:headless: 'new' — 使用 Chrome 的新版 Headless 模式,比旧版 headless 快约 20-30%,因为它不再走单独的渲染路径,而是和有头模式共享同一套代码--disable-dev-shm-usage — 在 Docker 等共享内存受限的环境中必不可少,否则 Chromium 会因 /dev/shm 空间不足而崩溃,改用 /tmp 目录--no-sandbox — 在容器内运行时需要关闭沙盒,因为容器通常没有足够的权限创建命名空间--disable-gpu — 无头模式下不需要 GPU 加速,关闭后可减少一个 GPU 进程的内存开销对于长时间运行的任务,Chromium 存在内存泄漏倾向,运行上千次后内存占用可能翻倍。建议定期重启浏览器实例:let browser;let taskCount = 0;const RESTART_THRESHOLD = 500;async function getBrowser() { if (!browser || taskCount >= RESTART_THRESHOLD) { if (browser) await browser.close(); browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-dev-shm-usage'] }); taskCount = 0; } taskCount++; return browser;}重启阈值需要根据实际内存监控数据调整。一个实用的监控方式是在每次任务后检查进程内存:const used = process.memoryUsage();if (used.rss > 1024 * 1024 * 1024) { // 超过 1GB await browser.close(); browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] });}页面加载策略页面加载是时间消耗最大的环节。默认的 waitUntil: 'load' 会等待所有资源(图片、CSS、字体、JS)加载完成,对爬虫来说往往不必要。// 爬虫场景:DOM 就绪即可开始提取数据await page.goto(url, { waitUntil: 'domcontentloaded' });// 需要 JS 渲染完成后提取动态内容await page.goto(url, { waitUntil: 'networkidle2' });// 需要确保所有异步请求都完成(如懒加载图片)await page.goto(url, { waitUntil: 'networkidle0' });四种策略的耗时对比:domcontentloaded 比 load 快 2-5 倍,比 networkidle0 快 5-10 倍,具体差距取决于页面资源量。选择策略时遵循一个原则:能用 domcontentloaded 就不用 load,能用 networkidle2 就不用 networkidle0。还有一种更精细的做法:先用 domcontentloaded 完成初始加载,再手动 waitForSelector 等待关键元素出现:await page.goto(url, { waitUntil: 'domcontentloaded' });await page.waitForSelector('.data-table', { timeout: 5000 });// 比直接用 networkidle0 更精准,不会浪费时间等无关请求拦截不必要的网络请求可以进一步降低加载时间和内存占用:await page.setRequestInterception(true);page.on('request', (request) => { const blocked = ['image', 'font', 'media', 'stylesheet']; if (blocked.includes(request.resourceType())) { request.abort(); } else { request.continue(); }});这个优化在抓取纯文本数据的场景下效果显著——页面加载速度可提升 50% 以上,内存占用降低 30-40%。但如果需要截屏或分析页面视觉布局,图片和样式表不能拦截,需根据场景灵活调整拦截列表。设置合理的超时时间同样重要,避免因个别慢页面拖垮整体效率:await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 // 15 秒超时,不给慢页面无限等待});并发控制与连接池Promise.all 可以并行处理多个页面,但无限制的并发会导致内存飙升和 CPU 争抢,甚至触发系统 OOM Killer。实际生产中必须控制并发数:async function processWithConcurrency(urls, concurrency = 3) { const browser = await puppeteer.launch({ headless: 'new' }); const results = []; for (let i = 0; i < urls.length; i += concurrency) { const batch = urls.slice(i, i + concurrency); const batchResults = await Promise.all( batch.map(async (url) => { const page = await browser.newPage(); try { await page.goto(url, { waitUntil: 'domcontentloaded' }); return await page.evaluate(() => document.body.innerText); } finally { await page.close(); } }) ); results.push(...batchResults); } await browser.close(); return results;}并发数的选择取决于机器配置:每打开一个 Page 大约需要 30-80MB 内存。一台 4GB 内存的机器,并发 5-10 个 Page 就接近极限。8GB 内存可以开 10-20 个并发,但还要考虑 CPU 核心数——Chromium 每个渲染进程都会占一个核心,并发数超过核心数时进程切换开销会抵消并发收益。更推荐的做法是使用 puppeteer-cluster 库,它内置了并发控制、自动重试和错误处理:const { Cluster } = require('puppeteer-cluster');const cluster = await Cluster.launch({ concurrency: Cluster.CONCURRENCY_CONTEXT, maxConcurrency: 5, puppeteerOptions: { headless: 'new' }});await cluster.task(async ({ page, data: url }) => { await page.goto(url, { waitUntil: 'domcontentloaded' }); const data = await page.evaluate(() => document.body.innerText); return data;});urls.forEach(url => cluster.queue(url));await cluster.idle();await cluster.close();puppeteer-cluster 的 CONCURRENCY_CONTEXT 模式使用 BrowserContext 而非新 Page 来隔离任务。Context 的创建和销毁比 Page 更轻量,且不会共享 Cookie 和存储——这对爬虫场景很关键,避免不同任务的登录态互相干扰。如果需要更强的隔离(不同 User-Agent、不同代理),可以用 CONCURRENCY_BROWSER 模式,每个任务一个独立的浏览器实例,代价是内存开销更大。对于更大规模的爬虫系统,可以实现一个浏览器连接池:class BrowserPool { constructor(maxSize = 3) { this.maxSize = maxSize; this.browsers = []; this.queue = []; } async init() { for (let i = 0; i < this.maxSize; i++) { this.browsers.push(await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-dev-shm-usage'] })); } } async acquire() { if (this.browsers.length > 0) { return this.browsers.pop(); } return new Promise(resolve => this.queue.push(resolve)); } release(browser) { if (this.queue.length > 0) { this.queue.shift()(browser); } else { this.browsers.push(browser); } } async closeAll() { await Promise.all(this.browsers.map(b => b.close())); }}内存泄漏防治内存泄漏是 Puppeteer 长时间运行的最大敌人。常见的泄漏源有三类:未关闭的 Page、未关闭的 BrowserContext、事件监听器未移除。务必在 finally 块中关闭资源:async function safeScrape(url) { const page = await browser.newPage(); try { await page.goto(url); return await page.evaluate(() => document.title); } finally { await page.close(); // 无论成功还是异常,都关闭 page }}使用 BrowserContext 隔离任务:const context = await browser.createIncognitoBrowserContext();const page = await context.newPage();try { await page.goto(url); // ... 执行任务} finally { await context.close(); // 关闭 context 会同时关闭所有属于它的 page}context.close() 比 page.close() 更彻底,它会清理该上下文下的所有页面、Cookie、LocalStorage 和缓存,防止跨任务数据污染。特别是当一个任务的 Cookie 会影响另一个任务的结果时(比如不同账号登录态),Context 隔离是必须的。通过 CDP 定期清理浏览器数据:const client = await page.target().createCDPSession();await client.send('Network.clearBrowserCache');await client.send('Network.clearBrowserCookies');相比 Puppeteer 的 page.deleteCookie() 和 page.evaluate(() => localStorage.clear()),CDP 方式更高效——一条命令就能清空所有缓存和 Cookie,而不需要逐个删除。移除不再需要的事件监听器:const handler = (request) => { /* ... */ };page.on('request', handler);// 任务完成后移除page.off('request', handler);未移除的监听器会持有对 page 对象的引用,阻止垃圾回收,是隐蔽但常见的泄漏源。选择器与执行效率Puppeteer 的 Node.js 进程和 Chromium 进程是分离的,page.$()、page.evaluate() 之间的每次调用都需要跨进程通信(IPC),涉及数据的序列化和反序列化。减少 IPC 调用次数是提升执行速度的关键:// 低效:3 次 IPC 调用const title = await page.$eval('.title', el => el.textContent);const price = await page.$eval('.price', el => el.textContent);const desc = await page.$eval('.desc', el => el.textContent);// 高效:1 次 IPC 调用完成所有提取const data = await page.evaluate(() => ({ title: document.querySelector('.title')?.textContent, price: document.querySelector('.price')?.textContent, desc: document.querySelector('.desc')?.textContent}));一次性提取所有数据比多次 $eval 快 3-5 倍,因为只产生一次 IPC 开销。这条规则在实际优化中经常被忽略,但对高频调用场景影响显著。另一个常见的低效模式是反复查询同一个元素:// 低效:每次都重新查找 DOMfor (let i = 0; i < 10; i++) { const text = await page.$eval('.item', (el, i) => el.children[i].textContent, i);}// 高效:一次提取所有子元素文本const texts = await page.evaluate(() => Array.from(document.querySelectorAll('.item')).map(el => el.textContent));选择器本身的效率也有差异:ID 选择器 > Class 选择器 > 标签选择器。但在爬虫场景下,选择器通常由目标页面的 DOM 结构决定,优化空间有限。真正值得投入精力的是减少 IPC 调用次数。CDP 进阶:性能监控与分析CDP(Chrome DevTools Protocol)是 Puppeteer 的底层协议,通过 createCDPSession() 可以访问比 Puppeteer API 更底层的功能,获取更详细的性能数据:const client = await page.target().createCDPSession();// 获取页面性能指标await client.send('Performance.enable');const { metrics } = await client.send('Performance.getMetrics');// 关键指标:// - JSHeapUsedSize:JS 堆已使用大小// - Nodes:DOM 节点数量(过多说明可能有泄漏)// - LayoutCount:布局重排次数(过多说明 DOM 操作低效)Performance.getMetrics 返回的指标可以帮助判断瓶颈在哪:JSHeapUsedSize 持续增长说明有内存泄漏,Nodes 过多说明 DOM 操作需要优化,LayoutCount 高说明频繁触发了重排。性能追踪:await page.tracing.start({ path: 'trace.json' });await page.goto(url);await page.tracing.stop();// 用 chrome://tracing 打开 trace.json 进行可视化分析生成的 trace.json 可以在 Chrome 的 chrome://tracing 页面加载,直观看到每个阶段的耗时分布——脚本执行、布局计算、绘制、网络请求各占多少时间。这在定位"页面加载慢到底是卡在哪里"时非常有效。网络监控:await client.send('Network.enable');client.on('Network.responseReceived', ({ response }) => { if (response.status >= 400) { console.log(`请求失败: ${response.url} - ${response.status}`); }});通过 CDP 监听网络事件,可以记录所有请求的状态码和耗时,帮助发现哪些第三方请求拖慢了页面,或者哪些接口返回了错误。反检测与稳定性频繁请求同一站点会触发反爬机制,导致性能骤降(验证码、封 IP、返回空白页)。虽然这不算传统意义上的"性能优化",但反爬触发后带来的重试和超时会严重影响整体效率。几个基本措施:隐藏 WebDriver 特征:await page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, 'webdriver', { get: () => false }); // 修复 permissions.query 在 headless 中的异常 const originalQuery = window.navigator.permissions.query; window.navigator.permissions.query = (parameters) => parameters.name === 'notifications' ? Promise.resolve({ state: Notification.permission }) : originalQuery(parameters);});evaluateOnNewDocument 在页面脚本执行前注入,确保页面检测时 navigator.webdriver 已经是 false。随机化操作间隔:const delay = Math.floor(Math.random() * 1000) + 500; // 500-1500ms 随机延迟await new Promise(resolve => setTimeout(resolve, delay));await page.click('.next-page');匀速访问是最明显的机器特征。加入随机延迟后,请求模式更接近真实用户,降低被风控系统标记的概率。设置合理的 User-Agent:await page.setUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');默认的 User-Agent 包含 "HeadlessChrome",是反爬系统最容易识别的特征之一。替换为真实浏览器的 UA 是最基本的反检测措施。这些措施不能绕过所有检测(比如基于 TLS 指纹的检测),但能显著降低被初级反爬系统识别的概率,避免因触发反爬导致的重试和超时,间接提升整体效率。核心优化优先级按照投入产出比排序,从高到低:复用浏览器实例 — 改动最小,收益最大,避免每次任务都启动 Chromium 进程选择合适的 waitUntil — 一行代码的改动,可能节省数秒等待时间拦截无用资源 — 爬虫场景下效果最显著,加载速度和内存双赢控制并发数 — 防止资源耗尽导致整体性能下降甚至系统崩溃finally 中关闭 Page/Context — 防止内存泄漏,保证长时间运行稳定合并 evaluate 调用 — 减少 IPC 开销,高频场景下效果明显定期重启浏览器 — 兜底策略,解决 Chromium 自身的内存泄漏问题面试中回答这个问题的关键不是罗列所有策略,而是说清楚每个优化解决了什么瓶颈,以及不同场景下的取舍——比如拦截资源在纯数据抓取中合适,但截屏场景下不行;domcontentloaded 快但可能拿不到 JS 渲染后的内容;并发数不是越多越好,要结合内存和 CPU 核心数综合考量。
前端阅读 05月28日 07:18

Puppeteer 如何实现页面截图与 PDF 生成?

核心答案Puppeteer 通过 page.screenshot() 和 page.pdf() 两个核心方法实现截图与 PDF 生成。截图支持全页、元素级别、裁剪区域等多种模式,可输出 PNG/JPEG 格式;PDF 生成基于 Chrome 的打印渲染引擎,支持自定义纸张、边距、页眉页脚等配置。两者均依赖 Headless Chrome 的渲染能力,PDF 生成仅支持无头模式。截图 API 详解page.screenshot() 的关键参数screenshot 方法接受一个可选配置对象,以下参数在实际开发中使用频率最高:path:文件保存路径,决定输出位置type:png 或 jpeg,PNG 支持透明通道,JPEG 体积更小quality:0-100,仅 JPEG 有效,推荐 80-90 之间平衡质量与体积fullPage:是否截取完整滚动区域,默认只截视口clip:{x, y, width, height} 裁剪指定区域omitBackground:设为 true 时背景透明,需配合 PNG 格式captureBeyondViewport:Puppeteer 9+ 新增,控制是否捕获视口外内容// 全页截图——最常用的场景await page.screenshot({ path: 'full.png', fullPage: true });// 裁剪区域截图await page.screenshot({ path: 'clip.png', clip: { x: 100, y: 100, width: 800, height: 600 }});// 透明背景截图(生成水印素材等场景)await page.screenshot({ path: 'transparent.png', type: 'png', omitBackground: true});元素级截图对特定 DOM 元素截图是自动化测试中的高频需求,直接调用元素实例的 screenshot 方法:const element = await page.$('.chart-container');await element.screenshot({ path: 'chart.png' });元素截图时注意:不支持 fullPage 参数,截图范围由元素自身尺寸决定。如果元素存在 overflow: hidden,被裁剪的部分不会出现在截图中。视口控制与截图的关系截图的默认范围是当前视口,视口尺寸通过 setViewport 设置:await page.setViewport({ width: 1920, height: 1080 });await page.screenshot({ path: 'desktop.png' });await page.setViewport({ width: 375, height: 667 });await page.screenshot({ path: 'mobile.png' });响应式测试中通常会循环切换多种视口尺寸,每种尺寸截一张图做对比。PDF 生成 API 详解page.pdf() 的关键参数pdf 方法基于 Chrome 的 Page.printToPDF 协议实现,核心参数如下:format:纸张格式,A0 到 A6、Letter、Legal、Tabloid、Ledgerlandscape:true 横向打印margin:{top, right, bottom, left} 页边距printBackground:是否渲染背景色和背景图,默认 falsedisplayHeaderFooter:是否显示页眉页脚headerTemplate / footerTemplate:HTML 模板字符串,支持 <span class="pageNumber"> 和 <span class="totalPages"> 特殊变量pageRanges:打印页码范围,如 '1-5, 8'scale:缩放比例,默认 1preferCSSPageSize:优先使用 CSS @page 定义的尺寸// 标准 A4 PDFawait page.pdf({ path: 'doc.pdf', format: 'A4' });// 带页眉页脚的 PDFawait page.pdf({ path: 'with-footer.pdf', format: 'A4', displayHeaderFooter: true, footerTemplate: '<div style="font-size:9px;text-align:center;width:100%;">第 <span class="pageNumber"></span> 页 / 共 <span class="totalPages"></span> 页</div>', margin: { top: '1cm', right: '1cm', bottom: '1.5cm', left: '1cm' }});// 横向 + 自定义纸张await page.pdf({ path: 'landscape.pdf', width: '297mm', height: '210mm', landscape: true});PDF 生成的限制与注意事项必须在无头模式下运行——这是最容易被忽略的限制。有头模式下调用 page.pdf() 会直接抛异常。如果项目需要同时进行可视化调试和 PDF 生成,可以通过环境变量动态切换:const browser = await puppeteer.launch({ headless: process.env.GENERATE_PDF ? 'new' : false});字体缺失问题在 Linux 服务器上尤为常见。中文字符渲染成方块或空白,是因为系统缺少中文字体。解决方案是安装字体包(如 fonts-noto-cjk)或将字体文件打包进项目。背景色丢失是因为 printBackground 默认为 false。CSS 中的 background-color 和 background-image 不会出现在 PDF 中,必须显式设置 printBackground: true。截图与 PDF 的实战场景场景一:网页归档与合规存证金融、法务等行业需要对网页内容做定期归档,保存为 PDF 是最常见的做法:async function archivePage(url, outputPath) { const browser = await puppeteer.launch({ headless: 'new' }); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'networkidle2' }); await page.pdf({ path: outputPath, format: 'A4', printBackground: true, margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' } }); await browser.close();}waitUntil: 'networkidle2' 确保异步加载的内容全部渲染完毕。对于 SPA 页面,可能需要额外 waitForSelector 等待关键 DOM 挂载。场景二:批量截图的响应式测试同时输出多种设备尺寸的截图,用于视觉回归检测:async function responsiveScreenshots(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); const viewports = [ { name: 'mobile', width: 375, height: 667 }, { name: 'tablet', width: 768, height: 1024 }, { name: 'desktop', width: 1920, height: 1080 } ]; for (const vp of viewports) { await page.setViewport(vp); await page.goto(url, { waitUntil: 'networkidle2' }); await page.screenshot({ path: `${vp.name}.png`, fullPage: true }); } await browser.close();}场景三:发票/报告 PDF 生成用 page.setContent() 注入 HTML 模板,再调用 page.pdf() 生成 PDF,是后端动态生成文档的经典方案:async function generateInvoice(data) { const browser = await puppeteer.launch({ headless: 'new' }); const page = await browser.newPage(); await page.setContent(buildInvoiceHTML(data), { waitUntil: 'networkidle0' }); await page.pdf({ path: `invoice_${data.number}.pdf`, format: 'A4', printBackground: true, margin: { top: '20px', right: '20px', bottom: '20px', left: '20px' } }); await browser.close();}模板中的样式使用内联 CSS 或 <style> 标签,不要依赖外部样式表——setContent 不会自动加载外部资源。性能优化策略浏览器实例复用每次截图或生成 PDF 都启动浏览器实例开销很大,推荐复用同一个 browser 对象:const browser = await puppeteer.launch();for (const url of urls) { const page = await browser.newPage(); await page.goto(url, { waitUntil: 'networkidle2' }); await page.screenshot({ path: `${Date.now()}.png` }); await page.close();}await browser.close();并行处理Promise.all 配合多个 page 实例实现并行,但要注意控制并发数量,防止内存溢出:const CONCURRENCY = 3;for (let i = 0; i < urls.length; i += CONCURRENCY) { const batch = urls.slice(i, i + CONCURRENCY); await Promise.all(batch.map(async (url) => { const page = await browser.newPage(); await page.goto(url); await page.screenshot({ path: `${Date.now()}.png` }); await page.close(); }));}拦截无关资源截图和 PDF 生成通常不需要图片、字体、音视频资源,拦截这些请求能显著提速:await page.setRequestInterception(true);page.on('request', (req) => { const blocked = ['image', 'font', 'media', 'stylesheet']; if (blocked.includes(req.resourceType())) { req.abort(); } else { req.continue(); }});注意:PDF 生成如果需要保留样式,不应拦截 stylesheet 资源。常见问题与排查思路截图出现空白或加载不全——检查是否使用了正确的 waitUntil 策略。domcontentloaded 只等 DOM 解析,不等待图片和异步内容。推荐 networkidle2,它在网络连接不超过 2 个时认为加载完成。对于懒加载页面,需要手动滚动到底部触发加载后再截图。PDF 分页位置不理想——Chrome 的分页算法基于内容高度计算,无法精确控制。可以通过 CSS break-before、break-after、break-inside: avoid 属性影响分页行为。中文字体渲染异常——Linux 服务器需要安装中文字体包。Docker 环境下建议在 Dockerfile 中添加 RUN apt-get install -y fonts-noto-cjk。内存持续增长——确保每次操作后调用 page.close(),避免 page 实例泄漏。长时间运行的脚本建议定期重启 browser 实例。超时错误——复杂页面可能需要更长的加载时间,通过 timeout 参数调整:await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });面试追问方向page.pdf() 为什么只支持无头模式?因为 PDF 生成调用的是 Chrome DevTools Protocol 的 Page.printToPDF,该协议只在 headless 模式下可用。有头模式下的打印走的是系统打印对话框,无法通过 CTP 直接输出文件。如何实现懒加载页面的完整截图?需要先注入滚动脚本逐步触发懒加载,等所有内容挂载后再截图。Puppeteer 截图和 html2canvas 有什么区别?Puppeteer 在真实浏览器渲染后截图,结果与用户看到的一致;html2canvas 在 JS 层重新绘制 DOM,对 CSS 支持有限,跨域图片等场景容易出问题。
前端阅读 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:17

Puppeteer 如何实现网络请求拦截?有哪些实际应用场景?

Puppeteer 通过 CDP(Chrome DevTools Protocol)提供的 Network 域能力实现请求拦截,核心 API 是 page.setRequestInterception(true)。启用后,每个请求都会被暂停,必须手动调用 continue()、abort() 或 respond() 才能放行。这一机制在爬虫加速、接口 Mock、安全测试等场景中非常实用。启用请求拦截的基本方式const puppeteer = require("puppeteer");(async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); // 启用请求拦截 await page.setRequestInterception(true); page.on("request", (request) => { // 每个请求必须被处理,否则页面会卡住 request.continue(); }); await page.goto("https://example.com"); await browser.close();})();关键点:setRequestInterception(true) 必须在页面导航前调用;每个被拦截的请求必须调用 continue()、abort() 或 respond() 之一,否则请求会一直挂起。请求拦截的四种核心操作continue —— 放行请求直接放行原始请求,也可以在放行的同时修改请求参数:page.on("request", (request) => { // 修改请求头后放行 request.continue({ headers: { ...request.headers(), Authorization: "Bearer token123", }, });});continue() 支持覆盖 url、method、postData、headers 四个字段,可以实现请求重定向、修改 POST 数据等操作。abort —— 终止请求直接阻止请求发出,常用于屏蔽广告、图片、字体等非必要资源:page.on("request", (request) => { if (request.resourceType() === "image") { request.abort(); } else { request.continue(); }});abort() 可传入错误码,默认是 failed,常用值包括 aborted、accessdenied、connectionrefused 等。respond —— 直接返回响应不向服务器发送请求,直接在本地构造响应返回。这是接口 Mock 的核心手段:page.on("request", (request) => { if (request.url().includes("/api/user")) { request.respond({ status: 200, contentType: "application/json", body: JSON.stringify({ id: 1, name: "test-user" }), }); } else { request.continue(); }});respond() 支持 status、headers、contentType、body 四个字段,可以完整模拟服务器行为。响应监听 —— 获取服务端返回数据通过 response 事件监听服务端实际返回的内容,常用于数据采集和接口监控:page.on("response", async (response) => { if (response.url().includes("/api/data")) { const data = await response.json(); console.log("接口返回:", data); }});注意:response.json() 只能调用一次,且只有 JSON 格式的响应才能解析。资源类型过滤request.resourceType() 返回请求的资源类型,可用于批量过滤:const blockedTypes = ["image", "font", "stylesheet", "media"];page.on("request", (request) => { if (blockedTypes.includes(request.resourceType())) { request.abort(); } else { request.continue(); }});Puppeteer 支持的资源类型包括:document、stylesheet、image、media、font、script、xhr、fetch、websocket、eventsource、manifest、texttrack、other。实际应用场景爬虫加速:屏蔽非必要资源爬取数据时,图片、字体、CSS 对数据提取无用,屏蔽后页面加载速度可提升 50% 以上:await page.setRequestInterception(true);page.on("request", (request) => { const useless = ["image", "font", "stylesheet", "media"]; if (useless.includes(request.resourceType())) { request.abort(); } else { request.continue(); }});接口 Mock:前后端联调后端接口未就绪时,前端可以用 respond() 直接 Mock 数据,不依赖任何 Mock 服务:const mockData = { "/api/users": { users: [{ id: 1, name: "Alice" }] }, "/api/posts": { posts: [{ id: 1, title: "Hello" }] },};page.on("request", (request) => { for (const [path, data] of Object.entries(mockData)) { if (request.url().includes(path)) { request.respond({ status: 200, contentType: "application/json", body: JSON.stringify(data), }); return; } } request.continue();});广告与追踪屏蔽屏蔽已知广告域名和追踪脚本,减少无关请求:const blockedDomains = ["ads.example.com", "analytics.example.com", "tracker.example.com"];page.on("request", (request) => { if (blockedDomains.some((d) => request.url().includes(d))) { request.abort(); } else { request.continue(); }});自动注入认证头需要对所有请求添加 Token 时,用 continue() 覆盖 headers 即可,无需在每个请求中手动处理:page.on("request", (request) => { request.continue({ headers: { ...request.headers(), Authorization: "Bearer your-token-here", }, });});网络请求监控与性能分析记录所有请求和响应的时间戳与状态码,用于性能分析和接口排查:const logs = [];page.on("request", (request) => { logs.push({ type: "request", url: request.url(), method: request.method(), resourceType: request.resourceType(), time: Date.now(), }); request.continue();});page.on("response", (response) => { logs.push({ type: "response", url: response.url(), status: response.status(), time: Date.now(), });});await page.goto("https://example.com");console.log("请求总数:", logs.filter((l) => l.type === "request").length);console.log("响应总数:", logs.filter((l) => l.type === "response").length);错误处理page.on("requestfailed", (request) => { console.error("请求失败:", request.url()); console.error("原因:", request.failure()?.errorText);});常见失败原因包括:网络断开、DNS 解析失败、SSL 证书错误、被 abort() 主动终止等。面试追问与注意事项Q:拦截对所有请求都会生效吗?不是。导航请求(主文档请求)在部分场景下可能无法被拦截,且 WebSocket 升级请求的处理方式与普通 HTTP 请求不同。Q:请求拦截对性能有什么影响?启用拦截后,每个请求都要经过 JavaScript 事件循环处理,会增加请求延迟。对于高频请求场景(如 WebSocket 消息),建议按条件拦截而非全量拦截。Q:如何避免重复处理请求?调用 request.isInterceptResolutionHandled() 检查请求是否已被处理,避免在多个监听器中对同一请求重复调用 continue() 或 abort()。Q:与 Playwright 的请求拦截有什么区别?Playwright 使用 page.route() API,支持路由模式匹配(如 page.route("**/api/**", handler)),语法更简洁。Puppeteer 则需要手动判断 URL。两者底层都基于 CDP,核心能力一致。
服务端阅读 05月28日 07:17

Redis 与 MySQL、MongoDB、Memcached 有什么区别?如何选择?

Redis、MySQL、MongoDB、Memcached 是后端开发中最常用的四种数据存储方案,面试中经常被放在一起考察。它们的设计目标完全不同,理解本质差异才能做出正确的技术选型。核心区别:一张表看懂| 维度 | Redis | MySQL | MongoDB | Memcached ||------|-------|-------|---------|-----------|| 存储介质 | 内存为主,可持久化 | 磁盘为主 | 磁盘(内存映射) | 纯内存 || 数据模型 | Key-Value + 多种数据结构 | 关系型表 | 文档型(BSON) | Key-Value(仅String) || 事务支持 | 有限事务(MULTI/EXEC) | 完整ACID | 4.0起支持多文档事务 | 无 || 持久化 | RDB + AOF | 天然持久化 | 天然持久化 | 无,宕机即丢 || 查询能力 | 按Key操作,有限范围查询 | SQL,复杂关联聚合 | MQL,支持索引和聚合 | 仅GET/SET || 单机QPS | 10万+ | 数千~数万 | 数万 | 10万+ || 一致性模型 | 最终一致性 | 强一致性 | 可配置(最终/强) | 无一致性保证 || 水平扩展 | Cluster分片 | 分库分表(复杂) | 原生分片 | 客户端一致性Hash |Redis vs MySQL:缓存与持久化的抉择本质区别在于存储介质和一致性模型。Redis 数据驻留内存,读写延迟在亚毫秒级,但默认不保证数据持久化——即使开启 AOF,也存在最多 1 秒的数据丢失窗口。MySQL 数据落盘,通过 redo log 和 doublewrite 机制保证数据安全,但读写延迟在毫秒到十毫秒级。面试中常问的一个问题:Redis 能否替代 MySQL?答案是不能。两者的定位完全不同:Redis 适合做缓存层和实时数据层,数据可以丢失或从源头重建MySQL 适合做持久化存储层,数据不能丢失且需要事务保证生产中的经典架构是 Redis + MySQL 组合:读请求先查 Redis,命中则直接返回;未命中则查 MySQL,结果回写 Redis。但这里有几个关键问题需要注意:双写一致性:先更新 MySQL 再删缓存,存在短暂不一致窗口。对一致性要求高的场景,可用 Canal 监听 binlog 同步更新 Redis缓存穿透:查询不存在的数据,绕过缓存直击数据库。用布隆过滤器或缓存空值解决缓存击穿:热点 Key 过期瞬间大量请求涌入数据库。用互斥锁或永不过期+异步刷新缓存雪崩:大批 Key 同时过期。用随机过期时间打散Redis vs MongoDB:两种 NoSQL 的不同思路Redis 和 MongoDB 虽然都属于 NoSQL,但设计哲学完全不同。Redis 追求极致性能,数据全在内存中,数据结构精心设计,每种操作的时间复杂度都有明确保证。它更像一个高性能的数据结构服务器。MongoDB 追求灵活性,文档模型允许 schema 自由变化,嵌套文档减少了关联查询的需要。它更像一个增强版的 MySQL。关键区别:数据量:Redis 受内存限制,通常存热点数据;MongoDB 可存储 TB 级数据查询复杂度:Redis 只支持基于 Key 的操作;MongoDB 支持条件查询、聚合管道适用场景:Redis 做缓存/计数器/排行榜/分布式锁;MongoDB 做内容管理/日志/IoT 数据/用户画像Redis vs Memcached:缓存之争的终局这组对比面试频率极高。核心结论:新项目直接选 Redis,没有理由选 Memcached。Memcached 的仅存优势:多线程架构,在 value 超过 100KB 时吞吐量更高更简单的部署,适合纯缓存场景Redis 全面碾压的点:支持丰富数据结构(Memcached 只有 String)支持持久化(Memcached 宕机数据全丢)支持主从复制和集群(Memcached 靠客户端分片)支持发布订阅、Lua 脚本、Stream(Memcached 无)单线程模型反而简化了并发控制,小数据性能更优Memcached 在 2015 年之前还有市场份额,现在基本已被 Redis 完全取代。面试中回答"选 Redis"即可,但要说清楚为什么。技术选型:根据场景做决策选型不是二选一,而是根据业务特征匹配最合适的工具。选 Redis 的场景:需要亚毫秒级响应、数据可以容忍短暂丢失、操作模式简单(按 Key 读写)。典型用例:缓存、Session、排行榜、计数器、分布式锁、限流器。选 MySQL 的场景:数据必须持久化且保证一致性、需要复杂关联查询和事务、业务模型稳定。典型用例:用户系统、订单系统、支付系统。选 MongoDB 的场景:数据结构频繁变化、单文档较大且需要灵活查询、需要水平扩展。典型用例:CMS、日志分析、IoT、用户画像。选 Memcached 的场景:基本没有了。除非维护旧系统,否则没有理由新项目选 Memcached。生产架构中的组合模式实际项目中几乎不会只用一种存储,常见的组合方案:Redis + MySQL(最经典):Redis 缓存热点数据,MySQL 持久化存储。注意做好双写一致性、缓存穿透/击穿/雪崩的防护。Redis + MySQL + MongoDB:Redis 做缓存,MySQL 存核心业务数据,MongoDB 存非结构化数据(日志、配置、内容)。Redis + MySQL + Elasticsearch:Redis 缓存,MySQL 存储,ES 负责全文检索和复杂搜索。无论哪种组合,核心原则是:每种存储只做它最擅长的事,不要让 MySQL 做缓存,也不要让 Redis 做持久化。追问:缓存与数据库的一致性如何保证?这是上述选型之后必然的追问,也是面试高频考点。先更新数据库,再删缓存 是最常用的策略。为什么是删缓存而不是更新缓存?因为更新可能涉及复杂计算,且并发场景下容易产生脏数据。一致性保证的三个层级:弱一致性:先更新DB再删缓存,容忍短暂不一致,适合大多数业务最终一致性:通过消息队列或 Canal 监听 binlog 异步更新缓存,保证最终一致强一致性:读写都走数据库,或使用分布式锁,牺牲性能换一致性,仅金融等场景使用面试时回答到第二层即可,重点是说清楚各方案的 trade-off。
服务端阅读 05月28日 07:16

Puppeteer 无头模式和有头模式有什么区别?

Puppeteer 的无头模式(Headless)和有头模式(Headful)是两种浏览器运行方式,核心差异在于是否渲染图形界面,这直接决定了它们的性能表现、调试能力和适用场景。核心区别无头模式下浏览器不创建可视化窗口,所有页面渲染和脚本执行在内存中完成;有头模式则启动完整的 Chrome GUI 窗口,每一步操作都可以肉眼观察。这个看似简单的差异会引发一系列连锁影响:资源消耗:无头模式省去了 GUI 渲染的开销,内存占用通常低 30%-50%,启动速度快 20% 左右User Agent 差异:旧版无头模式的 UA 包含 HeadlessChrome 标识,网站可据此识别并拒绝请求;有头模式的 UA 与普通 Chrome 完全一致渲染一致性:部分网站在无头模式下的渲染结果与有头模式不同,原因包括 GPU 加速差异、字体渲染路径不同、视口默认值不一致等反爬检测:无头模式缺少 navigator.plugins、window.chrome 等浏览器特征,更容易被反爬系统检测三种无头模式的演进Puppeteer 的无头模式并非一成不变,Chrome 的迭代带来了三种变体:旧版无头模式(headless: true)默认值,基于独立的 HeadlessChrome 实现,与正常 Chrome 共享极少代码。问题在于它的行为与真实浏览器差异较大,容易被网站检测。const browser = await puppeteer.launch({ headless: true});新版无头模式(headless: "new")Chrome 112+ 引入,使用与有头模式完全相同的 Chrome 代码库,仅跳过可视化输出。渲染结果与有头模式几乎一致,推荐在新项目中优先使用。const browser = await puppeteer.launch({ headless: "new"});chrome-headless-shell(headless: "shell")Puppeteer 21+ 提供,是专为自动化设计的精简二进制文件,体积更小、启动更快,但不支持扩展和部分 Chrome 特性,适合纯服务端批处理场景。const browser = await puppeteer.launch({ headless: "shell"});有头模式的使用方式有头模式需要显式关闭 headless,同时可以配合 DevTools 和慢放模式辅助调试:const browser = await puppeteer.launch({ headless: false, devtools: true, // 自动打开开发者工具 slowMo: 250 // 每步操作延迟 250ms,便于观察});关键配置项:slowMo 让操作可追踪,devtools 提供完整调试面板,defaultViewport 可设置视口大小。性能对比| 指标 | 旧版 headless | 新版 headless | headless shell | 有头模式 ||------|-------------|-------------|--------------|--------|| 内存占用 | 低 | 中 | 最低 | 高 || 启动速度 | 快 | 中 | 最快 | 慢 || 渲染一致性 | 差 | 好 | 中 | 基准 || 反检测能力 | 弱 | 较强 | 弱 | 强 || 扩展支持 | 不支持 | 支持 | 不支持 | 支持 |各模式适用场景无头模式适用于:CI/CD 流水线中的自动化测试——服务器通常没有显示器大规模网页抓取——资源占用低,可并发更多实例定时任务和批量处理——截图、PDF 生成、数据采集性能基准测试——减少 GUI 对测试结果的干扰有头模式适用于:脚本开发调试阶段——实时观察页面行为,快速定位问题复杂交互场景调试——如动画、拖拽、弹窗等需要视觉确认的操作反爬对抗——部分网站检测到无头特征后拒绝服务,有头模式可以绕过教学演示——展示自动化流程的每一步无头模式被检测的常见原因及应对实际项目中,无头模式最常见的坑就是被网站识别。以下是被检测的主要原因和解决思路:User Agent 泄露:旧版 headless 的 UA 包含 HeadlessChrome,解决方法是手动覆盖:await page.setUserAgent( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");浏览器特征缺失:无头模式下 navigator.plugins 为空、navigator.languages 仅含 "en-US"、缺少 window.chrome 对象。可使用 puppeteer-extra-plugin-stealth 自动修补:const puppeteer = require("puppeteer-extra");const StealthPlugin = require("puppeteer-extra-plugin-stealth");puppeteer.use(StealthPlugin());const browser = await puppeteer.launch({ headless: "new" });WebGL 和 Canvas 指纹:无头模式下 GPU 加速不可用,Canvas 指纹与有头模式不同。新版 headless 模式已大幅改善此问题。最佳实践:优先使用新版无头模式(headless: "new")+ stealth 插件,绝大多数场景下可获得与有头模式一致的渲染和反检测效果。环境切换的工程实践在实际项目中,通常需要根据运行环境动态切换模式:const puppeteer = require("puppeteer");const isDev = process.env.NODE_ENV === "development";const browser = await puppeteer.launch({ headless: isDev ? false : "new", devtools: isDev, slowMo: isDev ? 100 : 0, args: isDev ? [] : ["--no-sandbox", "--disable-setuid-sandbox"]});开发环境用有头模式便于调试,生产环境用新版无头模式兼顾性能和一致性。--no-sandbox 参数在 Docker 等容器环境中通常必需,因为默认的沙箱机制需要特定内核权限。面试追问方向Puppeteer 新版无头模式与旧版的核心实现差异是什么?(共享 Chrome 代码库 vs 独立实现)如何让无头模式通过反爬检测?(stealth 插件 + 新版 headless + UA 覆盖)chrome-headless-shell 适合什么场景?有什么限制?(纯服务端批处理,不支持扩展)为什么同样的代码在无头和有头模式下渲染结果不同?(GPU 加速、字体渲染、视口默认值差异)
前端阅读 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:09

优化 Ollama 性能需要调整哪些参数?

优化 Ollama 性能该从哪些方面入手?Ollama 的性能瓶颈通常出现在三个环节:模型加载、推理计算和内存调度。优化思路可以归纳为——选对量化、调好参数、用满硬件。下面逐项展开。模型量化怎么选?量化是影响推理速度和显存占用最直接的参数。Ollama 支持多种量化级别,核心区别在于精度和速度的权衡:| 量化格式 | 模型体积 | 推理速度 | 精度损失 | 适用场景 ||---------|---------|---------|---------|---------|| Q4KM | 最小 | 最快 | 较明显 | 显存紧张、追求速度 || Q5KM | 适中 | 较快 | 轻微 | 多数场景的推荐选择 || Q8_0 | 较大 | 较慢 | 极小 | 对输出质量要求高 || F16 | 最大 | 最慢 | 无 | 调试或精度验证 |# 下载不同量化版本ollama pull llama3.1:8b-q4_k_mollama pull llama3.1:8b-q8_0实际测试中,8GB 显存的 RTX 4060 运行 Q4KM 量化的 7B 模型,速度可以从 3-4 tok/s 提升到 30-45 tok/s,差距在一个数量级。2026 年 Ollama 还新增了 NVFP4 量化支持,让本地推理结果能和云端生产环境保持一致。选择建议:先从 Q4KM 起步,如果输出质量不满意再升到 Q5KM,一般不需要更高量化。Modelfile 里哪些参数值得调?在 Modelfile 中通过 PARAMETER 指令可以精细控制推理行为:PARAMETER temperature 0.7 # 控制输出随机性,代码生成建议 0.1-0.3,对话 0.6-0.8PARAMETER top_p 0.9 # 核采样阈值,和 temperature 二选一调整即可PARAMETER top_k 40 # 候选 token 数,一般 20-60PARAMETER num_ctx 4096 # 上下文窗口,短对话设 2048 省显存PARAMETER repeat_penalty 1.1 # 重复惩罚,1.05-1.1 之间即可PARAMETER num_gpu 99 # GPU 卸载层数,不是 GPU 数量PARAMETER num_batch 512 # 批处理大小,吞吐优先可调到 1024-2048几个容易踩的坑:num_gpu 的含义是模型有多少层放到 GPU 上计算,不是 GPU 的数量。比如 Llama 2 7B 有 32 层,设成 32 就全部走 GPU,显存不够就减到 20 让部分层回退 CPU。num_ctx 直接影响显存占用,从 4096 减到 2048 可以省出相当可观的显存,短对话场景放心缩减。num_batch 调大能提高吞吐量,但也会吃更多显存,需要和 num_ctx 一起权衡。GPU 和显存怎么管?GPU 是 Ollama 性能的关键,显存不够用是最常见的问题。# 指定使用的 GPUexport CUDA_VISIBLE_DEVICES=0# 查看显存使用情况nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits# 启用 Flash Attention,显存占用可降低 30%-50%export OLLAMA_FLASH_ATTENTION=1显存不够时的降级策略:开启 OLLAMA_FLASH_ATTENTION=1,这是最优先的操作,几乎无副作用降低量化级别,从 Q80 换到 Q5KM 或 Q4K_M减少 num_ctx,短对话用 2048 甚至 1024减少 num_gpu,让部分层回退 CPU开启低显存模式,KV 缓存放到 CPU 内存,速度会下降但能跑起来苹果 M 系列芯片有独特优势——统一内存架构意味着显存等于内存。M2/M3 跑 7B-14B 模型性能接近入门级独立显卡,2026 年 Ollama 切换 MLX 引擎后 M5 芯片在 70B 以上模型上表现更是突出。并发请求怎么处理?Ollama 默认单并发,生产环境需要调整:# 环境变量方式export OLLAMA_NUM_PARALLEL=4 # 并行处理请求数export OLLAMA_MAX_LOADED_MODELS=3 # 最大同时加载模型数export OLLAMA_MAX_QUEUE=20 # 排队上限export OLLAMA_KEEP_ALIVE=30m # 模型保持加载时长,-1 表示永久也可以在 Modelfile 里设置:PARAMETER num_parallel 4OLLAMA_KEEP_ALIVE 很实用——默认 5 分钟没请求就卸载模型,设长一点能避免冷启动。频繁使用的服务建议设成 30m 或 -1。CPU 模式有什么优化空间?没有 GPU 的机器也能跑,但参数要针对性调整:PARAMETER num_thread 6 # CPU 线程数,建议设为物理核心数的 60%-80%PARAMETER num_batch 128 # 小批量减少内存压力PARAMETER num_ctx 2048 # 缩短上下文PARAMETER num_gpu 0 # 强制全走 CPU服务器级 CPU 可以开启 NUMA 优化:export OLLAMA_NUMA=1纯 CPU 模式跑 Q4 量化的 1B-7B 模型,速度大约 5-15 tok/s,能用但不快,适合低频调用场景。怎么监控和排查性能问题?# 查看当前运行的模型和资源占用ollama ps# 查看 Ollama 服务日志ollama logs# 实时监控 GPU 使用watch -n 1 nvidia-smi几个关键指标:首 token 延迟:反映模型加载和首次推理速度,正常应该在 2 秒以内推理速度:tok/s 数值,7B 模型 GPU 跑 30+ tok/s 算正常GPU 利用率:推理时应该在 80% 以上,否则说明有瓶颈显存占用:跑起来后应该接近满载,剩很多说明显存没利用好如果 GPU 利用率低,检查 num_gpu 是否设够了、num_batch 是否太小。如果频繁 OOM,按前面的降级策略逐项排查。不同硬件大致能跑什么模型?| 模型规模 | 最低显存/内存 | 推荐硬件 | 参考速度 ||---------|-------------|---------|---------|| 1B-3B | 无需 GPU | 8GB RAM | 30-60 tok/s (M2) || 7B-8B | 8GB | RTX 3080 / M2 Pro | 40-80 tok/s (GPU) || 13B-14B | 12GB | RTX 3080 Ti / M3 Max | 25-45 tok/s || 30B-34B | 24GB | RTX 4090 / M2 Ultra | 15-25 tok/s || 70B | 48GB | 双卡 4090 / M2 Ultra | 8-15 tok/s |注意这是 Q4 量化下的参考值,Q8 或 F16 所需显存会翻倍。选择模型时先看自己硬件的上限,再在量化级别上做取舍。
前端阅读 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 同样需要类似的反检测处理。两个工具在反爬场景下的表现差异不大,关键在于如何模拟真实用户行为。