在内容型网站、图片社区、商品列表、灵感平台等前端场景中,无限滚动 + 懒加载 + 瀑布流几乎是“高频组合拳”。
它们可以显著提升信息承载能力和浏览沉浸感,但同时也常带来性能、稳定性与可维护性问题:滚动抖动、首屏慢、图片错位、内存上涨、SEO困难、移动端掉帧……

本文将用一篇完整实战,从 0 到 1 搭建一个可落地模块,技术栈为:

  • Vue3(Composition API)
  • Vite
  • IntersectionObserver(懒加载与触底检测)
  • 瀑布流布局算法(JS列高分配方案)
  • 免费满血版 DeepSeek(用于工程辅助:代码生成、重构、性能诊断建议)

目标不是“能跑就行”,而是做出一个可复用、可扩展、可优化的工程模块。


一、为什么把这三件事放在一起做?

先明确三者分工:

  • 无限滚动:解决数据加载方式,让用户持续浏览
  • 懒加载:解决资源加载时机,降低首屏压力
  • 瀑布流:解决视觉布局,让高度不一内容自然排列

这三者组合后会互相影响。例如:

  • 无限滚动会不断新增节点,导致布局计算压力持续上升;
  • 懒加载图片在“未加载→加载完成”时高度变化,可能引发瀑布流错位;
  • 瀑布流列数在响应式切换时会重排,若与滚动触底逻辑耦合不当,容易重复请求。

所以本实战的关键是:
先定义模块边界,再做协同优化。


二、项目初始化与目录结构

建议使用清晰的模块拆分,而不是把逻辑堆在一个页面文件里。

bash

npm create vite@latest vue-waterfall-demo -- --template vue-ts cd vue-waterfall-demo npm i npm i axios

推荐目录(示意):

txt

src/ api/ feed.ts composables/ useInfiniteScroll.ts useLazyImage.ts useWaterfall.ts components/ WaterfallList.vue WaterfallItem.vue LazyImage.vue utils/ throttle.ts scheduler.ts views/ Home.vue

拆分原则:

  • useInfiniteScroll:只关心“何时加载下一页”
  • useLazyImage:只关心“何时给img真实src”
  • useWaterfall:只关心“如何排布卡片位置”

三、数据模型设计:先定协议,后写页面

假设接口返回如下结构:

ts

export interface FeedItem { id: string title: string cover: string width: number height: number author: string }

注意这里有 width/height,非常关键。
瀑布流要减少布局抖动,必须尽量提前知道媒体尺寸比。

请求函数:

ts

// src/api/feed.ts import axios from 'axios' export async function fetchFeed(page: number, pageSize = 20) { const { data } = await axios.get('/api/feed', { params: { page, pageSize } }) return data as { list: any[]; hasMore: boolean } }


四、无限滚动:用观察哨兵替代 scroll 监听

传统 window.onscroll 方案可做,但维护成本和性能压力较高。
更推荐 IntersectionObserver + 底部哨兵元素。

ts

// src/composables/useInfiniteScroll.ts import { onMounted, onBeforeUnmount, ref } from 'vue' export function useInfiniteScroll(loadMore: () => Promise<void>) { const sentinelRef = ref<HTMLElement | null>(null) const loading = ref(false) const finished = ref(false) let observer: IntersectionObserver | null = null const onIntersect: IntersectionObserverCallback = async (entries) => { const entry = entries[0] if (!entry.isIntersecting || loading.value || finished.value) return loading.value = true try { await loadMore() } finally { loading.value = false } } onMounted(() => { observer = new IntersectionObserver(onIntersect, { root: null, rootMargin: '300px 0px 300px 0px', threshold: 0 }) if (sentinelRef.value) observer.observe(sentinelRef.value) }) onBeforeUnmount(() => observer?.disconnect()) return { sentinelRef, loading, finished } }

核心点:

  • rootMargin 预加载,避免到底才请求;
  • 用 loading 锁防止并发重复请求;
  • finished 控制停止观察逻辑(可选优化:finished 时 unobserve)。

五、懒加载:图片组件化,减少重复代码

封装一个 LazyImage.vue,让任何列表项都能复用:

