从零开始封装瀑布流布局组件

110 阅读2分钟

我重生了,魂穿到了一个落魄前端程序员身上,凭借着前生大牛的记忆,我将改变这个人的职业生涯。此时领导坐在我面前,说想要一个类似淘宝小红书那样的瀑布流布局,我斜嘴一笑,这对我来说岂不是手到擒来。

我们常见的布局方式有弹性布局、网格布局、表格布局等,这些布局方式都比较容易实现。而瀑布流布局的每个子元素高度都不相同,例如淘宝、小红书等。这种布局就需要js的帮助了。

image.png

我们的基本思路是:将卡片插入布局时计哪列高度最小,则将卡片插入该列。利用transform: "translate()"样式设置每个卡片的位置,我们还需要知道卡片分几列,然后根据容器宽度计算出每列的宽度,卡片之间还需要自定义间距,这样才会好看。

这样就确定了组件的几个props:

  • colSpan:卡片分几列
  • gap:卡片的横向和纵向间隔
<script setup lang="ts">
import {WaterfallFlowService} from "./service";
import {computed, onMounted, Ref, ref} from "vue";

export interface Props {
  colSpan?: number;
  gap?: [number, number];
}

const props = withDefaults(defineProps<Props>(), {
  colSpan: 2,
  gap: () => [20, 20],
})

const emit = defineEmits<{
  (e: 'resize', width: number): void;
}>()

const waterfallFlowRef: Ref<HTMLElement | null> = ref(null)
const waterfallInnerRef: Ref<HTMLElement | null> = ref(null)

// 核心类包含了布局的核心方法
const service = new WaterfallFlowService(props.colSpan, props.gap);

onMounted(() => {
  // 页面挂载后和监听到resize事件时调用resize方法
  resize()
  function resize() {
    // resize方法获取容器宽度,并调用reLayout方法
    if (!waterfallInnerRef.value) return;
    const rect = waterfallInnerRef.value.getBoundingClientRect();
    service.reLayout(rect.width);
    emit("resize", rect.width)
  }
  window.addEventListener("resize", resize)
})

// 容器高度
const height = computed(() => `${service.model.height}px`)

// 将核心方法导出去
defineExpose(service)
</script>
<template>
  <div ref="waterfallFlowRef" class="waterfall-flow" @scroll="onScroll">
    <div ref="waterfallInnerRef" class="waterfall-inner">
      <!-- 遍历渲染waterfalls,里面需要包含卡片的位置信息 -->
      <div
        v-for="data in service.model.waterfalls"
        class="waterfall-item"
        :style="{
          width: data.width + 'px',
          height: data.height + 'px',
          transform:`translate(${data.x}px, ${data.y}px)`,
        }">
      </div>
        <div>{{data}}</div>
    </div>
  </div>
</template>
<style lang="scss" scoped>
.waterfall-flow {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: auto;
  box-sizing: border-box;
  .waterfall-inner {
    width: 100%;
    height: v-bind(height);
    .waterfall-item {
      position: absolute;
    }
  }
  .state-container {
    position: relative;
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100%;
    height: 30px;
  }
}
</style>

下面封装组件的核心类

import {reactive} from "vue";

// 定义每张卡片需要的数据格式
interface WaterfallItem {
  x: number;
  y: number;
  width: number;
  height: number;
  data: Record<string, unknown>
}

interface Model {
  waterfalls: WaterfallItem[];
  width: number;
  height: number;
  unitWidth: number;
}

export class WaterfallFlowService{

  static StateBoxHeight = 40;

  model: Model = reactive({
    // 存放所有卡片的列表
    waterfalls: [],
    // 容器宽度
    width: 0,
    // 容器高度
    height: 0,
    // 卡片宽的
    unitWidth: 0
  })

  // 记录每一列的当前高度
  colHeight: number[] = []

  constructor(
    public colSpan: number,
    public gap: [number, number]
  ) { }

  // 页面挂载后和监听到resize事件时调用reLayout方法
  // 记录容器宽度、计算每张卡片的宽度
  reLayout(width: number) {
    this.model.width = width;
    const [col] = this.gap;
    this.model.unitWidth = (width - (this.colSpan - 1) * col) / this.colSpan;
    // 从新设置每张卡片的布局
    this.resetWaterfall()
  }

