瀑布流布局深度解析(js版)

1. 前言

瀑布流布局,是一种比较流行的网站页面布局方式。等宽不等高,后面的元素依次添加到前一行最矮的元素下方。例如淘宝,小红书都采用了瀑布流布局,这里放张本示例效果的截图:

2. 核心思想

  1. 通过预加载获取图片的宽高(如果服务端能直接返回图片信息更好)

  2. 第一行开始摆放图片,并将图片的高度存在数组中,例如第一行有两列,高度数组为arr=[100,200]

  3. 获取数组中最小值100,以及下标0。最小值100就是第三张图片的top。下标0*(图片宽度+padding)就是第三张图片的left。

  1. 确定第三张图片位置信息的同时,更新高度数组,将第三张的高度加到数组的最小值上,例如这里变为arr = [340,200],然后很明显地4张的top取数组的最小值200,第4张的left为最小值的下标1*(图片宽度200+padding值)如此反复。

3. 数据准备

新建imgs.ts,存放图片信息

js 复制代码
export default [
  {
    url: "https://tse2-mm.cn.bing.net/th/id/OIP-C.tJ293_qmktdZiODFiC2sygHaE4?w=277&h=183&c=7&r=0&o=5&pid=1.7",
    info: "我是第1张图片",
  },
  {
    url: "https://tse2-mm.cn.bing.net/th/id/OIP-C.319YjT8mAzDbcd83QKl72wHaEo?w=300&h=187&c=7&r=0&o=5&pid=1.7",
    info: "我是第2张图片",
  },
  {
    url: "https://tse1-mm.cn.bing.net/th/id/OIP-C.Zte3ljd4g6kqrWWyg-8fhAHaEo?w=272&h=180&c=7&r=0&o=5&pid=1.7",
    info: "我是第3张图片",
  },
  {
    url: "https://tse4-mm.cn.bing.net/th/id/OIP-C.5xAgxwljfeS9C5bTx6pQuQHaEo?w=200&h=187&c=7&r=0&o=5&pid=1.7",
    info: "我是第4张图片",
  },

  {
    url: "https://tse4-mm.cn.bing.net/th/id/OIP-C.SYm1k3ZdW86JptueyTGPJgHaE7?w=272&h=182&c=7&r=0&o=5&pid=1.7",
    info: "我是第5张图片",
  },
  {
    url: "https://tse2-mm.cn.bing.net/th/id/OIP-C.7sAjIeoQYWnXV_QnuYs1jQHaEK?w=302&h=180&c=7&r=0&o=5&pid=1.7",
    info: "我是第6张图片",
  },
  {
    url: "https://tse3-mm.cn.bing.net/th/id/OIP-C.r0OnuYkvsbqBrYk3kUT53AHaKX?w=99&h=181&c=7&r=0&o=5&pid=1.7",
    info: "我是第7张图片",
  },
  {
    url: "https://tse3-mm.cn.bing.net/th/id/OIP-C.TbL0XKZ2jNF3cpqDF5dnawHaEo?w=286&h=180&c=7&r=0&o=5&pid=1.7",
    info: "我是第8张图片",
  },
  {
    url: "https://tse4-mm.cn.bing.net/th/id/OIP-C.QiENtPtG3CIjC6yr0P-bMQHaFj?w=249&h=187&c=7&r=0&o=5&pid=1.7",
    info: "我是第9张图片",
  },
  {
    url: "https://tse1-mm.cn.bing.net/th/id/OIP-C.T4oxzDuFmfBHsHX_zBEYkQHaFj?w=166&h=187&c=7&r=0&o=5&pid=1.7",
    info: "我是第10张图片",
  },
  {
    url: "https://tse2-mm.cn.bing.net/th/id/OIP-C.PblCAZjGQhdLpHc3AyEjVgHaIG?w=184&h=192&c=7&r=0&o=5&pid=1.7",
    info: "我是第11张图片",
  },
  {
    url: "https://tse4-mm.cn.bing.net/th/id/OIP-C.KMv2fG1yzkp1Zrn9BRY6uQHaJc?w=132&h=180&c=7&r=0&o=5&pid=1.7",
    info: "我是第12张图片",
  },
  {
    url: "https://tse1-mm.cn.bing.net/th/id/OIP-C.r5nmhiLH9H0gTuRY4cZhiAHaHa?w=165&h=180&c=7&r=0&o=5&pid=1.7",
    info: "我是第13张图片",
  },
  {
    url: "https://tse4-mm.cn.bing.net/th/id/OIP-C.fEobO2mQrHIcyp564DolKQHaEK?w=315&h=180&c=7&r=0&o=5&pid=1.7",
    info: "我是第14张图片",
  },
  {
    url: "https://tse1-mm.cn.bing.net/th/id/OIP-C.JVWr8GO8VHavMm9yEyVwgQHaFj?w=229&h=180&c=7&r=0&o=5&pid=1.7",
    info: "我是第15张图片",
  },

  {
    url: "https://tse1-mm.cn.bing.net/th/id/OIP-C.j_0oZrDZX2B4DNYPqVcOswHaHa?w=172&h=180&c=7&r=0&o=5&pid=1.7",
    info: "我是第16张图片",
  },
  {
    url: "https://tse4-mm.cn.bing.net/th/id/OIP-C.byqRULijwzwRE2m3lN8uWwHaLH?w=122&h=184&c=7&r=0&o=5&pid=1.7",
    info: "我是第17张图片",
  },
  {
    url: "https://tse1-mm.cn.bing.net/th/id/OIP-C.7BuhSMPYLQqruILu8YFKKQHaH7?w=183&h=195&c=7&r=0&o=5&pid=1.7",
    info: "我是第18张图片",
  },
  {
    url: "https://tse1-mm.cn.bing.net/th/id/OIP-C.cCtz3qg4ewqQm1KM9V906wHaFI?w=265&h=183&c=7&r=0&o=5&pid=1.7",
    info: "我是第19张图片",
  },
];

