前端面试题手册

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

前端阅读 05月30日 02:24

Promise 并发控制如何实现?

Promise 并发控制就是限制同一时间运行的异步任务数量。面试可以先说结论:维护一个任务池,启动任务后放入 executing,数量达到上限就 await Promise.race(executing),等任意任务结束再继续放新任务。最后用 Promise.all 收集全部结果。真实项目里常用于批量请求、上传、爬取、发邮件,目的是保护浏览器连接数、服务端限流和内存。追问并发控制和 Promise.all 有什么区别?Promise.all 会一次性启动所有任务;并发控制只同时跑固定数量。前者适合少量独立任务,后者适合几百、几千个任务。为什么用 Promise.race?它能等“最先完成的一个任务”。有任务完成后,池子空出位置,就可以继续补下一个任务。失败任务怎么处理?看业务。要快速失败就让错误抛出;要尽量完成全部任务,就给每个任务包一层 catch,返回 {status, value, reason}。并发数怎么定?没有固定答案。浏览器请求可从 4-8 开始;Node 服务要看下游 QPS、CPU、连接池和超时率,再动态调。写段代码async function pool(limit, list, worker) { const ret = []; const running = []; for (const item of list) { const p = Promise.resolve().then(() => worker(item)); ret.push(p); const e = p.finally(() => running.splice(running.indexOf(e), 1)); running.push(e); if (running.length >= limit) await Promise.race(running); } return Promise.all(ret);}
前端阅读 05月30日 02:24

Promise 性能优化面试怎么答?

Promise 性能优化的核心不是“少用 Promise”,而是少制造没必要的异步层级,让能并行的任务并行,让高频请求有缓存、去重和并发限制。面试里先答三点:避免 new Promise 包一层已有 Promise;独立任务用 Promise.all 并行;批量任务别一次性打满,用队列或 p-limit 控制数量。再补一句:性能问题通常来自请求瀑布、重复请求、长链微任务和未释放的引用。追问Promise.all 一定更快吗?不一定。只有任务互不依赖时才更快;如果后一个请求依赖前一个结果,强行并行会写错逻辑。Promise.all 还会遇到一个失败就整体失败的问题。为什么不建议包一层 new Promise?已有 Promise 直接返回即可。多包一层会增加对象创建、微任务调度和错误传播复杂度,还容易漏掉 reject。请求去重怎么做?用 Map 保存进行中的 Promise,相同 key 直接复用;结束后在 finally 里删除,避免内存泄漏。长 Promise 链会慢吗?真正慢的通常不是链本身,而是链里塞了大量同步计算或无意义 then。可读性差时改 async/await,但不要把独立任务写成串行 await。写段代码const pending = new Map();function once(key, fn) { if (pending.has(key)) return pending.get(key); const p = fn().finally(() => pending.delete(key)); pending.set(key, p); return p;}async function load() { const [user, posts] = await Promise.all([ once('user', fetchUser), once('posts', fetchPosts) ]); return { user, posts };}
前端阅读 05月30日 01:39

MobX 中 observable 怎么用?有哪些注意事项?

MobX 的 observable 用来把普通状态变成“可被追踪的状态”。组件、computed、autorun 或 reaction 读取它时,MobX 会记录依赖;之后状态变化,相关派生值和视图就会自动更新。现在项目里更常用 makeAutoObservable 或 makeObservable,装饰器写法能见到,但要看团队 Babel/TypeScript 配置。注意:修改状态最好放在 action 里,大对象可用 shallow 降低追踪成本。追问makeAutoObservable 和 makeObservable 有什么区别?makeAutoObservable 会按成员类型自动推断:字段是 observable,getter 是 computed,方法通常是 action。makeObservable 需要手动标注,麻烦一点,但控制更精确,适合复杂 store。observable 默认是深度追踪吗?是的,普通对象会递归转成可观察结构。数据很大、嵌套很深,或者只关心引用变化时,可以用 shallow,避免不必要的代理和依赖追踪。为什么建议在 action 中修改状态?action 能把多次修改合并成一次事务,减少中间状态暴露,也方便开启 enforceActions 做约束。异步请求完成后修改 observable,常用 runInAction。observable 和 computed 怎么配合?observable 存原始状态,computed 负责派生结果。比如 todos 是 observable,completedTodos 应该是 computed,而不是每次在组件里重复 filter。项目里常见坑是什么?一是解构 observable 后丢失响应式读取场景;二是把 observable 对象直接传给不支持代理的第三方库;三是冻结对象或随意替换深层结构,导致追踪和更新不符合预期。写段代码class TodoStore { todos = [] filter = 'all' constructor() { makeAutoObservable(this) } addTodo(text) { this.todos.push({ text, done: false }) } get doneTodos() { return this.todos.filter(t => t.done) }}
前端阅读 05月30日 01:39

MobX 中 computed 有什么作用?和 reaction 怎么选?

