前端性能排查思路:页面卡顿时,不要一上来就怀疑框架
页面一卡,先该录一段 Performance 面板,而不是先猜是不是框架太重。
因为"框架重"这个结论既没法证实也没法证伪,改起来还特别费劲。真正能推进排查的,是先把"慢"分类,再用数据验证每一类。团队里常见的第一反应往往是:是不是框架太重、组件太多、状态管理有问题。这些方向确实可能有关,但一上来就把账算到"大框架""大项目"头上,通常解决不了问题,只会让人对着一个模糊的方向使劲。
这套顺序的核心是先量后改。测量之前的任何优化都是赌,赌对了是运气,赌错了浪费的是真金白银的开发时间,还可能在没问题的地方引入新复杂度。所以哪怕再有把握,我也先录一段数据把瓶颈坐实,再动手。
我以前也爱凭经验猜。列表卡就先加 useMemo,页面慢就先拆包,接口慢就催后端。上个月客户反馈"筛选卡顿",我改了半天 React 渲染,Performance 一录才看清真正耗时的是一段同步权限计算,每次筛选都遍历几万条权限关系。方向猜错时,优化越努力越浪费时间。所以现在的顺序是:先复现、录制、分类,再动代码。慢一点,但结论靠得住。
先分清是哪一种慢
性能问题不是一种。至少分四类:首屏加载慢、页面切换慢、滚动卡顿、输入点击反馈慢。这四类对应的排查方向可能完全不同,混在一起谈没有意义。
用户说"页面有点卡",第一步不是改代码,是把描述具体化:哪个页面、什么设备、什么网络、哪个操作、卡多久、是不是必现。没这个上下文,后面的优化很容易变成到处试错。
能不能稳定复现,几乎决定了这次排查顺不顺。必现的好办,录一段 Performance 就能对着看;偶发的最难,它往往和数据量、网络抖动、某个特定账号的权限规模、某台设备的性能相关。遇到偶发的,我会先想办法把它变成必现——拿用户那份数据在本地灌一遍、用 DevTools 把网络和 CPU 都降到对方那档、切到对方同样的账号权限。很多"玄学卡顿"一旦能稳定复现,就不玄了,成因往往就是前面那几类里的某一种,只是平时数据量小盖住了。把"偶发"逼成"必现",是排查里性价比最高的一步。
如果线上有 RUM 数据,更该先看真实用户指标。LCP、INP、CLS 替代不了具体排查,但能告诉你问题主要落在加载、交互还是布局稳定性上。这里今年有个值得留意的变化:INP(Interaction to Next Paint)从去年起以实验指标身份进了 Web Vitals,Google 已经放话它会取代 FID 成为正式的交互指标。它比 FID 强在哪——FID 只量第一次交互的输入延迟,INP 量的是一次交互从输入到下一帧绘制的完整延迟,把事件处理里的长任务和渲染都算进去了。所以线上如果 INP 高,基本就是在指主线程有活儿堵着,这个信号比 FID 精确得多。我已经开始在监控里同时采两个指标观察。
首屏慢:先看资源和请求
用户第一次打开就慢,优先看资源和请求,别急着怀疑框架。常见原因是 JS 包体积过大、图片没压缩、首屏接口串行、字体加载阻塞文本、第三方脚本提前加载、首屏打进了低频模块。
这类问题跟框架关系往往不大,更多是资源策略不合理。首页只展示一张简单列表,却把图表库、富文本编辑器、后台管理模块全打进首屏包,换再轻的框架也快不了。
这里有个诊断顺序值得固定下来:先分"下载"和"解析执行"两段。一个 2MB 的包,可能是网络慢、下了两秒,也可能是网下得挺快但 JS 解析编译执行占了两秒——这两种成因,优化手段完全不同。前者靠拆包、压缩、CDN、缓存,后者靠减代码量、去掉重依赖、把非首屏逻辑延后。Network 看下载耗时,Performance 看脚本执行耗时,两个面板对着看,才知道该往哪使劲。很多人一见首屏慢就闷头拆包,结果瓶颈其实在执行,白忙一场。
排查先看 Network 面板:HTML、JS、CSS、图片、接口各花了多久。再看 Performance 或 Lighthouse,把"下载慢"和"执行慢"分开。我会把首屏慢拆成三段:资源什么时候到、JS 什么时候执行完、主要内容什么时候画出来。如果资源下得很快但 LCP 仍高,问题多半在 JS 执行、图片解码或渲染阻塞;如果是接口串行拖慢首屏,盲目压 JS 不会有明显改善。
今年这些 LCP 的成因,Chrome DevTools 的 Performance Insights 面板给的诊断更直白了,会直接告诉你 LCP 元素是谁、渲染被什么挡住。真要定位阻塞,比自己从火焰图里一帧帧扒要快。
包体积这块,光看总大小不够,要看是谁把包撑大的。我习惯在 Vite 项目里挂一个 rollup-plugin-visualizer(webpack 那边对应 webpack-bundle-analyzer),打出来的树状图能一眼看到某个日期库、某个图表库、某个 moment 全量 locale 占了多大一块。很多首屏慢的根子就是一个本可以按需引入或换轻量替代的依赖被整包打进来了。定位到之后,动态 import() 做按需加载、路由级别拆 chunk,比在业务代码里抠几个 useMemo 见效得多。
第三方脚本是首屏另一个容易被放过的大头。统计、客服、广告、A/B 这类脚本,一个个看着不大,堆起来能把主线程占掉很久,而且它们的执行时间不进你自己的火焰图函数名里,容易被漏。排查时在 Network 面板按域名排一下,非本站域名的脚本加起来多大、什么时候加载、有没有阻塞渲染,一目了然。能延迟的加 defer 或 async,能等交互后再加载的就交互后再插,别让一个客服 SDK 拖着首屏一起等。
SSR 项目:注意 hydration 这段隐性成本
如果项目是服务端渲染的,首屏还有一段容易被忽略的成本:hydration。服务端先吐出 HTML,用户看到内容很快,但页面这时还不能交互,要等 JS 下载、执行、把事件绑回 DOM,这段时间点上去是没反应的。表现出来就是"看着加载完了,点不动",用户体感很差,却不容易归类——它既不是纯资源慢,也不是纯执行慢。
排查看两点:一是 hydration 的 JS 有多大、执行多久(回到包体积那套方法);二是有没有整页一次性 hydrate。今年 Next.js 的 App Router 五月刚转稳定,RSC 这套把不需要交互的部分留在服务端、只给真正要交互的组件发客户端 JS,正是冲着减少 hydration 成本去的,我在评估要不要把内部项目往这个方向迁——但它还很新,我只敢在内部项目试,对外的还在观望。即便不上这些新东西,把大的、非首屏必需的交互组件用动态导入延后 hydrate,也能把这段成本摊开。
重复打开慢:先看缓存有没有生效
同一个页面第一次慢可以理解,第二次、第三次还一样慢,那基本是缓存没用上。这是首屏排查里一条独立的线索:不是资源本身大,是明明能复用的资源每次都重新下。
打开 Network 面板刷一次,看静态资源那一列的 Status 和 Size——如果 JS、CSS、图片每次都是 200、每次都在传字节,而不是 from disk cache 或 304,说明缓存头没配对。带内容 hash 的构建产物应该配长期强缓存(Cache-Control: max-age=31536000, immutable),文件名变了自然换新,不用协商;HTML 这类入口文件则要 no-cache,每次回来校验,保证能拿到最新的资源引用。这套配好,回访的首屏能省掉一大截下载时间,比在代码里优化半天更立竿见影。接口数据能缓存的也别每次都请求,配合 ETag 或前端做一层短时缓存都行,关键是先确认"该复用的到底有没有被复用"。
请求慢:先看串行链路
有些页面慢,不是单个接口慢,是请求链路被串成了一条线:
1获取用户信息 -> 获取权限 -> 获取菜单 -> 获取列表
每步都等上一步,总耗时被叠加。能并行的就并行:
1async function loadPage() { 2 const [profile, permissions, list] = await Promise.all([ 3 getProfile(), 4 getPermissions(), 5 getList(), 6 ]); 7 8 return { profile, permissions, list }; 9}
但并行不是无脑并行。如果列表接口依赖权限结果,就不能强行并行,优化前先把依赖关系画清楚。
瀑布式请求还有个更隐蔽的版本:不是代码写成了串行,而是渲染逻辑逼出了串行。父组件请求完拿到 id,才渲染子组件,子组件挂载后再拿这个 id 去请求详情——从代码上看每处都合理,连起来就是一条请求瀑布。这种在 Network 面板的瀑布图里最直观:一格接一格阶梯状往下掉,而不是挤在一起。看到阶梯,就该想能不能把下游请求的依赖提前,或者在数据层把这几个请求合并成一次。定位它靠的不是读代码,是看瀑布图的形状。
接口层还要盯失败路径。有人把请求并行化,却忘了统一错误处理,一个非关键接口挂掉把整个页面打崩。我的做法是分关键请求和增强请求:核心数据失败就展示错误态,统计、推荐、辅助配置失败可以降级,别阻塞主流程。Promise.all 有个脾气要记住——任何一个 reject 整体就 reject,非关键接口要单独包一层容错,或者用 Promise.allSettled 拿到每个的结果再决定降级策略。
交互卡顿:先看渲染链路和主线程
点按钮、切筛选、敲文字时卡,优先排查渲染链路。常见原因是大量无谓重渲染、主线程做了重计算、列表过大没虚拟化、频繁读写布局属性导致重排、动画用了不适合的 CSS 属性、第三方脚本抢主线程。
比如输入框每输一个字就重过滤几万条数据,很容易卡:
1const filtered = items.filter((item) => item.name.includes(keyword));
数据量大时可以防抖、服务端搜索、Web Worker,或缓存计算结果:
1const filtered = useMemo(() => { 2 return items.filter((item) => item.name.includes(keyword)); 3}, [items, keyword]);
useMemo 不是万能答案。它适合缓存确实昂贵、依赖明确的计算;计算本身很轻的话,滥用反而添复杂度。计算真的重,也别只想着 memo——防抖、服务端搜索、分页、虚拟列表、Web Worker 都是选择。比如导入 Excel 后做大批量校验,放主线程会把输入和点击一起卡死,拆到 Worker 后用户至少还能操作页面。
这里还能借一个新工具:把长任务切片。React 18 的并发能力早已稳定,useTransition 和 startTransition 能把过滤这类非紧急更新标成可中断的,让输入这一侧先响应。它不是让计算变快,是让紧急更新不被非紧急更新堵住:
1const [isPending, startTransition] = useTransition(); 2 3function onChange(e) { 4 setKeyword(e.target.value); // 紧急:输入框立刻更新 5 startTransition(() => { 6 setQuery(e.target.value); // 非紧急:过滤结果可以稍后 7 }); 8}
真要查重渲染,先看是谁在触发
前面说别把重渲染当唯一答案,但当排查确实指向它时,也别停在"组件渲染太多次"这个结论上——要往上一层查是谁触发的。React 里无谓重渲染大多来自几个固定源头:父组件每次渲染都新建了传给子组件的对象或函数(引用变了,子组件跟着重渲)、Context 的 value 每次都是新对象导致所有消费者一起重渲、状态提得太高使一处变化牵动一大片。
用 React DevTools Profiler 录一次交互,看高亮的是哪些组件、渲染原因写的是什么(props changed 还是 hooks changed),能直接把源头指出来。找到之后对症下药:稳定引用用 useCallback/useMemo 兜住,但只在确实因为引用变化引发下游重渲时才用,别无脑套;Context 拆细,把频繁变的和不常变的分开,别让一个大 value 拖着全部消费者;状态尽量放到用得着它的最近层级,别一律提到顶。
这一步的关键是有据可查——先用 Profiler 看清"渲染了几次、为什么渲染",再决定加不加 memo,而不是先撒一把 useMemo 再看效果。后者常见的结果是复杂度上去了、卡顿没解决,因为真正的瓶颈根本不在渲染次数上。
别把"重渲染"当成唯一答案
很多性能文章爱把问题都归到重渲染,现实里不一定。页面卡可能来自 JS 计算太重、DOM 数量太多、样式重排频繁、动画掉帧、图片解码耗时、第三方脚本执行过长。
所以排查别只盯组件 render 次数。React DevTools Profiler 能定位 React 层的渲染成本,但如果问题出在图片、布局或脚本执行,还得回浏览器 Performance 面板。我在 Performance 里通常先看三件事:Main 线程有没有超过 50ms 的长任务;Layout 和 Recalculate Style 是不是频繁出现;交互发生后到下一帧绘制之间卡在哪。
火焰图里大头是脚本,就继续看函数栈;Layout 很重,就回到 DOM 数量、样式选择器和读写布局;Paint 很重,就看图片、阴影、滤镜和绘制区域。有一类经典坑专门制造重排——在循环里交替读写布局属性(读 offsetHeight 触发一次强制同步布局,再写 style 让布局失效,下一次读又强制布局),这叫 layout thrashing。把读和写分批,读全读完再统一写,能省掉大量重复布局。
布局抖动是另一种"卡",容易被漏掉
前面四类慢里,输入点击卡最容易被注意到,布局抖动反而常被漏掉,因为它不"卡",是"跳"——内容加载完之后突然往下窜一截,用户正要点的按钮被挤走。这对应的指标是 CLS,成因和主线程无关,排查方向也完全不同。
常见来源就几种:图片没写宽高,图片一到就把后面的内容顶下去;字体加载导致文字换行重排;异步插入的横幅、广告位没预留空间;动画改了 top/left/height 这类会触发重排的属性。
对应的做法也直接:图片一律带 width/height 或用 aspect-ratio 占好位;异步内容预留骨架占位,别让它凭空插进来把布局撑变形;动画只动 transform 和 opacity,这两个属性走合成层,不触发布局和重排。
1/* 用 transform 做位移,不碰 top/left,避免每帧重排 */ 2.panel-enter { 3 transform: translateX(100%); 4 transition: transform 0.2s; 5}
Performance 面板里录一段,能看到 Layout Shift 的标记落在哪个时间点,对着那一帧看是哪个元素在动,比盯着数字猜快得多。
列表最容易把小问题放大
列表是性能重灾区,因为一个小成本乘以几百几千行就被放大。常见的是一次渲染所有数据、每行都挂复杂组件、每行都绑一堆事件、滚动触发布局计算、key 不稳定导致整表重建。
列表长就优先虚拟列表,只渲染可视区域。即便暂时不引库,也要避免一次把几千个 DOM 节点全渲染出来。key 别随便用数组下标:
1items.map((item) => <Row key={item.id} item={item} />);
稳定 key 能让框架正确复用节点,减少无谓重建。还要留意"每行都很聪明"——每行自己请求权限、自己监听窗口、自己创建复杂 formatter,单行成本看着小,乘几百行就很可观。能提到列表层统一算的,别每行重复做。事件也能省:几百行各绑一个点击,不如在容器上做一次事件委托。
虚拟列表也不是银弹,用它之前先确认瓶颈真在"渲染了太多 DOM"。如果每行本身就很重(复杂计算、图表、频繁请求),只做虚拟化只是把重成本延后到滚动时触发,滚起来照样卡;这种得先把单行做轻,再谈虚拟化。另外虚拟列表和不定高行、锚点定位、无障碍读屏都有摩擦,引入前要掂量这些代价值不值。先量清楚"到底是 DOM 太多还是每行太重",再决定用哪招,别一见长列表就上虚拟化。
越用越卡,多半是内存泄漏
有一种慢不是一开始就慢,是页面开着开着越来越卡,切几次路由后风扇转起来。这类基本指向内存泄漏,排查工具和前面几种都不一样,要用 Memory 面板的堆快照。
单页应用里最常见的泄漏源就几种:组件卸载了但 addEventListener 没解绑、setInterval 没清、ResizeObserver/IntersectionObserver 没 disconnect、闭包意外抓住了大对象让它一直不能回收。React 里对应的就是 useEffect 少写了清理函数:
1useEffect(() => { 2 const timer = setInterval(tick, 1000); 3 window.addEventListener("resize", onResize); 4 return () => { 5 clearInterval(timer); 6 window.removeEventListener("resize", onResize); 7 }; 8}, []);
排查方法很务实:打开 Memory 面板,做一次堆快照,反复进出那个可疑页面几遍,再拍一次,对比两次快照里哪类对象数量只增不减。数量随操作次数线性增长的那类,往往就是没被卸载掉的组件实例或监听器。找到之后回去补清理,比盲目怀疑"是不是框架内存管理有问题"靠谱。
动画卡顿:先分清是布局层还是合成层
动画掉帧单独拎出来说,因为它的排查逻辑和 JS 卡顿不同。一个动画流不流畅,取决于它触发的是布局、绘制还是合成。改 left/top/width/height 会触发重排,每帧都要重新算布局,容易掉帧;改 transform 和 opacity 走的是合成层,由 GPU 处理,不碰主线程布局。
1/* 卡:每帧重排 */ 2.bad { transition: left 0.3s; } 3 4/* 顺:走合成层 */ 5.good { transition: transform 0.3s; }
Performance 面板录一段动画,看每帧是不是稳定在 16ms 内、有没有出现大块的 Layout 或 Paint。如果一个位移动画的火焰图里全是 Layout,基本就是用错了属性。will-change 可以提前把元素提升到合成层,但别滥用,提太多合成层反而吃显存。这类问题和框架无关,纯粹是 CSS 属性选得对不对。
图片:既拖首屏也拖交互
图片值得单独说,因为它同时出现在好几类慢里——首屏 LCP 元素常常就是一张主图,长列表滚动卡也常是因为一次性塞了几百张图。排查图片先看三样:格式、尺寸、加载时机。
格式上,一张几百 KB 的 PNG 换成 WebP 往往能砍掉一多半,浏览器早就全面支持 WebP 了,这是笔很划算的账。尺寸上,别拿一张 2000 像素宽的原图去填一个 200 像素的头像位,浏览器缩放不省下载也不省解码,该用 srcset 按屏幕给不同尺寸。加载时机上,首屏外的图片一律 loading="lazy",进视口再加载;反过来,LCP 那张主图别偷懒 lazy,要尽早加载甚至预加载,不然 LCP 直接被拖高。
1<img src="hero.webp" width="800" height="400" fetchpriority="high" /> 2<img src="thumb.webp" width="120" height="120" loading="lazy" />
解码耗时也别忽略,一张超大图 decode 本身就占主线程时间,能在服务端就压到合适尺寸,就别让客户端硬扛。图片这块的优化很多是"配置对不对",不是"代码巧不巧",但收益经常比抠 JS 大得多。
优化要有前后对比
性能优化最怕"感觉快了"。改动前后至少记一个指标:首屏资源总大小、LCP、INP、接口耗时、主线程长任务数、某个交互的响应时间、列表渲染行数和耗时。哪怕只是拿浏览器工具截个图,也比凭感觉可靠。
我会在 PR 里写清前后对比:
1筛选输入响应: 2- before: 主线程最长 task 180ms,输入明显掉帧 3- after: 最长 task 42ms,过滤逻辑移到 Worker
这类记录对后续维护很有用。以后有人改回同步计算,review 时能看到当初为什么要拆出去。
光有单次前后对比还不够,得防回退。一个性能优化上线后,很容易在后续迭代里被不知情的人悄悄改回去——有人为了赶功能又把重计算搬回主线程,或者引了个大依赖把首屏包撑大。防这个可以在 CI 里加一道包体积门槛,某个 chunk 超过阈值就让构建失败;关键页面也可以挂个 Lighthouse CI,指标掉出范围就报警。把"性能不许退化"变成流水线里的一条硬约束,比靠人记着靠谱得多——性能是很容易被日常迭代一点点吃掉的,没有闸门拦着,优化过的东西迟早原路退回去。
监控这块光靠开发时录制不够,因为你录不到真实用户的设备和网络。web-vitals 这个官方库能在生产环境采集真实的 LCP、INP、CLS,上报到自己的埋点:
1import { onLCP, onINP, onCLS } from "web-vitals"; 2 3onLCP(report); 4onINP(report); 5onCLS(report);
有了线上分布,排查就不再靠个别用户的截图,而是能看 P75 分位在哪个页面、哪个操作上超标,再有的放矢地去复现。开发时的优化对不对,最终要拿线上真实分布来验,不是拿开发机的感觉。
字体也会阻塞首屏文本
字体是首屏里一类很隐蔽的慢。自定义 web 字体没加载完时,浏览器可能一直不显示文字(FOIT,一片空白),或者先用系统字体顶上、字体到了再换(FOUT,跳一下)。前者直接拖高用户看到内容的时间,后者制造布局抖动,两种都影响体感。
排查在 Network 里看字体文件多大、什么时候开始下、有没有阻塞文本渲染。处理办法也成熟:font-display: swap 让文字先用系统字体显示、字体到了再替换,别让用户对着空白等;关键字体用 <link rel="preload"> 提前拉;能只保留用到的字重和字形就子集化,一个中文字体全量几 MB,子集化后能小一个数量级。
1@font-face { 2 font-family: "Brand"; 3 src: url("/fonts/brand.woff2") format("woff2"); 4 font-display: swap; 5}
这类问题录 Performance 时不一定显眼,但它实打实地卡在"用户什么时候能读到字"这一步上,属于首屏排查里不该漏的一环。
别忽略低端设备
开发机的性能有欺骗性。Mac 上 20ms 的计算,在低端安卓 WebView 里可能是 100ms。Performance 面板能做 CPU throttling,真机也尽量覆盖一两台性能普通的设备。我判断一个优化是否有效,至少看桌面正常环境和低性能模拟环境,只在开发机上"感觉顺了"不算数。
网络也一样有欺骗性。公司 WiFi 下几百 KB 的包无感,用户在地铁里弱网、高延迟、时断时续,串行请求和大包的代价会被成倍放大。DevTools 里把网络降到 Slow 4G 甚至更差跑一遍,很多在快网下藏得好好的问题——首屏接口串行、资源没缓存、大图没懒加载——就都冒出来了。CPU 降四倍、网络降到弱网,这两档一起开,基本就是在模拟真实用户里体验最差的那批人,优化能不能扛住他们,才算真过关。
排查就这么个顺序:先把慢分成首屏、切换、滚动、输入点击几个具体场景,再从资源、请求、渲染、主线程四个方向拿数据验证。把问题定位清楚,比找什么神奇配置管用得多——该拆包就拆包,该并行就并行,该虚拟化就虚拟化,该减主线程计算就减计算。只有能被测量的优化,才值得上线。