Code前端首页关于Code前端联系我们

Vue3不装第三方插件能做自适应多列瀑布流吗?性能怎么优化到丝滑?

terry 56分钟前 阅读数 29 #Vue

为什么很多人纠结“不装第三方”

其实瀑布流的核心逻辑不算复杂,但很多人第一反应还是去搜插件,主要是怕踩这几个坑:一是手动写多列对齐容易忽上忽下,尤其是图片加载前后高度差特别大的场景;二是自适应列数的时候,布局会“跳页式闪烁”;三是图片懒加载、无限滚动和瀑布流结合的时候,容易出现大量空白区域或者性能卡顿,但第三方插件也有问题:比如很多是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的判断,但如果接口返回的数据里没有宽高比怎么办?

让后端接口返回图片的宽高比

这是最推荐的方法,因为后端可以在上传图片的时候就把宽高比存到数据库里,前端直接用就行,几乎不需要额外的计算,而且兼容性最好。

前端预加载图片获取宽高

如果后端接口实在没有宽高比,前端可以先预加载一张很小的缩略图,或者预加载图片的前几个字节(只获取宽高信息,不加载完整图片),来获取宽高比,但预加载完整图片的缩略图会增加额外的请求,预加载前几个字节需要用XMLHttpRequestrange请求,兼容性稍微差一点,而且实现起来比较复杂。

用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前端网发表,如需转载,请注明页面地址。

热门