Vue 3瀑布流组件实现详解 - 图片展示方案

发布于:2025-06-22 ⋅ 阅读:(13) ⋅ 点赞:(0)

引言:瀑布流布局的魅力与应用场景

在当今富媒体内容主导的网络环境中,瀑布流布局已成为展示图片商品等内容的流行方式。它通过动态布局算法在有限空间内最大化内容展示,提供视觉连续性流畅浏览体验。本文将深入探讨如何使用Vue 3实现一个功能完备的瀑布流组件,并解决图片懒加载等关键问题。

瀑布流组件核心实现

1. 组件设计思路

我们的瀑布流组件解决了以下关键问题:

  • 响应式布局:根据容器宽度自动调整列数
  • 高效图片加载:支持懒加载和预加载模式
  • 视觉优化:平滑的位置过渡动画
  • 错误处理:优雅的加载失败处理机制
  • 高定制性:通过插槽支持自定义内容

2. 核心算法实现

列数与列宽计算
const calculateColumns = () => {
  wrapperWidth.value = waterfallWrapper.value.clientWidth;
  // 响应式断点处理
  const sortedBreakpoints = Object.keys(props.breakpoints)
    .map(Number)
    .sort((a, b) => b - a);
  
  // 根据断点确定列数
  for (const breakpoint of sortedBreakpoints) {
    if (wrapperWidth.value >= breakpoint) {
      cols.value = props.breakpoints[breakpoint].rowPerView;
      break;
    }
  }
  
  // 计算列宽(考虑间距和对齐方式)
  if (props.hasAroundGutter) {
    colWidth.value = (wrapperWidth.value - props.gutter * 2 - 
                    (cols.value - 1) * props.gutter) / cols.value;
    offsetX.value = props.gutter;
  } else {
    colWidth.value = (wrapperWidth.value - 
                    (cols.value - 1) * props.gutter) / cols.value;
    offsetX.value = 0;
  }
  
  // 处理对齐方式
  if (props.align === 'center') {
    const totalWidth = cols.value * colWidth.value + 
                      (cols.value - 1) * props.gutter;
    offsetX.value = (wrapperWidth.value - totalWidth) / 2;
  } else if (props.align === 'right') {
    const totalWidth = cols.value * colWidth.value + 
                      (cols.value - 1) * props.gutter;
    offsetX.value = wrapperWidth.value - totalWidth;
  }
};
瀑布流布局算法
const calculateLayout = () => {
  const columnHeights = new Array(cols.value).fill(0);
  let maxHeight = 0;

  items.forEach((item, index) => {
    // 寻找高度最小的列
    let minColHeight = columnHeights[0];
    let colIndex = 0;
    for (let i = 1; i < cols.value; i++) {
      if (columnHeights[i] < minColHeight) {
        minColHeight = columnHeights[i];
        colIndex = i;
      }
    }

    // 计算元素位置
    const x = colIndex * (colWidth.value + props.gutter) + offsetX.value;
    const y = columnHeights[colIndex];
    
    // 应用位置变换
    item.style.transform = `translate3d(${x}px, ${y}px, 0)`;
    item.style.width = `${colWidth.value}px`;
    
    // 更新列高度
    const itemHeight = item.offsetHeight || 200;
    columnHeights[colIndex] += itemHeight + props.gutter;
    
    // 更新容器高度
    if (columnHeights[colIndex] > maxHeight) {
      maxHeight = columnHeights[colIndex];
    }
  });

  wrapperHeight.value = maxHeight;
};

3. 高级功能实现

智能图片懒加载
const initLazyLoad = () => {
  observer.value = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        // 设置loading占位图
        if (props.loadingImg) img.src = props.loadingImg;
        
        // 延迟加载实际图片
        setTimeout(() => {
          loadImage(img);
          // 图片加载完成触发布局更新
          img.onload = () => debouncedLayout();
          
          // 错误处理
          if (props.errorImg) {
            img.onerror = () => {
              img.src = props.errorImg;
              debouncedLayout(); // 错误时也更新布局
            };
          }
          
          img.removeAttribute('data-src');
        }, props.delay);
        
        observer.value.unobserve(img);
      }
    });
  }, { threshold: 0.01 });
  
  // 观察所有懒加载图片
  const lazyImages = waterfallWrapper.value.querySelectorAll('img[data-src]');
  lazyImages.forEach(img => observer.value.observe(img));
};
防抖优化性能
const debounce = (fn, delay) => {
  let timeoutId;
  return (...args) => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), delay);
  };
};

// 使用防抖优化布局计算
const debouncedLayout = debounce(() => {
  calculateColumns();
  calculateLayout();
}, props.posDuration);

