瀑布流布局的实现:绝对定位 + 3D 转换布局 VS CSS Grid

176 阅读9分钟

最近面试的时候,被提问到了如何实现瀑布流布局,因之前业务上接触实现过,遂将Grid布局的大致实现思路进行阐述,然而面试官不完全赞同,如果出现大量不定高的数据该如何处理,希望我通过JS动态计算的角度来解释,经过一番交流之后,勉强回答一部分,于是我打算对这两种实现方法进行梳理与比较,分析两者的优劣与适用场景,希望能更深入理解瀑布流布局。

布局的定义与基本概念

Grid布局

定义: CSS Grid 布局是一种基于二维网格的布局系统,允许开发者通过行和列来定义元素的布局。每个元素在网格中占据指定的区域,能够自动根据内容调整位置。

适用场景: Grid 布局主要用于创建复杂的、固定的布局结构,如多列文本、图像展示等。特别适合需要精确对齐、响应式设计和一致网格的场景。

绝对定位 + translate3d布局

定义: 这种布局方法使用 position: absolute 使元素脱离文档流,可以通过 transform: translate3d() 进行三维平移,利用 GPU 加速来高效地处理布局和位置的变化,通常用于动态内容加载和需要动画效果的场景。

适用场景: 这种布局方式广泛应用于需要流式排列且动态调整位置的场景,比如瀑布流布局,特别适用于大规模、异步加载的内容。

布局的实现思路与代码

以下主要从实现思路和注意要点两方面对这两种布局的实现思路进行阐述并展示其实现效果,父组件调用的逻辑与传参基本一致,以下展示父组件调用逻辑:

<template>
  <div class="app">
    <div class="container" ref="fContainerRef">
      <FSWaterFallGrid :bottom="20" :column="column" :gap="10" :page-size="20" :request="getData">
        <template #item="{ index: index }">
          <div
            class="card-box"
            :style="{
              background: colorArr[index % (colorArr.length - 1)],
            }"
          >
          </div>
        </template>
      </FSWaterFallGrid>
    </div>
  </div>
</template>

<script setup lang="ts">
import data1 from "./config/data1.json";
import data2 from "./config/data2.json";
import FsWaterfall from "./components/FSWaterFall.vue"; // 绝对定位 + 3D 转换布局
import FSWaterFallGrid from "./components/FSWaterFallGrid.vue" // Grid布局
import type { ICardItem } from "./utils/types";
import { onMounted, onUnmounted, ref } from "vue";

const colorArr = ["#409eff", "#67c23a", "#e6a23c", "#f56c6c", "#909399"];

const fContainerRef = ref<HTMLDivElement | null>(null);
const column = ref(4);
const fContainerObserver = new ResizeObserver((entries) => {
  changeColumn(entries[0].target.clientWidth);
});

const changeColumn = (width: number) => {
  if (width > 960) {
    column.value = 5;
  } else if (width >= 690 && width < 960) {
    column.value = 4;
  } else if (width >= 500 && width < 690) {
    column.value = 3;
  } else {
    column.value = 2;
  }
};

onMounted(() => {
  fContainerRef.value && fContainerObserver.observe(fContainerRef.value);
});

onUnmounted(() => {
  fContainerRef.value && fContainerObserver.unobserve(fContainerRef.value);
});

const list1: ICardItem[] = data1.data.items.map((i) => ({
  id: i.id,
  url: i.note_card.cover.url_pre,
  width: i.note_card.cover.width,
  height: i.note_card.cover.height,
}));
const list2: ICardItem[] = data2.data.items.map((i) => ({
  id: i.id,
  url: i.note_card.cover.url_pre,
  width: i.note_card.cover.width,
  height: i.note_card.cover.height,
}));

const list = [...list1, ...list2];

const getData = (page: number, pageSize: number) => {
  return new Promise<ICardItem[]>((resolve) => {
    setTimeout(() => {
      resolve(list.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize));
    }, 1000);
  });
};
</script>

<style scoped lang="scss">
.app {
  width: 100vw;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  .container {
    width: 100%;
    height: 100%;
    margin: 2%;
  }
  .card-box {
    position: relative;
    width: 100%;
    height: 100%;
    border-radius: 10px;
  }
}
</style>

