腾讯前端面试:如何排查页面卡顿及链路追踪?
这个问题来源于一次腾讯前端实习岗的面试。这个问题是这样的:
面试官问:
问题一:这里现在有 A、B、C 三个组件,用户反馈很卡。卡顿的原因可能是首屏加载卡,也可能是分页切换卡,还可能是加载数据卡顿。我们应该怎么排查是哪个原因?
问题二:如果 A 组件是一个筛选组件,当 A 组件改变了筛选项,导致 B 列表、C 统计图组件的更新突然变卡,请从 Vue 执行的链路来分析整个过程。
问题一解答
问题一:这里现在有 A、B、C 三个组件,用户反馈很卡。卡顿的原因可能是首屏加载卡,也可能是分页切换卡,还可能是加载数据卡顿。我们应该怎么排查是哪个原因?
-
首屏卡
可能原因:
- 所有组件同步加载,未做代码分割
- 初始数据请求过多或串行
- 大型第三方库(如 ECharts、Monaco Editor)未异步加载
- 大量图片未懒加载,或图片未压缩
- 组件内
setup中有同步耗时操作(如遍历大数组) (这个原因在真实业务开发中遇到的比较少,一般存在于库或者工具的开发)
排查工具:
- Chrome Performance:录制首屏,查看 Long Task 分布,哪个函数占用时间长
- Network:查看瀑布图,是否存在串行请求、大体积 JS bundle
- Vue Devtools → Timeline:查看组件挂载顺序和耗时
解决方案:
- 路由懒加载
() => import('./B.vue') - 异步组件
<Suspense>+defineAsyncComponent(对非首屏关键模块做 code splitting 或者 async component,首屏关键路径上的资源反而要尽可能减少异步加载,否则可能会让首屏更慢) - 图片懒加载
v-lazy/loading="lazy" - 将初始化请求改为并行
Promise.all - 使用
shallowRef避免深层响应式初始化开销 (主要用于库或者工具开发的优化,真正业务开发中使用极少)
这么多条目记不住怎么办?所以我更希望从一条完整的请求渲染链路来回答。从发送网络请求到 JS 执行,然后 Vue 初始化并渲染页面,紧接着浏览器执行布局、绘制以及合成等操作,最终呈现给用户并可以交互。这里面的每一个细节,其实刚好对应着我们排查的流程点。
我详细说一些关键部分,比如页面出来得晚。举个例子,我们的 JS 下载需要 2 秒钟,接口请求 1 秒钟,Vue 初始化 500ms,这种就属于经典的 FCP 或 LCP 页面初始化慢的情况。
当然还有另一种情况:页面出来了,但是一打开就很卡。虽然页面开始显示,但这种情况其实是主线程被长任务占用了,这就是经典的运行时 Performance。
所以,我们需要区分是“页面出现得晚”,还是“页面已经出现,但主线程的长任务导致了交互卡顿”:
- 如果是页面出现得晚:我们重点要看网络、资源和初始化链路。
- 如果是长任务导致的交互卡顿:我们需要重点看主线程、长任务以及 JS 的执行。
-
分页切换卡
可能原因:
- 切换分页时整个列表组件销毁重建,而非复用
- 每次切换都重新请求全量数据,或前端做全量数据的分页(数据量过大)
- 切换动画导致强制回流(如修改
height、top等几何属性) - 分页组件本身渲染复杂(如带大量按钮、下拉)
排查工具:
- Performance:录制切换动作,观察 Layout / Recalc Style 耗时
- Vue Devtools → Component Tree:查看切换时组件是否被卸载再挂载
- Console:打印
mounted和unmounted日志,确认组件生命周期
解决方案:
- 使用虚拟滚动(如
vue-virtual-scroller)只渲染可视区域 - 分页数据只请求当前页,不做全量加载
- 动画使用
transform/opacity避开 Layout - 分页控件用
shallowRef或v-memo减少不必要的子组件更新 (只是作为一种优化思路,真实开发中能不用就不用,具体原因下面有写到)
这里特别强调,不建议直接说 keep-alive 。因为我们需要先确认分页切换时,到底发生的是一个组件的更新,还是组件发生了卸载和挂载。千万不能在没有确定生命周期行为之前,就直接使用 keep-alive。
-
加载数据卡
可能原因:
- 请求返回后,前端对大量数据做了复杂转换(排序、分组、深拷贝)且放在
watch或computed中同步执行 - 数据更新触发了大量 watcher 和 computed 重新求值
- 没有 loading 状态,用户在等待时界面僵死(因为主线程被占)
- 请求未做防抖,用户连续操作导致并发请求堆积
排查工具:
- Performance:查看数据返回后的 Long Task,定位是哪段代码占用了主线程
- Sources → Call Stack:在数据更新的地方打断点,查看调用栈
- Vue Devtools → Components:观察数据更新后哪些组件重新渲染
当然,这里面试官可能会追问:通过 performance 看到两秒的长任务,怎么确定是 Vue?
此时我们主要可以这样回答:
- 通过 performance 定位到主线程的长任务。
- 通过 call tree 和 bottom up 来看具体的 JS function。
- 如果 function 落到 render reactive effect 或者 patch 等 API 上,再结合 Vue 的 dev tools,就可以定位到具体的组件。
解决方案:
- 数据处理移到 Web Worker 中,主线程只负责赋值
- 使用
computed缓存而非methods在模板中调用 - 对大量数据使用
Object.freeze冻结静态数据,跳过响应式包装 (平时的业务开发用到的少,一般用于库的开发) - 加入 loading 骨架屏,避免用户感知空白 (这里很多同学有一个误区:骨架屏只是优化用户体验,而不是优化性能的。如果我们主线程卡顿好几秒,其实放个骨架屏也没有用,因为主线程被 JS 占用,连骨架屏自己都可能无法及时绘制)
- 使用
debounce或AbortController取消过期请求,避免过期结果回写前端,减少不必要的客户端处理
上面对于数据请求响应后做监听的行为,非常建议把详细的链条说出来:
- 当 HTTP 响应之后,我们一步解析得到响应数据,并且修改响应式的 state,进而触发 trigger 变更。
- 通过 Vue 的 scheduler 调度,然后组件 update job 进行入队,之后 nextTick 可批量执行。
- 页面通过 render 进行渲染,进而生成或者复用虚拟节点。
- 然后执行 patch, 操作 DOM 更新。
- 浏览器最终进行布局、绘制或者合成。
因此,如果面试官想要更深入地回答的话,其实性能问题还可能存在于这些场景:
- 数据处理
- 响应式触发
- Computed 和 watcher
- 组件的 render
- 虚拟 DOM 的 diff 和 patch
- 浏览器的 layout 和 paint
问题二解答
问题二:如果 A 组件是一个筛选组件,当 A 组件改变了筛选项,导致 B 列表、C 统计图组件的更新突然变卡,请从 Vue 执行的链路来分析整个过程。
我把这个问题的执行链路拆分一下便于思考:
filter.category = 'book'
1. filter.category 被修改
2. Proxy setter / reactive trigger
3. 找到依赖 category 的 effects
4. Vue scheduler 对更新任务进行调度
5. 相关 component update job 被执行
6. B/C 如果确实依赖该状态,则重新执行 render
7. render 产生新的 VNode
8. Vue patch 新旧 VNode
9. DOM 更新
10. 浏览器执行 Style/Layout/Paint/Composite
-
现象本质
A 的筛选项(通常是 reactive 对象或 Pinia store 里的 state)发生变化后,B 和 C 组件重新渲染,但渲染耗时明显过长,导致界面卡顿。
-
Vue 链路中的卡顿根源
如果面试官并没有强调具体的场景,我们可以根据这几种原因依次分析。
着重强调一下关于列表中的 key:当列表只是尾部追加,没有重排序时,Index 作为 key 未必会造成明显的问题;只有在列表发生插入、删除或者排序的时候,Index 作为 key 并不能稳定地表示业务实体的身份,这时候才容易出现错误的复用。
当然,其实如果面试官要追问的话,我们也不能直接说父组件更新就一定会导致整棵子树的重新渲染,因为 Vue 3 内部其实已经做了非常多的优化。
父组件更新之后,Vue 会对其子组件进行必要的更新检查。是否导致子组件真正执行更新,其实还要看子组件依赖的 props 变化以及编译优化后的 patch 信息等,而不能简单地说整个子组件全部会重新渲染。
-
排查方法
- Vue Devtools → Performance 面板:录制筛选项变化过程,查看 B/C 组件的
render耗时、setup调用次数、computed 重新求值次数。 - console.time 打点:在 B/C 的
beforeUpdate和updated钩子中打印耗时,定位哪个组件最慢。 - 检查 props 引用:确认 A 传给 B/C 的筛选项是否每次都生成新对象(如
...state展开),导致子组件认为 props 变了。
这些只是排查的方法,那我们应该如何快速定位呢?我这里把定位做了一些分层,可以供大家参考:
第一层是数据层。通过筛选改变进而触发接口请求,返回 10 万条数据,这样就可以去检查我们的数据量以及序列化、反序列化和数据转换等操作。
第二层是响应式层。比如说我们的 state 发生了改变,进而触发了 trigger,那么其他的响应式 API(比如 computed、watch 或者组件的副作用)都会执行。
在这种情况下,我们需要思考:
- 是否监听了过大的对象?
- 是否存在无关的依赖?
- computed 是否会频繁地失效?
- watch 是否触发了大量的副作用?
第三层是 Vue 渲染层。当组件更新时,触发 render 渲染,并且进行虚拟 DOM 对比更新以及 patch。
这种情况下,我们需要思考:
- render 是否执行了太多次?
- 列表是不是有成千上万个节点,或者存在大量无意义的更新?
- props 是否因为引用的变化导致了更新?
- key 是否合理?
最后一个层面是浏览器的渲染层面。这一点其实主要回答的是“浏览器渲染原理”这道面试题。我们根据渲染原理,比如说 DOM 更新触发 style 的变更,进而触发重排、重绘以及合成层。其中主要涉及以下情况:
- 大量的 DOM 强制同步布局;
- 复杂的 CSS 动画;
- 大面积的绘制。
Chrome DevTools 的 Performance / Rendering / Layers 等工具可以进一步确认是 JS 执行、布局绘制还是合成阶段占用时间。
-
解决方案
// 1. 精细化响应式:只监听具体字段
const filter = reactive({ category: '', priceRange: [] })
// B 组件只关心 category
const filteredItems = computed(() => items.filter(i => i.category === filter.category))
// 2. 用 computed 缓存昂贵计算
const sortedList = computed(() => [...rawList].sort(/* ... */))
// 3. 子组件包裹 memo (Vue 3.3+)(了解即可,开发中很少用)
<script setup>
defineOptions({ inheritAttrs: false })
</script>
// 或用 shallowRef + triggerRef 手动控制更新(了解即可,开发中很少用)
// 4. 列表加稳定 key,且不要用 index
<li v-for="item in list" :key="item.id">
// 5. 使用 Pinia getter 缓存派生数据
export const useStore = defineStore('main', () => {
const expensiveList = computed(() => heavyComputation(items.value))
})
同时这里我们也需要明确,computed 虽然能够缓存高昂计算,但是不是用了 computed 就一定快。computed 的依赖是否稳定,以及依赖变化频率是否合理,才是关键。
还有,官方很明确地强调过,v-memo 应该尽量少使用。只有明确确定存在大量无意义的子树更新时,才会考虑使用 v-memo 跳过特定子树的更新。这是一种非常偏底层的微优化,我们面试时候可以说,但在工作中一定要慎重再慎重地去使用。
话术总结
面对“页面卡顿”,我不会先猜是哪个组件有问题,而是先用 Performance 把卡顿定位到网络、JS 执行、Vue 更新还是浏览器渲染阶段。
如果是 Vue 更新导致的卡顿,我会从响应式数据变更开始追:A 修改筛选条件 → 触发对应响应式依赖 → Vue scheduler 调度更新任务 → 相关组件执行 render → 生成 VNode → patch → DOM 更新 → 浏览器进行 Layout/Paint。
然后分别检查四类问题:
第一,是否有过大的响应式依赖和无意义的 computed/watch;
第二,是否因为 props 引用变化等原因导致无意义组件更新;
第三,render、diff、列表渲染是否过重;第四,DOM 更新后是否产生大量 Layout/Paint。
最后再根据定位结果选择 computed、v-memo、虚拟列表、拆分组件、Web Worker 等具体优化,而不是一上来就堆优化手段。

浙公网安备 33010602011771号