還記得那是周五的一個晚上,那天正在寢室用筆記本玩賽博朋克2077 happy 中,突然女朋友給我發了一個小紅書的超鏈接,我像往常一樣無視發送的內容直奔鏈接準備看完趕緊應付幾句話繼續游戲
打開鏈接以為跟其他類似產品一樣都會讓你直接選擇下載 App,沒想到小紅書的 PC 端驚到我了,首頁出乎意料的好看
那時候年輕的我還不知道瀑布流是什么,只是覺得這個布局配合卡片的一些點擊交互很有感覺,嘖嘖嘖,我一個大男的看一些女性的推薦都被吸引到了,不過吸引我的不是內容,而是它的布局和交互
這段時間全在忙學校實訓不寫文章手都癢了,今天就來研究一下小紅書的瀑布流布局
其實掘金上已經有很多篇文章都講過小紅書瀑布流的實現,但是如果仔細觀察小紅書使用的是瀑布流虛擬列表,關于這一點我幾乎沒有見到一篇文章有著重講解的,都是簡單講講瀑布流實現了事
但我認為小紅書首頁布局最大的亮點就在瀑布流和虛擬列表的結合,所以這段時間就來深入講解一下這塊實現的原理
本文屬于瀑布流虛擬列表的前置篇 ,因為瀑布流虛擬列表牽扯的概念比較多所以需要拆分講解,這次就先科普一下基礎瀑布流的實現,用 Vue3 + TS 封裝一個瀑布流組件
上手寫代碼之前我們簡單介紹一下瀑布流布局的實現思路
瀑布流布局的應用場景主要在于圖片或再搭配一點文字形成單獨的一張卡片,由于圖片尺寸不同會造成卡片大小不一,按照常規的盒子布局思路無外乎只有兩種:
獨占一行不用講肯定不符合我們想要實現的效果,而緊挨著排列由于卡片大小問題會出現這樣的情況:
當前行高度最大的盒子決定了下一行盒子擺布的起始位置,如果卡片之間高度差距過大就會出現大量的留白
很顯然常規布局并不能很好的利用空間,給人帶來的視覺效果也較為混亂
而使用瀑布流布局很好的解決了這一點,我們打破常規布局的方案,使用定位或者位移來控制每張卡片的位置,最大化彌補卡片之間的留白情況
所以瀑布流布局的核心實現思想:
如果按照這樣的思想我們改造上面圖中卡片擺放的順序:
①②③ 按照順序緊挨著排布
④ 準備排布時找到最小高度列是第三列,所以會排布在 ③ 下面
⑤ 準備排布時找到最小高度列是第二列,所以會排布在 ② 下面
⑥ 準備排布時找到最小高度列是第一列,所以會排布在 ① 下面
可以看到這種布局方式解決了第一行和第二行中間留白的情況,布局時卡片再帶一點間距視覺效果會更好,同理剩下圖片卡片擺放也是按照這樣的思路
關于圖片相關的數據我就不自己準備了,有現成的數據接口那當然拿來用啦
我們直接使用小紅書的數據接口把數據粘下來保存到本地即可:
不過稍微動點腦子就知道像這樣的網站針對于圖片一定會加上防盜鏈的,所以我也就不費勁繞開處理了,主要是要提取它的尺寸信息,至于圖片就先隨便給個顏色占位了
在開始寫代碼之前還有這一個問題需要討論:一般情況下瀑布流布局后端返回的數據不止有圖片的鏈接還有圖片的寬高信息(比如小紅書中針對于單個卡片就有 width 和 height 字段)
有了這些信息前端使用時無需再單獨獲取 img DOM 就能夠快速計算卡片縮放后的寬高以及后續的位置信息
但如果后端沒有返回這些信息只給了圖片鏈接那就只能全部交給前端來處理,因為圖片尺寸信息在瀑布流實現中是必須要獲取到的,這里就需要用到圖片預加載技術
簡單描述一下就先提前訪問圖片鏈接進行加載操作但不展示在視圖上,后續使用該鏈接后圖片會從緩存中加載而不是向服務器請求,因此被稱之為預加載
而在瀑布流當中我們就是提前訪問圖片鏈接來獲取其尺寸信息,我們封裝為一個工具函數:
function preLoadImage(link) {
return new Promise((resolve, reject)=> {
const img=new Image();
img.src=link;
img.onload=()=> {
// load 事件代表圖片已經加載完畢,通過該回調才訪問到圖片真正的尺寸信息
resolve({ width: img.width, height: img.height });
};
img.onerror=(err)=> {
reject(err);
};
});
}
所以假設如果有很多圖片,那么就必須要保證所有圖片全部加載完畢獲取到尺寸信息后才能開始瀑布流布局流程,如果一旦有一張圖片加載失敗就會導致瀑布流布局出現問題
好處就是用戶看到的圖片是直接從緩存進行加載的速度很快,壞處就是剛開始等待所有圖片加載會很慢
而如果后端返回圖片尺寸信息我們就無需考慮圖片是否加載完成,直接根據其尺寸信息先進行布局,之后利用圖片懶加載技術即可,所以真實業務場景肯定還是后端攜帶信息更好
前面鋪墊了這么多終于要開始寫代碼了,我們還是按照以前的老規矩,先看看整個 DOM 結構是什么樣:
其實和虛擬列表差不多,只需要一個容器 container、列表 list 以及數據項 item
只不過封裝組件后 item 后續會使用 v-for 遍歷出來,同時可以定義插槽讓父組件展示圖片,這些后續再說
<div class="fs-waterfall-container">
<div class="fs-waterfall-list">
<div class="fs-waterfall-item"></div>
</div>
</div>
container 作為整個瀑布流的容器它是需要展示滾動條的, list 作為 item 的容器可以開啟相對定位,而 item 開啟絕對定位,由于我會通過 translate 來控制每張卡片的位置,所以每張卡片定位統一放到左上角即可:
.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;
}
}
既然封裝成組件必然少不了 props 傳遞來進行配置,針對于瀑布流其實只需要這幾個屬性
而對于單個數據項我們只需要圖片的信息,其他的信息都不重要
小紅書的瀑布流還有 title 以及 author 信息影響整個卡片的高度,這塊放到最后實現,我們先只展示圖片
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;
}
接下來我們定義組件內部狀態:
const containerRef=ref<HTMLDivElement | null>(null); // 綁定 template 上的 container,需要容器寬度
const state=reactive({
isFinish: false, // 判斷是否已經沒有數據,后續不再發送請求
page: 1,
cardWidth: 0, // // 容器內卡片寬度
cardList: [] as ICardItem[], // 卡片數據源
cardPos: [] as ICardPos[], // 卡片擺放位置信息
cloumnHeight: new Array(props.column).fill(0) as number[], // 存儲每列的高度,進行初始化操作
});
初始化操作只有兩個工作:計算卡片寬度 、發送請求獲取數據
初始化時最重要的就是先計算出該瀑布流布局中卡片的寬度是多少,即 state.cardWidth,每列的寬度都是固定的
其實計算方法很簡單,直接來看下圖就知道怎么計算了:
const containerWidth=containerRef.value.clientWidth;
state.cardWidth=(containerWidth - props.gap * (props.column - 1)) / props.column;
注意使用 clientWidth 作為容器的寬度(clientWidth 不會計算滾動條的寬度)
之后就需要封裝一個發送請求獲取數據的函數了,需要注意的就是獲取數據后要判斷是否為空來決定后續是否還發送請求:
const getCardList=async (page: number, pageSize: number)=> {
if (state.isFinish) return;
const list=await props.request(page, pageSize);
state.page++;
if (!list.length) {
state.isFinish=true;
return;
}
state.cardList=[...state.cardList, ...list];
computedCardPos(list); // key:根據請求的數據計算卡片位置
};
我們整合到 init 方法中,在 onMounted 里進行調用:
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);
}
};
onMounted(()=> {
init();
});
下面就到瀑布流核心實現環節了,我們在實現思路中談到每當后續卡片進行布局時都需要計算最小列高度將其擺放至下面,很顯然計算最小列高度方法是被頻繁使用的,關鍵在于獲取最小列以及最小列高度,這里可以直接使用計算屬性實現:
因為還要獲取下標,所以沒法直接 Math.min 了,直接遍歷比較出最小值即可:
const minColumn=computed(()=> {
let minIndex=-1,
minHeight=Infinity;
state.columnHeight.forEach((item, index)=> {
if (item < minHeight) {
minHeight=item;
minIndex=index;
}
});
return {
minIndex,
minHeight,
};
});
在上面一小節的發送請求函數中的末尾有一個 computedCardPos 方法我們沒有實現,它就是每當獲取到新的數據后計算新數據卡片的位置信息,將其保存至 state.cardPos 中
我們來看它的實現步驟:
下面就直接粘代碼了:
const computedCardPos=(list: ICardItem[])=> {
list.forEach((item, index)=> {
const cardHeight=Math.floor((item.height * state.cardWidth) / item.width);
if (index < props.column) {
state.cardPos.push({
width: state.cardWidth,
height: cardHeight,
x: index % props.column !==0 ? 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 % props.column !==0 ? minIndex * (state.cardWidth + props.gap) : 0,
y: minHeight,
});
state.columnHeight[minIndex] +=cardHeight + props.gap;
}
});
};
這里的計算可能會有一些疑問,簡單做下解答吧:
有了 state.cardPos 位置信息就可以修改 template 模板了,我們遍歷數據設置位置樣式即可:
<template>
<div class="fs-waterfall-container" ref="containerRef">
<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"></slot>
</div>
</div>
</div>
</template>
到此我們的瀑布流已經可以看到效果了,我們在父組件里使用一下看看
這里就不再解釋了,稍微寫點結構和樣式,導入最早扒來的小紅書數據,按照規定屬性傳入即可,只不過我們不能使用圖片鏈接(防盜鏈問題),就稍微寫一個帶顏色的盒子吧:
<template>
<div class="app">
<div class="container">
<fs-waterfall :bottom="20" :column="4" :gap="10" :page-size="20" :request="getData">
<template #item="{ item, index }">
<div
class="card-box"
:style="{
background: colorArr[index % (colorArr.length - 1)],
}"
>
<!-- <img :src="item.url" /> -->
</div>
</template>
</fs-waterfall>
</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";
import { ICardItem } from "./components/type";
const colorArr=["#409eff", "#67c23a", "#e6a23c", "#f56c6c", "#909399"];
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];
console.log(list.length);
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: 700px;
height: 600px;
border: 1px solid red;
}
.card-box {
position: relative;
width: 100%;
height: 100%;
border-radius: 10px;
}
}
</style>
效果還是不錯的,但是觸底加載更多我們還沒有實現,下一步就來實現它
這其實也很好實現,我們只需給 container 添加滾動事件即可,按照以往的判斷觸底套路,再用上我們前面封裝的獲取數據函數即可
不過需要注意兩個問題:
const state=reactive({
// ...
loading: false,
});
const getCardList=async (page: number, pageSize: number)=> {
// ...
state.loading=true;
const list=await props.request(page, pageSize);
// ...
state.loading=false;
};
const computedCardPos=(list: ICardItem[])=> {
list.forEach((item, index)=> {
// 增加另外條件,cardList <=pageSize 說明是第一次獲取數據,第一行緊挨排布
if (index < props.column && state.cardList.length <=props.pageSize) {
// ...
} else {
// ...
}
});
};
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);
}
});
<template>
<!-- 綁定 scroll 事件 -->
<div class="fs-waterfall-container" ref="containerRef" @scroll="handleScroll">
<!-- ... -->
</div>
</template>
嗯,效果不錯,至于 loading 蒙層以及圖片的懶加載效果我就不做了,留給大伙自行拓展吧
到此一個基礎的圖片瀑布流組件已經封裝完成了,接下來我們來深入研究一下小紅書的瀑布流
拋開小紅書中虛擬列表的實現先不談,還有一點就是展示的卡片不僅有圖片信息,還有文字信息:
這些文字信息你會發現它還是不定高的,這就比較麻煩了,無法確定單個卡片的高度會導致瀑布流布局計算出現問題
不過如果仔細分析的話,你會發現它只有兩種情況: title 文本是單行或者雙行,這點直接從 css 就可以看得出來:
后來發現還有一種情況是連 title 都沒有,但是出現的情況比較少,這點先不考慮了
不僅如此,小紅書的瀑布流還是響應式的,如果你去改變視口寬度,可能會出現一種情況:單行文本由于卡片寬度的壓縮變成了雙行
而關于 author 我發現它的高度是定死的 20px:
所以總結下來最大的兩個問題:
廢話不多說,我們先封裝一個小紅書卡片組件出來,當然只實現最基本的樣式效果,圖片依舊直接純色占位:
<template>
<div class="fs-book-card-container">
<div class="fs-book-card-image">
<!-- <img :src="props.detail.url" /> -->
</div>
<div class="fs-book-card-footer">
<div class="title">{{ props.detail.title }}</div>
<div class="author">
<div class="author-info">
<div class="avatar" />
<!-- <img :src="props.detail.avatar" class="avatar" /> -->
<span class="name">{{ props.detail.author }}</span>
</div>
<div class="like">100</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface ICardDetail {
bgColor: string;
title: string;
author: string;
imageHeight: number;
[key: string]: any;
}
const props=defineProps<{
detail: ICardDetail;
}>();
</script>
<style scoped lang="scss">
.fs-book-card {
&-container {
width: 100%;
height: 100%;
background-color: #fff;
}
&-image {
width: 100%;
height: v-bind("`${props.detail.imageHeight}px`");
border: 1px solid #eee;
border-radius: 20px;
background-color: v-bind("props.detail.bgColor");
}
&-footer {
padding: 12px;
font-size: 14px;
.title {
margin-bottom: 8px;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
color: rgba(51, 51, 51, 0.8);
}
.author {
font-size: 13px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
.author-info {
flex: 1;
display: flex;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.avatar {
margin-right: 6px;
width: 20px;
height: 20px;
border-radius: 20px;
border: 1px solid rgba(0, 0, 0, 0.08);
background-color: v-bind("props.detail.bgColor");
}
.name {
width: 80%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgba(51, 51, 51, 0.8);
}
}
}
}
}
</style>
</style>
emm ,大差不差吧~ 無非是沒有圖片罷了
下面就要大改之前實現的瀑布流組件了,思路很簡單,現在最大的問題就是卡片的高度不固定了,我們需要自己獲取 DOM 來計算
也就是說之前我們實現 computedCardPos 方法要分兩步了:
首先我們改造之前存儲卡片位置信息的數據結構,現在高度分為:卡片高度、卡片內圖片高度:
export interface IBookCardPos {
width: number;
imageHeight: number; // 圖片高度
cardHeight: number; // 卡片高度
x: number;
y: number;
}
在獲取到數據信息后我們增加一個計算卡片圖片高度的方法,并將其添加到記錄卡片位置信息的數組中,而卡片高度和位置信息統一為 0,等下一步 DOM 更新后獲取計算:
const computedImageHeight=(list: ICardItem[])=> {
list.forEach((item)=> {
const imageHeight=Math.floor((item.height * state.cardWidth) / item.width);
state.cardPos.push({
width: state.cardWidth,
imageHeight: imageHeight,
cardHeight: 0,
x: 0,
y: 0,
});
});
};
添加完成之后我們需要等待一次 nextTick,保證上面的位置信息 DOM 已經進行了掛載(但是還沒有渲染到界面上)
nextTick 之后我們就需要計算真正卡片的高度以及其位置了,我們獲取 list DOM 并在內部獲取其 children 遍歷獲取其高度,位置計算和之前一樣:
const listRef=ref<HTMLDivElement | null>(null);
const getCardList=async (page: number, pageSize: number)=> {
// ...
computedCardPos(list);
// ...
};
const computedCardPos=async (list: ICardItem[])=> {
computedImageHeight(list);
await nextTick();
computedRealDomPos(list);
};
const computedRealDomPos=(list: ICardItem[])=> {
const children=listRef.value!.children;
list.forEach((_, index)=> {
const nextIndex=state.preLen + index;
const cardHeight=children[nextIndex].getBoundingClientRect().height;
if (index < props.column && state.cardList.length <=props.pageSize) {
state.cardPos[nextIndex]={
...state.cardPos[nextIndex],
cardHeight: cardHeight,
x: nextIndex % props.column !==0 ? nextIndex * (state.cardWidth + props.gap) : 0,
y: 0,
};
state.columnHeight[nextIndex]=cardHeight + props.gap;
} else {
const { minIndex, minHeight }=minColumn.value;
state.cardPos[nextIndex]={
...state.cardPos[nextIndex],
cardHeight: cardHeight,
x: minIndex % props.column !==0 ? minIndex * (state.cardWidth + props.gap) : 0,
y: minHeight,
};
state.columnHeight[minIndex] +=cardHeight + props.gap;
}
});
state.preLen=state.cardPos.length;
};
注意這里的 nextIndex 計算,這是因為要考慮到觸底加載更多的情況,我們在狀態中增加了 preLen 屬性用來保存當前已經計算過的卡片位置數組長度,等觸底加載更多數據再重復走計算邏輯時它的索引就應該從 preLen 開始往后計算
同樣在 template 模板中,我們使用插槽把卡片里圖片高度拋出,正好可以讓我們封裝的小紅書卡片使用:
<template>
<div class="fs-book-waterfall-container" ref="containerRef" @scroll="handleScroll">
<!-- 獲取 list DOM ref -->
<div class="fs-book-waterfall-list" ref="listRef">
<div
class="fs-book-waterfall-item"
v-for="(item, index) in state.cardList"
:key="item.id"
:style="{
width: `${state.cardWidth}px`,
transform: `translate3d(${state.cardPos[index].x}px, ${state.cardPos[index].y}px, 0)`,
}"
>
<!-- 傳遞 imageHeight 給小紅書卡片組件 -->
<slot name="item" :item="item" :index="index" :imageHeight="state.cardPos[index].imageHeight"></slot>
</div>
</div>
</div>
</template>
這時候父組件使用瀑布流組件時就可以這樣用了:
<template>
<div class="app">
<div class="container">
<fs-book-waterfall :bottom="20" :column="4" :gap="10" :page-size="20" :request="getData">
<template #item="{ item, index, imageHeight }">
<fs-book-card
:detail="{
imageHeight,
title: item.title,
author: item.author,
bgColor: colorArr[index % (colorArr.length - 1)],
}"
/>
</template>
</fs-book-waterfall>
</div>
</div>
</template>
<script setup lang="ts">
import data1 from "./config/data1.json";
import data2 from "./config/data2.json";
import FsBookWaterfall from "./components/FsBookWaterfall.vue";
import FsBookCard from "./components/FsBookCard.vue";
import { ICardItem } from "./components/type";
const colorArr=["#409eff", "#67c23a", "#e6a23c", "#f56c6c", "#909399"];
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,
title: i.note_card.display_title,
author: i.note_card.user.nickname,
}));
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,
title: i.note_card.display_title,
author: i.note_card.user.nickname,
}));
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: 700px;
height: 600px;
border: 1px solid red;
}
.box {
width: 250px;
}
}
</style>
效果還不錯,就是這樣做性能就比不上定高的實現了,畢竟現在計算位置信息都需要進行 DOM 操作
小紅書的瀑布流響應式一共實現了兩點:
第一點很好實現,我們可以監聽容器 DOM 尺寸改變后重置數據并走一遍所有的計算邏輯即可,只不過這是一個頻繁回流的過程,建議上個防抖更好:
這里監聽 DOM 尺寸變化推薦使用 ResizeObserver,我就不再封裝直接使用了,至于怎么使用看 MDN 文檔
ResizeObserver - Web API 接口參考 | MDN (mozilla.org)
// 創建監聽對象
const resizeObserver=new ResizeObserver(()=> {
handleResize();
});
// 重置計算卡片寬度以及之前所有的位置信息
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=[];
state.preLen=0;
computedCardPos(state.cardList);
});
const init=()=> {
if (containerRef.value) {
//...
resizeObserver.observe(containerRef.value);
}
};
// 掛載時監聽 container 尺寸變化
onMounted(()=> {
init();
});
// 卸載取消監聽
onUnmounted(()=> {
containerRef.value && resizeObserver.unobserve(containerRef.value);
});
可以再給 item 上添加一個過渡,顯得更自然一些:
.fs-book-waterfall {
&-item {
// ...
transition: all 0.3s;
}
}
可以可以,這樣就好看多了
接下來我們看斷點響應式的實現,它的實現其實有兩種:
為了兼容我們之前的實現就使用第二種方式,我們隨便在父組件設幾個斷點然后監聽外部 container 元素的寬度修改 column 即可:
const fContainerRef=ref<HTMLDivElement | null>(null);
const column=ref(5);
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);
});
而在瀑布流組件中我們使用 watch 監聽 column 變化,發生變化就進行回流重新計算布局:
watch(
()=> props.column,
()=> {
handleResize();
}
);
完美!這才是完整的瀑布流!
最后源碼奉上,有圖片版的瀑布流以及小紅書版的瀑布流,沒有怎么組織,但功能反正實現了:
DrssXpro/waterfall-demo: Vue3 + TS:模仿小紅書封裝瀑布流組件 (github.com):https://github.com/DrssXpro/waterfall-demo
終于把瀑布流基礎篇講完了,下一篇就直接來瀑布流虛擬列表組件了,這次實現一個完完整整的小紅書版瀑布流!
作者:討厭吃香菜
鏈接:https://juejin.cn/post/7322655035699396660
# 一、前言
隨著音視頻的爆發式的增長,各種推拉流應用場景應運而生,基本上都要求各個端都能查看實時視頻流,比如PC端、手機端、網頁端,在網頁端用websocket來接收并解碼實時視頻流顯示,是一個非常常規的場景,單純的http-flv模式受限于最大6個通道同時顯示,一般會選擇ws-flv也就是websocket的flv視頻流,這種突破了6個的限制,而且實時性很好,音視頻也都有,264/265都支持,webrtc不支持265很惱火,現在這么多265攝像頭,盡管webrtc實時性最好,但是不支持265這一點,就幾乎少掉一大半用戶,尤其是視頻監控行業。
以前也就思考過,既然是264/265的視頻流過來,收到后用ffmpeg直接解碼應該就可以播放,受限于之前的認知有限,以為一定要一個打開的地址才行,比如ffmpeg中常規操作就是avformat_open_input中填入一個地址,這個地址可以是本地音視頻文件,也可以是網絡視頻流等,反正要有一個地址才行。直到近期要解決如何采集ws://這種地址的視頻流的時候,才徹底靜下心來研究這一塊。原來ffmpeg從ffmpeg2開始就一直有這個機制,并不是一定要地址的,也可以是內存中的數據,需要定義一個AVIOContext對象,然后將formatCtx->pb賦值就完事,那邊通過avio_alloc_context的回調函數獲取數據即可。你只需要打開websocket收到數據,往緩存接口存數據就好,ffmpeg在調用打開也好讀取av_read_frame也好,都會從緩存數據中取數據,取到數據就去解碼。也就是在原來的代碼基礎上,加上少量的幾十行代碼就好,整個解碼機制完全不變,硬解碼也不用變,真舒服真完美,不得不佩服ffmpeg的設計,天才接口設計。
## 二、效果圖
通達信軟件中,我們為大家提供了可以查看當日主力資金流入流出的功能,也提供了查看3日、5日、20日等不同時間段的主力資金流入流出數據的功能,這些在之前的文章中都有介紹。
今天繼續跟大家介紹一個厲害的查看主力資金的功能,那就是“實時資金流向”功能,這個功能有別于前面講過的以日為周期查看主力資金的那些功能,它的獨到之處在于,可以查看日內每個小時的主力資金流入流出情況。
以通達信普及版為例,其功能入口就在:
市場——專項分析——實時資金流向(如下圖)
在這個功能中,我們可以按“小時”來查看市場上不同板塊及個股的資金流入流出情況,比如9:30-10:30、10:30-11:30、13:00-14:00、14:00-15:00這幾個時間段,我們都可以分段來查看主力資金的流入流出情況。(如下圖)
這樣我們就可以在盤中以最快的速度查看到當前主力資金流向了哪個板塊、朝哪個板塊集中,從而為我們的短線決策提供幫助了。
還沒有用過這個功能的朋友,趕快使用起來吧!