瀑布流優化:我把小紅書的瀑布流虛擬列表撕出來了

扯皮

接上篇瀑布流內容,我仔細研究發現小紅書的首頁佈局並不是簡單的瀑布流,所以停下手邊的遊戲開始打開 F12 慢慢查看🤔

不知不覺忘記了女朋友跟我發的小紅書鏈接具體內容,一頭紮在了小紅書的首頁,快半小時過去纔想起忘來給女朋友回消息了😇

當我反應過來趕緊發消息後,發現兩極反轉,這次換成我被冷落了😶😶😶

嘖嘖嘖,如今女朋友已經成爲了前女友,我們也已經分手有將近兩年時間了,記得分手半年之後身邊的朋友推薦我讀一本書:《非暴力溝通》,說能夠加強自己的溝通能力,雖然以後會成爲碼農但人與人之間的溝通還是需要的,所以提前學習一下溝通技巧

讀完之後發現自己純粹是一個🤡,書中舉的反例都讓哥們踩完了,而且完美的應用到了女友身上🤡🤡🤡

如今寫最近兩篇文章時腦子不斷浮現以前的日子,回想起來自己真是鐵直男啊,不過還好前女友和我都是初戀,雙方彼此還是比較包容的,都是沒談過戀愛的傻瓜罷了

不過紙包不住火,初戀雖然美好,但是沒有任何戀愛經驗,女方矯情一些,男方再直男一些,直接就寄了😑

感覺大學的戀愛就是一句話:一個敢說,一個敢信...

只能說一場戀愛下來最大的成長就是讓我踩了不少關於女生的坑,也感受到了年輕時代愛情的美好,同時掌握了一些溝通的技巧吧😁

不知道以後如果還有機會的話同一個坑自己會不會長記性呢😔😔😔

正文

這次扯的無關內容有點多哈,不過都是有感而發😜

終於來到了關於虛擬列表的最終篇:「使用 Vue3 + TS 實現瀑布流虛擬列表」,其實之前的幾篇文章可以說都是爲了這一篇做鋪墊,沒想到會有這麼多人看😂,其實我當時的想法是希望這篇文章閱讀量高一些來着...😶

如果你還不瞭解虛擬列表相關實現的話,請出門左轉👇:

定高的虛擬列表會了,那不定高的...... 哈,我也會!看我詳解一波!🤪🤪🤪 - 掘金 (juejin.cn)(https://juejin.cn/post/7317820788546977811)

面試官:能否用原生 JS 手寫一個虛擬列表... 啊?你還真能寫啊? - 掘金 (juejin.cn)(https://juejin.cn/post/7309786535934869504)

如果你還不瞭解瀑布流實現的話,請出門右轉👇:

女友看了小紅書的 pc 端後,問我這個瀑布流的效果是怎麼實現的?🤔 - 掘金 (juejin.cn)(https://juejin.cn/post/7322655035699396660)

本文不再去額外講解兩者的具體實現原理,所以如果你都瞭解了,那請進來坐,我們來一點一點分析瀑布流虛擬列表的實現過程🤗

開始之前還是想聲明一下:我不可能實現的和小紅書的瀑布流虛擬列表一模一樣,因爲我也沒有扒過它的源碼不知道它的具體實現方案是怎樣的,但是做出瀑布流虛擬列表的效果還是可以的🤗,爭取最終實現的效果和小紅書保持一致吧

以及 「本文實現只是一個 demo 級別,僅提供一個實現思路,切勿隨便在真實業務場景裏使用」 ❗ ❗ ❗

淺談瀑布流虛擬列表

在寫代碼之前還是簡單講講瀑布流虛擬列表的優勢,它屬於 「瀑布流的優化方案」

我們知道瀑布流每張卡片都需要計算佈局,隨着你滾動加載更多在視圖上展示的 DOM 數量也會越來越多,那麼帶來的問題就是出現滾動卡頓掉幀的情況

且假如你的視圖上已經有 1000 個卡片了,如果現在更改視口觸發迴流後會直接來 1000 次 DOM 操作計算位置並佈局,這無疑帶來巨大的性能損耗

而瀑布流虛擬列表結合了虛擬列表的特性,保證在視圖上真實渲染的 DOM 只控制在容器範圍內,而其他數據只存儲在內存當中,當進行滾動時根據每張卡片計算的位置來控制顯隱實現 "「有限的 DOM 來加載無限的數據」" 的效果

當然也並不是用到瀑布流就無腦想着虛擬列表進行優化,任何技術始終會有它自己的缺陷,具體是否使用還要根據業務場景來定,如果普通的瀑布流沒有達到性能瓶頸那完全沒必要使用該技術

組件結構搭建及樣式

瀑布流虛擬列表的 DOM 結構實際上與瀑布流的結構以及樣式大體上相同,依舊是隻需要 container、list、item 這三個內容即可,設置 container 滾動條,list 開啓相對定位,item 開啓絕對定位,item 通過 transform 來設置卡片具體位置,統一定位到左上角:

<div class="fs-virtual-waterfall-container">
  <div class="fs-virtual-waterfall-list">
    <div class="fs-virtual-waterfall-item">
        <slot ></slot>
    </div>
  </div>
</div>
.fs-virtual-waterfall {
  &-container {
    width: 100%;
    height: 100%;
    overflow-y: scroll;
    overflow-x: hidden;
  }
  &-list {
    position: relative;
    width: 100%;
  }
  &-item {
    position: absolute;
    top: 0;
    left: 0;
    box-sizing: border-box;
  }
}

組件 props 和 初始化狀態

首先來設置組件的 props,我們還是先來講固定寬高的卡片,且數據源已經攜帶了圖片的尺寸信息:

export interface IVirtualWaterFallProps {
  gap: number; // 卡片間隔
  column: number; // 瀑布流列數
  pageSize: number; // 單次請求數據數量
  request?: (page: number, pageSize: number) => Promise<ICardItem[]>; // 數據請求方法
}

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

然後就是組件內部的狀態了,因爲這次的組件具備了虛擬列表和瀑布流兩個特性,所以內部的狀態定義還是比較多的,我們慢慢解釋

首先就是數據源的狀態,都是老熟人了:

const dataState = reactive({
  loading: false, // 發送請求 loading 狀態
  isFinish: false, // 請求數據是否已經結束(返回空數據)
  currentPage: 1, // 當前頁數
  list: [] as ICardItem[], // 數據源
});

緊接着是虛擬列表和瀑布流相關的狀態,我們需要 「容器的寬度計算出卡片寬度」,需要用到 「容器高度和 start 來計算出 end」

這裏的容器高度其實可以類比虛擬列表的最大容納量,start 就是 startIndex,end 就是 endIndex,只不過現在是二維佈局,不再是簡單的一個列表索引了,至於爲什麼這樣計算我們後面再揭曉:

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

const scrollState = reactive({
  viewWidth: 0,
  viewHeight: 0,
  start: 0
});

const end = computed(() => scrollState.viewHeight + scrollState.start);

以及瀑布流單獨的數據狀態,「存儲卡片的二維數組」,針對於瀑布流的每一列包含了 「當前列高和當前列的所有卡片 list」,注意這裏存儲的卡片數據與普通的數據源不同,它多了卡片自身的位置信息,以及後續我們要進行動態綁定的樣式(偏移量)

interface IColumnQueue {
  list: IRenderItem[];
  height: number;
}

// 渲染視圖項
 interface IRenderItem {
  item: ICardItem; // 數據源
  y: number; // 卡片距離列表頂部的距離
  h: number; // 卡片自身高度
  style: CSSProperties; // 用於渲染視圖上的樣式(寬、高、偏移量)
}

const queueState = reactive({
  queue: Array(props.column)
    .fill(0)
    .map<IColumnQueue>(() => ({ list: [], height: 0 })), // 存儲所有卡片的二維數組
  len: 0, // 存儲當前視圖上展示的所有卡片數量
});

有了它之後就需要回憶我們瀑布流實現的原理了,其中有一個關鍵步驟就是計算 「當前的最小高度列以及其索引」

這次由於結合虛擬列表還需要計算一下最大高度列,因爲這個值代表着 「DOM 結構中 list 的 height 樣式」

const computedHeight = computed(() => {
  let minIndex = 0,
    minHeight = Infinity,
    maxHeight = -Infinity;
  queueState.queue.forEach(({ height }, index) => {
    if (height < minHeight) {
      minHeight = height;
      minIndex = index;
    }
    if (height > maxHeight) {
      maxHeight = height;
    }
  });

  return {
    minIndex,
    minHeight,
    maxHeight,
  };
});

我們拿到了最高列的高度,就可以設置樣式了,之後再在 template 上進行綁定即可:

const listStyle = computed(() =({ height: `${computedHeight.value.maxHeight}px` } as CSSProperties));

這個思路其實和虛擬列表是一樣的,因爲我們滾動條的高度是與當前數據源的長度對應的,普通虛擬列表的實現還要動態綁定 transform 來實現虛擬效果,這裏由於結合瀑布流,我們不再這樣做了

確認視圖範圍、計算 renderList

在初始化狀態中我們有了 start 和 end,按照虛擬列表的步驟應該就可以直接通過 slice 截取數據源設置最終展示到視圖上的 renderList 了🤔 ,不過這裏畢竟不止實現了虛擬列表,別忘了還有瀑布流😇

在上面定義 queueState 狀態時我們有一個 「IRenderItem」, 它纔是最終要渲染在視圖上的 item 項, renderList 裏的內容是 IRenderItem 而不是數據源 ICardItem」

「queueState 只是一個瀑布流的二維抽象數據結構,最終還要轉換成一個一維的 renderList 列表渲染到視圖上」,至於二維的效果已經在 IRenderItem 中存儲了,就是它的 style 樣式屬性

明白了這一點其實就知道了我們 「這回並不是要截取數據源,而是要截取 queueState 裏的 list 二維數組」

現在問題是 queueState 裏是一個二維結構,我們需要先把它轉換爲一維的,二維數組轉一維怎麼轉呢🤔?

用 reduce 直接秒了🤪:

const cardList = computed(() => queueState.queue.reduce<IRenderItem[]>((pre, { list }) => pre.concat(list), []));

之後就到了截取操作,而到此就進入了**「瀑布流虛擬列表的第一個關鍵實現」**🧐

這回可沒有像虛擬列表的 startIndex、endIndex 索引了,有的是 start、end 這兩個值,我們來看這兩個值具體的含義,直接上圖:

在沒有滾動的初始狀態,這兩個值是這樣的:

而當進行滾動時 container 的 scrollTop 值發生變化,「實際上當我們觸發滾動事件時需要始終讓 start 爲 scrollTop 值」,至於 end 的本質就是 「scrollTop + container Height」

爲什麼要這麼做呢🤔?這牽扯到滾動,在解答這個問題之前我們先來看一下渲染在視圖上卡片的 IRenderItem 中信息字段的含義,主要關注 y、h 兩個字段:

總結一下:「y 是該卡片與 list 頂部的距離,h 是該卡片的高度」

根據這種佈局方式你會發現 「⑥ 的 y 值 = ① 的 y 值 + ① 的 h 值」,這個關係式是我們後面需要計算位置的關鍵,這點我們後面再說

我們知道虛擬列表只會保留出現在 container 視圖中的 DOM,之前虛擬列表是通過 startIndex、endIndex 來確定這個視圖範圍,現在結合了瀑布流就不一樣了,我們單獨看一張卡片,觀察它什麼時候滾出視圖:


上面兩張圖畫出了①、⑥ 卡片滾出視圖的情景,我們重點關注 y、h 與 start(scrollTop) 三者的關係,不難發現有這樣的結論:

「針對於一個 item,當其 y + h <= start 時表示它已超出 container 視圖範圍」

別忘了這只是向上滾動,如果卡片本身就在 container 外面即在它的下面,我們再來看看這種情況:

我們也能得出一個結論:

「針對於一個 item,當其 y >= end 時表示它已超出 container 視圖範圍」

有了這樣的關係,不就直接能確認視圖範圍了嘛🤪:

「針對於一個 item,當其 y + h > start && y < end 時表示它在 container 視圖中」

利用我們扁平化的 cardList,直接上代碼計算 renderList:

const renderList = computed(() => cardList.value.filter((i) => i.h + i.y > scrollState.start && i.y < end.value));

現在 template 上就可以利用 renderList 遍歷渲染 item 並綁定其樣式了,我們順便把插槽補充上:

<template>
  <div class="fs-virtual-waterfall-container" ref="containerRef">
    <div class="fs-virtual-waterfall-list" :style="listStyle">
        <div class="fs-virtual-waterfall-item" v-for="{ item, style } in renderList" :style="style" :key="item.id">
          <slot ></slot>
        </div>
    </div>
  </div>
</template>

數據源 item 轉換爲渲染卡片

不知道大夥看完上一節之後是否有這樣的疑問:卡片 item 的 h、y 值以及樣式在哪計算呢?數據源 dataState 怎麼轉換成 queueState?🤔

這一節就來解決這個問題,也是整個 「瀑布流虛擬列表的第二個關鍵實現」

我們實現一個 addInQueue 函數,它用來將原數據源 item 轉換爲 cardItem 並添加到 dataState 隊列裏,添加的規則很簡單,就是瀑布流佈局操作:

const addInQueue = (size: number) => {
  for (let i = 0; i < size; i++) {
    const minIndex = computedHeight.value.minIndex;
    const currentColumn = queueState.queue[minIndex];
    const before = currentColumn.list[currentColumn.list.length - 1] || null;
    const dataItem = dataState.list[queueState.len];
    const item = generatorItem(dataItem, before, minIndex);
    currentColumn.list.push(item);
    currentColumn.height += item.h;
    queueState.len++;
  }
};

這裏乍一看信息比較多,其實過程就是 「瀑布流中找到最小高度列,然後計算出一個 cardItem,將其添加到對應的隊列當中」,不過還是需要做一下講解的,我們一點一點來看 🤓

首先就是函數的參數 size,它代表着此次添加到瀑布流隊列中卡片的數量,與單次請求到的數據量保持一致

之後就到了我們熟悉的瀑布流計算環節了,我們針對於單個卡片添加來看:

  1. 通過 computedHeight 找到最小高度列的索引

  2. 通過索引以及 queueState 找到當前最小高度列

  3. 計算新添加的卡片 item 信息(h、y、style)

  4. 將新添加的卡片添加到當前最小高度列裏面

  5. 更新當前最小列的高度(累加新的卡片高度),累計到當前瀑布流隊列裏 (len++)

這裏面最大的問題在於如何計算卡片的信息,我們以添加 ⑥ 爲例,現在它已經找到最小高度列是第一列:

「首先我們需要獲取到當前列的最後一項 ①」,因爲之前我們已經分析出這樣的關係式:「⑥ 的 y 值 = ① 的 y 值 + ① 的 h 值」,這就是代碼中 before 代表的含義,但別忘了它可能爲空,因爲當第一行數據添加時隊列裏還沒有數據呢

之後我們需要獲取到 「⑥ 的數據源信息」,我們給 queueState 預留了 len 屬性,它就是 「用來保存當前的瀑布流隊列裏所有卡片的數量」,我們直接利用該屬性值作爲索引去訪問 dataSource 即可

現在還差 ⑥ 的 h 值以及它的 style 樣式,這些我們將其封裝到了 generatorItem 函數中實現:

const generatorItem = (item: ICardItem, before: IRenderItem | null, index: number): IRenderItem => {
  const rect = itemSizeInfo.value.get(item.id);
  const width = rect!.width;
  const height = rect!.height;
  let y = 0;
  if (before) y = before.y + before.h + props.gap;

  return {
    item,
    y,
    h: height,
    style: {
      width: `${width}px`,
      height: `${height}px`,
      transform: `translate3d(${index === 0 ? 0 : (width + props.gap) * index}px, ${y}px, 0)`,
    },
  };
};

這裏又冒出來了一個 itemSizeInfo,它是用來輔助計算卡片的寬度高度的

關於卡片高度的計算就是我們瀑布流一個交叉相乘的算法,只不過還需要用到它的數據源圖片的尺寸

我們直接將數據源的數據映射到 itemSizeInfo 中提前計算出每個卡片的寬度高度,之後可以通過 id 直接訪問到該數據對應的卡片信息:

interface IItemRect {
  width: number;
  height: number;
}

const itemSizeInfo = computed(() =>
  dataState.list.reduce<Map<ICardItem["id"], IItemRect>>((pre, current) => {
    const itemWidth = Math.floor((scrollState.viewWidth - (props.column - 1) * props.gap) / props.column);
    pre.set(current.id, {
      width: itemWidth,
      height: Math.floor((itemWidth * current.height) / current.width),
    });
    return pre;
  }, new Map())
);

到此我們已經實現了數據源與卡片信息的轉化,當請求獲取到數據之後調用 addInQueue 函數即可,響應式數據會自動再計算 renderList 更新視圖的

現在一個簡單的瀑布流虛擬列表核心原理都已經被我們實現了🧐剩下的其實就是代碼組裝呈現效果

組織代碼呈現效果

初始化操作

初始化操作的主要任務如下:

  1. 計算容器寬度和高度以及初始化 start 值

  2. 請求獲取數據

  3. 獲取到數據後將其轉化保存至 queueState 中

  4. 添加滾動事件

const initScrollState = () => {
  scrollState.viewWidth = containerRef.value!.clientWidth;
  scrollState.viewHeight = containerRef.value!.clientHeight;
  scrollState.start = containerRef.value!.scrollTop;
};

const init = async () => {
  initScrollState();
  const len = await loadDataList();
  len && addInQueue(len);
};

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

獲取數據沒什麼好說的,就是注意要把獲取到數據的長度返回,給後面添加到 queueState 隊列使用:

const loadDataList = async () => {
  if (dataState.isFinish) return;
  dataState.loading = true;
  const list = await props.request(dataState.currentPage++, props.pageSize);
  if (!list.length) {
    dataState.isFinish = true;
    return;
  }
  dataState.list.push(...list);
  dataState.loading = false;
  return list.length;
};

滾動事件主要是將 start 設置爲 scrollTop 值,其次考慮觸底加載處理

需要注意的是觸底的條件,是 「滾動到當前最小列的高度,而不是最大列高度」

const handleScroll = rafThrottle(() => {
  const { scrollTop, clientHeight } = containerRef.value!;
  scrollState.start = scrollTop;
  if (scrollTop + clientHeight > computedHeight.value.minHeight) {
    !dataState.loading &&
      loadDataList().then((len) => {
        len && addInQueue(len);
      });
  }
});

別忘了最後在 template 模板上給 container 添加對應的 scroll 事件~

<template>
  <div class="fs-virtual-waterfall-container" ref="containerRef" @scroll="handleScroll">
    <div class="fs-virtual-waterfall-list" :style="listStyle">
      <div class="fs-virtual-waterfall-item" v-for="{ item, style } in renderList" :key="item.id" :style="style">
        <slot ></slot>
      </div>
    </div>
  </div>
</template>

準備數據

關於數據還是把上次小紅書的扒過來,直接組織一下使用就行:

// config/index.ts
import { ICardItem } from "../components/type";
import data1 from "./data1.json";
import data2 from "./data2.json";

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

const list1: ICardItem[] = data1.data.items.map((i) => ({
  id: i.id,
  width: i.note_card.cover.width,
  height: i.note_card.cover.height,
  title: i.note_card.display_title,
  author: i.note_card.user.nickname,
}));

const list2: ICardItem[] = data2.data.items.map((i) => ({
  id: i.id,
  width: i.note_card.cover.width,
  height: i.note_card.cover.height,
  title: i.note_card.display_title,
  author: i.note_card.user.nickname,
}));

const list = [...list1, ...list2].map((item, index) => ({ bgColor: colorArr[index % (colorArr.length - 1)], ...item }));

export default list;

查看組件效果

哈,因爲這次我們在初始化之前已經把關鍵計算全部實現過了,所以初始化結束之後就能夠看到效果啦😎

現在在父組件中就能夠愉快的使用我們的瀑布流虛擬列表組件了😃:

<template>
  <div class="app">
    <div class="container">
      <FsVirtualWaterfall :request="getData" :gap="15" :page-size="15" :column="3">
        <template #item="{ item }">
          <div
            class="test-item"
            :style="{
              background: item.bgColor,
            }"
          ></div>
        </template>
      </FsVirtualWaterfall>
    </div>
  </div>
</template>

<script setup lang="ts">
import FsVirtualWaterfall from "./components/FsVirtualWaterfall.vue";
import { ICardItem } from "./components/type";
import list from "./config/index";

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 {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100vw;
  height: 100vh;
  .container {
    width: 70vw;
    height: 90vh;
    border: 1px solid red;
  }

  .test-item {
    width: 100%;
    height: 100%;
    box-sizing: border-box;
    border-radius: 10px;
  }
}
</style>

來看看效果咋樣,注意看右邊控制檯的變化:

感覺還不錯,至少瀑布流的樣子有了,觀察 DOM 數量也符合虛擬列表的特性😁😁😁

響應式實現和卡片動畫效果

響應式實現

雖然現在瀑布流虛擬列表的效果有了,但是還沒有實現瀑布流的響應式,大體的實現過程是一樣的,當視口改變時都需要重新計算位置,我們一起來簡單看一下:

卡片的所有位置信息都在 queueState 裏,所以當視口改變時我們需要 「全部重新計算 queueState 中的卡片數據」,我們實現一個 reComputedQueue 來重新計算:

const reComputedQueue = () => {
  // 清空 queueState 的所有卡片數據
  queueState.queue = new Array(props.column).fill(0).map<IColumnQueue>(() => ({ list: [], height: 0 }));
  queueState.len = 0;
  // 根據當前數據源長度全部重新計算添加
  addInQueue(dataState.list.length);
};

對於視口改變我們執行 resize 事件並添加防抖:

const handleResize = debounce(() => {
  initScrollState();
  reComputedQueue();
}, 300);

響應式的實現過程就直接複製粘貼瀑布流的實現咯,如果還不瞭解的可以看下之前瀑布流實現中的響應式實現過程,已經很清楚了:

女友看了小紅書的 pc 端後,問我這個瀑布流的效果是怎麼實現的?🤔 - 掘金 (juejin.cn)(https://juejin.cn/post/7322655035699396660#heading-15)

就是這裏的響應式如果給 item 加上過渡效果感覺怪怪的,應該是卡片全部重新計算的原因動畫移動莫名其妙🤔,不像普通瀑布流依舊是在原來的 item DOM 上調整位置進行過渡,所以索性不加了:

卡片動畫效果

雖然響應式的過渡效果很爛,但是其實可以 **「單獨給卡片添加一些動畫」**🧐

因爲這與普通的瀑布流不同,虛擬列表的不斷滾動都會引起單個卡片 DOM 的創建和移除,如果給這些卡片添加一些動畫的話效果要比普通的瀑布流一批卡片創建好太多:

比如更改一下透明度添加一個漸入的動畫效果:

由於視頻轉 gif 的原因動畫可能會有一些掉幀,實際動畫效果是很絲滑的

// 父組件卡片添加樣式
.test-item {
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  border-radius: 10px;
  animation: OpacityAnimate 0.25s;
}
@keyframes OpacityAnimate {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

這樣滾動的時候給人的視覺效果可比普通的瀑布流好太多了,這都得益於虛擬列表的特性😎:

當然你也可以讓動畫複雜一些,滾動時讓卡片有一個上移的動畫,這效果更讚了👍,顯得更加自然:

.test-item {
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  border-radius: 10px;
  animation: MoveAnimate 0.25s; 
 }

@keyframes MoveAnimate {
  from {
    opacity: 0;
    transform: translateY(200px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

最最重要的是這些動畫都是父組件給卡片 DOM 添加的,與我們實現的瀑布流虛擬列表組件無關,想要什麼動畫隨便你🤪

小紅書效果實現

終於來到我們的主角了,小紅書的一些問題在瀑布流的文章當中已經分析過了

響應式效果我們上面已經實現了,重點就在它的文本不定高的問題:

這裏可以再扯一小段,其實按照之前的分析我們知道小紅書的卡片 title 文本只有 「零行、一行、兩行」 這三種情況,它的 author 部分是寫死的高度

我一開始的想法是前端完全可以根據這個文本內容長度來預估出它應該要展示幾行,然後針對於這三種情況我們只需要固定三種 title 高度就行,這樣做就 「完美避免了獲取每個卡片高度的 DOM 操作」,大大提高瀑布流虛擬列表的性能

後來發現就如瀑布流那篇文章裏面所說由於響應式的影響,可能會造成卡片的寬度進行壓縮,這時候原來一行的文本又變成了兩行,這是真一點脾氣都沒有了😑😑😑... 老老實實 DOM 操作獲取高度吧

動態卡片高度的計算思路

雖然知道要進行 DOM 操作來獲取卡片高度,但是現在又有問題了,這跟普通的瀑布流不一樣啊😅

回想一下我們瀑布流是怎麼計算的:

  1. 根據返回的圖片尺寸確定卡片的寬度,讓當前的所有卡片根據該寬度先進行掛載

  2. 通過 nextTick 獲取所有卡片 DOM 來確定高度

  3. 計算所有卡片位置進行瀑布流佈局

但我們目前實現的瀑布流虛擬列表偏向於定高情景,比如 generatorItem 生成卡片這些步驟都與圖片高度信息的高度強相關,如果現在要強行插入一個真實 DOM 高度就需要大改之前的實現了,「所有的代碼結構都因爲要獲取這個高度而改變」

我嘗試了很多方法,思來想去還是不願意在原來實現上進行大改,畢竟架子已經搭起來了,更希望的是在架子以外的地方補充額外的功能,最後再將它添加到架子上🤔

最終我選擇了一個不太尋常但還是能夠解決問題的方案:「用一個盒子臨時存放添加的卡片,等它掛載獲取到真實高度後再把它卸載」

我們只需要最終卡片高度這個信息,後面按照我們之前實現的流程走一遍就 OK 了

改造之前的 type 類型

和之前的思路一樣,由於動態高度的原因,我們區分出圖片高度以及卡片高度,新增加一個高度字段:

export interface IBookColumnQueue {
  list: IBookRenderItem[];
  height: number;
}

export interface IBookRenderItem {
  item: ICardItem;
  y: number;
  h: number;
  imageHeight: number;
  style: CSSProperties;
}

export interface IBookItemRect {
  width: number;
  height: number;
  imageHeight: number;
}

創建臨時 DOM 獲取真實卡片高度

首先我們給 template 模板上增加一個 temporary-list 的盒子,同時增加一個 temporaryList 來存儲將要添加的卡片數據,對其進行遍歷以渲染出卡片。既然是臨時的,我們增加一個 isShow 變量來控制,當獲取到真實卡片高度信息後這個盒子就可以卸載了,掛載我們真正的卡片:

<template>
  <div class="fs-virtual-waterfall-container" ref="containerRef" @scroll="handleScroll">
    <div class="fs-virtual-waterfall-list" :style="listStyle">
      <div
        v-if="isShow"
        class="fs-virtual-waterfall-item"
        v-for="{ item, style, imageHeight } in renderList"
        :key="item.id"
        :style="style"
      >
        <slot ></slot>
      </div>
      <!-- 臨時存儲要添加的卡片,用來獲取動態卡片的高度 -->
      <div id="temporary-list" v-else>
        <div v-for="{ item, style, imageHeight } in temporaryList" :style="style">
          <slot ></slot>
        </div>
      </div>
    </div>
  </div>
</template>
const temporaryList = ref<IBookRenderItem[]>([]);
const isShow = ref(false);

所以現在的思路就是我們獲取到數據之後不再立即執行之前實現的 addInQueue 函數,而是先將這些數據存入到 temporaryList 中,之後在 temporary-list 盒子中掛載卡片

不過在實現之前還要更改之前的一個計算屬性:itemSizeInfo,還記得它嗎?它是用來映射數據源存儲每個卡片的寬度和高度的(根據容器寬度以及數據圖片尺寸信息計算)

但現在不能把它設置爲計算屬性了,因爲後續的操作需要我們手動修改 itemSizeInfo,而且現在有圖片高度以及卡片高度兩個字段。雖然 computed 也可以讓我們自定義 setter,但是我嘗試了一下還是不太方便...😑

我們先單獨實現一個 setSizeInfo 方法,剛開始卡片的高度初始化爲 0,寬度和圖片的高度都可以計算:

const itemSizeInfo = ref(new Map<ICardItem["id"], IBookItemRect>());

const setItemSize = () => {
  itemSizeInfo.value = dataState.list.reduce<Map<ICardItem["id"], IBookItemRect>>((pre, current) => {
    const itemWidth = Math.floor((scrollState.viewWidth - (props.column - 1) * props.gap) / props.column);
    pre.set(current.id, {
      width: itemWidth,
      height: 0,
      imageHeight: Math.floor((itemWidth * current.height) / current.width),
    });
    return pre;
  }, new Map());
};

之後就來實現 mountTemporaryList,在這裏掛載臨時的卡片,並獲取其真實高度:

const mountTemporaryList = (list: ICardItem[]) => {
  isShow.value = false;
  list.forEach((item) => {
    const rect = itemSizeInfo.value.get(item.id)!;
    temporaryList.value.push({
      item,
      y: 0,
      h: 0,
      imageHeight: rect.imageHeight,
      style: {
        width: `${rect.width}px`,
      },
    });
  });
  nextTick(() => {
    const list = document.querySelector("#temporary-list")!;
    [...list.children].forEach((item, index) => {
      const rect = item.getBoundingClientRect();
      temporaryList.value[index].h = rect.height;
      updateItemSize();
      isShow.value = true;
    });
  });
};

思路很簡單,就是根據請求的數據 list 遍歷,配合 itemSizeInfo 的初始數據來添加到 temporaryList 中,後面會執行 diff 算法裏的掛載邏輯,我們通過 nextTick 獲取掛載的卡片 DOM 來拿到卡片的最終高度,將它更新到 temporaryList 以及 itemSizeInfo 中,存儲之後我們的 temporaryList 任務就完成了,通過 isShow 來控制將它卸載完事

這裏的 updateItemSize 方法還沒實現,就是從更新過的 temporaryList 裏獲取卡片最終高度來更新 itemSizeInfo:

const updateItemSize = () => {
  temporaryList.value.forEach(({ item, h }) => {
    const rect = itemSizeInfo.value.get(item.id)!;
    itemSizeInfo.value.set(item.id, { ...rect, height: h });
  });
};

對接之前實現流程

OK,我們繞了一大圈最終把卡片的真實高度給存到 itemSizeInfo 裏了,其實剩下的步驟就跟之前實現定高的瀑布流虛擬列表一樣了,因爲我們就是隻需要 itemSizeInfo 這裏的高度信息來計算位置,只不過還要修改一下之前的實現來對接當前的實現流程,比如之前類型新增了 imageHeight 這個字段,需要調整一下之前的代碼,這塊我就省略了...😜

重點講解一下改動影響較大的,首先是請求數據方法 loadDataList 改動,之前爲了配合 addInQueue 我們把新獲取的數據長度返回了,這次我們配合 mountTemporaryList 直接將數據返回:

const loadDataList = async () => {
  // ...
  const list = await props.request(dataState.currentPage++, props.pageSize);
  // ...
  
  // before: return list.length
  return list;
};

同樣初始化操作我們不再獲取數據後執行 addInQueue,而是改成 mountTemporaryList 的執行:

const init = async () => {
  // ...
  // before: const len = await loadDataList();
  const list = await loadDataList();
  // 初始化 itemSizeInfo 信息
  setItemSize();
  // 獲取到數據後
  list && mountTemporaryList(list.length);
  // before: len && addInQueue(len);
};

而真正要執行 addInQueue 的地方是我們更新完真實卡片高度後的 mountTemporaryList 裏:

const mountTemporaryList = (list: ICardItem[]) => {
  isShow.value = false;
  // ...
  nextTick(() => {
    // ...
    isShow.value = true;
    updateItemSize();
    // 獲取到真實高度,開始執行瀑布流計算邏輯
    addInQueue(temporaryList.value.length);
    // 注意每次結束之後都需要清空,方便滾動加載更多數據添加
    temporaryList.value = [];
  });
};

之後就是滾動加載更多以及響應式尺寸變動的重新計算處理,都需要替換成 mountTemporaryList:

const handleScroll = rafThrottle(() => {
  const { scrollTop, clientHeight } = containerRef.value!;
  scrollState.start = scrollTop;
  if (scrollTop + clientHeight > computedHeight.value.minHeight) {
    !dataState.loading &&
      loadDataList().then((list) => {
        list && setItemSize();
        list && mountTemporaryList(list);
      });
    // before
    // !dataState.loading &&
    //   loadDataList().then((len) => {
    //     len && addInQueue(len);
    //   });
  }
});
const reComputedQueue = () => {
  
  queueState.queue = new Array(props.column).fill(0).map<IBookColumnQueue>(() => ({ list: [], height: 0 }));
  queueState.len = 0;
  // before: addInQueue(dataState.list.length);
  setItemSize();
  mountTemporaryList(dataState.list);
};

注意我們之前實現的 setItemSize 是屬於初始化,所以這裏滾動以及重新計算都會將已經計算的真實高度重置爲 0,我們需要區分出初始化和更新的情況:

const setItemSize = () => {
  itemSizeInfo.value = dataState.list.reduce<Map<ICardItem["id"], IBookItemRect>>((pre, current) => {
    const itemWidth = Math.floor((scrollState.viewWidth - (props.column - 1) * props.gap) / props.column);
    // 如果 itemSizeInfo 裏已經存在值,使用存在的高度,而不是重置爲 0 
    const rect = itemSizeInfo.value.get(current.id);
    pre.set(current.id, {
      width: itemWidth,
      height: rect ? rect.height : 0,
      imageHeight: Math.floor((itemWidth * current.height) / current.width),
    });
    return pre;
  }, new Map());
};

呈現效果

以上幾步都完成後我們基本上就能夠看到一個不定高的瀑布流虛擬列表了,我們改一下父組件的代碼,用上我們之前瀑布流中封裝的小紅書卡片:

之前瀑布流文章中忘了解釋爲什麼還需要把 imageHeight 傳出來給子組件

主要是因爲錄製 gif 的緣故,如果使用圖片的話會導致 gif 文件過大無法上傳,因此卡片組件的圖片直接用色塊 div 代替了,但是 div 肯定要給個固定的高度才能撐起來

所以如果使用是真實圖片的話直接設置寬度 100% 讓其自由伸展就行,無需再單獨用一個 imageHeight 字段設置圖片高度

<template>
  <div class="app">
    <div class="container" ref="fContainerRef">
      <FsBookVirtualWaterfall :request="getData" :gap="15" :page-size="20" :column="column">
        <template #item="{ item, imageHeight }">
          <fs-book-card
            :detail="{
              imageHeight,
              title: item.title,
              author: item.author,
              bgColor: item.bgColor,
            }"
          />
        </template>
      </FsBookVirtualWaterfall>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import FsBookCard from "./components/FsBookCard.vue";
import FsBookVirtualWaterfall from "./components/FsBookVirtualWaterfall.vue";
import { ICardItem } from "./components/type";
import list from "./config/index";
const fContainerRef = ref<HTMLDivElement | null>(null);
const column = ref(6);
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 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 {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100vw;
  height: 100vh;
  .container {
    width: 1400px;
    height: 600px;
    border: 1px solid red;
  }

}

</style>

見證奇蹟的時刻家人們!😎😎😎(GIF 12MB)

無非是沒有圖片展示,但效果基本上和小紅書的瀑布流虛擬列表差不太多了😎

其實這個方案一出來不定高的瀑布流虛擬列表就可以實現了,比如可以完全脫離圖片,假設你只展示爲文本不定高的卡片也可以使用這個思路

但是如果一旦使用帶有圖片的卡片就一定要保證後端有返回圖片的尺寸信息,不然神仙都救不了,等圖片全部加載完黃花菜都涼了🤣

淺談優化思路

到此我們的瀑布流虛擬列表組件就結束了,但是性能還是比較差的,如果只是展示圖片的話定高實現還算可以,但是不定高的目前還是有比較大的缺陷

像常規的虛擬列表優化方案我這裏就不再過多講了,已經有很多文章提供思路🧐

包括小紅書它優化了虛擬列表最頭疼的一個點就是滾動白屏問題,爲了防止快速滾動或者跳斷滾動小紅書直接把滾動條給禁了,沒想到吧,直接從用戶根源解決問題😅😅😅 所以真想省事還真可以這樣做

這裏主要講一個使用我這種實現方案可以優化的點

注意這裏優化思路需要明白上面講的所有實現流程,不然真不一定能 get 到

首先我們思考一個這樣的問題:假設我們使用不定高的實現不斷向下滾動加載更多數據,這時候從後端接收到的數據已經達到了 10000 條(存儲在內存當中),現在我們突然更改了一下視口尺寸觸發 resize 邏輯,還記得之前怎麼處理的嗎?我們把代碼拉過來看看:

const reComputedQueue = () => {
  setItemSize();
  queueState.queue = new Array(props.column).fill(0).map<IBookColumnQueue>(() => ({ list: [], height: 0 }));
  queueState.len = 0;
  mountTemporaryList(dataState.list);
};

現在這裏的 dataState.list 就有 10000 條數據,我們執行了mountTemporaryList 邏輯,這時候 10000 條數據會變爲卡片被掛載到 temporary-list 中,我們還要獲取高度,相當於你來了 10000 次的 DOM 操作,嘖嘖嘖,估計狠狠地掉幀,甚至卡死都有可能

包括你的 pageSize 設置的大一些,那觸底請求加載過來的數據也是一樣需要都先進行 DOM 操作獲取高度,這個 pageSize 越大就越會造成你的視圖卡頓

所以考慮到我們經常吹噓的虛擬列表十萬條數據的場景,這個問題肯定是需要解決的

解決問題的關鍵就是內存裏的這 10000 條數據我們真的要全部重新計算嗎?其實沒有必要,我爲什麼不只先重新計算前 50 條,然後慢慢再往後計算?或者再精確一點,我們 「只先重新計算比視圖上能夠展示的卡片數量多幾個,當我們滾動加載時再接着計算個幾十條數據添加到視圖上這樣累加,而不是一口氣全部計算完成」

計算完成之後你的卡片位置已經固定了,所以你想怎麼滾動就怎麼滾動,那就是虛擬列表的事了,這個性能其實還算 OK 的

所以不難發現我們上面實現的方案性能瓶頸其實就在 「不斷滾動添加新數據時的 DOM 計算」,以及 「視口尺寸改變的重新 DOM 計算」

我們假設視圖容器只能展示 4 個卡片,而我們每次獲取到的數據是 10 條,那麼我們之前實現的方案是這樣的:

這種方案的缺點就是 「後端返回的數據量與前端處理計算的數量強綁定」,實際上完全沒必要與後端返回的數量一致

所以我們優化的方案是這樣的(5MB):

後端返回數據數量一定,但是我們可以不一次性全部處理完

初始化與之前相同,這時候 list 和 queueList 中有 10 條數據

第一次滾動觸底時我們去後端獲取額外 10 條,這時候 list 有 20 條數據,但我們後續選擇 「只計算兩條數據」,此時 queueList 爲 12 條

第二次滾動觸底時我們 「無需再請求後端數據」,而是在原來 list 裏 20 條數據中 「再取兩條數據」 添加到 queueList 中,變爲 14 條

直到觸底時 queueList 與 list 相等時說明已經全部處理完,我們需要請求後端數據,然後繼續重複第一次觸底操作

我們這樣對比:

之前方案:

第一次觸底:發送請求獲取十條數據 十次 DOM 操作 十次計算

第二次觸底:發送請求獲取十條數據 十次 DOM 操作 十次計算

優化方案:

第一次觸底:發送請求獲取十條數據 兩次 DOM 操作 兩次計算

第二次觸底:兩次 DOM 操作 兩次計算

...

第 n 次觸底:判斷 list.length === queueList.length 發送請求獲取十條數據 兩次 DOM 操作 兩次計算

相當於我們 「把之前的 DOM 計算進行了分片,增大了觸底觸發的頻率,但減少了每次的 DOM 操作」

至於這裏每次 DOM 操作具體設置的值需要根據使用場景來定,默認可以設置成 column 值,或者比它大一些也可以

這種優化其實在我們之前代碼實現很容易做到,因爲我們的 addInQueue 的參數就是相當於設置這個值的,我們只不過統一都是 pageSize 大小,現在我們進行修改即可,並且修改我們原來的 scroll 實現

上代碼!首先我們添加一個計算屬性 hasMoreData,它就是 queueList.length 與 list.length 的比較,我們將用它來區分滾動是請求數據還是在原有數據上添加到隊列當中:

const hasMoreData = computed(() => queueState.len < dataState.list.length);

新增一個傳值 props:enterSize,這裏爲了兼容之前的寫法就先設置爲可選,實際上是必填的,該值代表着滾動時進隊的個數,決定我們每次滾動操作 DOM 的數量,默認可以給一個 column * 2 的大小:

export interface IVirtualWaterFallProps {
  gap: number;
  column: number;
  pageSize: number;
  enterSize?: number;
  request: (page: number, pageSize: number) => Promise<ICardItem[]>;
}

修改之前 mountTemporaryList 裏的邏輯,參數傳值不再是 list 而是一個長度且不再是 pageSize 大小,使用 enterSize 代表要進隊的個數

const mountTemporaryList = (size = props.enterSize) => {
// 判斷當前隊列是否已滿,不再額外添加
  if (!hasMoreData.value) return;
  isShow.value = false;
  for (let i = 0; i < size!; i++) {
    const item = dataState.list[queueState.len + i];
    // 可能存在 item 爲空的情況(enterSize 過大)添加過程中已經超出了數據源 list 的範圍, 直接退出循環
    if (!item) break;
    const rect = itemSizeInfo.value.get(item.id)!;
    temporaryList.value.push({
      item,
      y: 0,
      h: 0,
      imageHeight: rect.imageHeight,
      style: {
        width: `${rect.width}px`,
      },
    });
  }
 // ...
};

修改滾動事件處理函數,改爲我們的優化邏輯,數據源長度大於當前隊列長度時不再發送請求而是選擇入隊,當隊列已滿時再選擇發送請求獲取數據入隊:

const handleScroll = rafThrottle(() => {
  const { scrollTop, clientHeight } = containerRef.value!;
  scrollState.start = scrollTop;
  if (!dataState.loading && !hasMoreData.value) {
    loadDataList().then((len) => {
      len && setItemSize();
      len && mountTemporaryList();
    });
    return;
  }
  if (scrollTop + clientHeight > computedHeight.value.minHeight) {
    mountTemporaryList();
  }
});

當然初始化時入隊的 size 還是與 pageSize 保持一致,loadDataList 又改爲返回數據的長度了,反覆橫跳了屬於是😅:

const init = async () => {
  initScrollState();
  resizeObserver.observe(containerRef.value!);
  const len = await loadDataList();
  setItemSize();
  len && mountTemporaryList(len);
};

最後在響應式這塊重新計算時先傳入 pageSize 即可:

const reComputedQueue = () => {
  setItemSize();
  queueState.queue = new Array(props.column).fill(0).map<IBookColumnQueue>(() => ({ list: [], height: 0 }));
  queueState.len = 0;
  mountTemporaryList(props.pageSize); // key
};

現在我們在滾動時給發送請求和滾動入隊打上日誌:

優化前日誌:

因爲我設置了請求定時器 1s 延遲,而優化前的觸底又沒有給提前距離,所以滾動到底部會看到短暫的白屏請求數據的情況,而優化後選擇了多次觸發滾動入隊邏輯可以儘可能保證整個滾動的流暢度

最重要的還是我之前提到 DOM 操作次數分片,具體哪種性能更高這個還待驗證,並不是說分片就一定好,但是這個思路確實是正確的

而且這個優化其實也適配我們之前定高的實現邏輯,把獲取卡片高度的步驟全部移除就行了

當然這個方案有一個巨大的缺陷目前我還沒有解決:「響應式重新計算問題」,當我們滾動到比較靠低的地方時改變了尺寸,這時候會清空隊列重新入隊,入隊的長度 pageSize 假設爲 20 ,但是我們滾動到底部時當前隊列已經達到 40 條了,所以當前處於底部的位置是靠近 40 條的數據。這時候重新計算過後你會發現 「視圖展示的數據跟滾動的位置就會不匹配」

避免這個問題也行,但是犧牲了用戶體驗,就是每次重新計算時設置 scrollTop 把讓它回到頂部:

const reComputedQueue = () => {
  setItemSize();
  queueState.queue = new Array(props.column).fill(0).map<IBookColumnQueue>(() => ({ list: [], height: 0 }));
  queueState.len = 0;
  containerRef.value!.scrollTop = 0;
  mountTemporaryList(props.pageSize);
};

但肯定不是最好的方案,我們當前這種優化方案在 「響應式上是沒有滾動記憶的」,目前還沒找到更好的解決辦法🤔下去我再研究研究

不過後來我又發現小紅書好像也是這樣做的,當列數改變的時候它也是直接回滾到頂部🧐

End

到此我們的瀑布流虛擬列表的實現終於結束了🥳🥳🥳

源碼奉上:DrssXpro/virtualwaterfall-demo: Vue3+TS:實現小紅書瀑布流虛擬列表組件 (github.com)(https://github.com/DrssXpro/virtualwaterfall-demo)

補充一個使用 React 實現定高的思路,核心實現步驟基本一樣,只不過由於狀態異步更新以及依賴問題需要變動一下👇:

DrssXpro/virtual-waterfall-demo-react: React + TS:實現簡易定高瀑布流虛擬列表 (github.com)(https://github.com/DrssXpro/virtual-waterfall-demo-react)

這篇文章說實話是我寫的耗時最長的一篇文章,因爲在決定要寫瀑布流虛擬列表的文章時我自己其實還不怎麼會😅,包括不定高的實現都是自己寫到這部分時纔開始思考實踐最終輸出內容的

所以如果你能一直堅持看到這裏,相信你是真的比較好奇這種瀑布流虛擬列表的具體實現過程,也歡迎評論交流提供不同的實現思路或者方案🥰

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/-MeXdHyVMd3gnCT_N_t98A