Grid布局

实现思路:
  • 容器: 使用 display: grid 创建一个网格容器,设定 grid-template-columns 来定义列数,grid-gap 来控制元素之间的间距。
  • : 每一个瀑布流项是一个网格项,根据需要设置其 grid-column 和 grid-row,自动排列。
  • 响应式设计:Grid 布局可以通过媒体查询调整列数,确保在不同屏幕尺寸下有良好的布局表现。
  • 代码示例
.fs-waterfall-container {
  width: 100%;
  height: 100%;
  overflow-y: scroll;
  overflow-x: hidden;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}                                                         

.fs-waterfall-list {
  display: grid;
  gap: 10px;
  grid-auto-rows: 10px; /* 设定最小行高度单位,匹配 JavaScript 计算的行数 */
}
注意要点:
  • grid-row-end解决不同卡片高度:
const gridRowSpan = Math.ceil(cardHeight / state.rowHeightUnit); // 计算需要占用的行数
state.cardPos[index] = { gridRowSpan };
:style="{ gridRowEnd: `span ${state.cardPos[index]?.gridRowSpan || 1}` }"
  • 节流滚动,解决异步加载数据错乱的情况:
具体代码:
export interface IWaterFallProps {
    gap: number; // 卡片间隔
    column: number; // 瀑布流列数
    bottom: number; // 距底距离(触底加载更多)
    pageSize: number;
    request: (page: number, pageSize: number) => Promise<ICardItem[]>;
}

export interface ICardItem {
    id: string | number;
    url?: string;
    width: number;
    height: number;
    [key: string]: any;
}

// 单个卡片计算的位置信息,设置样式
export interface ICardPos {
    width?: number;
    height?: number;
    x?: number;
    y?: number;
    gridRowSpan?: number;
}
<template>
  <div class="fs-waterfall-container" ref="containerRef" @scroll="handleScroll">
    <div
      class="fs-waterfall-list"
      :style="{
        gridTemplateColumns: `repeat(${props.column}, 1fr)`
      }"
    >
      <div
        class="fs-waterfall-item"
        v-for="(item, index) in state.cardList"
        :key="item.id"
        :style="{
          gridRowEnd: `span ${state.cardPos[index]?.gridRowSpan || 1}`, // 动态渲染item的高度
        }"
      >
        <slot name="item" :item="item" :index="index"></slot>
      </div>
    </div>
  </div>
</template>
  
<script setup lang="ts">
  import { onMounted, reactive, ref } from "vue";
  import { rafThrottle } from "../utils/utils";
  import type { IWaterFallProps, ICardItem, ICardPos } from "../utils/types";
  
  const props = defineProps<IWaterFallProps>();
  
  defineSlots<{
    item(props: { item: ICardItem; index: number }): any;
  }>();
  
  const containerRef = ref<HTMLDivElement | null>(null);
  
  const state = reactive({
    isFinish: false,
    loading: false,
    page: 1,
    column: 1, // 动态列数
    cardWidth: 0,
    rowHeightUnit: 10, // 每行的高度单位
    cardList: [] as ICardItem[],
    cardPos: [] as ICardPos[],
  });
  
  const getCardList = async (page: number, pageSize: number) => {
    if (state.isFinish) return;
    state.loading = true;
    const list = await props.request(page, pageSize);
    state.page++;
    if (!list.length) {
      state.isFinish = true;
      alert('数据加载完毕')
      return;
    }
    state.cardList = [...state.cardList, ...list];// 后续加载的数据进行合并
    computedCardPos(state.cardList);
    state.loading = false;
  };
  
  const computedCardPos = (list: ICardItem[]) => {
    list.forEach((item, index) => {
      const cardHeight = Math.floor((item.height * state.cardWidth) / item.width);
      const gridRowSpan = Math.ceil(cardHeight / state.rowHeightUnit); // 计算需要占用的行数
      state.cardPos[index] = { gridRowSpan };
    });
  };
  
  const init = () => {
    if (containerRef.value) {
      const containerWidth = containerRef.value.clientWidth;
      state.cardWidth = Math.floor(containerWidth / props.column); // 每个卡片的宽度
      getCardList(state.page, props.pageSize);
    }
  };
  
  const handleScroll = rafThrottle(() => {
    const { scrollTop, clientHeight, scrollHeight } = containerRef.value!;
    // 满足触底条件后,进行数据加载
    if (scrollHeight - clientHeight - scrollTop <= props.bottom) {
      !state.loading && getCardList(state.page, props.pageSize);
    }
  });
  
  onMounted(() => {
    init(); // 初始化
  });
