Yongzhi

theme

瀑布流的实现、优化与扩展

前言

瀑布流布局对前端来说是非常熟悉的场景,最近工作上有相关需求,于是便找了一下相关插件,发现都不是太完美,于是决心自己手写一个瀑布流,我的遇到的场景是图片大小不同,但是图片描述高度是相同的。如下图

imageswaterfall-demo

双列瀑布流

这里使用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>
    )
}
图片Card

接下来是重点,我们需要根据服务端返回的数据渲染列表。在渲染之前我们需要先进行计算当前图片应该是放在左列和右列,由于图片原尺寸不一致所以我们要进行系数换算, 在系数换算之前我们需要先拿到图片的宽高

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>
    )
}
多列瀑布流
双列瀑布流

其他信息