4. 核心代码分析

1. 图片的宽度+图片的padding 得到每一项小容器的宽度

js 复制代码
const colWidth = computed(() => props.imgWidth + props.gap);

2. calcuCols 方法用来计算得到列数

外层容器的宽度 / 小容器的宽度 得到布局的列数

js 复制代码
const calcuCols = () => {
    const width = (container.value as HTMLDivElement).offsetWidth;
    return Math.max(Math.floor(width / colWidth.value), 2);
  };

3. preload 方法用来得到图片高度

使用loadedCount定位已加载图片的数量,防止滚动加载添加数据时,重复计算图片高度

对图片预加载,得到图片高度,为了防止失真,渲染高度_height = 图片宽度 * 图片宽高比

如果图片加载失败,可添加_error:true,渲染失败时图片的占位图

最后如果已加载的数量loadedCount等于数组list的长度,说明数据_height已全部处理好,可以进行渲染了

js 复制代码
const preload = () => {
  const list: Item[] = cloneDeep(props.list);
  list.forEach((item, index) => {
    if (index < loadedCount.value) return; // 只对新加载图片进行预加载
    let oImg = new Image();
    oImg.src = item.url;
    oImg.onload = oImg.onerror = (e: any) => {
      loadedCount.value++;

      // 预加载图片,计算图片容器的高
      list[index]._height =
        e.type === "load"
          ? Math.round(props.imgWidth * (oImg.height / oImg.width))
          : props.imgWidth;

      if (e.type == "error") {
        list[index]._error = true;
        console.log("图片加载失败", list[index]);
      }
      if (loadedCount.value === list.length) {
        //图片_height属性已添加,执行渲染
        imgs.value = list;
        isFirstLoad.value = false;
        nextTick(() => {
          loading.value = false;
          waterfall(); //在下一个钩子中,控制图片的位置
        });
      }
    };
  });
};

4. waterfall 方法用来确定图片的位置,进行布局

确定一个开始索引beginIndex,防止滚动加载时,重复计算图片的位置信息

当索引 i 小于列数时,说明是第一行,图片的top为0,left为索引*每一项小容器的宽度

当索引 i 大于列数时,说明非第一行,取出高度数组中最小值minHeight以及对应的下标minIndex,新图片的top就是minHeight,新图片的left就是minIndex*每一项小容器的宽度。并更新高度数组。

最后排列完后,更新beginIndex的值,下次有新数据添加时,从该下标开始。

