Skip to content

大数据渲染、图片懒加载、瀑布流整理 ​

整理日期:2026-07-10

这份文档只保留真实开发会用到的写法。项目里的模拟数据、耗时对比、主线程心跳、naive 全量渲染反例、Worker 造数等内容不作为业务模板使用。

总结 ​

真实项目优先选择这条链路:

txt
后端分页接口 -> 前端轻量 map -> shallowRef 存列表 -> RecycleScroller / CSS 瀑布流渲染

只有在后端确实一次返回很大的 raw 数据,并且前端转换很重时,才考虑:

txt
真实接口大包 -> 分片投喂 Worker -> Worker 流式回传 -> appendInSlices 合并 -> shallowRef -> 虚拟列表

图片相关统一用:

txt
固定尺寸或固定比例占位 -> img loading="lazy" -> decoding="async" -> load 后淡入

阅读范围 ​

推荐保留或借鉴:

分类文件保留原因
大数据分页渲染src/composables/usePagedList.ts分页状态、shallowRef、请求竞态处理、加载更多
大数据分页渲染src/views/list-prod/index.vue分页接口 + RecycleScroller 的生产形态
图片懒加载src/composables/useMediaPagedList.ts媒体分页状态,真实项目把本地图片索引换成 CDN URL
图片懒加载src/components/list-media/LazyImage.vue固定占位、原生懒加载、虚拟列表复用 DOM 时重置状态
图片懒加载src/views/list-media/index.vue虚拟列表 + 图片懒加载组合
瀑布流src/composables/useInfiniteScroll.tsIntersectionObserver 底部哨兵加载更多
瀑布流src/components/list-feed/FeedCard.vue卡片拆分、封面固定比例、break-inside: avoid
瀑布流src/views/list-feed/index.vueCSS columns 瀑布流 + 自动加载更多
大包兜底src/utils/timeSlice.tsyieldToMain / appendInSlices,仅用于大批量追加
大包兜底src/workers/list-transform.worker.ts接口大包且转换很重时的 Worker 流式转换骨架
大包兜底src/composables/useHugeListPipeline.ts只参考 Worker 投喂和合并结构,删掉指标和假接口

不建议搬到真实项目:

文件原因
src/api/mockPagedList.ts浏览器内造数 + 假延迟
src/api/mockMediaList.ts浏览器内造数 + 本地图片轮询
src/api/mockHugeList.ts模拟超大接口,真实项目应接后端
src/utils/listData.ts演示造数和故意 CPU 运算
src/workers/list-data.worker.tsWorker 造数,不是业务取数
src/composables/useTimeSliceGenerate.ts主线程分片造数,真实数据来自接口
src/composables/useListDataWorker.ts纯 Worker 造数 + 一次性回传,偏实验室
src/views/list-demo/index.vue对比页,包含 naive 反例、心跳、耗时指标
src/views/list-demo-combo/index.vue对比页,保留思路即可,不搬 UI 和指标
src/views/list-demo-api/index.vue大包演示页,只参考特殊场景流水线

一、大数据渲染 ​

1. 推荐方案:分页 + shallowRef + 虚拟列表 ​

适用场景:

  • 数据可能超过 100 条。
  • 列表行高度固定或基本固定。
  • 后端支持 page / pageSize / total / list。
  • 每页数据量不大,通常 20 到 100 条。

核心点:

  • 列表用 shallowRef<Item[]>([]),避免 Vue 深层代理大数组里的每个对象。
  • 追加列表时必须换新引用:items.value = items.value.concat(nextPage)。
  • 搜索、刷新时重置到第 1 页。
  • 用请求序号或 AbortController 处理过期响应。
  • DOM 渲染用 vue-virtual-scroller 的 RecycleScroller。

项目现有写法来自:

  • src/composables/usePagedList.ts
  • src/views/list-prod/index.vue
  • src/components/list-demo/ListViewport.vue