MobX 的 computed 用来声明“由 observable 推导出来的值”,比如过滤后的列表、总价、表单是否有效。它的关键点是自动追踪依赖、懒计算、缓存结果:没人读取时不算,依赖没变时重复读取也不重算。面试回答要强调:computed 应该像纯函数,只负责返回值,不要发请求、写日志或修改状态;这些副作用应该交给 reaction。追问computed 为什么能提升性能?因为它会缓存上一次计算结果。只有依赖的 observable 变化,并且 computed 再次被读取时,MobX 才会重新计算;复杂过滤、排序、聚合都适合放进去。computed 和普通 getter 有什么区别?普通 getter 每次访问都执行。computed getter 会被 MobX 管理依赖和缓存,在 observer、autorun、reaction 等响应式上下文中效果最明显。computed 里能不能写异步请求?不建议,也不应该。computed 要同步返回派生值;异步请求会产生副作用,应该用 action 改状态,再用 computed 读取状态生成结果。computed 和 reaction 怎么选?要“算出一个值”,选 computed;要“值变了以后做一件事”,选 reaction。比如 completedTodos 是 computed,userId 变化后拉接口是 reaction。项目里有什么坑?不要在 computed 里返回每次都新建且结构相同的对象,否则可能让观察者误以为结果变了。需要时可以拆小 computed,或使用结构比较配置。写段代码class TodoStore { todos = [] filter = 'all' constructor() { makeAutoObservable(this) } get visibleTodos() { if (this.filter === 'done') return this.todos.filter(t => t.done) return this.todos }}
前端阅读 05月30日 01:39

MobX 中 autorun、reaction 和 when 有什么区别?

MobX 里的 reaction 用来处理副作用:状态变了以后,去做日志、请求、持久化、路由跳转这类“不产生派生值”的事。常见有三种:autorun 会立即执行并自动追踪用到的 observable;reaction 把“追踪什么”和“执行什么”分开,更适合精确控制触发条件;when 只在条件第一次满足时执行一次,然后自动清理。面试里要先说清:派生数据用 computed,副作用才用 reaction。追问autorun 和 reaction 有什么区别?autorun 会立即跑一次,函数里读到什么 observable 就追踪什么。reaction 先用 data 函数明确返回要追踪的数据,只有这个数据变化时才执行 effect,适合监听 userId、query 这类明确字段。when 适合什么场景?适合“一次性条件触发”,比如用户登录成功后加载资料、初始化完成后启动订阅。它触发一次后会自动 dispose,不适合长期监听。reaction 里最容易踩什么坑?一是忘记清理 disposer,组件卸载后还在监听;二是在 reaction 里修改自己依赖的状态,造成循环触发;三是异步请求回来后没用 runInAction 修改状态。reaction 和 computed 怎么选?要返回可缓存的派生值,用 computed;要调用接口、写 localStorage、打印日志、操作外部系统,用 reaction。一个记忆法是:computed 回答“值是什么”,reaction 回答“变化后做什么”。写段代码const dispose = reaction( () => store.query, query => { if (query.length > 2) store.search(query) }, { delay: 300 })// React 卸载或不再需要时// dispose()
前端阅读 05月30日 01:39

MobX 异步操作为什么要用 runInAction 或 flow?

MobX 处理异步的关键不是“能不能 await”,而是 await 之后的状态修改已经离开原来的 action。开启 enforceActions 时,接口返回后直接改 observable 容易报警,也会让更新边界不清。常用做法有两种:简单请求用 async/await + runInAction,在成功、失败分支里集中更新 data/loading/error;流程复杂、需要取消任务时用 flow(function*(){}),把 await 换成 yield。不要说 async action 会自动包住整个异步过程,它只覆盖同步阶段。追问runInAction 和 flow 怎么选?普通接口请求选 runInAction,写法接近日常 async/await;多步骤流程、需要取消、想少写包装代码时选 flow。为什么 await 后还要重新进 action?因为 await 后是新的 tick,原 action 已结束。MobX 官方也强调 await 后的状态修改不在同一个执行阶段。loading 和 error 应该怎么写?进入请求前设 loading=true、清空 error;成功和失败分支都要把 loading=false 放进 action,避免页面一直转圈。实际项目最常见的坑是什么?最常见是 catch 里只记录错误,忘了重置 loading;其次是连续修改多个字段却没用 runInAction,导致严格模式报警。写段代码async fetchUser(id) { this.loading = true this.error = null try { const data = await api.getUser(id) runInAction(() => { this.user = data; this.loading = false }) } catch (e) { runInAction(() => { this.error = e.message; this.loading = false }) }}
前端阅读 05月29日 01:09

Web3 前端如何与后端服务协作?有哪些典型场景?

Web3 前端与后端的协作围绕链上和链下两条数据通路展开。链上交互通过钱包连接(window.ethereum)直接调用智能合约的 view/pure 方法读取状态、通过用户签名发送交易;链下交互则走传统 REST/GraphQL API,由后端代理聚合数据、管理会话、处理敏感逻辑。典型场景有五个:一是钱包身份验证——前端获取钱包地址并签名消息,后端验证签名后签发 JWT;二是读取合约状态——前端直接调用 view 函数或通过后端缓存聚合;三是发送交易——前端构造交易参数由用户在钱包确认签名,后端监听链上事件确认结果;四是事件监听——后端订阅合约事件(Transfer、Approval 等)通过 WebSocket 推送前端;五是链上数据索引——使用 The Graph 等索引服务将链上事件转为可查询的 GraphQL API,避免前端直接扫描区块。追问前端直接调用合约 view 函数和通过后端代理读取各有什么优劣?何时选哪种?用户签名消息的 EIP-712 标准是什么?比普通个人签名好在哪里?The Graph 的工作原理是什么?subgraph 如何定义和部署?如何处理后端服务宕机时前端的降级策略?能否直接切换到 RPC 节点?多链 DApp 中如何管理不同链的 provider 和合约实例?写段代码// 前端连接钱包并签名验证const accounts = await window.ethereum .request({ method: 'eth_requestAccounts' });const signer = new ethers.BrowserProvider( window.ethereum).getSigner();const signature = await signer .signMessage('login-nonce-123');// 将 address + signature 发给后端验证
前端阅读 05月29日 00:26