js 复制代码
const waterfall = () => {
    const imgBoxEls = (scrollEl.value as HTMLDivElement).children;
    let top, left, height;
    if (beginIndex.value == 0) colsHeight.value = [];
    for (let i = beginIndex.value; i < imgs.value.length; i++) {
      height = (imgBoxEls[i] as HTMLDivElement).offsetHeight;
      if (i < cols.value) {
        //第一排,直接把高塞入数组
        colsHeight.value.push(height);
        top = 0;
        left = i * colWidth.value;
      } else {
        const minHeight = Math.min(...colsHeight.value); // 最低高低
        const minIndex = colsHeight.value.indexOf(minHeight); // 最低高度的索引
        top = minHeight;
        left = minIndex * colWidth.value;
        // 更新colsHeight,元素的高度加到最小高度上
        colsHeight.value[minIndex] = minHeight + height;
      }
      (imgBoxEls[i] as HTMLDivElement).style.left = left + "px";
      (imgBoxEls[i] as HTMLDivElement).style.top = top + "px";
    }
    beginIndex.value = imgs.value.length; // 排列完之后,新增图片从这个索引开始预加载图片和排列
  };

5. onScroll 方法用来监听触底事件

loading控制加载状态,如果加载时又触底,可忽略

如果触底触发scrollReachBottom事件,通知父组件添加数据

js 复制代码
const onScroll = () => {
    //如果正在预加载
    if (loading.value) return;
    const minHeight = Math.min(...colsHeight.value);
    if (scrollEl.value) {
      if (
        scrollEl.value.scrollTop + scrollEl.value.offsetHeight >
        minHeight - props.reachBottomDistance
      ) {
        loading.value = true;
        emit("scrollReachBottom");
      }
    }
  };

6. onResize 方法用来监听屏幕resize事件

如果resize后,列数不变,则不处理

如果列数变了,则重新获取所有图片的布局信息,并进行排列

js 复制代码
const onResize = () => {
  const old = cols.value;
  cols.value = calcCols();
  if (old === cols.value) return; // 列数不变直接退出
  beginIndex.value = 0; // 开始排列的元素索引
  waterfall(); //进行排列
};

5. Waterfall.vue组件完整代码

js 复制代码
<template>
  <div
    class="vue-waterfall-container"
    ref="container"
    :style="{
      width,
      height,
    }"
  >
    <div
      class="loading ball-beat"
      v-show="loading"
      :class="{ first: isFirstLoad }"
    >
      <div class="dot" v-for="(_, index) in 3" :key="index"></div>
    </div>
    <div class="vue-waterfall-scroll" ref="scrollEl">
      <div
        class="img-box"
        v-for="(item, index) in imgs"
        :class="['default-card-animation', { __err__: item._error }]"
        :key="index"
        :style="{
          padding: gap / 2 + 'px',
          width: colWidth + 'px',
        }"
        @click="handleClickImage(item)"
      >
        <div class="cardStyle">
          <div
            class="img-inner-box"
            :style="{
              width: imgWidth + 'px',
              height: item._height + 'px',
            }"
          >
            <img :src="item.url" />
          </div>
          <div class="img-box-footer">
            <slot :data="item" />
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import {
  ref,
  computed,
  watch,
  nextTick,
  onMounted,
  onBeforeUnmount,
} from "vue";
import { cloneDeep } from "lodash-es";

type Item = {
  url: string;
  info: string;
  [key: string]: any;
};

const props = withDefaults(
  defineProps<{
    width?: string;
    height?: string;
    imgWidth?: number;
    gap?: number;
    list: Item[];
    reachBottomDistance?: number;
  }>(),
  {
    width: "100%",
    height: "100%",
    imgWidth: 240,
    gap: 20,
    reachBottomDistance: 20,
  }
);

const emit = defineEmits<{
  scrollReachBottom: [value?: undefined];
  onClick: [value: Item];
}>();

const loading = ref(true); // 正在预加载中,显示加载动画
const isFirstLoad = ref(true); //首次加载
const imgs = ref<Item[]>([]); // 有height字段的图片列表
const cols = ref(0); // 列数
const loadedCount = ref(0); //大于此值为新增图片
const beginIndex = ref(0); // 大于此值为要新排列的图片
const colsHeight = ref<number[]>([]); //每列的高度,用于寻找最小高度

