傳統實現方式
當前文章的
gif
檔案較大,載入的時長可能較久
這裡我拿小紅書的首頁作為分析演示
可以看到他們的實現方式是傳統做法,把每個元素透過獲取尺寸,然後算出left
、top
的排版位置,最後在每個元素上設定偏移值,思路沒什麼好說的,就是算元素座標。那麼這種做法有什麼缺點?請看下面這張圖的操作
- 容器尺寸每發生一次變化,容器內部所有節點都需要更新一次樣式設定,當頁面元素過多時,視窗的尺寸變動卡到不得了;
- 實現起來過於複雜,需要對每個元素獲取尺寸然後進行計算,不利於後面修改佈局樣式;
- 每一次的容器尺寸發生變動,圖片元素都會閃爍一下(電腦好的可能不會);
最佳實現方式
吐槽:早在
2019
年我就將下面的這種實現方式應用在小程式專案上了,但是目前還沒見到有人會用這種方式去實現,為什麼會沒有人想到呢?表示不理解。
- 程式碼倉庫
- 預覽地址
- 備用預覽地址
先看一下效果
在上面的預覽地址中,開啟控制檯檢視節點元素,可以看到是沒有任何的js
控制樣式操作,而是全部交給css
的自適應來渲染,我在程式碼層中只需要把資料排列好就行。
實現思路
這裡我將把容器裡面分為4
列,如下圖
然後再在每列的陣列裡面按順序新增資料即可,這樣去佈局的好處既方便、相容性好、瀏覽器渲染效能開銷最低化,而且還不會破壞文件流,將操作做到極致簡單。剩下的只需要怎樣去處理每一列的陣列即可。
處理陣列邏輯
由於是要做成動態列,所以不能固定4
個陣列列表,那就做成動態對容器輸出N
列,最後再對每一列新增資料即可。這裡我用ResizeObserver
去代替window.onresize
,理由是在實際應用中,容器會受到其他佈局而影響,而非視窗變動,所以前者更精確一些,不過思路做法都是一樣的。
- 設定一個變數
column
,代表顯示頁面有多少列; - 宣告一個變數
cacheList
,用來快取介面請求回來的資料,也就是總資料; - 然後監聽容器的寬度去設定
column
的數量值; - 最後用
computed
根據column
的值生成一個二維陣列進行頁面渲染即可;
import { ref, reactive, computed, onMounted, onUnmounted } from "vue"; /** 每一個節點item的資料結構 */ interface ItemInfo { id: number title: string text: string /** 圖片路徑 */ photo: string } type ItemList = Array<ItemInfo>; const page = reactive({ /** 頁面中出現多少列資料 */ column: 4, update: 0, }); const pageList = computed(function() { const result = new Array(page.column).fill(0).map((_, index) => ({ id: index, list: [] as ItemList })); let columnIndex = 0; page.update; // TODO: 這裡放一個引用值,用於手動更新; for (let i = 0; i < cacheList.length; i++) { const item = cacheList[i]; result[columnIndex].list.push(item); columnIndex++; if (columnIndex >= page.column) { columnIndex = 0; } } console.log("重新計算列表 !!----------!!"); return result; }); let cacheList: ItemList = []; async function getData() { page.loading = true; const res = await getList(20); // 介面請求方法 page.loading = false; if (res.code === 1) { cacheList = cacheList.concat(res.data); // TODO: 手動更新,這裡不把`cacheList`放進`page`裡面是因為響應資料列表過多會影響效能 page.update++; } } let observer: ResizeObserver; onMounted(function() { getData() observer = new ResizeObserver(function(entries) { const rect = entries[0].contentRect; if (rect.width > 1200) { page.column = 4; } else if (rect.width > 900) { page.column = 3; } else if (rect.width > 600) { page.column = 2; } }); observer.observe(document.querySelector(".water-list")!); }); onUnmounted(function() { observer.disconnect(); })
這裡有個細節,我把page.update
丟進computed
中作為手動觸發更新的開關而不是把cacheList
宣告響應式的原因是因為頁面只需要用到一個響應陣列,如果把cacheList
也設定為響應式,那就導致了陣列過長時,響應式過多的效能開銷,所以這裡用一個引用值作為手動觸發更新依賴的方式會更加好。
這樣一個基本的瀑布流就完成了。
更完美的處理
細心的同學這時已經發現問題了,就是當某一列的圖片高度都很長時,會產生較大的空隙,因為是沒有任何的高度計算處理而是按照陣列順序的逐個新增導致,像下面這樣。
所以這裡就還需要最佳化一下邏輯
- 在獲取資料時,把每一個圖片的高度記錄下來並寫入到總列表中
- 在組裝資料時,先拿到高度最低的一列,然後將資料加入到這一列中
/** * 載入所有圖片並設定對應的寬高 * @param list */ async function setImageSize(list: ItemList): Promise<ItemList> { const total = list.length; let count = 0; return new Promise(function(resolve) { function loadImage(item: ItemInfo) { const img = new Image(); img.src = item.photo; function complete<T extends { width: number, height: number }>(target: T) { count++; item.width = img.width; item.height = img.height; if (count >= total) { resolve(list); } } img.onload = () => complete(img); img.onerror = function() { item.photo = defaultPic.data; complete(defaultPic); }; } for (let i = 0; i < total; i++) { loadImage(list[i]); } }); } async function getData() { page.loading = true; const res = await getList(20); // page.loading = false; if (res.code === 1) { const list = await setImageSize(res.data); page.loading = false; cacheList = cacheList.concat(list); // TODO: 手動更新,這裡不把`cacheList`放進`page`裡面是因為響應資料列表過多會影響效能 page.update++; } } const pageList = computed(function() { const result = new Array(page.column).fill(0).map((_, index) => ({ id: index, list: [] as ItemList, height: 0 })); /** 設定列的索引 */ let columnIndex = 0; // TODO: 這裡放一個引用值,用於手動更新; page.update; // 開始組裝資料 for (let i = 0; i < cacheList.length; i++) { const item = cacheList[i]; if (columnIndex < 0) { // 從這裡開始,將以最低高度列的陣列進行新增資料,這樣就不會出現某一列高度與其他差距較大的情況 result.sort((a, b) => a.height - b.height); // console.log("資料新增前 >>", item.id, result.map(ele => ({ index: ele.id, height: ele.height }))); result[0].list.push(item); result[0].height += item.height!; // console.log("資料新增後 >>", item.id, result.map(ele => ({ index: ele.id, height: ele.height }))); // console.log("--------------------"); } else { result[columnIndex].list.push(item); result[columnIndex].height += item.height!; columnIndex++; if (columnIndex >= page.column) { columnIndex = -1; } } } console.log("重新計算列表 !!----------!!"); // 最後排一下原來的順序再返回即可 result.sort((a, b) => a.id - b.id); // console.log("處理過的資料列表 >>", result); return result; });
這樣就達到完美的效果了,但是每次獲取資料的時候卻要等一會,因為要把獲取回來的圖片全部載入完才進行資料顯示,所以沒有基礎版的無腦組裝資料然後渲染快。除非然讓後端返回資料的時候也帶上圖片的寬高(不現實),只能在上傳圖片的操作中攜帶上。
終極版
有沒有一種幾不需要組裝資料,也不需要獲取圖片尺寸的方法?當然有,答案就是用css
中的grid
佈局!
在列表容器中
.waterfall-box { --column: 4; display: grid; grid-template-columns: repeat(var(--cloumn), 1fr); align-items: end; grid-gap: 0 20px; padding: 20px 0; }
看到--cloumn
應該猜到是什麼回事了吧,保持上面ResizeObserver
監聽列的操作不變,將.waterfall-box
動態設定變數即可,最後再處理一下每個 item 中的 grid-row-end
屬性即可
<template> <div class="waterfall-item" v-for="(item, index) in page.list" :key="item.id"> <img class="pic" :src="item.photo" alt="" :ref="e => setItemStyle(e as any, index)"> <div class="title">{{ item.title }}</div> <div class="content ellipsis_2">{{ item.text }}</div> </div> </template> <script setup lang="ts"> // ...省略重複程式碼 function setItemStyle(img: HTMLImageElement, index: number) { // console.log(index, img); if (!img) return; function update() { const item = img.parentElement; if (!item) return; const gapRows = index >= page.column ? (page.column * 2) : 0; const rows = Math.ceil(item.clientHeight / 2) + gapRows; item.style.gridRowEnd = `span ${rows}`; } update(); img.onload = update; img.onerror = function() { img.src = defaultPic.data; update(); }; } </script>
本文轉載於:https://juejin.cn/post/7345379926147252236#heading-0
如果對您有所幫助,歡迎您點個關注,我會定時更新技術文件,大家一起討論學習,一起進步。