Bun 在 I/O 性能方面做了哪些优化?

Bun用Zig语言编写,I/O优化的核心是绕过libuv,直接走系统调用。具体优化:文件I/O使用Bun.file()API,底层通过mmap内存映射和直接syscall读写,Linux上利用io_uring实现异步,避免Node.js经libuv线程池的开销;HTTP服务用Bun.serve()构建,基于零拷贝响应和原生HTTP解析器(不用Node的http-parser),基准吞吐量高出Node.js约3倍;SQLite内置驱动直接编译进运行时,省去FFI调用开销;启动优化通过原生TypeScript执行(无需tsc编译步骤)和全局缓存减少冷启动I/O。总结:Node.js的I/O路径是JS→V8→libuv→OS,Bun是JS→JavaScriptCore→Zig syscall→OS,少了中间层。追问io_uring在Linux上的性能优势具体来自哪里?macOS上Bun用什么替代方案?Bun.serve的零拷贝是如何实现的?Response对象哪些场景下会触发拷贝?Bun.file()和Node.js的fs.promises.readFile在处理大文件时的内存占用差多少?内置SQLite驱动和better-sqlite3相比,性能差距主要在哪?Bun的HTTP客户端(Bun.fetch)也做了类似的零拷贝优化吗?写段代码// Bun: 零拷贝文件服务const file = Bun.file('./data.json');Bun.serve({ fetch: (req) => new Response(file), // 直接返回文件流,无需读取到内存 port: 3000});
前端阅读 05月29日 00:25

Dify 支持哪些类型的输入输出格式?如何自定义数据处理逻辑?

Dify工作流的输入支持:文本(短文本/段落)、结构化数据(下拉选择/数字/复选框/JSON)、文件上传(PDF/Word/TXT/Markdown,也支持图片和音频)。输出默认为LLM生成的文本,可配置为结构化JSON(通过JSON Schema约束)。自定义数据处理有三种方式:代码节点直接写Python/Node.js脚本做数据转换、JSON拼接、算术运算;模板节点用Jinja2语法灵活格式化输出文本;变量赋值节点对字符串/数字/数组进行覆盖、追加、扩展等操作。此外参数提取器能用LLM从自然语言中推理出结构化参数,迭代节点支持对数组批量处理。自定义工具还可通过OpenAPI/Swagger规范接入外部API。追问代码节点和模板节点分别适合什么场景?性能差异大吗?参数提取器如何保证提取结果的结构化可靠性?提示词怎么写?文件上传后Dify内部怎么处理?PDF解析用的是哪个库?迭代节点处理大数组时有没有并发或超时限制?自定义工具的OpenAPI Schema最大能定义多少个接口?写段代码# Dify代码节点:提取关键字段并格式化import jsondef main(input_text: str) -> dict: result = { "length": len(input_text), "summary": input_text[:100] } return {"result": json.dumps(result, ensure_ascii=False)}
前端阅读 05月29日 00:25

FFmpeg支持哪些常见的音视频格式?

FFmpeg支持100+容器格式和200+编解码器,需区分容器和编码两层。容器层主流格式:MP4(兼容性最广)、MKV(多音轨/字幕)、WebM(Web优化)、MOV(Apple生态)、FLV(直播推流)、TS(HLS切片)。视频编码:H.264/AVC(最通用)、H.265/HEVC(同质量体积减半)、VP9(WebM默认)、AV1(开源下一代,FFmpeg 5.0+支持)。音频编码:AAC(流媒体标配)、Opus(低延迟实时通信最优)、MP3(兼容旧设备)、FLAC(无损)。关键认知:容器决定封装结构,编码决定压缩算法,同一容器可装不同编码(如MP4可装H.264或H.265)。用ffmpeg -encoders查看本地支持的编码器列表。追问H.264和H.265在FFmpeg中用什么编码器?libx264和h264_nvenc性能差多少?MP4容器能装VP9视频吗?为什么WebM比MP4更适合Web场景?Opus相比AAC在延迟和码率上有什么优势?为什么WebRTC选Opus?FLV容器为什么逐步被淘汰?HLS的TS切片方案解决了FLV的什么问题?ffmpeg -codecs和ffmpeg -encoders输出有什么区别?写段代码# 查看本地支持的所有H.265编码器ffmpeg -encoders 2>/dev/null | grep 265# MP4转WebM(VP9+Opus)ffmpeg -i in.mp4 -c:v libvpx-vp9 -crf 30 -b:v 0 -c:a libopus out.webm
前端阅读 05月29日 00:25

Dify 的部署方式有哪些?分别适用于哪些场景?

