瀑布流组件

0 阅读3分钟

该源码来自 uviewpro 地址为:uviewpro.cn/zh/componen…

我改成vue2的写法 优化了计时器

瀑布流插件

<template>
  <div class="waterfull">
    <div class="left" ref="leftRef">
      <slot name="left" :leftList="leftData"></slot>
    </div>
    <div class="right" ref="rightRef">
      <slot name="right" :rightList="rightData"></slot>
    </div>
  </div>
</template>
<script>
/** 
 * 
 * @author COOBY
 * @since 2026-01-26 14:19
 */
export default {
  props: {
    list: { type: Array, default: () => [] },
    addTime: { type: Number, default: 200 }
  },
  data() {
    return {
      timer: null,
      leftData: [],
      rightData: [],
      tempList: [],
    }
  },
  computed: {
    copyFlowList() {
      return this.deepClone(this.list);
    }
  },
  watch: {
    copyFlowList(nVal, oVal) {
      const startIndex = Array.isArray(oVal) && oVal.length > 0 ? oVal.length : 0;
      // 拼接上原有数据
      this.tempList = this.tempList.concat(this.deepClone(nVal.slice(startIndex)));
      this.splitData();
    },
    immediate: true
  },
  mounted() {
    this.tempList = this.deepClone(this.copyFlowList);
    this.splitData();
  },
  beforeDestroy() {
    if (this.timer) clearTimeout(this.timer);
    this.timer = null;
  },
  methods: {
    async splitData() {
      if (!this.tempList.length) return;
      if (!this.$refs.leftRef || !this.$refs.rightRef) return;
      await this.$nextTick();
      const leftRect = this.$refs.leftRef.offsetHeight;
      const rightRect = this.$refs.rightRef.offsetHeight;
      // 如果左边小于或等于右边,就添加到左边,否则添加到右边
      const item = this.tempList[0];
      // 解决多次快速上拉后,可能数据会乱的问题,因为经过上面的两个await节点查询阻塞一定时间,加上后面的定时器干扰
      // 数组可能变成[],导致此item值可能为undefined
      if (!item) return;
      if (leftRect < rightRect) {
        this.leftData.push(item);
      } else if (leftRect > rightRect) {
        this.rightData.push(item);
      } else {
        // 这里是为了保证第一和第二张添加时,左右都能有内容
        // 因为添加第一张,实际队列的高度可能还是0,这时需要根据队列元素长度判断下一个该放哪边
        if (this.leftData.length <= this.rightData.length) {
          this.leftData.push(item);
        } else {
          this.rightData.push(item);
        }
      }
      // 移除临时列表的第一项
      this.tempList.shift();
      // 如果临时数组还有数据,继续循环
      await this.$nextTick();
      if (this.tempList.length) {
        if (this.timer) clearTimeout(this.timer);
        this.timer = setTimeout(() => {
          this.splitData();
        }, Math.max(0, this.addTime)); // 防止负数
      }
    },
    deepClone(obj, cache = new WeakMap()) {
      if (obj === null || typeof obj !== 'object') return obj;
      if (cache.has(obj)) return cache.get(obj);
      let clone;
      if (obj instanceof Date) {
        clone = new Date(obj.getTime());
      } else if (obj instanceof RegExp) {
        clone = new RegExp(obj);
      } else if (obj instanceof Map) {
        clone = new Map(Array.from(obj, ([key, value]) => [key, this.deepClone(value, cache)]));
      } else if (obj instanceof Set) {
        clone = new Set(Array.from(obj, value => this.deepClone(value, cache)));
      } else if (Array.isArray(obj)) {
        clone = obj.map(value => this.deepClone(value, cache));
      } else if (Object.prototype.toString.call(obj) === '[object Object]') {
        clone = Object.create(Object.getPrototypeOf(obj));
        cache.set(obj, clone);
        for (const [key, value] of Object.entries(obj)) {
          clone[key] = this.deepClone(value, cache);
        }
      } else {
        clone = Object.assign({}, obj);
      }
      cache.set(obj, clone);
      return clone;
    }

  },
}
</script>
<style lang="less" scoped>
.waterfull {
  display: flex;
  gap: 2vw;
  width: 92vw;
  margin: 0 auto;

  .left,
  .right {
    flex: 1;
    background-color: #fff;
    height: fit-content;
  }
}
</style>

vue中使用

   <waterfull :list="videoList">
                  <template slot="left" slot-scope="{ leftList }">
                    <div v-for="(item, index) in leftList" :key="index">
                      <itemVideo :item="item"></itemVideo>
                    </div>
                  </template>
                  <template slot="right" slot-scope="{ rightList }">
                    <div v-for="(item, index) in rightList" :key="index">
                      <itemVideo :item="item"></itemVideo>
                    </div>
                  </template>
                </waterfull>

一、需求目标