  resetWaterfall() {
    // 根据列数初始化每列的当前高度
    this.colHeight = new Array(this.colSpan).fill(0);
    // data2Waterfall方法会从新设置每张卡片的位置信息
    this.model.waterfalls = this.model.waterfalls.map(
      (item, index) => this.data2Waterfall(item.data)
    );
    // 由于我们的卡片都是绝对布局,所以卡片并不会撑起容器的高度,所以需要单独设置容器高度,容器有了高度才能滚动。
    this.formatHeight()
  }

  // 添加卡片的方法dataList里时业务数据,调用data2Waterfall转换成卡片数据
  appendData(dataList: Array<Record<string, unknown>>) {
    dataList.forEach((data) => {
      this.model.waterfalls.push(this.data2Waterfall(data))
    })
    // 添加卡片也需要设置容器高度
    this.formatHeight()
  }

  // 将业务数据转换成卡片数据,业务数据中必须包含高度信息
  data2Waterfall(data: Record<string, unknown>): WaterfallItem {
    // findNextCol找到当前高度最小的列
    const colIdx = this.findNextCol()
    const [colGap, rowGap] = this.gap;
    // 取出高度最小列的高度
    const currentHeight = this.colHeight[colIdx];

    const res = {
      // 卡片宽度就是单位宽度
      width: this.model.unitWidth,
      // 高度 = 业务数据指定的卡片高度
      height: (data["height"] as number | undefined) || 60,
      // x = 第几列 * 单位宽 + 列间距
      x: colIdx * this.model.unitWidth + colIdx * colGap,
      // y = 当前列高度 + 行间距
      y: currentHeight ? currentHeight + rowGap : currentHeight,
      // 再将业务数据保存下来,组件外会用到
      data
    }
    // 更新当前列的高度
    this.colHeight[colIdx] = res.y + res.height;

    return res;
  }
  
  // 查找高度最小的列
  findNextCol() {
    if (!this.colHeight.length) return 0;
    const min = Math.min(...this.colHeight)
    return this.colHeight.findIndex((item) => item === min)
  }
  // 根据最大的列高设置容器的高度
  formatHeight() {
    this.model.height = Math.max(...this.colHeight);
  }
}

在业务组件里使用我们封装的瀑布流组件

<template>
  <div class="waterfall-flow-page">
    <WaterfallFlow
      ref="waterfallFlowRef"
      v-model:loading="model.loading"
      :colSpan="4"
    ></WaterfallFlow>
  </div>
</template>
<style lang="scss" scoped>
.waterfall-flow-page {
  width: 100%;
  height: 750px;
  background: #ffffff;
  box-sizing: border-box;
}
</style>
<script setup lang="ts">
import {onMounted, reactive, Ref, ref} from "vue";
import WaterfallFlow from "@/components/WaterfallFlow";
import { WaterfallFlowService } from "@/components/WaterfallFlow/src/service";

// 获取组件实例
const waterfallFlowRef: Ref<WaterfallFlowService | null> = ref(null)

// 初始化时设置数据
onMounted(() => {
  loadData()
})

const model = reactive({
  loading: false
})

let count = 0
function loadData() {
  for (let i = 0;i < 20;i++) {
    waterfallFlowRef.value?.appendData([{
      name: count++,
      height: Math.floor(Math.random()*(301-100)+100)
    }])
  }
}
</script>

为了让业务组件更好的定义卡片样式,所以我们将卡片作为插槽暴露出去

修改组件

<template>
  <div ref="waterfallFlowRef" class="waterfall-flow" @scroll="onScroll">
    <div ref="waterfallInnerRef" class="waterfall-inner">
      <div
        v-for="data in service.model.waterfalls"
        class="waterfall-item"
        :style="{
        width: data.width + 'px',
        height: data.height + 'px',
        transform:`translate(${data.x}px, ${data.y}px)`,
      }"
      >
        <!-- 将业务数据和位置信息传递出去 -->
        <slot :data="data.data" :width="data.width" :height="data.height" :x="data.x" :y="data.y"></slot>
      </div>

    </div>
  </div>
</template>

修改业务组件