生产版 composable 可以整理成通用版本:

ts
import { computed, onUnmounted, ref, shallowRef } from 'vue'

export interface PageQuery {
  page: number
  pageSize: number
  keyword?: string
  signal?: AbortSignal
}

export interface PageResult<Raw> {
  list: Raw[]
  total: number
  page: number
  pageSize: number
}

export interface UsePagedListOptions<Raw, Item> {
  pageSize?: number
  fetchPage: (query: PageQuery) => Promise<PageResult<Raw>>
  mapItem: (raw: Raw) => Item
}

export function useRealPagedList<Raw, Item>(
  options: UsePagedListOptions<Raw, Item>,
) {
  const items = shallowRef<Item[]>([])
  const total = ref(0)
  const page = ref(0)
  const pageSize = ref(options.pageSize ?? 30)
  const keyword = ref('')
  const loading = ref(false)
  const loadingMore = ref(false)
  const errorMessage = ref('')

  const hasMore = computed(() => items.value.length < total.value)
  const loadedLabel = computed(() => `${items.value.length} / ${total.value}`)

  let requestSeq = 0
  let controller: AbortController | null = null
  let disposed = false

  async function load(reset: boolean) {
    if (disposed) return

    if (!reset && (loading.value || loadingMore.value || !hasMore.value)) {
      return
    }

    const nextPage = reset ? 1 : page.value + 1
    const seq = ++requestSeq

    controller?.abort()
    controller = new AbortController()

    if (reset) {
      loading.value = true
      errorMessage.value = ''
    } else {
      loadingMore.value = true
    }

    try {
      const result = await options.fetchPage({
        page: nextPage,
        pageSize: pageSize.value,
        keyword: keyword.value || undefined,
        signal: controller.signal,
      })

      if (seq !== requestSeq || disposed) return

      const mapped = result.list.map(options.mapItem)
      items.value = reset ? mapped : items.value.concat(mapped)
      total.value = result.total
      page.value = result.page
      errorMessage.value = ''
    } catch (error) {
      if (seq !== requestSeq || disposed) return
      if (error instanceof DOMException && error.name === 'AbortError') return

      errorMessage.value =
        error instanceof Error ? error.message : '列表加载失败'

      if (reset) {
        items.value = []
        total.value = 0
        page.value = 0
      }
    } finally {
      if (seq === requestSeq) {
        loading.value = false
        loadingMore.value = false
      }
    }
  }

  function refresh() {
    return load(true)
  }

  function loadMore() {
    return load(false)
  }

  function search(nextKeyword: string) {
    keyword.value = nextKeyword.trim()
    return load(true)
  }

  onUnmounted(() => {
    disposed = true
    requestSeq += 1
    controller?.abort()
  })

  return {
    items,
    total,
    page,
    pageSize,
    keyword,
    loading,
    loadingMore,
    errorMessage,
    hasMore,
    loadedLabel,
    refresh,
    loadMore,
    search,
  }
}

虚拟列表视图模板:

vue
<script setup lang="ts">
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'

const ITEM_SIZE = 96
</script>

<template>
  <RecycleScroller
    class="list-scroller"
    :items="items"
    :item-size="ITEM_SIZE"
    key-field="id"
    v-slot="{ item }"
  >
    <article class="list-row">
      <!-- 行内容 -->
    </article>
  </RecycleScroller>
</template>

<style scoped>
.list-scroller {
  height: 100%;
}

.list-row {
  box-sizing: border-box;
  height: 88px;
  margin-bottom: 8px;
}
</style>

注意:item-size 要等于“行高 + 行间距”。如果不一致,滚动位置会跳。

2. 大批量追加的兜底:appendInSlices ​

分页每页 20 到 100 条时,直接 concat 即可,不需要时间分片。

只有单次要把几百到几千条数据灌进 UI 时,才用 appendInSlices:

