使用 IntersectionObserver + 哨兵元素实现长列表懒加载
2026年4月15日 15:48
一、背景与痛点
在一个设备监控数据看板项目中,设备列表可能包含 300+ 个设备卡片。如果一次性渲染全部 DOM 节点,会带来明显的性能问题:
- 首屏白屏时间长:300+ 卡片组件同时挂载,主线程阻塞
- 内存占用高:大量 DOM 节点常驻内存
- 交互卡顿:滚动、点击等操作响应延迟
为此,我们采用 IntersectionObserver + 哨兵元素 方案实现懒加载。
二、核心思路
整体思路可以概括为 "分页截取 + 哨兵触发" :
全量数据(300+) → 分页截取显示(每页15条) → 哨兵进入视口时追加下一页
关键设计:
-
数据全量存储,视图分页截取:
deviceList保存完整数据,displayDeviceList通过computed计算slice(0, end)返回当前应显示的子集 - 哨兵元素:在列表末尾放置一个不可见的 DOM 元素,当它进入视口时触发加载
- IntersectionObserver:原生浏览器 API,高效监听元素与视口的交叉状态,零滚动事件开销
三、架构图示
┌─────────────────────────────────────────────┐
│ Vue Component (data) │
│ deviceList: [...] // 全量300+设备 │
│ devicePageSize: 15 // 每页条数 │
│ deviceCurrentPage: 0 // 当前页码 │
│ observer: null // Observer实例 │
├─────────────────────────────────────────────┤
│ Computed Properties │
│ displayDeviceList → slice(0, pageSize*page) │
│ hasMoreDevices → displayed < total │
├─────────────────────────────────────────────┤
│ Template 渲染逻辑 │
│ v-for="device in displayDeviceList" │
│ ┌─── Card ───┐ ┌─── Card ───┐ ... │
│ └────────────┘ └────────────┘ │
│ ┌─── Sentinel (ref="sentinel") ───┐ │
│ │ v-if="hasMoreDevices" │ │
│ │ <加载更多设备...> │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────────┘
│ ▲
│ observe(sentinel) │ isIntersecting
▼ │
┌─────────────────────────────────────────────┐
│ IntersectionObserver │
│ rootMargin: '200px' // 提前200px触发 │
│ threshold: 0.1 │
│ → 触发 loadMoreDevices() │
│ → deviceCurrentPage++ │
│ → displayDeviceList 自动更新 → DOM 更新 │
│ → $nextTick → 重新绑定哨兵 │
└─────────────────────────────────────────────┘
四、核心代码实现
4.1 数据定义
data() {
return {
deviceList: [], // 全量设备数据
devicePageSize: 15, // 每页条数
deviceCurrentPage: 0, // 当前已加载页数
observer: null, // IntersectionObserver 实例
}
}
4.2 计算属性(视图截取 + 状态判断)
computed: {
/** 当前已加载的设备列表(懒加载切片) */
displayDeviceList() {
const end = this.devicePageSize * this.deviceCurrentPage
return this.deviceList.slice(0, end)
},
/** 是否还有更多设备可加载 */
hasMoreDevices() {
return this.displayDeviceList.length < this.deviceList.length
}
}
关键点:使用 computed 而非手动维护一个 displayed 数组,确保数据源变化时自动响应更新。
4.3 哨兵元素(模板)
<!-- 设备网格容器 -->
<div class="dm-device-grid">
<!-- 仅渲染 displayDeviceList 而非 deviceList -->
<div v-for="device in displayDeviceList" :key="device.id" class="dm-device-card">
<!-- 设备卡片内容 -->
</div>
<!-- 哨兵元素:仅在还有未加载数据时显示 -->
<div v-if="hasMoreDevices" ref="sentinel" class="dm-lazy-sentinel">
<i class="el-icon-loading" />
<span>加载更多设备...</span>
</div>
</div>
关键点:v-if="hasMoreDevices" 确保数据全部加载后哨兵消失,Observer 自动停止触发。
4.4 IntersectionObserver 初始化
initObserver() {
// 先断开旧观察器,防止重复绑定
this.disconnectObserver()
this.$nextTick(() => {
const sentinel = this.$refs.sentinel
if (!sentinel) return // 哨兵不存在(数据已全部加载)
this.observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
this.loadMoreDevices()
}
},
{
rootMargin: '200px', // 提前200px触发,用户无感知
threshold: 0.1
}
)
this.observer.observe(sentinel)
})
}
关键参数说明:
-
rootMargin: '200px':哨兵距离视口还有 200px 时就触发回调,提前加载数据,实现 无感加载 -
threshold: 0.1:哨兵 10% 可见时即触发
4.5 加载更多 & 清理
/** 加载更多设备 */
loadMoreDevices() {
if (!this.hasMoreDevices) return
this.deviceCurrentPage++
// 页码增加 → displayDeviceList 自动重新计算 → DOM 更新
// Vue 响应式保证了这一链条无需手动操作
},
/** 断开观察器(组件销毁 / 切换组织时调用) */
disconnectObserver() {
if (this.observer) {
this.observer.disconnect()
this.observer = null
}
}
4.6 数据加载后重置
async loadDeviceList(organizationId) {
this.deviceLoading = true
try {
const res = await fetchDeviceStatusList(params)
this.deviceList = res.data.data || []
// 重置懒加载分页
this.deviceCurrentPage = 1 // 初始加载第一页
this.$nextTick(() => {
this.initObserver() // 重新绑定观察器
})
} finally {
this.deviceLoading = false
}
}
4.7 生命周期钩子
mounted() {
// ...其他初始化
this.$nextTick(() => {
this.initObserver()
})
},
beforeDestroy() {
// 清理 Observer,防止内存泄漏
this.disconnectObserver()
}
五、数据流转全流程
用户滚动页面
│
▼
IntersectionObserver 检测哨兵进入视口(提前200px)
│
▼
回调触发 → loadMoreDevices()
│
▼
deviceCurrentPage++ (1→2→3...)
│
▼
displayDeviceList (computed) 自动重新计算
slice(0, 15*2) → slice(0, 15*3) → ...
│
▼
Vue 响应式更新 DOM(新增15个卡片)
│
▼
哨兵元素被推到更下方
│
▼
Observer 继续监听新位置的哨兵
│
... 重复直到 hasMoreDevices === false
│
▼
v-if="hasMoreDevices" = false → 哨兵从DOM移除
│
▼
Observer 无目标 → 自动不再触发
六、方案优势总结
| 对比维度 | 传统 scroll 事件 | 本方案 (IntersectionObserver) |
|---|---|---|
| 性能 | 滚动时高频触发,需 throttle/debounce | 浏览器底层异步回调,零性能损耗 |
| 代码复杂度 | 需手动计算元素位置 getBoundingClientRect
|
声明式配置 rootMargin/threshold |
| 兼容性 | 全兼容 | IE 不支持,现代浏览器均支持 |
| 触发精度 | 节流后可能延迟或重复触发 | 精确触发一次,无重复 |
额外优点:
- 零依赖:纯浏览器原生 API,无需引入第三方库(如 vue-virtual-scroller)
- 低侵入:仅需修改数据切片逻辑 + 添加哨兵元素,不改动现有卡片组件
-
提前加载:通过
rootMargin提前 200px 触发,用户几乎感知不到加载过程 - 自动停止:数据全部加载后哨兵自动移除,Observer 不再触发
七、注意事项与踩坑
-
$nextTick必不可少:initObserver中获取$refs.sentinel必须在 DOM 更新后执行,所以需要$nextTick包裹 -
重置时机:切换组织 / 重新加载数据时,必须重置
deviceCurrentPage并重新initObserver -
内存泄漏:
beforeDestroy中务必调用disconnectObserver()清理 -
Grid 布局兼容:哨兵元素需设置
grid-column: 1 / -1确保占满整行,不会被挤到某一列 -
v-if而非v-show:哨兵使用v-if控制而非v-show,这样数据全部加载后哨兵完全从 DOM 移除,Observer 自然不再触发