</script>
  
<style scoped lang="scss">
  .fs-waterfall-container {
    width: 100%;
    height: 100%;
    overflow-y: scroll;
    overflow-x: hidden;
  }
  
  .fs-waterfall-list {
    display: grid;
    gap: 10px;
    grid-auto-rows: 10px; /* 设定最小行高度单位,匹配 JavaScript 计算的行数 */
  }
  
  .fs-waterfall-item {
    width: 100%;
    box-sizing: border-box;
    transition: all 0.3s;
  }
</style>

绝对定位 + translate3d 布局

实现思路:
  • 容器: 使用 position: relative 为父元素提供定位上下文。
  • 项:每一个瀑布项使用 position: absolute 定位,根据计算的 X 和 Y 坐标通过 transform: translate3d() 来调整位置。
  • 动态加载:随着滚动事件触发,动态计算每一个元素的位置,并通过 translate3d() 在不触发重排的情况下进行平移,提升性能。
  • 优化: 通过 requestAnimationFrame 或 debounce 等方式控制滚动事件的触发频率,避免过度渲染。
.waterfall-container {
  position: relative;
  overflow: scroll;
}

.waterfall-item {
  position: absolute;
  transition: transform 0.3s;
}

注意要点:
  • 计算出当前高度最小的列
const minColumn = computed(() => {
  let minIndex = -1,
    minHeight = Infinity;

  state.columnHeight.forEach((currentHeight, index) => {
    if (currentHeight < minHeight) {
      minHeight = currentHeight;
      minIndex = index;
    }
  });

  return {
    minIndex,
    minHeight,
  };
});
  • 初始化或加载新数据时,需对新数据进行动态计算每一张卡片需要插入的具体位置信息。
const computedCardPos = (list: ICardItem[]) => {
  list.forEach((item, index) => {
    // 十字交叉法,算出卡片高度
    const cardHeight = Math.floor((item.height * state.cardWidth) / item.width);
    // 区分第一行展示还是第n行展示,注意卡片之间的间隙
    if (index < props.column && state.cardList.length <= props.pageSize) {
      state.cardPos.push({
        width: state.cardWidth,
        height: cardHeight,
        x: index ? index * (state.cardWidth + props.gap) : 0,
        y: 0,
      });
      state.columnHeight[index] = cardHeight + props.gap;
    } else {
      const { minIndex, minHeight } = minColumn.value;
      state.cardPos.push({
        width: state.cardWidth,
        height: cardHeight,
        x: minIndex ? minIndex * (state.cardWidth + props.gap) : 0,
        y: minHeight,
      });
      state.columnHeight[minIndex] += cardHeight + props.gap;
    }
  });
};
  • 根据列数,动态计算卡片的宽度。
  • 根据窗口视图进行响应,动态计算卡片宽度与位置信息。
具体代码:
<template>
  <div class="fs-waterfall-container" ref="containerRef" @scroll="handleScroll">
    <div class="fs-waterfall-list">
      <div
        class="fs-waterfall-item"
        v-for="(item, index) in state.cardList"
        :key="item.id"
        :style="{
          width: `${state.cardPos[index].width}px`,
          height: `${state.cardPos[index].height}px`,
          transform: `translate3d(${state.cardPos[index].x}px, ${state.cardPos[index].y}px, 0)`,
        }"
      >
        <slot name="item" :item="item" :index="index"></slot>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { debounce, rafThrottle } from "../utils/utils";
import type { IWaterFallProps, ICardItem, ICardPos } from "../utils/types";

const props = defineProps<IWaterFallProps>();

defineSlots<{
  item(props: { item: ICardItem; index: number }): any;
}>();

const containerRef = ref<HTMLDivElement | null>(null);