<script setup lang="ts"> import { ref, onMounted, onBeforeUnmount } from 'vue' const props = defineProps<{ src: string alt?: string placeholder?: string }>() const elRef = ref<HTMLImageElement | null>(null) const realSrc = ref(props.placeholder || '') let observer: IntersectionObserver | null = null onMounted(() => { observer = new IntersectionObserver((entries) => { const e = entries[0] if (e.isIntersecting) { realSrc.value = props.src observer?.disconnect() } }, { rootMargin: '200px' }) if (elRef.value) observer.observe(elRef.value) }) onBeforeUnmount(() => observer?.disconnect()) </script> <template> <img ref="elRef" :src="realSrc" :alt="alt || ''" loading="lazy" /> </template>

说明:

  • 浏览器原生 loading="lazy" 可作为兜底;
  • IntersectionObserver 可更精细控制阈值与预加载范围;
  • 真正工程中建议增加 error 状态图、骨架屏与淡入动画。

六、瀑布流核心:列高分配算法(稳定优先)

1)为什么不用纯 CSS?

CSS 多列或部分 Grid 技巧可以做“视觉瀑布流”,但在精细控制、动画过渡、动态插入稳定性上不如 JS 布局灵活。
实战里,我们使用“绝对定位 + 最短列插入”。

2)算法思路

  • 根据容器宽度计算列数 columnCount;
  • 每列维护当前高度 columnHeights[];
  • 新卡片总是放到“当前最短列”;
  • 记录卡片 top/left/width/height 用于渲染。

ts

// src/composables/useWaterfall.ts import { ref } from 'vue' export function useWaterfall(gap = 12, minColWidth = 220) { const positions = ref<{ id: string; top: number; left: number; width: number; height: number }[]>([]) const containerHeight = ref(0) function layout(items: any[], containerWidth: number) { const colCount = Math.max(1, Math.floor((containerWidth + gap) / (minColWidth + gap))) const colWidth = (containerWidth - gap * (colCount - 1)) / colCount const colHeights = new Array(colCount).fill(0) const next = items.map((item) => { const ratio = item.height / item.width || 1 const cardHeight = Math.round(colWidth * ratio) + 80 // 80为文本等附加区估算 let minIndex = 0 for (let i = 1; i < colCount; i++) { if (colHeights[i] < colHeights[minIndex]) minIndex = i } const top = colHeights[minIndex] const left = minIndex * (colWidth + gap) colHeights[minIndex] += cardHeight + gap return { id: item.id, top, left, width: colWidth, height: cardHeight } }) positions.value = next containerHeight.value = Math.max(...colHeights, 0) } return { positions, containerHeight, layout } }


七、页面集成:把三大能力串起来

WaterfallList.vue 负责统一编排:

  1. 初次加载第一页
  2. 触底加载下一页并 append
  3. 每次数据变化后重算瀑布流
  4. 图片懒加载完成时可触发局部修正(高级优化)

示意代码(简化):

<script setup lang="ts"> import { ref, onMounted, nextTick } from 'vue' import { fetchFeed } from '@/api/feed' import { useInfiniteScroll } from '@/composables/useInfiniteScroll' import { useWaterfall } from '@/composables/useWaterfall' const list = ref<any[]>([]) const page = ref(1) const hasMore = ref(true) const containerRef = ref<HTMLElement | null>(null) const { positions, containerHeight, layout } = useWaterfall(12, 220) async function loadMore() { if (!hasMore.value) return const res = await fetchFeed(page.value, 20) list.value.push(...res.list) hasMore.value = res.hasMore page.value += 1 await nextTick() relayout() } function relayout() { const w = containerRef.value?.clientWidth || 0 if (!w) return layout(list.value, w) } const { sentinelRef, finished } = useInfiniteScroll(loadMore) finished.value = !hasMore.value onMounted(async () => { await loadMore() window.addEventListener('resize', relayout) }) </script>


八、性能优化策略(重点)

仅实现功能不难,难的是“数据越多仍然顺滑”。

1)重排与重绘控制

  • resize 事件必须节流;
  • 批量更新样式,减少逐条 DOM 写入;
  • 尽量使用 transform 动画而不是频繁改 top/left(可做过渡层)。