ts
export function yieldToMain() {
  return new Promise<void>((resolve) => {
    const channel = new MessageChannel()
    channel.port1.onmessage = () => resolve()
    channel.port2.postMessage(null)
  })
}

export async function appendInSlices<T>(
  target: T[],
  chunk: T[],
  sliceSize = 200,
): Promise<void> {
  for (let i = 0; i < chunk.length; i += sliceSize) {
    target.push(...chunk.slice(i, i + sliceSize))
    await yieldToMain()
  }
}

典型用法:

ts
const buffer: OrderItem[] = []
const page = await fetchOrderPage({ page: 1, pageSize: 1000 })

await appendInSlices(buffer, page.list, 100)
items.value = buffer.slice()

3. Worker 只作为特殊场景 ​

不要为了“数据多”默认上 Worker。先确认瓶颈是不是 CPU 转换:

  • 只是 DOM 多:用虚拟列表。
  • 只是接口大:推动后端分页。
  • 每页很小:主线程 map 足够。
  • 后端一次返回大包,前端还要做重计算:再用 Worker。

可以保留的结构:

txt
主线程:
1. 创建转换 Worker
2. transform-start 告诉 Worker 总数和回传批次
3. raw-chunk 分片投喂原始数据
4. 收到 chunk 后 appendInSlices 合并
5. items.value = buffer.slice()

Worker:
1. 保存 session
2. raw -> item 转换
3. pendingEmit 满一批就 postMessage({ type: 'chunk' })
4. 最后一批 flush 后 postMessage({ type: 'done' })

要删掉的内容:

  • 故意烧 CPU 的三角函数循环。
  • apiMs / workerMs / firstVisibleMs / totalMs 这类对比指标。
  • 心跳 UI、进度条实验面板。
  • Worker 造数。

二、图片懒加载 ​

1. 推荐方案:固定占位 + 原生 lazy ​

项目保留核心来自 src/components/list-media/LazyImage.vue。

关键点:

  • 图片未加载前必须有固定尺寸或固定比例,否则列表和瀑布流会抖。
  • loading="lazy" 交给浏览器决定何时请求。
  • decoding="async" 避免图片解码阻塞主线程绘制。
  • 虚拟列表会复用 DOM,src 变化时要重置 loaded / failed。
  • 瀑布流里用 fill,父容器用 aspect-ratio 定高度。

精简版组件:

vue
<script setup lang="ts">
import { ref, watch } from 'vue'

const props = withDefaults(
  defineProps<{
    src: string
    alt?: string
    width?: number
    height?: number
    fill?: boolean
  }>(),
  {
    alt: '',
    width: 72,
    height: 72,
    fill: false,
  },
)

const loaded = ref(false)
const failed = ref(false)

watch(
  () => props.src,
  () => {
    loaded.value = false
    failed.value = false
  },
)

function handleLoad() {
  loaded.value = true
  failed.value = false
}

function handleError() {
  loaded.value = false
  failed.value = true
}
</script>

<template>
  <div
    class="lazy-image"
    :class="{ 'lazy-image--fill': props.fill }"
    :style="
      props.fill
        ? undefined
        : { width: `${props.width}px`, height: `${props.height}px` }
    "
  >
    <div
      class="lazy-image__placeholder"
      :class="{ 'lazy-image__placeholder--hidden': loaded }"
      aria-hidden="true"
    />

    <img
      class="lazy-image__img"
      :class="{ 'lazy-image__img--visible': loaded }"
      :src="props.src"
      :alt="props.alt"
      :width="props.fill ? undefined : props.width"
      :height="props.fill ? undefined : props.height"
      loading="lazy"
      decoding="async"
      @load="handleLoad"
      @error="handleError"
    />
  </div>
</template>

<style scoped>
.lazy-image {
  position: relative;
  flex-shrink: 0;
  overflow: hidden;
  border-radius: 10px;
  background: #e2e8f0;
}

.lazy-image--fill {
  width: 100%;
  height: 100%;
}

