简历项目经验描述
版本1 - 适合初中级
实现虚拟滚动组件,优化大数据列表渲染性能
- 基于虚拟滚动技术,实现万级数据列表的流畅渲染,帧率稳定 60fps
- 支持动态高度、双向滚动、无限加载等复杂场景
- 优化首屏渲染时间,10000 条数据首屏从 5s 降至 300ms
版本2 - 适合高级
从零实现高性能虚拟滚动引擎,支持百万级数据渲染
- 设计动态高度计算算法,通过二分查找和缓存策略,查询性能从 O(n) 优化到 O(log n)
- 实现二维虚拟化(虚拟表格),同时优化行列渲染,大表格滚动帧率提升 5 倍
- 通过 requestIdleCallback 分片渲染,避免长任务阻塞主线程,交互响应性提升 80%
- 解决滚动锚点定位、快速滚动白屏、高度抖动等 10+ 个技术难点
版本3 - 适合架构方向
主导虚拟化渲染架构设计,建立性能优化方法论
- 抽象虚拟化核心算法,支持列表、表格、瀑布流等多种布局模式
- 建立渲染性能监控体系,识别性能瓶颈并自动优化,长列表 FPS 提升 3 倍
- 设计渐进式渲染策略,首屏内容优先,非关键内容延迟渲染,LCP 降低 60%
面试标准回答话术
Q1: 什么是虚拟滚动?为什么要用它?
标准回答
"虚拟滚动是一种性能优化技术,核心思想是只渲染可视区域的内容,看不见的内容不渲染,从而减少 DOM 节点数量,提升性能。
为什么需要虚拟滚动呢?因为浏览器处理 DOM 的能力是有限的。如果一个列表有 10000 条数据,全部渲染成 DOM,会创建至少 10000 个节点。DOM 节点多了之后,浏览器在布局计算、重绘、事件监听这些方面的开销会非常大,页面会变得很卡。
我之前遇到过一个真实案例,一个监控日志列表,大概 5000 条记录。一开始没用虚拟滚动,直接全部渲染,页面打开需要 8 秒,滚动时一卡一卡的,用户体验很差。后来用了虚拟滚动,首屏时间降到 300ms,滚动帧率稳定在 60fps,完全是两个产品。
虚拟滚动的原理其实不复杂。假设列表容器高度是 600px,每条数据高度是 50px,那么可视区域最多显示 12 条数据。我只需要渲染这 12 条,加上上下各 5 条的缓冲区(防止滚动时白屏),总共渲染 22 条就够了。
用户滚动时,动态计算当前可视区域的起始索引和结束索引,只渲染这个范围内的数据。剩下的 4978 条数据虽然不渲染,但要用一个占位元素撑起总高度,让滚动条正常工作。
这样做的好处是,无论列表有多少条数据,DOM 节点数量都是恒定的,大约就是一屏的数量,性能不会随数据量增加而下降。"
Q2: 固定高度的虚拟滚动和动态高度的区别?
标准回答
"固定高度和动态高度是虚拟滚动的两种实现方式,难度差别很大。
固定高度比较简单。因为每条数据的高度是固定的,比如都是 50px,那计算就很方便。滚动位置是 1000px,除以 50px,就知道当前应该显示第 20 条数据。容器总高度等于数据总数乘以单条高度,这些都是 O(1) 的计算,性能很好。
但实际业务里,数据高度往往不是固定的。比如评论列表,短评论一行,长评论可能好几行,每条高度都不一样。这时候就必须用动态高度。
动态高度的难点在于,你不渲染就不知道高度,但要决定渲染哪些又必须知道高度,这是个鸡生蛋蛋生鸡的问题。
我的解决方案分几步:
第一步是预估。给每条数据设置一个预估高度,比如 80px。基于预估高度计算初始的渲染范围。
第二步是实测。数据渲染后,用 ResizeObserver 监听每个元素,获取真实高度。真实高度和预估高度有差异,比如实际是 120px,差了 40px。
第三步是更新缓存。把真实高度存到一个 Map 里,key 是索引,value 是高度。同时维护一个累计高度数组,记录每个索引之前所有元素的高度总和。这个数组可以用二分查找,快速定位某个滚动位置对应的索引。
第四步是调整滚动位置。如果真实高度和预估高度有差异,滚动位置可能不准确。我会在渲染后检查,如果发现偏差太大,就调整 scrollTop,保证视觉上的稳定。
整个过程是增量更新的,初始用预估高度快速渲染,用户看到内容后,后台测量真实高度,逐步修正。这样既保证了首屏速度,又能适应动态高度。"
Q3: 虚拟滚动如何处理快速滚动时的白屏?
标准回答
"快速滚动白屏是虚拟滚动常见的问题,主要原因是渲染跟不上滚动速度。
用户快速滑动列表时,滚动事件触发得非常频繁,每次都要重新计算可视范围、销毁旧节点、创建新节点,这些操作是有耗时的。如果滚动太快,新节点还没渲染完,用户就看到空白了。
我的解决方案有三个层面:
第一是缓冲区。不是严格按可视区域渲染,而是上下各多渲染几条,比如可视区域是 10 条,我渲染 20 条。这样即使用户滚动了,因为有缓冲区,新内容已经提前渲染好了,不会白屏。
第二是节流。滚动事件用 requestAnimationFrame 节流,保证每帧最多计算一次。这样避免了频繁的重新渲染,降低了 CPU 占用。
第三是占位优化。即使来不及渲染真实内容,也要先显示占位符,比如骨架屏。用户看到占位符,知道内容正在加载,比白屏体验好很多。
还有个进阶优化是预测式渲染。根据滚动方向和速度,预测用户会滚动到哪里,提前渲染那些内容。比如用户向下快速滚动,我就多渲染下面的内容,上面的少渲染一些。这个优化效果很明显,基本可以消除快速滚动的白屏。"
Q4: 虚拟表格(二维虚拟化)比虚拟列表难在哪?
标准回答
"虚拟表格是在虚拟列表基础上再加一个维度,既要虚拟化行,也要虚拟化列,复杂度翻倍。
虚拟列表只需要关心垂直滚动,计算当前显示哪些行。虚拟表格还要关心水平滚动,计算显示哪些列。而且行和列要同时计算,不能独立。
难点主要有几个:
第一是固定列的处理。表格通常有固定列,比如第一列是序号或名称,要固定在左侧,不随水平滚动消失。这就要求固定列和滚动列分开渲染,而且要保证行高对齐,不能错位。
第二是单元格合并。有些表格单元格会跨行或跨列合并,虚拟化后,如果合并的单元格一部分在可视区域,一部分不在,就会出问题。我的处理是,只要合并单元格有一部分可见,就整个渲染出来。
第三是列宽的动态计算。不同的列宽度不一样,而且可能是内容撑开的,不是固定值。这就需要类似动态高度的处理,先预估列宽,渲染后测量真实宽度,更新缓存。
第四是性能优化。二维虚拟化的计算量比一维大很多,每次滚动都要计算行列两个维度的范围。我做了一个优化,用空间换时间,把行列的渲染范围都缓存起来,相同的滚动位置不重复计算。
还有个细节是滚动的同步。如果表头是固定的,左侧列是固定的,内容区域可以滚动,这三个区域的滚动要同步。我用了一个主滚动容器,监听它的滚动事件,同步更新表头和左侧列的偏移量,保证视觉上是联动的。"
Q5: 无限滚动加载是怎么实现的?
标准回答
"无限滚动就是滚动到底部时自动加载下一页数据,常见于社交 feed 流、商品列表这些场景。
实现思路是监听滚动事件,判断是否接近底部,如果是就触发加载。判断的逻辑:
scrollTop + clientHeight >= scrollHeight - threshold
scrollTop 是已滚动的距离,clientHeight 是可视高度,scrollHeight 是总高度。threshold 是阈值,比如 100px,表示距离底部 100px 时就触发加载,不用等到完全滚到底。
但这样实现有个问题,就是滚动事件触发太频繁,会重复触发加载。我做了防抖和状态管理:
- 用一个 loading 状态,加载中时不重复触发
- 用一个 hasMore 状态,记录是否还有更多数据,没有就不加载了
- 加载成功后,把新数据追加到列表,更新 hasMore
还有个优化是,我不是监听 scroll 事件,而是用 IntersectionObserver。在列表末尾放一个哨兵元素,用 IntersectionObserver 监听它是否进入可视区域,进入就触发加载。这个方法性能更好,因为不需要频繁计算滚动位置。
结合虚拟滚动的话,要注意数据更新时不能重置滚动位置。我的做法是记录当前的滚动位置和可视范围的第一个元素,加载新数据后,定位到这个元素,保持滚动位置不变。这样用户感觉不到数据变化,体验很平滑。"
核心难点与解决方案
难点1: 动态高度的精确计算与性能优化
问题描述: 动态高度列表滚动时,需要频繁计算每个元素的位置,如何在保证精度的同时优化性能?
解决方案
"我设计了一套高度缓存和快速查询的系统。
数据结构设计
- 高度缓存 Map - 记录每个元素的真实高度
heightCache = new Map([
[0, 120], // 第 0 个元素高度 120px
[1, 80], // 第 1 个元素高度 80px
[2, 150], // 第 2 个元素高度 150px
])
- 累计高度数组 - 记录到每个位置为止的总高度
offsetCache = [
0, // 第 0 个元素顶部位置
120, // 第 1 个元素顶部位置
200, // 第 2 个元素顶部位置
350, // 第 3 个元素顶部位置
]
查询优化
用二分查找在累计高度数组里定位。比如滚动位置是 250px,通过二分查找找到第一个大于 250 的位置,就知道当前应该显示第 2 个元素。
时间复杂度从遍历的 O(n) 降到 O(log n)。10000 条数据,遍历需要 10000 次比较,二分查找只需要 14 次。
增量更新
元素高度变化时,不是重新计算所有元素的位置,而是只更新变化点之后的位置。比如第 10 个元素高度变了,只需要更新第 11 到 n 个元素的位置。
用一个 dirty 标记记录哪些位置需要更新,滚动时才真正计算,避免不必要的计算。
测量时机优化
用 ResizeObserver 监听元素高度变化,但不是立即更新缓存,而是收集变化,用 requestIdleCallback 在浏览器空闲时批量更新。这样不会阻塞用户交互。
通过这些优化,10000 条数据的列表,滚动帧率稳定在 60fps,没有明显的卡顿。"
效果数据
- 位置查询时间:从 10ms 降至 0.1ms
- 高度变化更新:从 100ms 降至 10ms
- 滚动帧率:从 30fps 提升至 60fps
难点2: 滚动锚点定位与抖动问题
问题描述: 动态高度列表中,如果某个元素的实际高度和预估高度差异很大,会导致滚动位置跳动,用户体验很差。
解决方案
"滚动抖动的根本原因是高度预估不准确。我的解决方案是锚点定位 + 增量调整。
锚点定位机制
- 滚动时,记录可视区域第一个元素的索引和它相对于容器顶部的偏移量
- 元素高度变化后,重新计算这个元素的位置
- 调整滚动位置,让这个元素保持在原来的视觉位置
举个例子:
滚动前:
- 第 10 个元素在可视区域顶部
- 相对容器顶部偏移 50px
- scrollTop = 1000px
第 5 个元素高度从预估 80px 变成实际 200px,差了 120px
滚动后:
- 第 10 个元素的位置变了,从 1000px 变成 1120px
- 为了保持视觉位置,scrollTop 也要加 120px
- 新的 scrollTop = 1120px
增量调整策略
不是一次性调整到目标位置,而是逐步调整,避免突兀。比如差距 120px,分 3 帧调整,每次 40px。用户感觉是平滑的过渡,不是跳跃。
预估优化
提高预估的准确性,减少调整的幅度。我分析了历史数据的高度分布,用平均值作为预估值,比固定值准确很多。
还做了自适应预估,根据已测量的元素高度,动态调整预估值。比如前 100 个元素平均高度是 90px,那后面的预估就用 90px,而不是初始的 80px。
高度变化限制
对于特别极端的情况,比如某个元素高度是预估值的 10 倍,会限制单次调整的幅度,避免滚动位置大幅跳动。
通过这些措施,滚动抖动基本可以控制在用户难以察觉的范围内。"
难点3: 长任务阻塞与渐进式渲染
问题描述: 虚拟列表首次渲染或滚动时,如果一次性渲染太多元素,会阻塞主线程,导致页面卡顿、无法交互。
解决方案
"我用了时间切片(Time Slicing)的思想,把一个长任务拆分成多个小任务,分帧执行。
渲染调度器
- 把要渲染的元素分成多个批次,每批 50 个
- 用 requestIdleCallback 或 requestAnimationFrame 调度,每帧渲染一批
- 渲染过程中检查帧预算,如果超时就暂停,下一帧继续
function renderInChunks(items, chunkSize = 50) {
let index = 0
function renderChunk(deadline) {
while (index < items.length && deadline.timeRemaining() > 0) {
const endIndex = Math.min(index + chunkSize, items.length)
const chunk = items.slice(index, endIndex)
// 渲染这一批
renderItems(chunk)
index = endIndex
}
if (index < items.length) {
requestIdleCallback(renderChunk)
}
}
requestIdleCallback(renderChunk)
}
优先级策略
不是按顺序渲染,而是按优先级:
- 首先渲染可视区域的元素(高优先级)
- 然后渲染缓冲区的元素(中优先级)
- 最后渲染其他元素(低优先级)
这样用户能最快看到内容,而不是等所有元素都渲染完。
占位优化
在元素真正渲染之前,先渲染一个占位符,比如骨架屏。占位符的渲染成本很低,可以快速显示,给用户正在加载的反馈。
懒渲染
对于复杂的元素(比如包含图片、富文本),不是立即完全渲染,而是先渲染简化版,等空闲时再完整渲染。
通过这些优化,即使渲染 1000 个元素,也不会阻塞主线程超过 16ms,保证了 60fps 的流畅度。"
完整技术实现
1. 虚拟列表核心组件
<!-- components/VirtualList/VirtualList.vue -->
<template>
<div
ref="containerRef"
class="virtual-list"
:style="{ height: `${height}px`, overflow: 'auto' }"
@scroll="handleScroll"
>
<!-- 占位元素,撑起总高度 -->
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
<!-- 可见元素 -->
<div
v-for="item in visibleItems"
:key="getItemKey(item)"
:ref="el => setItemRef(el, item)"
:style="{
position: 'absolute',
top: `${getItemOffset(item.index)}px`,
width: '100%',
}"
>
<slot :item="item.data" :index="item.index"></slot>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
const props = defineProps({
// 数据列表
items: {
type: Array,
required: true,
},
// 容器高度
height: {
type: Number,
required: true,
},
// 预估每项高度
estimatedItemHeight: {
type: Number,
default: 50,
},
// 缓冲区项数
overscan: {
type: Number,
default: 5,
},
// 获取项的唯一 key
itemKey: {
type: [String, Function],
default: 'id',
},
})
const containerRef = ref()
// 滚动位置
const scrollTop = ref(0)
// 高度缓存
const heightCache = new Map()
// 累计高度缓存
const offsetCache = ref([0])
// 元素引用 Map
const itemRefs = new Map()
// 总高度
const totalHeight = computed(() => {
return getItemOffset(props.items.length)
})
// 可见区域的起始和结束索引
const visibleRange = computed(() => {
const start = findStartIndex(scrollTop.value)
const end = findEndIndex(scrollTop.value + props.height, start)
return {
start: Math.max(0, start - props.overscan),
end: Math.min(props.items.length - 1, end + props.overscan),
}
})
// 可见的元素
const visibleItems = computed(() => {
const result = []
for (let i = visibleRange.value.start; i <= visibleRange.value.end; i++) {
result.push({
index: i,
data: props.items[i],
})
}
return result
})
// 获取项的 key
function getItemKey(item) {
if (typeof props.itemKey === 'function') {
return props.itemKey(item.data)
}
return item.data[props.itemKey] || item.index
}
// 获取项的高度
function getItemHeight(index) {
return heightCache.get(index) || props.estimatedItemHeight
}
// 获取项的偏移量
function getItemOffset(index) {
if (offsetCache.value[index] !== undefined) {
return offsetCache.value[index]
}
// 计算并缓存
let offset = offsetCache.value[offsetCache.value.length - 1] || 0
for (let i = offsetCache.value.length; i <= index; i++) {
offset += getItemHeight(i - 1)
offsetCache.value[i] = offset
}
return offsetCache.value[index]
}
// 二分查找起始索引
function findStartIndex(scrollTop) {
let left = 0
let right = props.items.length - 1
while (left <= right) {
const mid = Math.floor((left + right) / 2)
const offset = getItemOffset(mid)
if (offset === scrollTop) {
return mid
} else if (offset < scrollTop) {
left = mid + 1
} else {
right = mid - 1
}
}
return Math.max(0, right)
}
// 查找结束索引
function findEndIndex(bottom, start) {
let offset = getItemOffset(start)
for (let i = start; i < props.items.length; i++) {
offset += getItemHeight(i)
if (offset >= bottom) {
return i
}
}
return props.items.length - 1
}
// 设置元素引用
function setItemRef(el, item) {
if (el) {
itemRefs.set(item.index, el)
// 测量高度
measureItemHeight(el, item.index)
}
}
// 测量元素高度
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach(entry => {
const index = parseInt(entry.target.dataset.index)
if (!isNaN(index)) {
updateItemHeight(index, entry.contentRect.height)
}
})
})
function measureItemHeight(el, index) {
el.dataset.index = index
// 观察元素大小变化
resizeObserver.observe(el)
// 立即测量一次
const height = el.offsetHeight
updateItemHeight(index, height)
}
// 更新元素高度
function updateItemHeight(index, height) {
const oldHeight = getItemHeight(index)
if (oldHeight === height) return
// 更新缓存
heightCache.set(index, height)
// 更新偏移量缓存
const delta = height - oldHeight
for (let i = index + 1; i < offsetCache.value.length; i++) {
offsetCache.value[i] += delta
}
}
// 处理滚动
let rafId = null
function handleScroll(e) {
if (rafId) {
cancelAnimationFrame(rafId)
}
rafId = requestAnimationFrame(() => {
scrollTop.value = e.target.scrollTop
})
}
// 清理
onUnmounted(() => {
if (rafId) {
cancelAnimationFrame(rafId)
}
resizeObserver.disconnect()
})
// 暴露方法
defineExpose({
scrollToIndex: (index) => {
const offset = getItemOffset(index)
containerRef.value.scrollTop = offset
},
scrollTo: (offset) => {
containerRef.value.scrollTop = offset
},
})
</script>
<style scoped>
.virtual-list {
position: relative;
}
</style>
2. 虚拟表格组件
<!-- components/VirtualTable/VirtualTable.vue -->
<template>
<div class="virtual-table" :style="{ height: `${height}px` }">
<!-- 表头 -->
<div
class="virtual-table-header"
:style="{ transform: `translateX(-${scrollLeft}px)` }"
>
<table>
<thead>
<tr>
<th
v-for="col in visibleColumns"
:key="col.key"
:style="{ width: `${col.width}px` }"
>
{{ col.title }}
</th>
</tr>
</thead>
</table>
</div>
<!-- 表体 -->
<div
ref="bodyRef"
class="virtual-table-body"
@scroll="handleScroll"
>
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
<table>
<tbody>
<tr
v-for="row in visibleRows"
:key="row.index"
:style="{
position: 'absolute',
top: `${getRowOffset(row.index)}px`,
}"
>
<td
v-for="col in visibleColumns"
:key="col.key"
:style="{ width: `${col.width}px` }"
>
<slot
:name="`cell-${col.key}`"
:row="row.data"
:column="col"
:value="row.data[col.dataIndex]"
>
{{ row.data[col.dataIndex] }}
</slot>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
columns: {
type: Array,
required: true,
},
data: {
type: Array,
required: true,
},
height: {
type: Number,
required: true,
},
rowHeight: {
type: Number,
default: 50,
},
})
const bodyRef = ref()
const scrollTop = ref(0)
const scrollLeft = ref(0)
// 可见行范围
const visibleRowRange = computed(() => {
const start = Math.floor(scrollTop.value / props.rowHeight)
const end = Math.ceil((scrollTop.value + props.height) / props.rowHeight)
return {
start: Math.max(0, start - 5),
end: Math.min(props.data.length - 1, end + 5),
}
})
// 可见行
const visibleRows = computed(() => {
const result = []
for (let i = visibleRowRange.value.start; i <= visibleRowRange.value.end; i++) {
result.push({
index: i,
data: props.data[i],
})
}
return result
})
// 可见列(简化版,假设所有列都可见)
const visibleColumns = computed(() => props.columns)
// 总高度
const totalHeight = computed(() => {
return props.data.length * props.rowHeight
})
// 获取行偏移
function getRowOffset(index) {
return index * props.rowHeight
}
// 处理滚动
function handleScroll(e) {
scrollTop.value = e.target.scrollTop
scrollLeft.value = e.target.scrollLeft
}
</script>
<style scoped>
.virtual-table {
overflow: hidden;
}
.virtual-table-header {
overflow: hidden;
border-bottom: 1px solid #d9d9d9;
}
.virtual-table-body {
height: calc(100% - 50px);
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
</style>
3. 无限滚动加载
<!-- components/InfiniteScroll/InfiniteScroll.vue -->
<template>
<div ref="containerRef" class="infinite-scroll">
<slot :items="allItems"></slot>
<!-- 加载中 -->
<div v-if="loading" class="infinite-scroll-loading">
<a-spin />
<span>加载中...</span>
</div>
<!-- 已加载全部 -->
<div v-else-if="!hasMore" class="infinite-scroll-end">
没有更多了
</div>
<!-- 哨兵元素 -->
<div ref="sentinelRef" class="infinite-scroll-sentinel"></div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'
const props = defineProps({
// 加载函数
loadMore: {
type: Function,
required: true,
},
// 初始数据
initialItems: {
type: Array,
default: () => [],
},
// 距离底部多远触发加载
threshold: {
type: Number,
default: 100,
},
})
const containerRef = ref()
const sentinelRef = ref()
const allItems = ref([...props.initialItems])
const loading = ref(false)
const hasMore = ref(true)
const page = ref(1)
let observer = null
onMounted(() => {
// 使用 IntersectionObserver 监听哨兵元素
observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]
if (entry.isIntersecting && !loading.value && hasMore.value) {
load()
}
},
{
root: containerRef.value,
rootMargin: `${props.threshold}px`,
}
)
observer.observe(sentinelRef.value)
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
})
// 加载数据
async function load() {
if (loading.value || !hasMore.value) return
loading.value = true
try {
const result = await props.loadMore(page.value + 1)
if (result.data && result.data.length > 0) {
allItems.value = [...allItems.value, ...result.data]
page.value++
// 检查是否还有更多
if (result.data.length < result.pageSize) {
hasMore.value = false
}
} else {
hasMore.value = false
}
} catch (error) {
console.error('加载失败', error)
} finally {
loading.value = false
}
}
// 暴露方法
defineExpose({
reset: () => {
allItems.value = [...props.initialItems]
page.value = 1
hasMore.value = true
},
})
</script>
<style scoped>
.infinite-scroll-loading,
.infinite-scroll-end {
padding: 20px;
text-align: center;
color: #999;
}
.infinite-scroll-sentinel {
height: 1px;
}
</style>
4. 使用示例
<!-- views/VirtualListExample.vue -->
<template>
<div class="page">
<h2>虚拟列表示例</h2>
<VirtualList
:items="items"
:height="600"
:estimated-item-height="80"
item-key="id"
>
<template #default="{ item, index }">
<div class="list-item">
<div class="item-title">{{ item.title }}</div>
<div class="item-desc">{{ item.description }}</div>
<div class="item-footer">
<span>索引: {{ index }}</span>
<span>ID: {{ item.id }}</span>
</div>
</div>
</template>
</VirtualList>
</div>
</template>
<script setup>
import { ref } from 'vue'
import VirtualList from '@/components/VirtualList/VirtualList.vue'
// 生成测试数据
const items = ref(
Array.from({ length: 10000 }, (_, i) => ({
id: i,
title: `Item ${i}`,
description: `This is description for item ${i}. ${
i % 3 === 0 ? 'This is a longer description that will take more space.' : ''
}`,
}))
)
</script>
<style scoped>
.list-item {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.item-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;
}
.item-desc {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.item-footer {
font-size: 12px;
color: #999;
display: flex;
gap: 16px;
}
</style>
面试常见追问
Q: 虚拟滚动在移动端需要注意什么?
"移动端主要注意两点:
- 触摸滚动的惯性 - 移动端手指离开后,列表会继续滚动一段距离(惯性滚动)。这时不能停止监听滚动事件,要持续更新可视范围,否则会出现白屏。
- 性能更严苛 - 移动设备性能弱于 PC,要更激进地优化。我会减小缓冲区,降低渲染批次,用更简单的占位符。
还有一点是,移动端经常用下拉刷新,要和虚拟滚动结合好,防止冲突。"
Q: 如果列表项包含图片,如何优化?
"图片加载会改变元素高度,要特别处理:
- 占位高度 - 预先知道图片宽高比,用 padding-top 撑起占位高度,图片加载完不会改变布局
- 懒加载 - 只加载可视区域和缓冲区的图片,其他图片不加载
- IntersectionObserver - 用 IntersectionObserver 检测图片进入可视区域,进入才加载
还可以用渐进式图片,先加载低质量,再加载高质量,体验更好。"
Q: 虚拟滚动的性能瓶颈在哪?如何监控?
"性能瓶颈主要三个地方:
- 可视范围计算 - 二分查找优化到 O(log n) 后,基本不是瓶颈
- DOM 操作 - 频繁增删 DOM,优化方法是用 key 复用节点
- 渲染 - 元素太复杂,优化方法是简化 DOM 结构,用 CSS 代替 JS
监控方面,我用 Performance API 记录关键指标:
- scrollTop 变化到渲染完成的耗时
- 每帧的 FPS
- 长任务的数量和时长
发现性能问题,就用 Chrome DevTools 的 Performance 面板录制,分析火焰图,找出瓶颈。"
项目经验总结
踩过的坑
- iOS Safari 的橡皮筋效果 - 滚动到顶部或底部时,会有弹性效果,导致 scrollTop 变成负数,计算出错。解决:边界检查
- transform 导致子元素定位问题 - 用 transform 做偏移,子元素的 fixed 定位会失效。解决:改用 position: absolute
- 快速滚动时闪烁 - 缓冲区太小,元素还没渲染就进入可视区域。解决:增大缓冲区
- 高度缓存内存泄漏 - 列表数据变化时没有清空缓存。解决:watch 数据变化,重置缓存
性能数据
- 10000 行列表首屏渲染:< 300ms
- 滚动帧率:稳定 60fps
- 内存占用:仅与可视元素数量相关(~20 个),与总数据量无关
- 位置计算耗时:< 0.1ms(二分查找)
可以吹的点
- 支持固定高度和动态高度两种模式
- 自动处理高度变化和滚动锚点,无抖动
- 支持二维虚拟化(虚拟表格)
- 支持无限滚动和分页加载
- 性能优异,10 万条数据流畅滚动