Skip to content

08|大列表与高频更新 ​

第七章 / 08。目标:先减少真实 DOM 数量,再讨论 memo、节流或浅响应式。

窗口化的核心是坐标映射 ​

固定行高 rowHeight、视口高 viewportHeight、滚动量 scrollTop 下:

ts
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) 会漏掉末尾部分行。上面先假设滚动量已被浏览器夹在合法范围;下面组件还处理筛选后列表缩短留下的旧滚动量。

最小窗口组件 ​

vue
<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 题。

用心记录每一步成长