我们要实现一个组件,具备以下能力:

  • 接收一个动态变化的 list 数组(比如通过上拉加载新增数据);
  • 自动将新项“智能”分配到左列或右列,使两列高度尽可能平衡;
  • 支持自定义每项添加的间隔时间(模拟“逐个加载”的动画效果);
  • 避免因频繁更新导致的数据错乱或性能问题。

二、整体结构概览

<template>
  <div class="waterfull">
    <div class="left" ref="leftRef">
      <slot name="left" :leftList="leftData"></slot>
    </div>
    <div class="right" ref="rightRef">
      <slot name="right" :rightList="rightData"></slot>
    </div>
  </div>
</template>
  • 使用 <slot> 实现插槽分发,父组件可自由定义左右列的渲染方式;
  • 通过 ref 获取左右容器的真实 DOM 高度,用于判断插入位置;
  • 数据分为 leftDatarightData 两个数组,分别控制左右列内容。

三、核心逻辑拆解

1. 数据监听与增量处理

watch: {
  copyFlowList(nVal, oVal) {
    const startIndex = Array.isArray(oVal) && oVal.length > 0 ? oVal.length : 0;
    this.tempList = this.tempList.concat(this.deepClone(nVal.slice(startIndex)));
    this.splitData();
  },
  immediate: true
}
  • copyFlowList 是对 props.list 的深拷贝(避免直接修改原始数据);
  • list 变化时,只取新增部分slice(startIndex)),避免重复处理已有项;
  • 新增项先存入 tempList 临时队列,再交由 splitData 逐步分配。

为什么用临时队列?
因为我们希望“逐个”添加项(带时间间隔),而不是一次性塞入,这样能模拟真实加载过程,并防止 DOM 高度计算不准。


2. 智能分配算法:splitData

这是整个组件的灵魂函数

async splitData() {
  if (!this.tempList.length) return;
  if (!this.$refs.leftRef || !this.$refs.rightRef) return;

  await this.$nextTick(); // 确保 DOM 已更新

  const leftRect = this.$refs.leftRef.offsetHeight;
  const rightRect = this.$refs.rightRef.offsetHeight;

  const item = this.tempList[0];
  if (!item) return;

  if (leftRect <= rightRect) {
    this.leftData.push(item);
  } else {
    this.rightData.push(item);
  }

  this.tempList.shift(); // 移除已处理项

  await this.$nextTick(); // 等待新项渲染完成,高度更新

  if (this.tempList.length) {
    if (this.timer) clearTimeout(this.timer);
    this.timer = setTimeout(() => {
      this.splitData();
    }, Math.max(0, this.addTime));
  }
}

分配策略详解:

条件行为
leftHeight <= rightHeight新项放入左边
leftHeight > rightHeight新项放入右边

💡 为什么不是严格 < 而是 <=
这样能确保第一项优先放入左边,第二项若高度相同(都为0),则进入 else 分支中的兜底逻辑。

兜底逻辑(初始状态处理):

// 当左右高度相等(如初始都为0)时
if (this.leftData.length <= this.rightData.length) {
  this.leftData.push(item);
} else {
  this.rightData.push(item);
}
  • 防止前两项都塞到同一侧;
  • 保证左右列都能有内容,提升首屏体验。

3. 异步调度与防抖

  • 每次添加一项后,等待 DOM 渲染完成$nextTick())再计算下一次高度;
  • 使用 setTimeout 控制添加频率(addTime 默认 200ms);
  • 每次调用前 clearTimeout,防止多个定时器堆积(尤其在快速上拉加载时)。

⚠️ 注意:如果不加 $nextTick()offsetHeight 可能还是旧值,导致分配错误!


4. 深拷贝工具函数

deepClone(obj, cache = new WeakMap()) {
  // 处理 null、基本类型、Date、RegExp、Map、Set、Array、Object 等
  // 使用 WeakMap 防止循环引用
}
  • 避免外部传入的对象被组件内部修改;
  • 支持复杂嵌套结构,适用于大多数业务场景。

四、样式与布局

.waterfull {
  display: flex;
  gap: 2vw;
  width: 92vw;
  margin: 0 auto;

  .left, .right {
    flex: 1;
    background-color: #fff;
    height: fit-content; // 关键!让容器高度随内容增长
  }
}
  • 使用 flex: 1 让左右列等宽;
  • height: fit-content 确保容器高度能被 JS 正确读取(offsetHeight 依赖于此)。

五、使用示例

父组件中这样使用:

   <waterfull :list="videoList">
                  <template slot="left" slot-scope="{ leftList }">
                    <div v-for="(item, index) in leftList" :key="index">
                      <itemVideo :item="item"></itemVideo>
                    </div>
                  </template>
                  <template slot="right" slot-scope="{ rightList }">
                    <div v-for="(item, index) in rightList" :key="index">
                      <itemVideo :item="item"></itemVideo>
                    </div>
                  </template>
      </waterfull>
  • 完全解耦渲染逻辑,父组件决定如何展示每一项;
  • 支持任意内容(图片、卡片、文字等)。