08|大列表与高频更新
第七章 / 08。目标:先减少真实 DOM 数量,再讨论 memo、节流或浅响应式。
窗口化的核心是坐标映射
固定行高 rowHeight、视口高 viewportHeight、滚动量 scrollTop 下:
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
const end = Math.min(rows.length, Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan)
const offsetY = start * rowHeight
const totalHeight = rows.length * rowHeight页面只创建 [start, end) 的行,把内容层平移到 offsetY,外层占位仍保持 totalHeight。overscan 减少快速滚动时的空白,但会增加节点;它是测量参数。
视口覆盖 [scrollTop, scrollTop + viewportHeight);头部向下取整,底部向上取整,因为露出 1px 也需要渲染。比如行高36、视口288、scrollTop=1、overscan=0,应覆盖下标0到8共9行。只算 ceil(viewportHeight / rowHeight) 会漏掉末尾部分行。上面先假设滚动量已被浏览器夹在合法范围;下面组件还处理筛选后列表缩短留下的旧滚动量。
最小窗口组件
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = defineProps<{ rows: { id: string; label: string }[] }>()
const rowHeight = 36
const viewportHeight = 288
const overscan = 3
const scrollTop = ref(0)
const top = computed(() => Math.min(
Math.max(0, scrollTop.value), Math.max(0, props.rows.length * rowHeight - viewportHeight)
))
const start = computed(() => Math.max(0, Math.floor(top.value / rowHeight) - overscan))
const end = computed(() => Math.min(
props.rows.length,
Math.ceil((top.value + viewportHeight) / rowHeight) + overscan
))
const visible = computed(() => props.rows.slice(start.value, end.value))
</script>
<template>
<div
role="list"
:style="{ height: `${viewportHeight}px`, overflow: 'auto' }"
@scroll="scrollTop = ($event.currentTarget as HTMLElement).scrollTop"
>
<div :style="{ height: `${rows.length * rowHeight}px`, position: 'relative' }">
<div :style="{ transform: `translateY(${start * rowHeight}px)` }">
<div v-for="row in visible" :key="row.id" role="listitem" :style="{ height: `${rowHeight}px` }">
{{ row.label }}
</div>
</div>
</div>
</div>
</template>对 10,000 行、288px 视口,上例渲染的是可见行加 overscan,不是 10,000 个节点。真正验证应记录 .querySelectorAll('[role=listitem]').length、同一滚动脚本下的 Performance 时间线和内存;不要据此杜撰固定提速比例。
边界:可变行高、焦点与高频输入
- 可变行高需要测量缓存、估算与滚动锚点校正;上面的乘法模型会错。
- 被窗口移出的可聚焦行会卸载。键盘导航应在状态中维护 active id,必要时先滚动到目标再聚焦,不能依赖旧 DOM ref。
shallowRef可降低巨大不可变数据的深层代理开销,但更新必须替换根引用;原地push不触发更新。- 滚动事件可用
requestAnimationFrame合并到每帧一次计算;搜索输入更适合防抖请求,但本地筛选是否需要防抖取决于实际成本。
练习:验证窗口不越界
实现纯函数 getWindow({ length, rowHeight, viewportHeight, scrollTop, overscan }),返回 { start, end, offsetY, totalHeight }。参数全部有限;length/overscan 为非负整数,rowHeight 为正,viewportHeight 非负,非法输入抛 RangeError。scrollTop 夹在合法滚动范围。对顶部、中间、底部、空列表、负 scrollTop 与 overscan=0 的部分行写断言;要求 0 <= start <= end <= length,且底部不会切出数组范围。再说明可变行高为什么不能复用这套公式。
参考答案:第 7 章第 08 题。