性能优化策略

  1. GPU加速动画

    .waterfall-item {
      transition: transform 0.3s ease;
      will-change: transform;
    }
    
  2. 智能加载策略

    • 优先加载视口内图片
    • 设置加载延迟避免卡顿
    • 使用占位图保持布局稳定
  3. 高效的事件处理

    onMounted(() => {
      window.addEventListener('resize', debouncedLayout);
    });
    
    onUnmounted(() => {
      window.removeEventListener('resize', debouncedLayout);
      if (observer.value) observer.value.disconnect();
    });
    
  4. 响应式断点设计

    breakpoints: {
      1200: { rowPerView: 4 },
      800: { rowPerView: 3 },
      500: { rowPerView: 2 }
    }
    

常见问题解决方案

  1. 图片加载导致布局抖动问题

    • 使用固定比例的占位图容器
    • 预先设置图片尺寸属性
    • 添加加载过渡动画
  2. 白屏问题处理

    // 确保初始渲染可见
    nextTick(() => {
      calculateColumns();
      calculateLayout();
    });
    
  3. 大量数据性能优化

    • 虚拟滚动技术
    • 分页加载
    • 回收不可见DOM节点

总结

通过本文,我们实现了一个高性能可定制的Vue 3瀑布流组件,它具有以下特点:

  1. 智能化布局:自动计算最佳列数和位置
  2. 多种加载模式:支持懒加载和预加载
  3. 响应式设计:完美适配不同屏幕尺寸
  4. 优雅的错误处理:提供自定义占位图
  5. 平滑动画:GPU加速的位置过渡效果

插件完整代码

<template>
  <div ref="waterfallWrapper" class="waterfall-list" :style="{ height: `${wrapperHeight}px` }">
    <div v-for="(item, index) in list" :key="getKey(item, index)" class="waterfall-item">
      <slot name="item" :item="item" :index="index" :url="getRenderURL(item)" />
    </div>
  </div>
</template>

<script>
import { ref, watch, onMounted, onUnmounted, provide, nextTick } from "vue";