Dify提供三种主流部署方式:云服务(SaaS)即开即用,适合快速验证和中小团队,无需运维但数据存于Dify服务器;Docker自托管通过docker compose up一键拉起,适合需要数据自主可控的企业,是最常用的生产部署方案;Kubernetes集群部署基于Helm Chart编排,适合高可用、弹性伸缩和大规模并发场景。此外还有社区版源码部署,适合需要深度定制或二次开发的场景。选择依据:数据敏感选自托管,快速上手选云服务,企业级生产选K8s。Dify自托管依赖PostgreSQL、Redis和Nginx,Docker Compose方案已包含全部依赖。追问Docker自托管和K8s部署在资源开销上差多少?10人团队该选哪个?云服务版的数据隔离机制是什么?多租户场景下知识库数据是否会交叉?自托管升级版本时如何做到零停机?数据库迁移脚本谁负责执行?Dify的API扩展点和插件系统在不同部署方式下有差异吗?混合部署(敏感数据本地+推理云端)在Dify中如何实现?写段代码# docker-compose 自托管最小配置services: api: image: langgenius/dify-api:latest environment: DB_USERNAME: postgres DB_PASSWORD: ${DB_PW} REDIS_HOST: redis web: image: langgenius/dify-web:latest ports: ["80:80"]
前端阅读 05月29日 00:24

FFmpeg的核心组件包括哪些?分别有什么作用?

FFmpeg的核心组件分为库和命令行工具两大类。库层面:libavcodec负责音视频编解码(H.264/H.265/AAC等),libavformat处理容器封装与解封装(MP4/MKV/FLV),libavfilter实现滤镜链(缩放/旋转/叠加),libswscale做像素格式转换(YUV↔RGB),libswresample处理音频重采样与通道转换,libavutil提供通用数据结构(AVPacket/AVFrame)和工具函数,libavdevice抽象硬件设备交互。工具层面:ffmpeg是转码命令行入口,ffprobe探测媒体流信息,ffplay轻量播放器。转码流水线为:demux(libavformat)→ decode(libavcodec)→ filter(libavfilter)→ encode(libavcodec)→ mux(libavformat)。追问libavcodec和libavformat的职责边界在哪?为什么要把编解码和容器处理拆成两个库?转码时用-c copy跳过了流水线中哪些环节?为什么能实现无损且秒级完成?libswscale和libavfilter的scale滤镜功能重叠,实际项目中该用哪个?ffprobe如何快速获取视频时长和码率?底层调用了libavformat的哪个接口?硬件加速编解码(NVENC/QSV)在组件架构中如何接入?是否绕过了libavcodec?写段代码# 查看视频流信息ffprobe -v quiet -print_format json -show_streams input.mp4# H.264转H.265,音频直接拷贝ffmpeg -i input.mp4 -c:v libx265 -crf 28 -c:a copy output.mp4
前端阅读 05月28日 08:23

RxJS 中 Hot Observable 和 Cold Observable 有什么区别?