2)图片尺寸预占位(CLS优化)

如果不知道图片比例,加载后高度变化会导致整体跳动。
解决:接口返回原图宽高,前端按比例先占位。

3)虚拟化(超长列表必备)

当卡片达到几千条,DOM 数量会拖垮性能。
可加“可视区虚拟化”:只渲染视窗附近元素,离屏元素占位不渲染。

4)请求治理

  • 防抖/节流触底回调;
  • 请求取消(路由切换时 abort);
  • 幂等锁防重复加载页码;
  • 失败重试与退避策略(指数退避)。

5)内存治理

  • observer 用完即 disconnect;
  • 解绑无用事件监听;
  • 大对象列表避免深层响应式(可用 shallowRef)。

九、借助 DeepSeek 做工程提效(免费满血版实战思路)

在这个项目里,DeepSeek 不只是“写几行代码”,更适合承担以下角色:

  1. 脚手架生成与重构建议
    输入你的目录结构和目标,让它输出 composable 分层方案。
  2. 算法审查
    把瀑布流算法贴给它,让它指出时间复杂度瓶颈与边界条件(如极窄屏、异常比例图)。
  3. 性能诊断提示词
    让它按 Lighthouse 指标给优化清单,例如 LCP、CLS、INP 对应改造点。
  4. 异常场景补全
    让它列出“弱网、图片404、接口超时、快速切页”等测试场景和兜底策略。
  5. 单测样例生成
    自动生成对 layout 函数的测试用例:列高平衡、断点列数变化、空数据输入等。

建议提示词模板(可直接用):

你是资深前端架构师。我有一个 Vue3 瀑布流模块,包含无限滚动、懒加载。请从性能、稳定性、可维护性三个维度审查以下代码,并给出可落地重构方案,要求包含:问题级别、原因、修复代码片段、预估收益、潜在副作用。


十、工程化细节:真正上线前要做什么?

  1. 埋点体系首屏渲染时间首次触底加载耗时图片加载成功率/失败率用户滚动深度分布
  2. 灰度开关新瀑布流算法通过 feature flag 控制出现异常一键回退普通双列列表
  3. SSR/SEO 策略 无限滚动天生不利SEO,可结合:首屏 SSR + 后续客户端滚动提供分页路由兜底(?page=2)
  4. 可访问性(A11y)图片 alt 文本键盘可达“加载中/加载完成”状态可读(ARIA)

十一、常见问题与排查手册

Q1:为什么会重复请求下一页?

通常是哨兵元素持续在可视区内,且没有 loading 锁。
解决:加锁 + 进入加载后临时取消观察,加载完成再恢复。

Q2:为什么瀑布流会“塌陷”?

父容器未设置动态高度。
解决:根据列高最大值更新容器高度。

Q3:为什么移动端明显卡顿?

  • 节点过多未虚拟化
  • 图片过大未压缩
  • 滚动时同步计算太重
    解决:虚拟化 + 图像CDN + 分帧计算/节流。

Q4:懒加载后位置跳动严重?

没有比例占位。
解决:用宽高比容器预占位,图片加载后仅替换内容不改外层高度。


十二、总结:从“功能实现”到“体验产品”

基于 Vue3 实现无限滚动、懒加载、瀑布流并不难,难的是在真实业务里兼顾:

  • 流畅滚动体验
  • 长列表性能稳定
  • 图片加载质量
  • 网络异常可恢复
  • 代码结构可持续迭代

这也是为什么建议把逻辑拆到 composables,并引入 DeepSeek 参与“代码审查 + 性能优化 + 测试补全”的协作流程。
当你不再把它当作“问答工具”,而是当作“工程副驾驶”,前端迭代效率会明显提升。

附:一个可执行的迭代路线图(建议)

第1周(可用版)

  • 完成基础瀑布流 + 触底加载 + 懒加载
  • 跑通接口、空态、错误态

第2周(优化版)

  • 加入节流、请求取消、占位骨架屏
  • 完成 CLS/LCP 指标优化

第3周(生产版)

  • 加埋点、灰度、回滚预案
  • 引入虚拟化策略,压测长列表性能

做到这一步,这个模块基本就具备生产可用性了。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