Vue3不装第三方插件能做自适应多列瀑布流吗?性能怎么优化到丝滑?
为什么很多人纠结“不装第三方”
其实瀑布流的核心逻辑不算复杂,但很多人第一反应还是去搜插件,主要是怕踩这几个坑:一是手动写多列对齐容易忽上忽下,尤其是图片加载前后高度差特别大的场景;二是自适应列数的时候,布局会“跳页式闪烁”;三是图片懒加载、无限滚动和瀑布流结合的时候,容易出现大量空白区域或者性能卡顿,但第三方插件也有问题:比如很多是Vue2遗留过来的,Composition API支持得不好;有的默认配置不符合自己的业务,改源码又麻烦;还有的包体积不小,比如个别电商场景专用的插件,连动画、瀑布线都塞进去了,移动端加载起来可能拖慢首屏,所以如果业务需求只是基础的自适应多列、图片对齐、支持懒加载和无限滚动,完全可以自己写一个轻量的,而且更好控制细节。
第一步:先搞懂瀑布流的底层逻辑,别一上来就写代码
不管是用CSS Grid、Flex还是JS绝对定位,瀑布流的核心都是“让下一个元素落在当前高度最小的那一列下面”,只是不同的实现方式,对齐的时机和原理不一样。
CSS Grid其实是有瀑布流布局属性的,也就是grid-template-rows: masonry,而且Chrome、Edge、Safari(16.4以上)都已经支持了,但为什么很多人不用?首先是兼容性,国内还有不少用旧浏览器的用户,比如UC浏览器、QQ浏览器的某些版本,还不支持这个属性;其次是可控性差,比如你想让某列优先加载特定商品、或者给高度最小的列加个视觉提示(比如小红书之前偶尔会有的“最新动态优先落此列”的小标记),CSS Grid的瀑布流属性基本做不到,只能依赖JS。
Flex布局的话,理论上可以用多列Flex容器,但Flex是“从上到下、从左到右”排的,很难实现高度差对齐,除非给每一行单独计算高度,但这样会破坏瀑布流的“错落有致”,而且图片加载后还是会乱。
所以结论很明确:如果要兼顾兼容性、可控性和丝滑的体验,还是得用JS绝对定位的方式,配合CSS做基础的容器和列布局。
第二步:Vue3轻量版自适应多列瀑布流的基础实现
先明确我们的基础业务需求:移动端PC端自适应列数,比如移动端(≤768px)2列,平板(768px-1024px)3列,PC端(≥1024px)4列;图片加载前有占位,加载后自动调整布局;每一列的宽度是固定的,间距是固定的;容器的高度会随着列的高度变化自动撑开。
先搭基础的HTML/CSS结构
HTML结构很简单,就是一个外层瀑布流容器,里面是和列数对应的内层列容器,列容器下面才是真正的瀑布流元素,为什么要内层列容器?因为用JS绝对定位的话,直接给元素定位可能会因为定位上下文的问题出错,而且自适应列数的时候,直接换列容器的宽度更方便。
/* 外层容器:相对定位,用来给内层列容器和元素提供定位上下文,同时自动撑开高度 */
.waterfall-container {
position: relative;
width: 100%;
padding: 12px; /* 可以根据业务调整 */
box-sizing: border-box;
}
/* 内层列容器:隐藏定位,宽度动态计算,绝对定位在对应位置,间距靠padding-right实现 */
.waterfall-column {
position: absolute;
top: 0;
box-sizing: border-box;
padding-right: 12px; /* 列间距 */
}
/* 最后一列去掉右边的padding,避免容器溢出 */
.waterfall-column:last-child {
padding-right: 0;
}
/* 瀑布流元素:每个元素下面留12px的元素间距,图片自适应列容器宽度 */
.waterfall-item {
margin-bottom: 12px;
border-radius: 8px;
overflow: hidden;
background-color: #f5f5f5; /* 占位背景色 */
}
.waterfall-item img {
width: 100%;
display: block; /* 去掉图片下方的空白缝隙 */
}
再用Vue3的Composition API写核心逻辑
Composition API的优势是逻辑复用,比如我们可以把“计算列宽”“监听列数变化”“处理图片加载”“插入元素到最小高度列”这些逻辑封装成一个自定义Hook,叫useWaterfall,这样以后换个页面用瀑布流,直接导入就行。
自定义Hook的基础数据
我们需要的数据包括:瀑布流容器的DOM引用、瀑布流元素的列表、列数、列宽、列间距、每一列的当前高度数组、内层列容器的定位坐标数组。
// src/hooks/useWaterfall.ts
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
interface WaterfallOptions {
gap: number; // 列间距和元素间距,这里统一设成一个值,也可以分开设
columns: { [key: number]: number }; // 断点对应的列数,768: 2, 1024: 3, 1920: 4}
containerPadding: number; // 外层容器的左右内边距
}
export const useWaterfall = (options: WaterfallOptions) => {
// 解构配置,给默认值
const { gap = 12, columns = {768: 2, 1024: 3, 1920: 4}, containerPadding = 12 } = options;
// 基础数据
const containerRef = ref<HTMLElement | null>(null); // 外层容器的ref
const items = ref<any[]>([]); // 瀑布流元素列表,这里可以是图片URL数组,也可以是完整的商品数据
const columnCount = ref(2); // 当前列数,默认移动端2列
const columnWidth = ref(0); // 每一列的宽度
const columnHeights = ref<number[]>([]); // 每一列的当前高度
const columnPositions = ref<number[]>([]); // 每一列的left坐标
const loadedCount = ref(0); // 已加载图片的数量
// 计算列宽
const calculateColumnWidth = () => {
if (!containerRef.value) return;
const containerWidth = containerRef.value.clientWidth - containerPadding * 2; // 减去左右内边距
columnWidth.value = (containerWidth - gap * (columnCount.value - 1)) / columnCount.value;
};
// 计算当前断点下的列数
const calculateColumnCount = () => {
const windowWidth = window.innerWidth;
let currentColumns = 2;
// 倒序遍历断点,找到第一个小于等于当前窗口宽度的断点对应的列数
const breakpoints = Object.keys(columns).map(Number).sort((a, b) => b - a);
for (const breakpoint of breakpoints) {
if (windowWidth >= breakpoint) {
currentColumns = columns[breakpoint];
break;
}
}
// 如果列数变了,就重置所有数据
if (currentColumns !== columnCount.value) {
columnCount.value = currentColumns;
columnHeights.value = new Array(columnCount.value).fill(0);
columnPositions.value = [];
for (let i = 0; i < columnCount.value; i++) {
columnPositions.value.push(i * (columnWidth.value + gap));
}
loadedCount.value = 0;
// 这里等下要重新渲染所有元素,先记下来
}
};
// 监听窗口大小变化,更新列数和列宽
const handleResize = () => {
calculateColumnCount();
calculateColumnWidth();
// 更新内层列容器的位置和宽度
updateColumns();
// 重新插入所有元素
reRenderItems();
};
// 更新内层列容器的位置和宽度
const updateColumns = () => {
if (!containerRef.value) return;
const columnElements = containerRef.value.querySelectorAll('.waterfall-column') as NodeListOf<HTMLElement>;
columnElements.forEach((col, index) => {
col.style.left = `${columnPositions.value[index]}px`;
col.style.width = `${columnWidth.value}px`;
});
};
// 重新渲染所有元素
const reRenderItems = () => {
nextTick(() => {
// 这里先清空所有元素,再重新插入,等下写insertItem的时候会用到
if (!containerRef.value) return;
const columnElements = containerRef.value.querySelectorAll('.waterfall-column') as NodeListOf<HTMLElement>;
columnElements.forEach(col => {
col.innerHTML = '';
});
loadedCount.value = 0;
columnHeights.value = new Array(columnCount.value).fill(0);
items.value.forEach((item, index) => {
insertItem(item, index);
});
});
};
// 插入单个元素到最小高度列
const insertItem = (item: any, index: number) => {
// 先找到当前高度最小的列的索引
const minHeight = Math.min(...columnHeights.value);
const minIndex = columnHeights.value.indexOf(minHeight);
// 这里创建瀑布流元素,等下在组件里渲染可能更方便,先记下来
};
// 图片加载完成后的回调
const handleImageLoad = (index: number) => {
loadedCount.value++;
// 等所有图片都加载完了,再更新外层容器的高度
if (loadedCount.value === items.value.length) {
updateContainerHeight();
}
};
// 更新外层容器的高度
const updateContainerHeight = () => {
if (!containerRef.value) return;
const maxHeight = Math.max(...columnHeights.value);
containerRef.value.style.height = `${maxHeight}px`;
};
// 组件挂载后执行
onMounted(() => {
calculateColumnCount();
calculateColumnWidth();
// 初始化列位置
for (let i = 0; i < columnCount.value; i++) {
columnPositions.value.push(i * (columnWidth.value + gap));
}
// 监听窗口大小变化
window.addEventListener('resize', handleResize);
});
// 组件卸载后移除监听
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
// 监听items变化,插入新元素
watch(items, (newItems, oldItems) => {
const newLength = newItems.length;
const oldLength = oldItems.length;
if (newLength > oldLength) {
// 只插入新添加的元素
nextTick(() => {
for (let i = oldLength; i < newLength; i++) {
insertItem(newItems[i], i);
}
});
}
}, { deep: true });
return {
containerRef,
items,
columnCount,
columnWidth,
handleImageLoad,
};
};
然后在组件里使用这个自定义Hook
刚才的自定义Hook里,insertItem和创建元素的逻辑留到组件里写,因为每个业务的瀑布流元素结构不一样,比如有的是图片+文字,有的是纯图片。
<!-- src/components/Waterfall.vue -->
<template>
<div class="waterfall-container" ref="containerRef">
<!-- 动态生成内层列容器 -->
<div
v-for="(col, index) in columnCount"
:key="index"
class="waterfall-column"
:style="{ left: `${columnPositions[index]}px`, width: `${columnWidth}px` }"
>
<!-- 这里暂时不写元素,等下用JS动态插入,因为Vue的v-for是同步渲染,很难控制插入顺序 -->
</div>
</div>
</template>
<script setup lang="ts">
import { useWaterfall } from '@/hooks/useWaterfall';
import { nextTick, ref } from 'vue';
// 调用自定义Hook
const {
containerRef,
items,
columnCount,
columnWidth,
columnPositions,
handleImageLoad,
columnHeights,
insertItem,
reRenderItems
} = useWaterfall({
gap: 12,
columns: { 768: 2, 1024: 3, 1200: 4 },
containerPadding: 12,
});
// 重写insertItem逻辑,因为刚才的Hook里暴露出来的可能不够,这里用ref和nextTick重新组合一下
const rewrittenInsertItem = (item: any, index: number) => {
if (!containerRef.value) return;
const columnElements = containerRef.value.querySelectorAll('.waterfall-column') as NodeListOf<HTMLElement>;
const minHeight = Math.min(...columnHeights.value);
const minIndex = columnHeights.value.indexOf(minHeight);
// 创建瀑布流元素
const itemElement = document.createElement('div');
itemElement.className = 'waterfall-item';
itemElement.dataset.index = String(index);
// 创建图片元素
const imgElement = document.createElement('img');
// 如果有图片的宽高比,先设置占位高度,避免加载后布局跳动
if (item.aspectRatio) {
const placeholderHeight = columnWidth.value / item.aspectRatio;
itemElement.style.height = `${placeholderHeight}px`;
}
imgElement.src = item.url;
imgElement.alt = item.title || '瀑布流图片';
// 监听图片加载完成
imgElement.onload = () => {
// 如果之前设置了占位高度,加载完成后要去掉,让图片自适应
if (item.aspectRatio) {
itemElement.style.height = 'auto';
}
// 获取当前元素的总高度(包括margin-bottom)
const itemHeight = itemElement.offsetHeight + 12; // 这里的12是元素间距,要和配置里的gap一致
// 更新最小高度列的高度
columnHeights.value[minIndex] += itemHeight;
// 更新外层容器的高度
updateContainerHeight();
// 调用自定义Hook里的handleImageLoad,这里刚才的Hook可能没暴露updateContainerHeight,我们可以在组件里重写或者补全
handleImageLoad(index);
};
// 图片加载失败的处理
imgElement.onerror = () => {
// 可以设置默认图片
imgElement.src = 'https://via.placeholder.com/300x400?text=图片加载失败';
// 同样设置占位高度
const placeholderHeight = columnWidth.value * 1.33; // 默认4:3
itemElement.style.height = `${placeholderHeight}px`;
columnHeights.value[minIndex] += placeholderHeight + 12;
updateContainerHeight();
handleImageLoad(index);
};
// 把图片插入到瀑布流元素里
itemElement.appendChild(imgElement);
// 如果有文字,也可以在这里创建文字元素插入
if (item.title) {
const titleElement = document.createElement('div');Element.className = 'waterfall-item-title';Element.textContent = item.title;
itemElement.appendChild(titleElement);
}
// 把瀑布流元素插入到最小高度列里
columnElements[minIndex].appendChild(itemElement);
};
// 重写updateContainerHeight
const updateContainerHeight = () => {
if (!containerRef.value) return;
const maxHeight = Math.max(...columnHeights.value);
containerRef.value.style.height = `${maxHeight}px`;
};
// 监听items变化的逻辑也要补全
import { watch } from 'vue';
watch(items, (newItems, oldItems) => {
const newLength = newItems.length;
const oldLength = oldItems.length;
if (newLength > oldLength) {
nextTick(() => {
for (let i = oldLength; i < newLength; i++) {
rewrittenInsertItem(newItems[i], i);
}
});
}
}, { deep: true });
// 监听列数变化,重新渲染
watch(columnCount, () => {
nextTick(() => {
if (!containerRef.value) return;
const columnElements = containerRef.value.querySelectorAll('.waterfall-column') as NodeListOf<HTMLElement>;
columnElements.forEach(col => {
col.innerHTML = '';
});
columnHeights.value = new Array(columnCount.value).fill(0);
loadedCount.value = 0;
items.value.forEach((item, index) => {
rewrittenInsertItem(item, index);
});
});
});
// 模拟一些初始数据,实际项目中可以从接口获取
const mockItems = ref<any[]>([]);
const generateMockItems = (count: number) => {
const newItems = [];
for (let i = 0; i < count; i++) {
const width = Math.floor(Math.random() * 200) + 200; // 200-400
const height = Math.floor(Math.random() * 300) + 300; // 300-600
const aspectRatio = width / height;
newItems.push({
url: `https://picsum.photos/${width}/${height}?random=${Date.now() + i}`,
title: `这是第${mockItems.value.length + i + 1}张图片`,
aspectRatio,
});
}
mockItems.value = [...mockItems.value, ...newItems];
};
// 组件挂载后生成初始数据
import { onMounted } from 'vue';
onMounted(() => {
generateMockItems(20);
});
// 暴露给父组件的方法,比如无限滚动
defineExpose({
generateMockItems,
});
</script>
<style scoped>
/* 刚才的CSS在这里补充文字样式 */
.waterfall-item-title {
padding: 8px 12px;
font-size: 14px;
color: #333;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
background-color: #fff;
}
</style>
现在这个基础版的瀑布流已经可以用了,但还有几个问题:一是图片加载前如果没有宽高比的话,还是会有布局跳动;二是没有图片懒加载;三是无限滚动的时候,如果加载的速度太快,可能会出现DOM操作频繁导致的性能问题;四是列数变化的时候,还是会清空所有元素重新插入,有轻微的闪烁。
第三步:解决图片加载前的布局跳动
布局跳动(Layout Shift)是Google Core Web Vitals里的重要指标之一,会严重影响用户体验,所以必须解决,刚才的代码里已经加了aspectRatio的判断,但如果接口返回的数据里没有宽高比怎么办?
让后端接口返回图片的宽高比
这是最推荐的方法,因为后端可以在上传图片的时候就把宽高比存到数据库里,前端直接用就行,几乎不需要额外的计算,而且兼容性最好。
前端预加载图片获取宽高
如果后端接口实在没有宽高比,前端可以先预加载一张很小的缩略图,或者预加载图片的前几个字节(只获取宽高信息,不加载完整图片),来获取宽高比,但预加载完整图片的缩略图会增加额外的请求,预加载前几个字节需要用XMLHttpRequest的range请求,兼容性稍微差一点,而且实现起来比较复杂。
用CSS的aspect-ratio属性
Chrome、Edge、Safari(15以上)都已经支持aspect-ratio属性了,国内的主流浏览器也基本支持,这个属性可以直接给元素设置宽高比,不需要预加载图片,也不需要后端返回,如果接口有宽高比的话,直接用style="aspect-ratio: ${width}/${height}";如果没有的话,可以给一个默认的宽高比,比如4:3或者16:9,虽然不如真实的宽高比准确,但也能大大减少布局跳动。
我们可以在刚才的基础版代码里加上这个属性,兼容一下没有aspectRatio数据的情况:
// 在rewrittenInsertItem里修改
if (item.aspectRatio) {
itemElement.style.aspectRatio = `${item.aspectRatio}`;
} else {
itemElement.style.aspectRatio = '3/4'; // 默认3:4,适合小红书、朋友圈相册
}
// 去掉之前的占位height,因为aspect-ratio已经可以实现占位了
第四步:添加图片懒加载,减少首屏请求
图片懒加载也是优化首屏性能的重要方法之一,Vue3里可以用IntersectionObserver API来实现,这个API的性能比scroll事件监听要好很多,因为它是异步的,只有当元素进入视口的时候才会触发回调。
先在自定义Hook里添加IntersectionObserver的逻辑
// 在useWaterfall.ts里添加
const observer = ref<IntersectionObserver | null>(null);
const initObserver = () => {
observer.value = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const imgElement = entry.target as HTMLImageElement;
// 把data-src赋值给src
imgElement.src = imgElement.dataset.src || '';
// 停止观察
observer.value?.unobserve(imgElement);
}
});
}, {
rootMargin: '100px 0px', // 提前100px加载图片
threshold: 0,
});
};
// 在组件挂载后初始化IntersectionObserver
onMounted(() => {
initObserver();
// 其他之前的代码
});
// 暴露observer给组件
return {
// 其他之前的返回值
observer,
};
然后在组件里修改创建图片元素的逻辑
// 在rewrittenInsertItem里修改
const imgElement = document.createElement('img');
imgElement.dataset.src = item.url; // 先把真实的图片URL存到data-src里
imgElement.alt = item.title || '瀑布流图片';
imgElement.src = 'https://via.placeholder.com/300x400?text=加载中...'; // 设置占位图
// 等下在图片插入到DOM后,用observer观察它
// 把瀑布流元素插入到最小高度列里之后,添加观察
columnElements[minIndex].appendChild(itemElement);
if (observer.value) {
observer.value.observe(imgElement);
}
现在图片只有当进入视口前100px的时候才会加载,大大减少了首屏的请求数量,提升了首屏加载速度。
第五步:优化无限滚动的性能
无限滚动的时候,如果每次滚动到底部都加载20张图片,并且立即插入到DOM里,可能会出现DOM操作频繁导致的页面卡顿,这时候可以用防抖或者节流,但更推荐用Vue3的requestAnimationFrame(RAF)来分批插入图片,因为RAF可以保证DOM操作在浏览器的每一帧渲染之前执行,不会出现掉帧的情况。
先在组件里添加分批插入的逻辑
// 组件里添加
const insertQueue = ref<any[]>([]); // 插入队列
let isInserting = ref(false); // 是否正在插入
const batchInsertItems = () => {
if (insertQueue.value.length === 0 || isInserting.value) return;
isInserting.value = true;
// 每次插入5张图片,减少DOM操作次数
const batch = insertQueue.value.splice(0, 5);
nextTick(() => {
batch.forEach(({ item, index }) => {
rewrittenInsertItem(item, index);
});
isInserting.value = false;
// 继续插入下一批
if (insertQueue.value.length > 0) {
requestAnimationFrame(batchInsertItems);
}
});
};
// 修改监听items变化的逻辑,把新元素加入队列
watch(items, (newItems, oldItems) => {
const newLength = newItems.length;
const oldLength = oldItems.length;
if (newLength > oldLength) {
for (let i = oldLength; i < newLength; i++) {
insertQueue.value.push({ item: newItems[i], index: i });
}
requestAnimationFrame(batchInsertItems);
}
}, { deep: true });
// 修改重写reRenderItems的逻辑,也加入队列
watch(columnCount, () => {
nextTick(() => {
if (!containerRef.value) return;
const columnElements = containerRef.value.querySelectorAll('.waterfall-column') as NodeListOf<HTMLElement>;
columnElements.forEach(col => {
col.innerHTML = '';
});
columnHeights.value = new Array(columnCount.value).fill(0);
loadedCount.value = 0;
insertQueue.value = [];
items.value.forEach((item, index) => {
insertQueue.value.push({ item, index });
});
requestAnimationFrame(batchInsertItems);
});
});
现在每次插入图片都是分批次的,每次5张,用RAF保证在浏览器的每一帧渲染之前执行,大大减少了页面卡顿的情况。
第六步:优化列数变化时的闪烁
刚才的代码里,列数变化的时候会清空所有元素重新插入,有轻微的闪烁,这时候可以用Vue3的<transition-group>或者CSS的transition来给外层容器和列容器加一个过渡动画,但更推荐用CSS的transform: translate3d来触发GPU加速,让过渡更丝滑。
先修改CSS,给外层容器和列容器加过渡
/* 外层容器 */
.waterfall-container {
position: relative;
width: 100%;
padding: 12px;
box-sizing: border-box;
transition: height 0.3s ease-in-out; /* 高度变化加过渡 */
}
/* 内层列容器 */
.waterfall-column {
position: absolute;
top: 0;
box-sizing: border-box;
padding-right: 12px;
transition: left 0.3s ease-in-out, width 0.3s ease-in-out; /* left和宽度变化加过渡 */
transform: translate3d(0, 0, 0); /* 触发GPU加速 */
}
然后修改重写reRenderItems的逻辑,不要清空所有元素,而是先隐藏,再重新插入
不过这样实现起来比较复杂,因为要记录每个元素原来的位置,所以更简单的方法是给外层容器加一个opacity的过渡,列数变化的时候先把opacity设为0.5,重新插入完成后再设为1,这样用户体验会好很多:
// 组件里添加
const containerOpacity = ref(1);
// 修改watch(columnCount)的逻辑
watch(columnCount, () => {
containerOpacity.value = 0.5;
nextTick(() => {
if (!containerRef.value) return;
const columnElements = containerRef.value.querySelectorAll('.waterfall-column') as NodeListOf<HTMLElement>;
columnElements.forEach(col => {
col.innerHTML = '';
});
columnHeights.value = new Array(columnCount.value).fill(0);
loadedCount.value = 0;
insertQueue.value = [];
items.value.forEach((item, index) => {
insertQueue.value.push({ item, index });
});
// 等所有元素插入完成后,再把opacity设为1
const checkInsertComplete = () => {
if (insertQueue.value.length === 0 && !isInserting.value) {
containerOpacity.value = 1;
} else {
requestAnimationFrame(checkInsertComplete);
}
};
requestAnimationFrame(batchInsertItems);
requestAnimationFrame(checkInsertComplete);
});
});
// 给外层容器加opacity的绑定
<template>
<div class="waterfall-container" ref="containerRef" :style="{ opacity: containerOpacity }">
<!-- 其他之前的代码 -->
</div>
</template>
// 给外层容器加opacity的过渡
.waterfall-container {
/* 其他之前的CSS */
transition: height 0.3s ease-in-out, opacity 0.2s ease-in-out;
}
现在列数变化的时候,用户只会看到一个轻微的淡入淡出,不会看到空白的闪烁了。
第七步:其他优化点
优化图片加载失败的体验
刚才的代码里已经设置了默认图片,但还可以给默认图片加一个统一的样式,比如和占位背景色一致,或者加一个图标,让用户看起来更舒服。
给瀑布流元素加点击事件
如果是电商商品列表或者相册,肯定需要点击事件,刚才的代码里是用JS动态创建的元素,所以可以用事件委托的方式,把点击事件绑定在外层容器上,这样性能更好,不需要给每个元素都绑定事件:
// 组件里添加
const handleItemClick = (e: Event) => {
const target = e.target as HTMLElement;
const itemElement = target.closest('.waterfall-item');
if (itemElement) {
const index = Number(itemElement.dataset.index);
const item = items.value[index];
// 这里处理点击事件,比如跳转到详情页
console.log('点击了第', index + 1, '个元素', item);
}
};
// 给外层容器加点击事件
<template>
<div class="waterfall-container" ref="containerRef" :style="{ opacity: containerOpacity }" @click="handleItemClick">
<!-- 其他之前的代码 -->
</div>
</template>
优化移动端的触摸体验
如果是移动端的瀑布流,可以给外层容器加touch-action: pan-y,禁止触摸缩放和横向滚动,只允许纵向滚动,提升触摸体验:
.waterfall-container {
/* 其他之前的CSS */
touch-action: pan-y;
}
不装第三方插件的Vue3瀑布流,完全可以满足大多数业务需求
我们刚才实现的这个轻量版瀑布流,只有不到300行代码(包括自定义Hook和组件),没有依赖任何第三方插件,支持自适应多列、图片加载前占位(减少布局跳动)、图片懒加载、无限滚动分批插入、列数变化时的淡入淡出、事件委托等优化,性能丝滑,兼容性好,完全可以满足小红书、朋友圈相册、电商商品列表等大多数业务需求。
如果业务需求更复杂,比如需要瀑布线、拖拽排序、虚拟滚动(加载成千上万张图片),那还是可以考虑用第三方插件,比如vue3-masonry-wall或者vue-waterfall-plugin-next,但对于大多数中小项目来说,自己写一个轻量的更合适,因为更好控制细节,而且省包体积。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网