先搞清楚一个核心:数据生产者在哪Cold 和 Hot 的本质区别只有一个:数据生产者(Producer)是在订阅时创建,还是在 Observable 创建时就已经存在?Cold Observable:生产者在订阅时才创建,每个订阅者拿到独立的生产者,这就是"单播"(Unicast)Hot Observable:生产者在 Observable 创建时就已经存在,所有订阅者共享同一个生产者,这就是"多播"(Multicast)理解了这一点,后面所有特性都能推导出来,不需要死记硬背。Cold Observable:按需执行,人手一份Cold Observable 是"惰性"的——没有人订阅,它什么都不做。每次有新订阅者,它都会从头执行一遍逻辑,产生一份独立的数据流。import { Observable } from 'rxjs';const cold$ = new Observable(subscriber => { console.log('执行逻辑'); subscriber.next(Math.random()); subscriber.complete();});cold$.subscribe(v => console.log('订阅者A:', v));// 执行逻辑 → 订阅者A: 0.314159cold$.subscribe(v => console.log('订阅者B:', v));// 执行逻辑 → 订阅者B: 0.271828// 两次订阅各执行一次,随机值不同——因为每个订阅者有独立的生产者用一个生活类比:Cold Observable 像电影院的电影文件——每个观众点播时,影院单独为他播放一份,各看各的进度,互不影响。常见 Cold 操作符:of()、from()、interval()、timer()、ajax()、Angular 的 HttpClient.get()Hot Observable:共享数据流,先到先得Hot Observable 是"主动"的——不管有没有人订阅,生产者都在运作。新订阅者只能收到订阅之后的数据,之前发过的就错过了。import { Subject } from 'rxjs';const subject = new Subject();subject.subscribe(v => console.log('订阅者A:', v));subject.next(1); // 订阅者A: 1subject.subscribe(v => console.log('订阅者B:', v));subject.next(2); // 订阅者A: 2, 订阅者B: 2// 订阅者B 没收到 1,因为订阅晚了类比:Hot Observable 像电视直播——频道一直在播,你打开电视只能看到当前和后续的节目,之前的已经播完了回不来。常见 Hot 来源:Subject 及其变体(BehaviorSubject、ReplaySubject、AsyncSubject)、fromEvent() 绑定的 DOM 事件、WebSocket 连接单播 vs 多播:从源码角度理解Cold Observable 的 subscribe 函数里直接创建生产者:// Cold:每次 subscribe 都执行这个函数,各订阅者独立const cold$ = new Observable(subscriber => { const source = createProducer(); // 每个订阅者创建自己的生产者 source.onData(data => subscriber.next(data));});Hot Observable 的生产者在外部,subscribe 只是注册监听:// Hot:生产者已存在,subscribe 只是往里注册回调const hot$ = new Observable(subscriber => { externalSource.addListener(data => subscriber.next(data)); // 所有订阅者监听同一个 externalSource});所以 Cold → Hot 的本质就是把内部生产者提到外部,让多个订阅者共享。Cold 转 Hot 的三种方式share()——最常用share() 内部使用 Subject 实现多播,并且带 refCount 机制:当订阅者数从 1 降到 0 时自动断开上游,再有新订阅者时重新连接。import { interval } from 'rxjs';import { share, take } from 'rxjs/operators';const source$ = interval(1000).pipe(take(5));const shared$ = source$.pipe(share());shared$.subscribe(v => console.log('A:', v));setTimeout(() => shared$.subscribe(v => console.log('B:', v)), 2000);// A 和 B 共享同一个 interval 计时器// B 在第2秒加入,只能收到 2、3、4shareReplay(n)——缓存最近 n 个值shareReplay 在 share 的基础上缓存最近的 n 个值,新订阅者能立即收到缓存数据,解决"来晚了错过数据"的问题。import { interval } from 'rxjs';import { shareReplay, take } from 'rxjs/operators';const source$ = interval(1000).pipe(take(5), shareReplay(1));source$.subscribe(v => console.log('A:', v));setTimeout(() => { source$.subscribe(v => console.log('B:', v)); // B 立即收到缓存的最新的一个值,然后继续接收后续值}, 3000);关键区别:share() 的 refCount 在订阅者归零后断开上游,而 shareReplay() 默认不会断开(可通过 config.resetOnComplete 等参数调整)。publish() + connect()——手动控制publish() 把 Cold Observable 变成 ConnectableObservable,必须手动调用 connect() 才开始执行。适合需要先注册所有订阅者再启动数据流的场景。import { interval } from 'rxjs';import { publish, take } from 'rxjs/operators';const source$ = interval(1000).pipe(take(5), publish());source$.subscribe(v => console.log('A:', v));source$.subscribe(v => console.log('B:', v));// 此时不执行,等所有订阅者就绪source$.connect(); // 手动启动实际开发中的选择用 Cold 的场景HTTP 请求:每个组件独立获取数据,互不干扰独立计算:每个订阅者需要各自的处理结果可重复执行:每次订阅都希望从头获取完整数据用 Hot 的场景共享 HTTP 结果:多个组件需要同一接口的数据,用 shareReplay(1) 避免重复请求事件监听:DOM 事件、WebSocket 消息天然就是多播的状态管理:BehaviorSubject 持有最新状态,新订阅者立即获取当前值最容易踩的坑坑1:忘记共享导致重复请求// 每次订阅都发新请求——大忌const data$ = http.get('/api/data');data$.subscribe(handle1);data$.subscribe(handle2); // 又发了一次请求// 用 shareReplay 共享const data$ = http.get('/api/data').pipe(shareReplay(1));data$.subscribe(handle1);data$.subscribe(handle2); // 只发一次请求坑2:share() 的 refCount 陷阱const source$ = interval(1000).pipe(share());const sub1 = source$.subscribe(v => console.log('A:', v));const sub2 = source$.subscribe(v => console.log('B:', v));sub1.unsubscribe();sub2.unsubscribe();// 所有订阅者都取消后,上游停止source$.subscribe(v => console.log('C:', v));// 重新订阅,上游重新连接,C 从0开始收数据// 如果这里用 shareReplay(1),行为可能不同坑3:shareReplay 缓存过多// 缓存1000个值,内存会爆interval(1000).pipe(shareReplay(1000));// 通常缓存1个就够了interval(1000).pipe(shareReplay(1));一张表总结| 特性 | Cold Observable | Hot Observable ||------|----------------|----------------|| 生产者创建时机 | 订阅时 | Observable 创建时 || 数据流 | 每个订阅者独立 | 所有订阅者共享 || 传播方式 | 单播(Unicast) | 多播(Multicast) || 错过数据 | 不会,每次从头 | 会,只能收订阅后的数据 || 典型代表 | of、from、HTTP | Subject、DOM 事件 || 转 Hot | share()、shareReplay() | 不可转 Cold |记住核心判断:看生产者——订阅时创建就是 Cold,早就存在就是 Hot。 面试中如果能从生产者角度解释单播/多播的区别,再提到 share 的 refCount 机制和 shareReplay 的缓存策略,基本就能拿到高分。
前端阅读 05月28日 07:28

Prettier 与其他代码格式化工具有什么区别?如何选择?