const container = ref<HTMLDivElement | null>(null);
const scrollEl = ref<HTMLDivElement | null>(null);

const colWidth = computed(() => props.imgWidth + props.gap);

const calcCols = () => {
  const width = (container.value as HTMLDivElement).offsetWidth;
  return Math.max(Math.floor(width / colWidth.value), 2);
};

const preload = () => {
  const list: Item[] = cloneDeep(props.list);
  list.forEach((item, index) => {
    if (index < loadedCount.value) return; // 只对新加载图片进行预加载
    let oImg = new Image();
    oImg.src = item.url;
    oImg.onload = oImg.onerror = (e: any) => {
      loadedCount.value++;

      // 预加载图片,计算图片容器的高
      list[index]._height =
        e.type === "load"
          ? Math.round(props.imgWidth * (oImg.height / oImg.width))
          : props.imgWidth;

      if (e.type == "error") {
        list[index]._error = true;
        console.log("图片加载失败", list[index]);
      }
      if (loadedCount.value === list.length) {
        //图片_height属性已添加,执行渲染
        imgs.value = list;
        isFirstLoad.value = false;
        nextTick(() => {
          loading.value = false;
          waterfall(); //在下一个钩子中,控制图片的位置
        });
      }
    };
  });
};

const waterfall = () => {
  const imgBoxEls = (scrollEl.value as HTMLDivElement).children;
  let top, left, height;
  if (beginIndex.value == 0) colsHeight.value = [];
  for (let i = beginIndex.value; i < imgs.value.length; i++) {
    height = (imgBoxEls[i] as HTMLDivElement).offsetHeight;
    if (i < cols.value) {
      //第一排,直接把高塞入数组
      colsHeight.value.push(height);
      top = 0;
      left = i * colWidth.value;
    } else {
      const minHeight = Math.min(...colsHeight.value); // 最低高低
      const minIndex = colsHeight.value.indexOf(minHeight); // 最低高度的索引
      top = minHeight;
      left = minIndex * colWidth.value;
      // 更新colsHeight,元素的高度加到最小高度上
      colsHeight.value[minIndex] = minHeight + height;
    }
    (imgBoxEls[i] as HTMLDivElement).style.left = left + "px";
    (imgBoxEls[i] as HTMLDivElement).style.top = top + "px";
  }
  beginIndex.value = imgs.value.length; // 排列完之后,新增图片从这个索引开始预加载图片和排列
};

const onScroll = () => {
  //如果正在预加载
  if (loading.value) return;
  const minHeight = Math.min(...colsHeight.value);
  if (scrollEl.value) {
    if (
      scrollEl.value.scrollTop + scrollEl.value.offsetHeight >
      minHeight - props.reachBottomDistance
    ) {
      loading.value = true;
      emit("scrollReachBottom");
    }
  }
};

const onResize = () => {
  const old = cols.value;
  cols.value = calcCols();
  if (old === cols.value) return; // 列数不变直接退出
  beginIndex.value = 0; // 开始排列的元素索引
  waterfall(); //进行排列
};

const handleClickImage = (value: Item) => {
  emit("onClick", value);
};

watch(props.list, preload);

onMounted(() => {
  preload();
  cols.value = calcCols(); //根据容器宽度和图片宽度,得到列数
  window.addEventListener("resize", onResize);
  (scrollEl.value as HTMLDivElement).addEventListener("scroll", onScroll);
});

onBeforeUnmount(() => {
  window.removeEventListener("resize", onResize);
  (scrollEl.value as HTMLDivElement).removeEventListener("scroll", onScroll);
});
</script>