.lazy-image__placeholder {
  position: absolute;
  inset: 0;
  z-index: 1;
  background: #cbd5e1;
  transition: opacity 0.2s ease;
}

.lazy-image__placeholder--hidden {
  opacity: 0;
  pointer-events: none;
}

.lazy-image__img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 0.2s ease;
}

.lazy-image__img--visible {
  opacity: 1;
}
</style>

真实接口中,coverUrl 应该直接来自后端或 CDN:

ts
function mapRawToItem(raw: MediaRaw): MediaItem {
  return {
    id: raw.id,
    title: raw.title,
    description: raw.summary,
    coverUrl: raw.cover_url,
    updatedAt: raw.updated_at,
  }
}

不要保留项目里的 cover_index -> 本地 SVG 映射,那是演示用。

三、瀑布流 ​

1. 推荐方案:中等数据量用 CSS columns ​

项目保留核心来自:

  • src/views/list-feed/index.vue
  • src/components/list-feed/FeedCard.vue
  • src/composables/useInfiniteScroll.ts

适用场景:

  • 信息流、图片流、卡片流。
  • 每页追加 10 到 30 条。
  • 总 DOM 量控制在几百条以内。
  • 卡片高度不一,但不需要上万条同时留在 DOM。

CSS columns 版本简单稳定:

vue
<template>
  <section class="feed-masonry" aria-label="信息流">
    <FeedCard
      v-for="item in items"
      :key="item.id"
      :item="item"
    />
  </section>
</template>

<style scoped>
.feed-masonry {
  column-count: 2;
  column-gap: 12px;
}

@media (min-width: 900px) {
  .feed-masonry {
    column-count: 3;
  }
}
</style>

卡片要避免被分栏切断:

vue
<script setup lang="ts">
import LazyImage from '@/components/list-media/LazyImage.vue'
import type { FeedItem } from '@/types/feed'

defineProps<{
  item: FeedItem
}>()
</script>

<template>
  <article class="feed-card">
    <div class="feed-card__cover" :style="{ aspectRatio: item.coverRatio }">
      <LazyImage
        :src="item.coverUrl"
        :alt="item.title"
        fill
      />
    </div>
    <div class="feed-card__body">
      <h2 class="feed-card__title">{{ item.title }}</h2>
      <time class="feed-card__time">{{ item.updatedAt }}</time>
    </div>
  </article>
</template>

<style scoped>
.feed-card {
  break-inside: avoid;
  margin-bottom: 12px;
  overflow: hidden;
  border-radius: 14px;
  background: #fff;
}

.feed-card__cover {
  width: 100%;
  overflow: hidden;
  background: #cbd5e1;
}
</style>

项目里的 id % variants.length 轮换比例是演示用。真实项目应该使用后端返回的图片宽高:

ts
function getCoverRatio(width?: number, height?: number) {
  if (!width || !height) return '1 / 1'
  return `${width} / ${height}`
}

2. 触底加载:IntersectionObserver ​

保留 useInfiniteScroll.ts 的思路:页面底部放一个哨兵节点,进入滚动容器视口就加载下一页。

页面使用方式:

ts
const scrollRootRef = ref<HTMLElement | null>(null)
const sentinelRef = ref<HTMLElement | null>(null)

const canAutoLoad = computed(
  () => hasMore.value && !loading.value && !loadingMore.value,
)

useInfiniteScroll(sentinelRef, {
  enabled: canAutoLoad,
  root: scrollRootRef,
  rootMargin: '320px 0px',
  onLoadMore: () => loadMore(),
})

模板:

vue
<main ref="scrollRootRef" class="feed-page">
  <section class="feed-masonry">
    <FeedCard
      v-for="item in items"
      :key="item.id"
      :item="item"
    />
  </section>

  <div ref="sentinelRef" class="feed-sentinel" aria-hidden="true" />
</main>

样式:

css
.feed-page {
  height: 100%;
  overflow: auto;
  -webkit-overflow-scrolling: touch;
}

.feed-sentinel {
  width: 100%;
  height: 1px;
  margin-top: 8px;
}

如果全局 html/body/#app 有 overflow: hidden,必须传 root: scrollRootRef,否则 Observer 会按窗口视口判断,容易失效。

3. 什么时候不该用 CSS columns ​

不要在这些场景硬用当前方案:

  • 需要严格从左到右、从上到下的阅读顺序。
  • 要做拖拽排序。
  • 要上万卡片且长期留存。
  • 要精确定位、滚动恢复和动态测量。

这些场景考虑成熟库,例如虚拟瀑布流或 headless virtualizer。

四、更干净的生产组合案例 ​

下面是一个“真实接口 + 分页 + 懒加载图片 + 瀑布流 + 自动加载更多”的组合案例。它不依赖项目里的 mock 文件,也不包含效率对比代码。

API 类型 ​

ts
export interface FeedRaw {
  id: number
  title: string
  summary: string
  cover_url: string
  cover_width?: number
  cover_height?: number
  updated_at: string
}

export interface FeedItem {
  id: number
  title: string
  description: string
  coverUrl: string
  coverRatio: string
  updatedAt: string
}

接口函数 ​

项目已有 src/api/index.ts 的 axios 实例,可以这样接真实接口:

ts
import service from '@/api'
import type { PageQuery, PageResult } from '@/composables/useRealPagedList'

export async function fetchFeedPage(
  query: PageQuery,
): Promise<PageResult<FeedRaw>> {
  const { data } = await service<ApiResponse<PageResult<FeedRaw>>>({
    method: 'get',
    url: '/feed',
    params: {
      page: query.page,
      pageSize: query.pageSize,
      keyword: query.keyword,
    },
    signal: query.signal,
  })

  if (data.code !== 200) {
    throw new Error(data.message ?? '信息流加载失败')
  }

  return data.data
}

页面 ​

下面的 RealFeedCard 就是上一节卡片模板的生产版,使用 FeedItem.coverRatio,不要直接复用当前项目里按 id 轮换比例的演示组件。

vue
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
import { useRealPagedList } from '@/composables/useRealPagedList'
import RealFeedCard from '@/components/feed/RealFeedCard.vue'
import { fetchFeedPage } from '@/api/feed'
import type { FeedItem, FeedRaw } from '@/types/feed'

function mapFeed(raw: FeedRaw): FeedItem {
  return {
    id: raw.id,
    title: raw.title,
    description: raw.summary,
    coverUrl: raw.cover_url,
    coverRatio:
      raw.cover_width && raw.cover_height
        ? `${raw.cover_width} / ${raw.cover_height}`
        : '1 / 1',
    updatedAt: raw.updated_at,
  }
}

const {
  items,
  loading,
  loadingMore,
  errorMessage,
  hasMore,
  loadedLabel,
  refresh,
  loadMore,
  search,
} = useRealPagedList<FeedRaw, FeedItem>({
  pageSize: 20,
  fetchPage: fetchFeedPage,
  mapItem: mapFeed,
})

const keywordInput = ref('')
const scrollRootRef = ref<HTMLElement | null>(null)
const sentinelRef = ref<HTMLElement | null>(null)

const canAutoLoad = computed(
  () => hasMore.value && !loading.value && !loadingMore.value,
)

useInfiniteScroll(sentinelRef, {
  enabled: canAutoLoad,
  root: scrollRootRef,
  rootMargin: '320px 0px',
  onLoadMore: loadMore,
})

onMounted(() => {
  void refresh()
})

async function handleSearch() {
  await search(keywordInput.value)
  scrollRootRef.value?.scrollTo({ top: 0, behavior: 'auto' })
}
</script>

