首页/文章/javascript

做了这么久前端还不会手写瀑布流?(H5 & 小程序

2024-10-11
13375 分钟
...

前言

做了7年前端我一直不知道瀑布流是什么(怪设计师不争气啊,哈哈哈),我一直以为就是个普通列表,几行css解决的那种。

当然瀑布流确实有css解决方案,但是这个方案对于分页列表来说完全不能用,第二页内容一出来位置都变了。

我看了一下掘金的一些文章,好长啊,觉得还是自己想一下怎么写吧。就自己实现了一遍。希望思路给大家一点帮助。

分析瀑布流

以小红书的瀑布流为例,相同宽度不同高度的卡片堆叠在一起形成瀑布流。
这里有两个难点:

  • 卡片高度如何确定?

  • 堆叠布局如何实现?

卡片的高度 = padding + imageHeight + textHeight....

不固定的内容包括:图片高度、标题行数
也就是说当我们解决了图片和标题的高度问题,那么瀑布流的第一个问题就解决了。(感觉已经写好代码了一样)

堆叠问题——因为css没有这样的布局方式,所以肯定得用js实现。最简单的解决方案就是对每一个盒子进行绝对定位
这个问题就转换成计算现有盒子的定位问题。

从问题到代码

第一个问题——图片高度

无论是企业业务场景还是个人开发,通过后端返回图片的width、height都是合理且轻松的。

前端去获取图片信息,无疑让最重要的用户体验变得糟糕。前端获取图片信息并不困难,但是完全没有必要。

所以我直接考虑后端返回图片信息的情况。

const realImageHeight = imageWidth / imageHeight * cardContentWidth;  

图片高度轻松解决,无平台差异

第二个问题——文字高度

从小红书可以看出,标题有些两行有些一行,也有些三行。

如果你固定一行,这个问题完全可以跳过。

  • 方案一:我们可以用字数和宽度来计算可能得行数
    优势:速度快,多平台复用
    劣势:不准确(标题包括英文中文)
  • 方案二:我们可以先渲染出来再获取行数
    优势:准确
    劣势:相对而言慢,不同平台方法不同

准确是最重要的!选择方案二

其实方案二也有两种方案,一种是用canvas模拟,这样可以最大限度摆脱平台(h5、小程序)的限制,
然而我试验后,canvas还没找到准确的计算的方法(待后续更新)
第二种就是用div渲染一遍,获取行数或者高度。

创建一个带有指定样式的 div 元素
function createDiv(style: string): HTMLDivElement {  
    const div = document.createElement('div');  
    div.style.cssText = style;  
    document.body.appendChild(div);  
    return div;  
}  

计算文本数组在指定字体大小和容器宽度下的行数


* 计算文本数组在指定字体大小和容器宽度下的行数  
* @param texts - 要渲染的文本数组  
* @param fontSize - 字体大小以像素为单位
* @param lineHeight - 字体高度以像素为单位
* @param containerWidth - 容器宽度以像素为单位
* @param maxLine - 最大行数以像素为单位
* @returns 每个文本实际渲染时的行数数组  
*/  
export function calculateTextLines(  
    texts: string[],  
    fontSize: number,  
    lineHeight: number,  
    containerWidth: number,  
    maxLine?: number  
): number[] {  

    const div = createDiv(`font-size: ${fontSize}px; line-height: ${lineHeight}px; width: ${containerWidth}px; white-space: pre-wrap;`);  
    const results: number[] = [];  
    texts.forEach((text) => {  
        div.textContent = text;  
        
        const divHeight = div.offsetHeight;  
        const lines = Math.ceil(divHeight / lineHeight);  
        maxLine && lines > maxLine ? results.push(maxLine) : results.push(lines);  
    });  
  
    
    removeElement(div);  

    return results;  
}  

这个问题小程序如何解决放在文末

第三个问题——每个卡片的定位问题

解决了上面的问题,就解决了盒子高度的问题,这个问题完全就是相同宽度不同高度盒子的堆放问题了

问题的完整描述是这样的:

写一个ts函数实现将一堆小盒子,按一定规则顺序推入大盒子里
函数输入:小盒子高度列表
小盒子:不同小盒子高度不一致,宽度为stackWidth,彼此间隔gap
大盒子:高度无限制,宽度为width
堆放规则:优先放置高度低的位置,高度相同时优先放在左侧
返回结果:不同盒子的高度和位置信息

如果你有了这么清晰的描述,接下去的工作你只需要交给gpt来写你的函数


export interface Box {  
    x: number;  
    y: number;  
    height: number;  
}  

export class BoxPacker {  
    
    private boxes: Box[] = [];  
    
    private width: number;  
    
    private stackWidth: number;  
    
    private gap: number;  

    constructor(width: number, stackWidth: number, gap: number) {  
    this.width = width;  
    this.stackWidth = stackWidth;  
    this.gap = gap;  
    this.boxes = [];  
}  

public addBox(height: number): Box[] {  
    return this.addBoxes([height]);  
}  

public addBoxes(heights: number[], isReset?: boolean): Box[] {  
    isReset && (this.boxes = [])  
    console.log('this.boxes—————— ', JSON.stringify(this.boxes) )  

    for (const height of heights) {  
        const position = this.findBestPosition();  
        const newBox: Box = { x: position.x, y: position.y, height };  
        this.boxes.push(newBox);  
    }  
    return this.boxes;  
}  

private findBestPosition(): { x: number; y: number } {  
    let bestX = 0;  
    let bestY = Number.MAX_VALUE;  

    for (let x = 0; x <= this.width - this.stackWidth; x += this.stackWidth + this.gap) {  
        const currentY = this.getMaxHeightInColumn(x, this.stackWidth);  
        if (currentY < bestY || (currentY === bestY && x < bestX)) {  
            bestX = x;  
            bestY = currentY;  
        }  
    }  

    return { x: bestX, y: bestY };  
}  
  
private getMaxHeightInColumn(startX: number, width: number): number {  
    return this.boxes  
        .filter(box => box.x >= startX && box.x < startX + width)  
        .reduce((maxHeight, box) => Math.max(maxHeight, box.y + box.height + this.gap), 0);  
}  
}  
  

这样我们就实现了根据高度获取定位的功能了

来实现一波

核心的代码就是获取每个盒子的定位、宽高信息


const boxPacker = useMemo(() => {  
    return new BoxPacker(width, stackWidth, gap)  
}, []);  
  
const getCurrentPosition = (currentData: DataItem[], reset?: boolean) => {  
    
    const textLines = calculateTextLines(currentData.map(item => item.title),card.fontSize,card.lineHeight, cardContentWidth)  
    
    const imageHeight = currentData.map(item => (item.imageHeight / item.imageWidth * cardContentWidth))  
    
    const cardHeights = imageHeight.map((h, index) => (  
    h + textLines[index] * card.lineHeight + card.padding * 2 + (card?.otherHeight || 0)  
    )  
    );  
    
    const boxes = boxPacker.addBoxes(  
        cardHeights,  
        reset  
    )  
    
    return boxes.map((box, index) => ({  
        ...box,  
        title: currentData[index]?.title,  
        url: currentData[index]?.url,  
        imageHeight: imageHeight[index],  
    }))  
}  
set获取到的盒子信息
const [boxPositions, setBoxPositions] = useState<(Box & Pick<DataItem, 'url' | 'title' | 'imageHeight'>)[]>([]);  
useEffect(() => {  
    
    if (page === 1) {  
        setBoxPositions(getCurrentPosition(data, true))  
    } else {  
    
        setBoxPositions(getCurrentPosition(data.slice((page - 1) * pageSize, page * pageSize)))  
    }  
}, [])  

效果如下

小程序获取文本高度

从上面的分析可以看出来只有文本高度实现是不同的,如果canvas方案实验成功,说不定还能做到大一统。
目前没成功大家就先看看我的目前方案:先实际渲染文字然后读取信息,然后获取实际高度

import React, {useEffect, useMemo, useState} from 'react'  
import { View } from '@tarojs/components'  
import Taro from "@tarojs/taro";  
import './index.less'  
import {BoxPacker} from "./flow";  
  
const data = [  
    'vwyi这是一个标题,这是一个标题,这是一个标题,这是一个标题',  
    '这是一个标题',  
    '这是一个标题,这是一个标题,这是一个标题,这是一个标题',  
    '这是一个标题',  
    '这是一个标题,这是一个标题,这是一个标题,一个标题',  
    '这是一个标题,这是一个标题,这是一个标题,这题',  
    '这是一个标题,这是一个标题,这是一',  
    '这是一个标题,这是一个标题,这是一',  
];  
  
function Index() {  
const boxPacker = useMemo(() => new BoxPacker(320, 100, 5), []);  
  
const [boxPositions, setBoxPositions] = useState<any[]>([])  
function getTextHeights() {  
return new Promise((resolve, reject) => {  
    Taro.createSelectorQuery()  
        .selectAll('#textContainer .text-item')  
        .boundingClientRect()  
        .exec(res => {  
            if (res && res[0]) {  
                const heights = res[0].map(item => item.height);  
                resolve(heights);  
            } else {  
                reject('No buttons found');  
            }  
        });  
    });  
}  
useEffect(() => {  
    getTextHeights().then(h => {  
        setBoxPositions(boxPacker.addBoxes(h))  
    })  
}, [])  
return (  
<View className="flow-container">  
<View id="textContainer">  
{  
data.map((item, index) => (<View key={index} className="text-item">{item}</View>))  
}  
</View>  
<View className="text-box-container">  
{boxPositions.map((position, index) => (  
<View  
key={index}  
className="text-box"  
style={{  
left: `${position.x}px`,  
top: `${position.y}px`,  
height: `${position.height}px`,  
width: '100px', // 假设盒子的宽度固定为100px  
}}  
>  
{`${data[index]}`}  
</View>  
))}  
  
</View>  
  
</View>  
)  
}  
  
export default Index  
  

项目react源码地址

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
javascript2026-02-24
navigator.sendBeacon全指南
在前端开发中,埋点系统是必不可少的一环。我们经常需要在用户 关闭页面 、 刷新 或 跳转路由 时,向服务器发送最后一条统计数据(比如用户停留时长、页面跳出率)。 但这看似简单的需求,在实现时却危机四伏:请求发不出去?页面跳转卡顿?今天我们就来聊聊这个问题的终极解决方案 —— 。 一、 痛点与传统方案的挣扎 场景还原 当用户点击关闭按钮时,浏览器会触发生命周期事件( 或 )。如果我们直接使用普通的异步 AJAX ( 或 ) 发送请求,浏览...
学习
javascript2025-11-02
理解浏览器事件系统,从用户点击到事件对象的完整旅程
深入理解浏览器事件系统:从用户点击到事件对象的完整旅程 “当我点击页面按钮时,背后发生了什么?为什么回调函数能收到一个包含丰富信息的event对象?今天,让我们一起揭开浏览器事件系统的神秘面纱。” 一个令人困惑的现象 作为前端开发者,我们每天都在写这样的代码: 这段代码如此熟悉,以至于我们很少停下来思考:​ ​这个 对象到底从哪里来?它为什么能知道点击的精确坐标?为什么能识别是哪个元素被点击了?​ ​ 更神奇的是,当我们手动创建事件时:...
学习
javascript2025-10-01
实现大文件上传全流程详解
在日常开发中,大文件上传是个绕不开的坎——动辄几百 MB 甚至 GB 级的文件,直接上传不仅容易超时,还会让用户体验大打折扣。最近我用 Vue+Express 实现了一套完整的大文件上传方案,支持分片上传、断点续传、秒传和手动中。 一、先看效果:我们要实现什么? 先上核心功能清单,确保大家明确目标,知道我们要解决哪些实际问题: 大文件分片上传 :将文件切成固定大小的小片段分批上传,避免单次请求超时 秒传 :服务器已存在完整文件时,直接返...
学习
javascript2025-09-18
JavaScript 的多线程能力:Worker
如果你写过一些计算量稍大的 JavaScript 代码,比如图像处理、大量数据排序或者复杂的算法,你几乎肯定遇到过浏览器“卡死”的现象。点击页面没反应,动画也停了,就像整个世界都静止了。 这就是主线程被阻塞的典型后果。因为主线程既要负责执行 JavaScript,又要负责渲染页面、响应用户操作,一旦它被繁重的计算任务占满,就无暇顾及其他,用户体验便直线下降。 这个问题的根源,正是“主线程是单线程的”。那么,如何解决呢? 答案很简单:把这...
学习面试
javascript2025-09-15
一张 8K 海报差点把首屏拖垮
你给后台管理系统加了一个「企业风采」模块,运营同学一口气上传了 200 张 8K 宣传海报。首屏直接飙到 8.3 s,LCP 红得发紫。 老板一句「能不能像朋友圈那样滑到哪看到哪?」——于是你把懒加载重新翻出来折腾了一轮。 解决方案:三条技术路线,你全踩了一遍 1\. 最偷懒:原生 一行代码就能跑,浏览器帮你搞定。 🔍 关键决策点 2020 年后现代浏览器全覆盖,IE 全军覆没。 必须写死 ,否则 CLS 会抖成 PPT。 适用场景...
学习
javascript2025-09-10
🚀 Web Worker让你的应用丝滑
🌟 引言 在日常的前端开发中,你是否遇到过这样的困扰: 大数据处理时页面卡死 :处理几万条数据时,页面直接卡成PPT,用户点击毫无反应 复杂计算阻塞UI :图片处理、数据分析等计算密集型任务让整个应用假死 文件上传/下载卡顿 :大文件操作时,其他功能完全无法使用 实时数据处理性能差 :WebSocket接收大量数据时,页面渲染严重滞后 今天分享6个Web Worker的核心技巧,让你的应用告别卡顿,用户体验丝滑如德芙! 💡 核心技巧...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录