<style lang="scss">
.vue-waterfall-container {
  position: relative;

  .vue-waterfall-scroll {
    position: relative;
    width: 100%;
    height: 100%;
    overflow-x: hidden;
    overflow-y: scroll;
  }

  .img-box {
    position: absolute;
    box-sizing: border-box;

    //卡片出来时的动画
    &.default-card-animation {
      animation: show-card 0.4s;
      transition: left 0.6s, top 0.6s;
      transition-delay: 0.1s;
    }
    @keyframes show-card {
      0% {
        transform: scale(0.5);
      }
      100% {
        transform: scale(1);
      }
    }
    .cardStyle {
      box-shadow: rgba(0, 0, 0, 0.2) 0px 3px 1px -2px,
        rgba(0, 0, 0, 0.14) 0px 2px 2px 0px, rgba(0, 0, 0, 0.12) 0px 1px 5px 0px;
      border-radius: 4px;
      img {
        width: 100%;
        display: block;
        border-radius: 4px 4px 0 0;
      }
    }

    &.__err__ {
      .img-inner-box {
        background-image: url(data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAAeAAD/4QMraHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjMtYzAxMSA2Ni4xNDU2NjEsIDIwMTIvMDIvMDYtMTQ6NTY6MjcgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjk1M0JCM0QwNkVFNDExRThCNTJCQUQ2RDFGQzg0NzIxIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjk1M0JCM0NGNkVFNDExRThCNTJCQUQ2RDFGQzg0NzIxIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDUzYgKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6QTYwRUMyMDE2RUUzMTFFOEJCRTU5RTFDODg1ODgwMjYiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6QTYwRUMyMDI2RUUzMTFFOEJCRTU5RTFDODg1ODgwMjYiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAQCwsLDAsQDAwQFw8NDxcbFBAQFBsfFxcXFxcfHhcaGhoaFx4eIyUnJSMeLy8zMy8vQEBAQEBAQEBAQEBAQEBAAREPDxETERUSEhUUERQRFBoUFhYUGiYaGhwaGiYwIx4eHh4jMCsuJycnLis1NTAwNTVAQD9AQEBAQEBAQEBAQED/wAARCACRAJEDASIAAhEBAxEB/8QAZQAAAwEBAQAAAAAAAAAAAAAAAAIDAQQHAQEAAAAAAAAAAAAAAAAAAAAAEAACAQMDBAEFAAMBAAAAAAAAAQIRMQMhQRJRYYEycZHBIkITsdFSYhEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8A9AAAAAMFnNQWt9kAwkssVbV9CTnKb10XQVtLSyAd5ZuzURW27yfhmX9RlDI+wD4Vf/ZVi41SKCdeNI3YEnOXJtOiBZZr/wBGcMi2Ft7AXjli76PoOcqael0Mpyg9NV0A6QEhNTWl90MBoAAAAAAGGiTlxjXfZAZkycdEqyI3q26sOrd92CTm6bbsA1boh1i3lqPGKiqIZAYklYHY0x6tIDY2B3NdjEBgNJ3NACTxbx0E1TozoYsoqSowI2o06MtjyctGqSItODptsw6NX2YHSaJCXKNd90OAAAAYznnLnJvZWK5pUjRXloiNdwCjk0l9S0YqKohcSpGrvLUcDUBLK23x23FWNtVSAuHch/KXQP5PoBdqq77GJ1XfczFVKjVBcuPk6rXqBQCH8n0D+UugFwZD+bV1oNif5OOzQDyipKjI0cW0/qXYmVVjVXjqAkZcHXZ3OhHNXcthlWNHeOjAoAABDLKs6bISlWl1Busm+42Jfm30At2BmI1gRy+3gpD1XwTy+3gpH1QDASyt8uKdFuJRw1iwOjdBKy+TIutGbK3kAAxtJNuwiywdmA7s/glj9/BV04unQli9l8AWDsBjAhSja6D4pUnTZmZV+afUVOkk+4HUBgAc0bD4v2+fsJGw+L9vn7AVQMEDAjl9vBSHqvgnl9vBSHqvgDJwbfJC8JPSlEO5wTo3qMnUDEqNGz0jXoD08BRSXyBB/m6u2xvFPQ1qjoDAVNxqv1ZuJUnT5BGw9/AFQYAwJZf1+fsJKw+X9fn7CSsB0AAAc7VG13GxP82uoZFSbezFWkkwOgGCBgRy15adDZTaioq7QZPfwZQDFFfPc2MnB0vE1AwCc+WituPjaS4kzU6agPkS9hEVeqJJU0AAh7+ACHv4AqAAwI5X+aXQVKrS7g3WTY2ONZp7IC9AAAJ5lWNf+dSV1XqdL1VGc8lxk47bAUxyqqO60GIpuL5LyuxZNNVVtmAmVfkpbbi1RYAI1QVRYAI1QJ1aRYzdAbJ8Y1I1RZggI1SNxL8uW1NCoAYxckqKiu9Bm0lV23ZFtyfJ+F2Ayyr0K4VSNf8ArUnFcpKO250LRUQABoAYLkhzXdWHMA5u26uNGXF9tx8uOusfYlbTcC6aaqrdTTnTlHVfQrHJF6PRsBwAAC5i9vg0xbgaAIAAxtJVduosskVotWiTcpav6ANKXJ9the27sF9NyuLHTWXsA2OHBd3cYDQAAAAAAAwSeNS1syhgHNRxdJfUK10ujoaTuJLCrp0Amm1Ztdhv6z3Sfkxwmu4leqoBT+1P1f8AkZy4469daEaopllRqPRAH9ZOyp5Fbbu2+xmuyGUJvsAtaaWQUcnSP1Kxwq7dR0krALDGo63Y4GgAAAAAAAAAAAAAAAAAshJ7AAGK6B3YABsNx4gADAAAAAAAAAAAAAf/2Q==);
        background-repeat: no-repeat;
        background-position: center;
        background-size: 50% 50%;

        & > img {
          display: none;
        }
      }
    }
  }

  > .loading {
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
    bottom: 6px;
    z-index: 999;

    &.first {
      bottom: 50%;
      transform: translate(-50%, 50%);
    }

    &.ball-beat {
      > .dot {
        vertical-align: bottom;
        background-color: #4b15ab;
        width: 12px;
        height: 12px;
        border-radius: 50%;
        margin: 3px;
        animation-fill-mode: both;
        display: inline-block;
        animation: loading 0.7s 0s infinite linear;

        &:nth-child(2n-1) {
          animation-delay: 0.35s;
        }
      }
    }
    @keyframes loading {
      50% {
        opacity: 0.2;
        transform: scale(0.75);
      }
      100% {
        opacity: 1;
        transform: scale(1);
      }
    }
  }
}
</style>