const resizeObserver = new ResizeObserver(() => {
  handleResize();
});

const state = reactive({
  isFinish: false,
  loading: false,
  page: 1,
  cardWidth: 0,
  cardList: [] as ICardItem[],
  cardPos: [] as ICardPos[],
  columnHeight: new Array(props.column).fill(0) as number[],
});

const minColumn = computed(() => {
  let minIndex = -1,
    minHeight = Infinity;

  state.columnHeight.forEach((currentHeight, index) => {
    if (currentHeight < minHeight) {
      minHeight = currentHeight;
      minIndex = index;
    }
  });

  return {
    minIndex,
    minHeight,
  };
});

watch(
  () => props.column,
  () => {
    handleResize();
  }
);

const getCardList = async (page: number, pageSize: number) => {
  if (state.isFinish) return;
  state.loading = true;
  const list = await props.request(page, pageSize);
  state.page++;
  if (!list.length) {
    state.isFinish = true;
    alert('数据加载完毕')
    return;
  }
  state.cardList = [...state.cardList, ...list];
  computedCardPos(list);
  state.loading = false;
};

const computedCardPos = (list: ICardItem[]) => {
  list.forEach((item, index) => {
    // 十字交叉法,算出卡片高度
    const cardHeight = Math.floor((item.height * state.cardWidth) / item.width);
    // 区分第一行展示还是第n行展示,注意卡片之间的间隙
    if (index < props.column && state.cardList.length <= props.pageSize) {
      state.cardPos.push({
        width: state.cardWidth,
        height: cardHeight,
        x: index ? index * (state.cardWidth + props.gap) : 0,
        y: 0,
      });
      state.columnHeight[index] = cardHeight + props.gap;
    } else {
      const { minIndex, minHeight } = minColumn.value;
      state.cardPos.push({
        width: state.cardWidth,
        height: cardHeight,
        x: minIndex ? minIndex * (state.cardWidth + props.gap) : 0,
        y: minHeight,
      });
      state.columnHeight[minIndex] += cardHeight + props.gap;
    }
  });
};

const init = () => {
  if (containerRef.value) {
    const containerWidth = containerRef.value.clientWidth;
    state.cardWidth = (containerWidth - props.gap * (props.column - 1)) / props.column;
    getCardList(state.page, props.pageSize);
    resizeObserver.observe(containerRef.value);
  }
};

const handleScroll = rafThrottle(() => {
  const { scrollTop, clientHeight, scrollHeight } = containerRef.value!;
  const bottom = scrollHeight - clientHeight - scrollTop;
  if (bottom <= props.bottom) {
    !state.loading && getCardList(state.page, props.pageSize);
  }
});

const handleResize = debounce(() => {
  const containerWidth = containerRef.value!.clientWidth;
  state.cardWidth = (containerWidth - props.gap * (props.column - 1)) / props.column;
  state.columnHeight = new Array(props.column).fill(0);
  state.cardPos = [];
  computedCardPos(state.cardList);
});

onMounted(() => {
  init();
});

onUnmounted(() => {
  containerRef.value && resizeObserver.unobserve(containerRef.value);
});
</script>

<style scoped lang="scss">
.fs-waterfall {
  &-container {
    width: 100%;
    height: 100%;
    overflow-y: scroll;
    overflow-x: hidden;
  }

  &-list {
    width: 100%;
    position: relative;
  }
  &-item {
    position: absolute;
    left: 0;
    top: 0;
    box-sizing: border-box;
    transition: all 0.3s;
  }
}
</style>

效果展示

布局的特点与差异

以下,我将从布局控制,性能优化,适用场景,可维护性和兼容性五个方面进行更具体的比较。

布局控制

Grid 布局:

  • 灵活性: CSS Grid 允许开发者通过定义 来控制布局,同时支持 自动化布局。通过 grid-template-columns 和 grid-template-rows 可以轻松定义网格系统。
  • 元素对齐: Grid 布局天然支持元素的对齐,包括水平对齐(justify-items)和垂直对齐(align-items)。对齐操作很简便。
  • 自动排列: 元素会根据网格大小自动排列,无需手动计算元素的具体位置。
  • 子项排列: 当需要更复杂的布局时,Grid 的 grid-column 和 grid-row 能精确控制元素占据的网格区域,甚至可以使元素跨多个列和行。

