外观
大数据渲染、图片懒加载、瀑布流整理
整理日期: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.ts | IntersectionObserver 底部哨兵加载更多 |
| 瀑布流 | src/components/list-feed/FeedCard.vue | 卡片拆分、封面固定比例、break-inside: avoid |
| 瀑布流 | src/views/list-feed/index.vue | CSS columns 瀑布流 + 自动加载更多 |
| 大包兜底 | src/utils/timeSlice.ts | yieldToMain / 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.ts | Worker 造数,不是业务取数 |
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.tssrc/views/list-prod/index.vuesrc/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.vuesrc/components/list-feed/FeedCard.vuesrc/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 -> 删除