Prettier 和 ESLint 有什么本质区别?Prettier 是代码格式化工具,ESLint 是代码质量检查工具,二者不是替代关系而是互补关系。核心区别在于工作原理:Prettier 将代码解析为 AST(抽象语法树),然后按照自己的规则重新输出,保证同样的输入永远得到同样的输出;ESLint 则基于规则引擎逐行扫描代码,检测潜在的错误和反模式。实际项目中标准做法是两者结合:用 eslint-config-prettier 关闭 ESLint 中与格式化重叠的规则,让 Prettier 完全负责格式化(缩进、换行、引号风格),ESLint 专注代码质量(未使用变量、潜在 bug、最佳实践)。// .eslintrc.json{ "extends": ["eslint:recommended", "prettier"], "plugins": ["prettier"]}Prettier 相比 Beautify、Standard.js 的优势在哪?vs Beautify: Beautify 基于正则匹配做格式化,不具备 AST 解析能力,对复杂语法结构(如嵌套的三元表达式、链式调用)的格式化效果差,且输出不确定——同一份代码多次格式化可能产生不同结果。Prettier 基于 AST 重新打印代码,输出完全确定性,这是团队协作的基础。vs Standard.js: Standard.js 是"零配置"的代名词,但它不允许任何自定义——分号必须有或必须没有,没有中间地带。Prettier 同样开箱即用,但保留了少量关键配置(单引号/双引号、分号、行宽等),适合需要一定灵活性的团队。| 维度 | Prettier | Beautify | Standard.js ||------|----------|----------|-------------|| 解析方式 | AST | 正则 | AST || 输出确定性 | 完全确定 | 不确定 | 完全确定 || 可配置性 | 少量关键选项 | 丰富 | 几乎为零 || 多语言支持 | JS/TS/CSS/HTML/JSON/MD | JS/CSS/HTML | JS/TS |Biome 等新一代工具会取代 Prettier 吗?2026 年 Biome 成为最值得关注的替代方案。它用 Rust 编写,将格式化和 lint 合并为一个工具,在大型 monorepo 中性能优势显著:10,000+ 文件的项目,格式化+检查不到 200ms,而 ESLint+Prettier 组合需要近 12 秒。但 Prettier 短期内不会被取代,原因有三:生态成熟度: Prettier 拥有大量编辑器插件、预提交钩子、CI 集成方案,Biome 生态仍在追赶插件体系: Prettier 支持插件格式化额外语言(如 Java、Ruby、PHP),Biome 目前语言覆盖有限迁移成本: 已有项目的 .prettierrc 配置和格式化基线,切换工具意味着大量 diff选择建议: 新项目可以尝试 Biome,享受性能提升和简化配置;已有项目不必急于迁移,等 Biome 生态更成熟再说。Prettier 的 AST 重打印机制是什么意思?这是理解 Prettier 行为的关键。Prettier 的工作流程:解析(Parse): 将源代码解析为 AST遍历(Traverse): 遍历 AST 节点打印(Print): 根据行宽限制和自身规则重新输出代码这意味着 Prettier 不是"调整"你的代码,而是"重新生成"你的代码。你写的空行、多余括号、手动对齐——大部分都会被丢弃重写。这也是为什么 Prettier 配置选项少:它不是逐条规则控制,而是整体重打印,只暴露行宽、缩进等顶层参数。这种设计牺牲了灵活性,换来了确定性。实际项目中怎么配置 Prettier + ESLint?完整的工程化配置分三步:第一步:安装依赖npm install -D prettier eslint eslint-config-prettier eslint-plugin-prettier第二步:配置文件// .prettierrc{ "semi": true, "singleQuote": true, "printWidth": 80, "trailingComma": "es5"}// .eslintrc.json{ "extends": ["eslint:recommended", "plugin:prettier/recommended"], "env": { "es2024": true, "node": true }}plugin:prettier/recommended 做了三件事:加载 eslint-plugin-prettier(把 Prettier 规则作为 ESLint 规则运行)、加载 eslint-config-prettier(关闭 ESLint 格式化相关规则)、设置 prettier/prettier 为 error 级别。第三步:编辑器集成// .vscode/settings.json{ "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }}保存时先 Prettier 格式化,再 ESLint 自动修复,分工明确不冲突。第四步:Git 钩子自动化npm install -D husky lint-stagednpx husky initecho "npx lint-staged" > .husky/pre-commit// package.json{ "lint-staged": { "*.{js,ts}": ["eslint --fix", "prettier --write"], "*.{css,html,json,md}": ["prettier --write"] }}提交时自动格式化和检查,不合格的代码进不了仓库。Prettier 有哪些已知局限?配置不够灵活: 行宽以内无法手动换行,printWidth: 80 时超过 80 字符的链式调用会被强制换行,即使你手动排列得更易读。这是"确定性"的代价——不允许个人偏好覆盖工具判断。大项目性能瓶颈: Prettier 是单线程的,超大型项目全量格式化耗时较长。应对方式是用 lint-staged 只格式化变更文件,或引入缓存。版本升级可能产生 diff: Prettier 的格式化结果在不同大版本间可能有差异,团队必须锁定版本号,升级时全量格式化会产生大量无意义 diff。面试追问:什么时候不该用 Prettier?三种场景下 Prettier 不是最佳选择:遗留大型项目: 全量格式化会产生数千行 diff,干扰 code review,建议渐进式引入(只格式化新文件或变更文件)需要精细控制格式的场景: 如代码生成器输出、教学材料中特意安排的缩进,Prettier 的重打印会破坏这些刻意格式纯 Python 项目: Python 有 Black,设计理念与 Prettier 一致但针对 Python 语法优化,混用 Prettier 反而增加复杂度
前端阅读 05月28日 07:26

如何在 CI/CD 中集成 Prettier 做代码格式检查?