Absolute + translate3d 布局:

  • 灵活性: 通过 position: absolutetransform: translate3d(),每个元素的位置由开发者手动控制,适用于更加动态和灵活的布局方案。
  • 元素对齐: 没有内建的对齐系统,所有位置都必须通过计算手动设置,开发者需要动态调整每个元素的位置,尤其在瀑布流这种不规则布局中。
  • 自动排列: 不具备自动排列能力,需要通过 JavaScript 动态计算每个元素的位置(例如:列宽、高度等),非常依赖脚本。

性能优化

Grid 布局:

  • 性能:Grid 布局是一个 静态布局,适用于内容量相对较少,且变化不频繁的场景。对于需要频繁调整元素位置的复杂页面,可能会遇到性能瓶颈。
  • 渲染效率:Grid 布局会触发页面重排(reflow)和重绘(repaint)。当元素位置调整时,所有相关元素的布局需要重新计算,因此大规模的内容动态更新时可能会带来性能问题。
  • 硬件加速:Grid 布局不会自动使用硬件加速(GPU 加速),特别是在需要动画和动态滚动时,渲染性能较差。

Absolute + translate3d 布局:

  • 性能:translate3d 使用了 GPU 加速,因此它可以在频繁的动画或位置调整中保持较高的性能,尤其是在页面滚动、元素更新和动画过程中。
  • 渲染效率:由于 translate3d 是一种 合成层渲染,它不会触发重排(重新构建渲染树),只会触发重绘(更新元素的外观‌)。这意味着在频繁的动态操作中,性能表现更为优越,适用于大规模的异步加载和动态更新。
  • 硬件加速:translate3d 实际上能够利用硬件加速,使得在大量元素动态更新时,页面能够更流畅地渲染。

可维护性

Grid 布局:

  • 理解和维护:Grid 布局的语法简洁,元素的布局和对齐方式都可以通过 CSS 直接控制,维护起来非常直观。
  • 代码量:Grid 布局不需要借助 JavaScript 来控制布局,所有布局都通过纯 CSS 实现,减少了维护的复杂度。

Absolute + translate3d 布局:

  • 理解和维护:absolute + translate3d 布局需要通过 JavaScript 来动态计算元素的位置,随着页面内容的变化,需要不断更新位置。这使得代码变得复杂且难以维护。
  • 代码量:动态计算位置、监听滚动事件、管理多个列的高度等,都会增加开发和维护成本。

兼容性

Grid 布局:

  • 兼容性问题:虽然现代浏览器广泛支持 CSS Grid,但 IE11 及以下的浏览器不支持,可能需要在老旧浏览器中做兼容处理。
  • 前端框架支持:现代前端框架(如 React、Vue 等)与 Grid 布局兼容性良好,开发者可以更轻松地实现响应式布局。

Absolute + translate3d 布局:

  • 兼容性问题:absolute + translate3d 是基于 CSS 和 JavaScript 实现的,所有现代浏览器都支持这一组合。即便是 IE11 等老旧浏览器,基本也支持 absolute 和 transform。
  • 前端框架支持:同样地,这种方法与现代前端框架也高度兼容,但由于涉及到 JavaScript 动态控制,可能会带来更多的代码复杂性。

总结与比较

特性Grid布局Absolute + translate3d布局
性能渲染较重,容易触发重排更好性能,避免重排,使用 GPU 加速。
响应式支持会自适应窗口宽度需要动态计算卡片宽度,来自适应宽度
可维护性简单、易于理解,低维护成本复杂,需要手动管理位置,维护成本高
兼容性现代浏览器支持良好,老浏览器兼容性差支持广泛,兼容性好,但可能需要 JavaScript 控制
布局控制灵活的网格系统,自动布局手动计算位置,适合不规则布局
适用场景固定布局、流式布局、响应式设计动态内容加载、大规模异步更新、瀑布流布局

参考资料

女友看了小红书的pc端后,问我这个瀑布流的效果是怎么实现的?

最强大的 CSS 布局 —— Grid 布局

「中高级前端」干货!深度解析瀑布流布局