6. 父组件App.vue调用

js 复制代码
<template>
  <Waterfall
    :list="list"
    @scrollReachBottom="handleReachBottom"
    :imgWidth="300"
    @onClick="handleClick"
    :width="'100vw'"
    :height="'100vh'"
  >
    <!-- 自定义底部文案 -->
    <template v-slot="{ data }">
      <div style="width: 100%; height: 50px; overflow: hidden">
        <div>{{ data.info }}</div>
      </div>
    </template>
  </Waterfall>
</template>

<script setup lang="ts">
import { reactive } from "vue";
import Waterfall from "@/components/Waterfall.vue";
import imgs from "./imgs";

type ImageType = (typeof imgs)[0];

const list = reactive(imgs);

//触底之后添加数据
const handleReachBottom = () => {
  list.push(...imgs);
};

//图片的点击事件
const handleClick = (item: ImageType) => {
  console.log(item);
};
</script>

<style>
body {
  margin: 0;
}
</style>

7. 最后

本文展示了PC端使用JS实现瀑布流布局的核心思想,只要明白了核心思想,后期再加上防抖节流以及移动端等适配的业务代码就不难了。

希望各位点赞+收藏支持下,能被各位认可,也是我创作的动力。

项目demo Github地址:github.com/cwjbjy/Wate...

相关推荐
rhythmcc19 分钟前
【GoogleChrome】在开发者工具中修改js、css并生效
开发语言·javascript·css
小宇python41 分钟前
Web应用安全入门:架构搭建、漏洞分析与HTTP数据包处理
前端·安全·架构
珹洺1 小时前
从 HTML 到 CSS:开启网页样式之旅(二)—— 深入探索 CSS 选择器的奥秘
前端·javascript·css·网络·html
竺梓君1 小时前
JavaScript内存管理机制解析
javascript·全栈
liro1 小时前
CSS盒子模型
前端
热爱前端的小张1 小时前
包管理器
前端
冰冻果冻1 小时前
vue--制作随意滑动的相册
前端·javascript·vue.js
GISer_Jing2 小时前
前端测试工具(Jest与Mock)
前端·测试工具
licy__2 小时前
HTML 元素类型介绍
前端·css·html
谢尔登2 小时前
【Next】路由处理
服务器·javascript·css