export default {
  name: 'WaterfallList',
  props: {
    list: {
      type: Array,
      required: true,
      default: () => []
    },
    rowKey: {
      type: String,
      default: "id"
    },
    imgSelector: {
      type: String,
      default: "src"
    },
    width: {
      type: Number,
      default: 200
    },
    breakpoints: {
      type: Object,
      default: () => ({
        1200: { rowPerView: 4 },
        800: { rowPerView: 3 },
        500: { rowPerView: 2 }
      })
    },
    gutter: {
      type: Number,
      default: 10
    },
    hasAroundGutter: {
      type: Boolean,
      default: true
    },
    posDuration: {
      type: Number,
      default: 300
    },
    align: {
      type: String,
      default: "center",
      validator: (value) => ['left', 'center', 'right'].includes(value)
    },
    lazyLoad: {
      type: Boolean,
      default: true
    },
    crossOrigin: {
      type: Boolean,
      default: true
    },
    delay: {
      type: Number,
      default: 300
    },
    loadingImg: {
      type: String,
      default: ''
    },
    errorImg: {
      type: String,
      default: ''
    }
  },

  setup(props, { emit }) {
    const waterfallWrapper = ref(null);
    const wrapperWidth = ref(0);
    const colWidth = ref(0);
    const cols = ref(0);
    const offsetX = ref(0);
    const wrapperHeight = ref(0);
    const observer = ref(null);

    // 计算列数和列宽
    const calculateColumns = () => {
      if (!waterfallWrapper.value) return;

      wrapperWidth.value = waterfallWrapper.value.clientWidth;

      // 根据断点确定列数
      const sortedBreakpoints = Object.keys(props.breakpoints)
        .map(Number)
        .sort((a, b) => b - a); // 从大到小排序

      let foundCols = 1;
      for (const breakpoint of sortedBreakpoints) {
        if (wrapperWidth.value >= breakpoint) {
          foundCols = props.breakpoints[breakpoint].rowPerView;
          break;
        }
      }

      cols.value = foundCols;

      // 计算列宽
      if (props.hasAroundGutter) {
        colWidth.value = (wrapperWidth.value - props.gutter * 2 - (cols.value - 1) * props.gutter) / cols.value;
        offsetX.value = props.gutter;
      } else {
        colWidth.value = (wrapperWidth.value - (cols.value - 1) * props.gutter) / cols.value;
        offsetX.value = 0;
      }

      // 处理对齐方式
      if (props.align === 'center') {
        const totalWidth = cols.value * colWidth.value + (cols.value - 1) * props.gutter;
        offsetX.value = (wrapperWidth.value - totalWidth) / 2;
      } else if (props.align === 'right') {
        const totalWidth = cols.value * colWidth.value + (cols.value - 1) * props.gutter;
        offsetX.value = wrapperWidth.value - totalWidth;
      }
    };

    // 加载图片
    const loadImage = (img) => {
      const url = img.dataset?.src || img.getAttribute('data-src');
      if (url) {
        // 创建临时Image对象预加载
        const tempImage = new Image();
        tempImage.onload = () => {
          img.src = url;
          if (props.crossOrigin) img.crossOrigin = 'anonymous';
          img.removeAttribute('data-src');
          debouncedLayout(); // 关键:加载完成后触发布局更新
        };
        tempImage.onerror = () => {
          if (props.errorImg) img.src = props.errorImg;
          img.removeAttribute('data-src');
          debouncedLayout(); // 关键:加载失败时也触发布局更新
        };
        tempImage.src = url;
        return true;
      }
      return false;
    };

    // 加载所有图片(修改后)
    const loadAllImages = () => {
      if (!waterfallWrapper.value) return;

      const images = waterfallWrapper.value.querySelectorAll('img[data-src]');
      images.forEach(img => {
        // 设置loading占位图
        if (props.loadingImg) img.src = props.loadingImg;

        // 加载实际图片并监听加载完成事件
        const loaded = loadImage(img);

        // 错误处理
        if (loaded && props.errorImg) {
          img.onerror = () => {
            img.src = props.errorImg;
            debouncedLayout(); // 关键:错误时也触发布局更新
          };
        }
      });
    };

    // const loadAllImages = () => {
    //   if (!waterfallWrapper.value) return;

    //   const images = waterfallWrapper.value.querySelectorAll('img');
    //   images.forEach(img => {
    //     // 如果已经是加载状态则跳过
    //     if (img.src && !img.src.includes(props.loadingImg)) return;

    //     // 尝试加载图片
    //     const loaded = loadImage(img);

    //     // 设置错误处理
    //     if (loaded && props.errorImg) {
    //       img.onerror = () => {
    //         img.src = props.errorImg;
    //       };
    //     }
    //   });
    // };

    // 初始化懒加载
    const initLazyLoad = () => {
      if (!waterfallWrapper.value) return;

      // 清理旧的观察器
      if (observer.value) {
        observer.value.disconnect();
      }

      // 创建新的观察器
      observer.value = new IntersectionObserver((entries) => {
        entries.forEach(entry => {
          if (entry.isIntersecting) {
            const img = entry.target;
            if (img.dataset?.src || img.getAttribute('data-src')) {
              // 设置loading占位图
              if (props.loadingImg) {
                img.src = props.loadingImg;
              }

              // 延迟加载实际图片
              setTimeout(() => {
                loadImage(img);
                img.onload = () => debouncedLayout();

                if (props.errorImg) {
                  img.onerror = () => {
                    img.src = props.errorImg;
                  };
                }

                // 移除data-src属性
                img.removeAttribute('data-src');
              }, props.delay);
            }
            observer.value.unobserve(img);
          }
        });
      }, { threshold: 0.01 });

      // 观察所有懒加载图片
      const lazyImages = waterfallWrapper.value.querySelectorAll('img[data-src]');
      lazyImages.forEach(img => {
        observer.value.observe(img);
      });
    };

    // 计算布局
    const calculateLayout = () => {
      if (!waterfallWrapper.value || cols.value === 0) return;

      const items = waterfallWrapper.value.querySelectorAll('.waterfall-item');
      if (items.length === 0) return;

      const columnHeights = new Array(cols.value).fill(0);
      let maxHeight = 0;

      items.forEach((item, index) => {
        let minColHeight = columnHeights[0];
        let colIndex = 0;

        for (let i = 1; i < cols.value; i++) {
          if (columnHeights[i] < minColHeight) {
            minColHeight = columnHeights[i];
            colIndex = i;
          }
        }

        const x = colIndex * (colWidth.value + props.gutter) + offsetX.value;
        const y = columnHeights[colIndex];

        item.style.transform = `translate3d(${x}px, ${y}px, 0)`;
        item.style.width = `${colWidth.value}px`;
        item.style.position = 'absolute';
        item.style.left = '0';
        item.style.top = '0';
        item.style.visibility = 'visible';

        // 计算项目高度(包含所有图片)
        const itemHeight = item.offsetHeight || 200;
        columnHeights[colIndex] += itemHeight + props.gutter;

        // 更新最大高度
        if (columnHeights[colIndex] > maxHeight) {
          maxHeight = columnHeights[colIndex];
        }
      });

      wrapperHeight.value = maxHeight;
      emit('afterRender');
    };

    // 防抖函数
    const debounce = (fn, delay) => {
      let timeoutId;
      return (...args) => {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => fn.apply(this, args), delay);
      };
    };

    const debouncedLayout = debounce(() => {
      calculateColumns();
      calculateLayout();
    }, props.posDuration);

    // 初始化
    onMounted(() => {
      if (!waterfallWrapper.value) return;
      calculateColumns();

      nextTick(() => {
        if (props.lazyLoad) {
          initLazyLoad();
        } else {
          loadAllImages(); // 非懒加载模式直接加载图片
        }
        calculateLayout(); // 初始布局
      });

      window.addEventListener('resize', debouncedLayout);
    });

    // 清理
    onUnmounted(() => {
      if (observer.value) {
        observer.value.disconnect();
      }
      window.removeEventListener('resize', debouncedLayout);
    });

    // 监听数据变化(修改部分)
    watch(() => props.list, () => {
      debouncedLayout();
      nextTick(() => {
        if (props.lazyLoad) {
          initLazyLoad();
        } else {
          // 延迟加载确保DOM更新完成
          setTimeout(loadAllImages, 0);
        }
      });
    }, { deep: true });

    // 提供刷新方法
    provide('refreshWaterfall', debouncedLayout);

    const getRenderURL = (item) => {
      return item[props.imgSelector];
    };

    const getKey = (item, index) => {
      return item[props.rowKey] || index;
    };

    return {
      waterfallWrapper,
      wrapperHeight,
      getRenderURL,
      getKey
    };
  }
};
</script>