<template>
  <div class="waterfall-flow-page">
    <WaterfallFlow
      ref="waterfallFlowRef"
      v-model:loading="model.loading"
      :colSpan="4"
      @load="onLoad"
    >
      <!-- 使用插槽自定义卡片 -->
      <template #default="{data, y}">
        <div class="item">
          <div>{{data}}-{{y}}</div>
        </div>
      </template>
    </WaterfallFlow>
  </div>
</template>
<style lang="scss" scoped>
.waterfall-flow-page {
  width: 100%;
  height: 750px;
  background: #ffffff;
  box-sizing: border-box;
  .item {
    width: calc(100% - 2px);
    height: calc(100% - 2px);
    border: 1px solid red;
  }
}
</style>

查看效果

image.png

这样瀑布流布局就完成了

我们还需要一个触底加载更多数据的功能 首先定义几个状态

const props = withDefaults(defineProps<Props>(), {
  colSpan: 2,
  gap: () => [20, 20],
  // 是否正在加载
  loading: false,
  // 是否全部加载完成
  finish: false,
  // 是否加载失败
  error: false,
  // 离底部多少距离出发加载事件
  loadOffset: 40
})

定义事件

const emit = defineEmits<{
  // 将loading做成双向绑定
  (e: 'update:loading', loading: boolean): void;
  // 加载事件
  (e: 'load'): void;
  (e: 'resize', width: number): void;
}>()

监听滚动条事件

<template>
  <!-- 使用@scroll监听滚动条事件 -->  
  <div ref="waterfallFlowRef" class="waterfall-flow" @scroll="onScroll">
    <div ref="waterfallInnerRef" class="waterfall-inner">
      <div
        v-for="data in service.model.waterfalls"
        class="waterfall-item"
        :style="{
          width: data.width + 'px',
          height: data.height + 'px',
          transform:`translate(${data.x}px, ${data.y}px)`,
        }">
        <slot :data="data.data" :width="data.width" :height="data.height" :x="data.x" :y="data.y"></slot>
      </div>

    </div>
    // 显示当前加载状态,同样也支持插槽自定义
    <div class="state-container">
      <slot v-if="props.loading" name="loading">加载中...</slot>
      <slot v-if="props.error" name="error">加载失败</slot>
      <slot v-if="props.finish" name="finish">没有更多数据了</slot>
    </div>
  </div>
</template>
function onScroll() {
  if (!waterfallFlowRef.value) return;
  const rect = waterfallFlowRef.value.getBoundingClientRect()
  const offset = waterfallFlowRef.value.scrollHeight - rect.height - waterfallFlowRef.value.scrollTop
  // 如果滚动条离底部小于预置高度,并且loading、error、finish都为false,则触发加载事件
  if(offset < props.loadOffset && !props.loading && !props.error && !props.finish) {
    // 将loading变为true,避免触底重复触发load
    emit("update:loading", true)
    emit("load")
  }
}

业务组件监听load事件加载数据

<template>
  <div class="waterfall-flow-page">
    <WaterfallFlow
      ref="waterfallFlowRef"
      v-model:loading="model.loading"
      :colSpan="4"
      :finish="model.finish"
      @load="onLoad"
    >
    </WaterfallFlow>
  </div>
</template>
const model = reactive({
  loading: false,
  finish: false
})

function onLoad() {
  console.log('onLoad')
  loadData()
}

let count = 0
function loadData() {
  model.loading = true;
  // 模拟接口请求数据
  setTimeout(() => {
    for (let i = 0;i < 20;i++) {
      waterfallFlowRef.value?.appendData([{
        name: count++,
        height: Math.floor(Math.random()*(301-100)+100)
      }])
    }
    model.loading = false;
    if(count >= 1000) {
      // 加载1000条数据后将finish设为true将不会再触发load事件
      model.finish = true
    }
  }, 1000)
}

像淘宝,当你点进去某个卡片再出来后,它可能会认为你对这个商品感兴趣,于是会在卡片旁边插入一个类似的商品。所以我们的组件还需要在指定位置插入新卡片的功能,同样删除修改指定位置的卡片也需要,我们只要在指定位置插入数据,然后将该位置之后的卡片重新布局就可以了。