<template>
  <main ref="scrollRootRef" class="real-feed">
    <section class="real-feed__toolbar">
      <input
        v-model="keywordInput"
        class="real-feed__input"
        type="search"
        placeholder="搜索内容"
        @keydown.enter="handleSearch"
      />
      <button type="button" :disabled="loading" @click="handleSearch">
        搜索
      </button>
      <span class="real-feed__meta">{{ loadedLabel }}</span>
    </section>

    <p v-if="errorMessage" class="real-feed__error">
      {{ errorMessage }}
    </p>

    <div v-if="loading && items.length === 0" class="real-feed__state">
      加载中...
    </div>

    <div v-else-if="!loading && items.length === 0" class="real-feed__state">
      暂无内容
    </div>

    <section v-else class="real-feed__masonry" aria-label="信息流">
      <RealFeedCard
        v-for="item in items"
        :key="item.id"
        :item="item"
      />
    </section>

    <div ref="sentinelRef" class="real-feed__sentinel" aria-hidden="true" />

    <footer class="real-feed__footer">
      <span v-if="loadingMore">加载中...</span>
      <span v-else-if="!hasMore && items.length > 0">已经到底了</span>
    </footer>
  </main>
</template>

<style scoped>
.real-feed {
  box-sizing: border-box;
  height: 100%;
  overflow: auto;
  -webkit-overflow-scrolling: touch;
  padding: 16px;
}

.real-feed__toolbar {
  position: sticky;
  top: 0;
  z-index: 2;
  display: flex;
  gap: 8px;
  padding-bottom: 12px;
  background: #fff;
}

.real-feed__input {
  min-width: 0;
  flex: 1;
}

.real-feed__meta {
  color: #64748b;
  font-size: 13px;
}

.real-feed__error {
  color: #b91c1c;
}

.real-feed__state {
  display: grid;
  min-height: 40vh;
  place-items: center;
  color: #64748b;
}

.real-feed__masonry {
  column-count: 2;
  column-gap: 12px;
}

.real-feed__sentinel {
  height: 1px;
  margin-top: 8px;
}

.real-feed__footer {
  display: grid;
  min-height: 40px;
  place-items: center;
  color: #94a3b8;
  font-size: 13px;
}

@media (min-width: 900px) {
  .real-feed__masonry {
    column-count: 3;
  }
}
</style>

这个案例相比项目现有 demo 的改进:

  • 接口通过 fetchPage 注入,不再把 composable 绑定到 mock 文件。
  • 用 AbortController 取消过期请求。
  • 保留分页、竞态、自动加载、懒加载、瀑布流这些真实能力。
  • 删除造数、耗时指标、心跳、策略对比、说明面板。

五、选择规则 ​

场景推荐方案
普通业务表格或列表,几十条普通 v-for 即可
可能几百到几千条,行高固定分页 + RecycleScroller
图片列表,行高固定分页 + RecycleScroller + LazyImage
小红书式信息流,中等数据量分页 + CSS columns + LazyImage + useInfiniteScroll
后端一次返回大包,但转换轻推动后端分页;短期可 appendInSlices
后端一次返回大包,转换很重Worker 流式转换 + appendInSlices + 虚拟列表
上万张瀑布流卡片长期存在虚拟瀑布流库,不建议手写 CSS columns

六、搬代码清单 ​

真实业务里优先搬这些:

txt
src/composables/usePagedList.ts 的状态机思路
src/composables/useInfiniteScroll.ts
src/components/list-media/LazyImage.vue
src/components/list-feed/FeedCard.vue 的卡片结构
src/utils/timeSlice.ts 中的 appendInSlices(按需)
src/workers/list-transform.worker.ts 的流式转换协议(按需)

搬过去后要替换:

txt
mockPagedList / mockMediaList / mockHugeList -> 真实 api
cover_index -> cover_url
id 轮换 aspect-ratio -> 后端图片 width / height
stage / metrics / pulse / progress -> 删除或改成正式埋点
list-data.worker -> 删除

基于 VitePress 构建的个人学习文档知识库