<style scoped>
.waterfall-list {
  position: relative;
  width: 100%;
  margin: 0 auto;
}

.waterfall-item {
  position: absolute;
  visibility: hidden;
  transition: transform 0.3s ease;
  will-change: transform;
  box-sizing: border-box;
}
</style>

调用示例

<template>
  <div class="container">
    <h1>图片瀑布流懒加载示例</h1>
    <!-- 瀑布流组件 -->
    <waterfall-list :list="imageList" :lazy-load="false" :cross-origin="true" :delay="300"
      loading-img="https://via.placeholder.com/300x200?text=Loading..."
      error-img="https://via.placeholder.com/300x200?text=Error" :width="300" :gutter="15" @afterRender="onAfterRender">
      <template #item="{ item, url }">
        <div class="image-card">
          <!-- 使用data-src实现懒加载 -->
          <img :data-src="url" :alt="item.title" class="image" @load="onImageLoad" />
          <div class="info">
            <h3>{{ item.title }}</h3>
            <p>{{ item.description }}</p>
          </div>
        </div>
      </template>
    </waterfall-list>

    <!-- 加载更多按钮 -->
    <button class="load-more" @click="loadMoreImages" :disabled="isLoading">
      {{ isLoading ? '加载中...' : '加载更多' }}
    </button>
  </div>
</template>

<script>
import { ref } from 'vue';
import WaterfallList from '../components/vWaterfall.vue'; // 根据实际路径调整

export default {
  components: {
    WaterfallList
  },
  setup() {
    // 模拟图片数据
    const generateImages = (count, startIndex = 0) => {
      return Array.from({ length: count }, (_, i) => ({
        id: startIndex + i,
        title: `图片 ${startIndex + i + 1}`,
        description: `这是第 ${startIndex + i + 1} 张图片的描述`,
        src: `https://picsum.photos/id/${startIndex + i + 10}/300/200`
      }));
    };

    const imageList = ref(generateImages(12));
    const isLoading = ref(false);

    // 图片加载完成回调
    const onImageLoad = (e) => {
      console.log('图片加载完成', e.target);
    };

    // 瀑布流渲染完成回调
    const onAfterRender = () => {
      console.log('瀑布流布局完成');
    };

    // 加载更多图片
    const loadMoreImages = () => {
      isLoading.value = true;
      setTimeout(() => {
        const newImages = generateImages(6, imageList.value.length);
        imageList.value = [...imageList.value, ...newImages];
        isLoading.value = false;
      }, 1000);
    };

    return {
      imageList,
      isLoading,
      onImageLoad,
      onAfterRender,
      loadMoreImages
    };
  }
};
</script>

<style scoped>
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

h1 {
  text-align: center;
  margin-bottom: 30px;
  color: #333;
}

.image-card {
  background: #fff;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s ease;
}

.image-card:hover {
  transform: translateY(-5px);
}

.image {
  width: 100%;
  height: auto;
  display: block;
  background: #f5f5f5;
  transition: opacity 0.3s ease;
}

.info {
  padding: 15px;
}

.info h3 {
  margin: 0 0 8px 0;
  font-size: 16px;
  color: #333;
}

.info p {
  margin: 0;
  font-size: 14px;
  color: #666;
}

.load-more {
  display: block;
  width: 200px;
  margin: 30px auto;
  padding: 12px 24px;
  background: #4a8cff;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
  transition: background 0.3s ease;
}

.load-more:hover {
  background: #3a7be0;
}

.load-more:disabled {
  background: #ccc;
  cursor: not-allowed;
}
</style>

网站公告

今日签到

点亮在社区的每一天
去签到