为什么要用 Prettier 拦截代码格式问题代码格式不一致是团队协作中最容易引发无意义争论的问题。Prettier 通过"零配置强制统一"的思路消除了这类争议,但仅靠开发者自觉运行 Prettier 并不可靠——有人会忘记格式化,有人会选择性忽略。把 Prettier 检查嵌入 CI/CD 流水线,是保证代码库格式一致性的最后防线。推荐的两层防护策略:本地 Git Hook 做即时拦截 + CI 流水线做兜底检查。前者让开发者在提交前就能发现问题,后者防止绕过 Hook 的代码进入主分支。本地拦截:Git Hooks 配置Husky + lint-staged 方案这是目前最主流的方案,lint-staged 的核心优势是只格式化本次提交涉及的文件,不会全量扫描,提交速度有保障。安装依赖 npm install --save-dev husky lint-staged prettier npx husky install npm pkg set scripts.prepare="husky install"配置 lint-staged在 package.json 中添加: { "lint-staged": { "*.{js,jsx,ts,tsx}": [ "prettier --write" ], "*.{json,css,scss,md}": [ "prettier --write" ] } }分开配置的好处是后续可以为 JS/TS 文件加入 ESLint 检查,而不影响纯样式或文档文件。创建 pre-commit Hook npx husky add .husky/pre-commit "npx lint-staged"常见坑:Husky 不生效未执行 husky install:克隆仓库后需要手动运行一次 npm preparecorehooks 被覆盖:某些工具(如 Gerrit)会修改 Git hooks 路径,检查 git config core.hooksPathlint-staged 卡住:文件路径含空格或中文时需要用引号包裹 glob 模式CI 流水线集成GitHub Actions创建 .github/workflows/prettier.yml:name: Prettier Checkon: push: branches: [main] pull_request: branches: [main]jobs: prettier: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx prettier --check "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}"关键细节:用 npm ci 而非 npm install,前者严格按照 lock 文件安装,CI 环境更稳定--check 模式只检查不修改,符合 CI "只读" 原则缩小 glob 范围到 src/ 目录,避免扫描 node_modules 或构建产物如果希望 PR 中直接看到哪些文件格式不对,可以用 --list-different 替代 --check,它会列出有问题的文件名,输出更直观:- run: npx prettier --list-different "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}"GitLab CI在 .gitlab-ci.yml 中添加:prettier: stage: test image: node:20-alpine script: - npm ci - npx prettier --check "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}" only: - merge_requests - main cache: key: ${CI_COMMIT_REF_SLUG} paths: - node_modules/用 node:20-alpine 镜像比完整 Node 镜像小 5 倍以上,流水线启动更快。加上 cache 配置避免每次都全量安装依赖。如果想在 GitLab 的 Code Quality 报告中展示 Prettier 错误,可以使用 @studiometa/prettier-formatter-gitlab 将输出转为 GitLab 可识别的格式。Jenkins在 Jenkinsfile 中添加阶段:stage('Format Check') { steps { sh 'npm ci' sh 'npx prettier --check "src/**/*.{js,jsx,ts,tsx}"' }}Bitbucket Pipelines在 bitbucket-pipelines.yml 中添加:pipelines: pull-requests: '**': - step: name: Prettier Check image: node:20-alpine script: - npm ci - npx prettier --check "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}"Prettier 配置文件示例CI 检查的准确性依赖项目中有明确的 Prettier 配置。创建 .prettierrc:{ "semi": true, "singleQuote": true, "trailingComma": "es5", "printWidth": 100, "tabWidth": 2}同时创建 .prettierignore 排除不需要检查的内容:node_modulesdistbuildcoverage*.min.jspackage-lock.json配置必须在本地和 CI 之间保持一致——这也是为什么 Prettier 要作为 devDependencies 安装而非全局安装,npm ci 会确保 CI 环境拿到和本地完全相同的版本。Prettier 与 ESLint 的协作Prettier 只管格式,ESLint 管代码质量,两者配合才是完整方案。核心原则是用 eslint-config-prettier 关闭 ESLint 中与 Prettier 冲突的规则:npm install --save-dev eslint-config-prettier在 .eslintrc.js 中:module.exports = { extends: [ 'eslint:recommended', 'prettier' // 必须放在最后,覆盖前面的格式相关规则 ]}CI 中可以合并为一条检查:script: - npm ci - npx eslint "src/**/*.{js,ts}" - npx prettier --check "src/**/*.{js,ts,json,css,md}"Monorepo 场景的优化策略在 Turborepo 或 Nx 管理的 monorepo 中,全量 npm ci + 全局 Prettier check 会非常慢。两个优化方向:用 Turborepo 的 filter 定位变更包: npx turbo run format:check --filter=...[HEAD^]只检查本次提交影响到的包。用 changesets 圈定范围:在 CI 中先用 git diff 找出变更的包目录,再对对应目录执行 Prettier check。Prettier 的 --cache 选项(Prettier 3.1+ 支持):只检查未缓存的文件,对大型仓库效果显著: npx prettier --check --cache "src/**/*.{js,ts}"缓存默认写入 node_modules/.cache/prettier,CI 中记得把这个目录加入缓存配置。CI 检查失败怎么办当 CI 报告格式不一致时,最快的修复方式是在本地执行:npx prettier --write "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}"如果频繁出现格式不一致,排查以下几点:Prettier 版本不一致:检查 package.json 中的版本,确保本地和 CI 的 npm ci 安装的是同一个版本编辑器格式化插件冲突:VS Code 中可能同时有多个格式化扩展在生效,在设置中指定 Prettier 为默认格式化器.editorconfig 与 .prettierrc 冲突:两者同时存在时 Prettier 优先,但建议统一到 .prettierrc 中管理核心要点总结本地 Git Hook 做即时反馈,CI 流水线做兜底保障,两者缺一不可--check 模式用于 CI,--write 模式用于本地修复,不要在 CI 中用 --write用 npm ci 代替 npm install 保证依赖版本一致性Prettier 与 ESLint 配合时,eslint-config-prettier 必须放在 extends 最后Monorepo 项目用 filter 或 --cache 缩小检查范围,避免全量扫描拖慢流水线
前端阅读 05月28日 07:26

Prettier 命令行工具有哪些常用命令和选项?

Prettier 命令行工具有哪些常用命令和选项?Prettier 的命令行工具是日常开发中格式化代码的核心手段,掌握常用命令和关键选项不仅能提升开发效率,也是前端工程化面试中的高频考点。核心命令格式化文件:--write--write 是最常用的选项,直接修改文件为格式化后的内容:# 格式化单个文件npx prettier --write src/index.ts# 格式化整个项目npx prettier --write .面试追问:--write 会先写入临时文件再原子替换原文件,避免写入中断导致文件损坏。检查格式:--check--check 只检查文件是否符合 Prettier 格式,不修改文件。文件不合规时退出码为 1,因此广泛用于 CI 流水线:npx prettier --check "src/**/*.{js,ts}"# 在 CI 中使用npx prettier --check . || echo "存在未格式化的文件"列出差异文件:--list-different--list-different(简写 -l)只输出格式不一致的文件路径,不输出格式化内容,适合脚本处理:npx prettier --list-different "src/**/*.js"与 --check 的区别:--check 会输出详细的人类可读信息,--list-different 只输出文件路径,更便于后续管道处理。查看差异:--diff--diff 输出格式化前后的 diff 对比,方便在不修改文件的前提下预览变更:npx prettier --diff src/app.ts配置与忽略指定配置文件:--config默认 Prettier 会沿目录向上查找 .prettierrc 等配置文件,使用 --config 可指定自定义配置:npx prettier --config .prettierrc.staging.json --write src/查找配置路径:--find-config-path输出给定文件实际使用的配置文件路径,用于排查配置生效问题:npx prettier --find-config-path src/index.ts# 输出: /project/.prettierrc忽略文件:--ignore-path默认使用 .prettierignore,可通过 --ignore-path 指定自定义忽略文件:npx prettier --ignore-path .gitignore --write .将 .gitignore 复用为忽略规则是一个实用技巧。忽略未知文件类型:--ignore-unknown格式化整个项目时,遇到 Prettier 不支持的文件类型默认会报错,加上此选项会自动跳过:npx prettier --write --ignore-unknown .缓存与性能启用缓存:--cache大型项目格式化耗时较长,--cache 通过缓存未变更文件的格式化结果显著提升速度:npx prettier --write --cache "src/**/*.ts"缓存位置:--cache-location指定缓存文件的存储路径:npx prettier --write --cache --cache-location .prettiercache src/缓存策略:--cache-strategy支持两种策略:metadata(默认):根据文件修改时间判断,速度快但不够精确content:根据文件内容哈希判断,更精确但稍慢npx prettier --write --cache --cache-strategy content src/输出控制输出到标准输出不加 --write 时,Prettier 将格式化结果输出到 stdout,不修改原文件:npx prettier src/index.ts指定输出目录:--out-dir将格式化结果写入指定目录而非原文件,适合生成格式化副本:npx prettier "src/**/*.js" --out-dir formatted/标准输入:--stdin-filepath从标准输入读取代码时,Prettier 无法判断文件类型,通过此选项指定虚拟路径:echo "const x=1" | npx prettier --stdin-filepath index.ts这在编辑器集成和管道场景中非常关键。与工程化工具集成在 package.json 中配置脚本{ "scripts": { "format": "prettier --write "src/**/*.{js,ts,json,css,md}"", "format:check": "prettier --check "src/**/*.{js,ts,json,css,md}"", "format:all": "prettier --write --ignore-unknown ." }}配合 lint-staged 只格式化暂存文件{ "lint-staged": { "*.{js,ts,css,md}": "prettier --write" }}这样配合 husky 的 pre-commit 钩子,每次提交只格式化本次变更的文件,避免全量格式化带来的提交噪音。在 CI 中强制格式检查- name: Check formatting run: npx prettier --check .--check 在文件不合规时返回退出码 1,CI 流水线会因此失败,确保仓库中不会混入未格式化的代码。调试命令调试检查:--debug-check格式化文件并检查格式化是否改变了 AST,用于排查 Prettier 自身的 bug。不能与 --write 同时使用:npx prettier --debug-check src/index.ts查看帮助与版本npx prettier --helpnpx prettier --versionPrettier 命令行工具在日常开发中主要用于格式化和检查,在工程化体系中则通过 --check 与 CI 集成、通过 --list-different 与 lint-staged 配合,理解每个命令的应用场景比记住参数更重要。
前端阅读 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: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 支持有限,跨域图片等场景容易出问题。