Yongzhi
瀑布流的实现、优化与扩展
前言
瀑布流布局对前端来说是非常熟悉的场景,最近工作上有相关需求,于是便找了一下相关插件,发现都不是太完美,于是决心自己手写一个瀑布流,我的遇到的场景是图片大小不同,但是图片描述高度是相同的。如下图
双列瀑布流
这里使用
javascript
搭配flex
布局实现
首先我们需要构建一个瀑布流框架,这里为了方便css
部分使用tailwindcss完成
export function Waterfall() {
const [leftData, setleftData] = useState<Array<CardData>>([])
const [rightData, setRightData] = useState<Array<CardData>>([])
return (
<section className="w-full h-[600px]">
<div className="w-full flex px-2.5 gap-y-1 h-fit">
<div className="flex flex-col grow">
{
leftData.map((card, i) => <Card {...card} key={i} />)
}
</div>
<div className="flex flex-col grow">
{
rightData.map((card, i) => <Card {...card} key={i} />)
}
</div>
</div>
</section>
)
}
interface CardData {
source: string
description: string
width: number
height: number
}
const Card = ({ source, description }: CardData) => {
return (
<div className="w-full my-1 h-fit">
<img src={source} alt="waterfall" className='w-full h-auto my-0' />
<small className='h-6 text-sm text-center truncate'>{description}</small>
</div>
)
}
接下来是重点,我们需要根据服务端返回的数据渲染列表。在渲染之前我们需要先进行计算当前图片应该是放在左列和右列,由于图片原尺寸不一致所以我们要进行系数换算, 在系数换算之前我们需要先拿到图片的宽高
const calculateImage = (image: string):Promise<{height: number, width: number}> => new Promise((resolve,reject) => {
const img = new Image()
img.src = image
img.onload = () => {
resolve({
width: img.width,
height: img.height
})
}
img.onerror = e => {
reject(e)
}
})
我们需要根据服务端渲染的数据去实时计算当前图片的宽高,以及当前图片所属的位置
const calculatePosition = async (cards: Array<CardData>) => {
let leftHeight = leftData.reduce((pre, nex) =>pre + nex.height, 0)
let rightHeight = rightData.reduce((pre, nex) =>pre + nex.height, 0)
for (let i = 0; i < cards.length; i++) {
const element = cards[i];
const { width, height } = await calculateImage(element.source)
element.height = 100 / width * height
if (leftHeight <= rightHeight) {
leftHeight += element.height
setleftData((list) => ([...list, element]))
} else {
rightHeight += element.height
setRightData((list) => [...list, element])
}
}
}
点击查看完整代码
interface CardData {
source: string
description: string
width: number
height: number
}
const calculateImage = (image: string):Promise<{height: number, width: number}> => new Promise((resolve,reject) => {
const img = new Image()
img.src = image
img.onload = () => {
resolve({
width: img.width,
height: img.height
})
}
img.onerror = e => {
reject(e)
}
})
const Card = ({ source, description }: CardData) => {
return (
<div className="w-full my-1 h-fit">
<img src={source} alt="waterfall" className='w-full h-auto my-0' />
<small className='h-6 text-sm text-center truncate'>{description}</small>
</div>
)
}
export default function Waterfall() {
const [leftData, setleftData] = useState<Array<CardData>>([])
const [rightData, setRightData] = useState<Array<CardData>>([])
const fetchData = async () => {
const { data } = await axios.get('/api/waterfall')
calculatePosition(data.list)
}
const calculatePosition = async (cards: Array<CardData>) => {
let leftHeight = leftData.reduce((pre, nex) =>pre + nex.height, 0)
let rightHeight = rightData.reduce((pre, nex) =>pre + nex.height, 0)
for (let i = 0; i < cards.length; i++) {
const element = cards[i];
const { width, height } = await calculateImage(element.source)
element.height = 100 / width * height
if (leftHeight <= rightHeight) {
leftHeight += element.height
setleftData((list) => ([...list, element]))
} else {
rightHeight += element.height
setRightData((list) => [...list, element])
}
}
}
useEffect(() => {
fetchData()
},[])
return (
<section className="w-full h-[600px] overflow-y-auto">
<div className="w-full flex px-2.5 gap-y-1 h-fit gap-2.5">
<div className="flex flex-col grow">
{
leftData.map((card, i) => <Card {...card} key={i} />)
}
</div>
<div className="flex flex-col grow">
{
rightData.map((card, i) => <Card {...card} key={i} />)
}
</div>
</div>
</section>
)
}
多列瀑布流
多列瀑布流的实现其实和双列瀑布流没有太多区别,双列瀑布流是每列一个list
各自渲染各自,多列就是将所有的列放在一个list
中,每个list
管理各自数据
所以我们需要先构建一个由列数确定的二维数组,每个元素代表每列,元素中的card
数据代表当前列的数据
const column = 5
const [waterfall, setWaterfall] = useState<Array<Array<CardData>>>(new Array(column).fill(new Array()))
确定列之后我们通过计算来确定决定每个card
所在的列
const calculatePosition = async (cards: Array<CardData>) => {
const targets = waterfall.map((list, index) => ({
height: list.reduce((pre, nex) => pre + nex.height, 0),
index
}))
for (let i = 0; i < cards.length; i++) {
targets.sort((a, b) => a.height - b.height)
const index = targets[0].index
const element = cards[i];
const { width, height } = await calculateImage(element.source)
element.height = 100 / width * height
setWaterfall(fall => {
return fall.map((_, key) => {
if (key === index) return [..._, element]
return [..._]
})
})
targets[0].height += element.height
}
}
通过计算过后我们整理好了对应的数据 如下:
[[{"source":"http://dummyimage.com/725x638","description":"片发领交江油况适学利知由理","height":88},{"source":"http://dummyimage.com/807x587","description":"离选第建情才响三点术究进","height":72.73853779429987}],[{"source":"http://dummyimage.com/530x767","description":"从实精车法改运较力究色究","height":144.7169811320755}],[{"source":"http://dummyimage.com/539x707","description":"由人龙数情达动公构它好根文况月","height":131.16883116883116},{"source":"http://dummyimage.com/767x875","description":"毛节则九际少话原花土工取理气部","height":114.08083441981748}],[{"source":"http://dummyimage.com/707x723","description":"划水京养界统外之布县精所见","height":102.26308345120226},{"source":"http://dummyimage.com/567x809","description":"例便收除因现号志信到火大提种","height":142.68077601410934}],[{"source":"http://dummyimage.com/958x759","description":"段至开件过通才业压强拉间","height":79.22755741127348},{"source":"http://dummyimage.com/648x671","description":"划非除市地家东给存始程战他","height":103.54938271604938}]]
拿到数据后进行渲染:
<section className="w-full h-[600px] overflow-y-auto">
<div className="w-full flex px-2.5 gap-y-1 h-fit gap-2.5">
{
waterfall.map((parent, index) => {
return (
<div className="flex flex-col grow" key={`parent-${index}`}>
{
parent.map((card, i) => <Card {...card} key={i} />)
}
</div>
)
})
}
</div>
</section>
点击查看完成代码
interface CardData {
source: string
description: string
width: number
height: number
}
const calculateImage = (image: string):Promise<{height: number, width: number}> => new Promise((resolve,reject) => {
const img = new Image()
img.src = image
img.onload = () => {
resolve({
width: img.width,
height: img.height
})
}
img.onerror = e => {
reject(e)
}
})
const Card = ({ source, description }: CardData) => {
return (
<div className="w-full my-1 h-fit">
<img src={source} alt="waterfall" className='w-full h-auto my-0' />
<small className='h-6 text-sm text-center truncate'>{description}</small>
</div>
)
}
export default function MoreColumnWaterfall() {
const column = 5
const [waterfall, setWaterfall] = useState<Array<Array<CardData>>>(new Array(column).fill(new Array()))
const fetchData = async () => {
const { data } = await axios.get('/api/waterfall')
calculatePosition(data.list)
}
const calculatePosition = async (cards: Array<CardData>) => {
const targets = waterfall.map((list, index) => ({
height: list.reduce((pre, nex) => pre + nex.height, 0),
index
}))
for (let i = 0; i < cards.length; i++) {
targets.sort((a, b) => a.height - b.height)
const index = targets[0].index
const element = cards[i];
const { width, height } = await calculateImage(element.source)
element.height = 100 / width * height
setWaterfall(fall => {
return fall.map((_, key) => {
if (key === index) return [..._, element]
return [..._]
})
})
targets[0].height += element.height
}
}
useEffect(() => {
fetchData()
},[])
return (
<section className="w-full h-[600px] overflow-y-auto">
<div className="w-full flex px-2.5 gap-y-1 h-fit gap-2.5">
{
waterfall.map((parent, index) => {
return (
<div className="flex flex-col grow" key={`parent-${index}`}>
{
parent.map((card, i) => <Card {...card} key={i} />)
}
</div>
)
})
}
</div>
</section>
)
}
优化
-
数据加载优化
-
图片加载优化
数据加载优化
优化主要是从以上两个角度来切入,首先针对于第一点,因为我们在给图片排序时是使用javascript
获取图片信息来计算,在没有计算出结果时是无法决定
当前元素在那一列,所以需要服务端配合,返回对应的图片的宽高,避免渲染时的开销,所以删除对应的函数,从服务端返回的数据中获取对应信息
请删除以下代码:
const calculateImage = (image: string):Promise<{height: number, width: number}> => new Promise((resolve,reject) => {
const img = new Image()
img.src = image
img.onload = () => {
resolve({
width: img.width,
height: img.height
})
}
img.onerror = e => {
reject(e)
}
})
const calculatePosition = async (cards: Array<CardData>) => {
let leftHeight = leftData.reduce((pre, nex) =>pre + nex.height, 0)
let rightHeight = rightData.reduce((pre, nex) =>pre + nex.height, 0)
for (let i = 0; i < cards.length; i++) {
const element = cards[i];
- const { width, height } = await calculateImage(element.source)
- element.height = 100 / width * height
+ element.height = 100 / element.width * element.height
if (leftHeight <= rightHeight) {
leftHeight += element.height
setleftData((list) => ([...list, element]))
} else {
rightHeight += element.height
setRightData((list) => [...list, element])
}
}
}
图片加载优化
对于图片加载,我们应该去使用懒加载。当card
没有在视图中展示时我们不应加载它,当出现在视图中的时候我们才去加载,这样会可以节省数据和带宽、减少CDN花销以及提升SEO。其实关于图片懒加载img
标签本身有一个loading
选项,它的值时可选的你可以点击这里查看详细信息
- 由于在视口外的图片不被及时加载,懒加载可以节省带宽的使用。这有利于提升特别是手机用户的使用性能。
- 懒加载可以确保仅从CDN请求的图片被加载,减少了服务器花销。
- 页面速度是影响SEO的关键要素——搜索引擎也更有可能推荐你的页面。因为页面加载时间少,搜索引擎会乐于推荐你的页面。
因为浏览器兼容性问题,所以我们需要手动实现一个图片懒加载。传统实现是当scroll
事件发生时,调用元素的getBoundingClientRect
方法来计算是否可见,
但是密集的scroll
事件与庞大的计算会导致我们的性能下降。幸运的是浏览器提供了另一个APIIntersectionObserver
可以自动判断元素是否在可见区域,在这里我们给予100像素的缓冲,在距离视图区域100像素内我们进行加载
const Card = ({ source, description, width, height }: CardData) => {
const imgRef = useRef<HTMLImageElement>(null)
+ const container = useRef<HTMLImageElement>(null)
+ useEffect(() => {
+ const ob = new IntersectionObserver((entries) => {
+ if (entries[0].isIntersecting) {
+ imgRef.current!.src = imgRef.current?.getAttribute('data-src')!
+ ob.unobserve(imgRef.current!)
+ }
+ }, { rootMargin: '100px' })
+ ob.observe(imgRef.current!)
+ return () => {
+ ob.disconnect()
+ }
+ }, [])
+ useLayoutEffect(() => {
+ const imgHeight = container.current!.clientWidth / width * height
+ imgRef.current!.height = imgHeight
+ }, [])
return (
<div className="w-full my-1 h-fit">
- <img src={source} alt="waterfall" className='w-full h-auto my-0' />
+ <img data-src={source} ref={imgRef} alt="waterfall" className='w-full h-auto my-0' />
<small className='h-6 text-sm text-center truncate'>{description}</small>
</div>
)
}