小程序瀑布流升级版

本文实现了uniapp和taro两个版本的小程序瀑布流。

上文使用双列布局实现了简单版的瀑布流,但是在小程序上面由于css不兼容,以及一次请求数据过多,图片高度不确定,会导致下滑加载数据过程中,页面上下抽搐,卡顿。
于是我换了一种相对复杂的方式,来完成小程序瀑布流的实现。

实现思路

  • 使用float布局,分为左侧和右侧两个列表
  • 在请求拿到分页数据后,遍历数据,计算leftBox 和 rightBox的高度。
  • 比较高度,将数据item push到高度小的那个盒子

uniapp版本

代码

  • 新建WaterfallList文件夹,及文件夹下的index.vue文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    <template>
    <view class="waterfallList">
    <view id="leftList" class="leftList">
    <ProductItem v-for="item in leftData" :key="item.id" :productData="item"></ProductItem>
    </view>
    <view id="rightList" class="rightList">
    <ProductItem v-for="item in rightData" :key="item.id" :productData="item"></ProductItem>
    </view>
    </view>
    </template>

    <script>
    import ProductItem from "./ProductItem.vue";

    export default {
    components: {ProductItem},
    data() {
    return {
    leftData: [],
    rightData: []
    };
    },
    methods: {
    async loadMore(data) {
    for (const item of data) {
    // 获取高度
    const leftListHeight = await this.getDomHeight('#leftList')
    const rightListHeight = await this.getDomHeight('#rightList')

    if (leftListHeight < rightListHeight) {
    this.leftData.push(item)
    } else {
    this.rightData.push(item)
    }
    }
    },
    getDomHeight(selector) {
    return new Promise(resolve => {
    uni.createSelectorQuery().in(this).select(selector).fields(
    {
    size: true,
    },
    (data) => {
    resolve(data.height)
    }
    ).exec();
    })
    }
    }
    }
    </script>

    <style lang="scss" scoped>
    .waterfallList {
    overflow: hidden;
    }
    .leftList {
    width: 48%;
    float: left;
    }
    .rightList {
    width: 48%;
    float: right;
    }
    </style>
  • ProductItem组件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    <template>
    <view class="productItem">
    <image class="productImage" :src="productData.pic" lazy-load="true" mode="widthFix"></image>
    <view class="container">
    <view class="productName">{{ productData.name }}</view>
    <view class="priceBox">
    <view>
    <text class="priceIcon">¥</text>
    {{ productData.price }}
    <text class="unit" v-if="productData.tag">/箱</text>
    </view>
    <view class="iconBox">
    <uni-icons color="#fff" type="cart" size="32rpx"></uni-icons>
    </view>
    </view>
    <view v-if="productData.tag" class="tag">
    <view class="iconBoxTag">
    <image class="medalsIcon" :src="getStatic('/index/medals.png')" mode="scaleToFill"></image>
    </view>
    <view class="tagText">{{productData.tag}}</view>
    <view class="tagOrder">TOP 1</view>
    </view>
    </view>
    </view>
    </template>

    <script>
    import {getStatic} from "@/utils/global";

    export default {
    methods: {getStatic},
    props: {
    productData: {
    type: Object,
    default: {}
    }
    },
    data() {
    return {};
    }
    }
    </script>

    <style lang="scss" scoped>
    .productItem {
    width: 344rpx;
    border-radius: 15rpx;
    overflow: hidden;
    background-color: #fff;
    margin-bottom: 20rpx;
    break-inside: avoid;
    }
    .productItem .productImage {
    width: 344rpx;
    }

    .container {
    padding: 0 22rpx 30rpx 22rpx;
    font-size: 24rpx;

    .productName {
    color: #212121;
    font-size: 28rpx;
    font-weight: 500;
    overflow: hidden;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    overflow:hidden;
    /*! autoprefixer: off */
    -webkit-box-orient: vertical;
    }

    .priceBox {
    color: #ff0000;
    font-size: 40rpx;
    line-height: 32rpx;
    font-weight: bold;
    padding-top: 22rpx;
    display: flex;
    justify-content: space-between;
    align-items: center;

    .priceIcon {
    font-size: 26rpx;
    }
    .unit {
    font-size: 28rpx;
    color: #CFCFCF;
    }
    .iconBox {
    width: 52rpx;
    height: 52rpx;
    border-radius: 50%;
    overflow: hidden;
    background: linear-gradient( 180deg, #FA2C19 0%, #FE5617 100%);
    display: flex;
    justify-content: center;
    align-items: center;
    }
    }
    .tag {
    margin-top: 20rpx;
    width: 304rpx;
    height: 48rpx;
    background: #FAF5EC;
    border-radius: 8rpx;
    display: flex;
    align-items: center;
    .iconBoxTag {
    width: 52rpx;
    height: 48rpx;
    overflow: hidden;
    background: linear-gradient( 166deg, #E3CB8B 0%, #D5A443 100%);
    border-radius: 8rpx 0rpx 8rpx 8rpx;
    display: flex;
    align-items: center;
    justify-content: center;
    .medalsIcon {
    width: 36rpx;
    height: 36rpx;
    }
    }
    .tagText{
    margin-left: 16rpx;
    font-weight: 500;
    font-size: 26rpx;
    color: #C89839;
    }
    .tagOrder {
    margin-left: 8rpx;
    font-size: 26rpx;
    color: #C89839;
    font-weight: 500;
    }
    }
    }
    </style>
  • 在index中引入WaterfallList组件,通过this.$refs调用WaterfallList组件的loadMore方法,将请求的分页数据传入

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    // 下滑加载推荐商品数据
    <template>
    <WaterfallList ref="waterfallRef"></WaterfallList>
    </template>

    <script >
    import WaterfallList from '@/components/WaterfallList/index.vue'
    export default {
    components: {
    WaterfallList
    },
    data() {
    return {
    pageNum: 1,
    loadingType: 'more'
    }
    },
    methods: {
    async loadListData() {
    if (this.loadingType === 'more') {
    this.loadingType = 'loading'
    // 请求数据
    const { data } = await fetchProductList({pageNum: this.pageNum, pageSize:10})
    if (data.length === 0) {
    this.loadingType = 'nomore'
    return
    }
    // 调用loadMore更新列表
    await this.$refs.waterfallRef.loadMore(data)
    this.pageNum++
    this.loadingType = 'more'
    }
    },
    }
    }
    </script>

taro版本

  • tsx部分

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    import React, { useState, forwardRef, useImperativeHandle, useRef } from 'react';
    import Taro from '@tarojs/taro';
    import { View } from '@tarojs/components';

    import styles from './index.module.less';

    type getElementSizeType = (selector: string) => Promise<{ width: number; height: number }>;

    interface WaterfallListProps {
    renderItem: (item: any[], index: number) => React.ReactNode; // 渲染函数,外部传入的 item 组件
    }

    /**
    * @desc 获取元素大小
    * @param selector - DOM 选择器
    * @returns Promise<{width: number; height: number}> - 返回元素的宽高
    */
    const getElementSize: getElementSizeType = (selector: string) => {
    return new Promise((resolve, reject) => {
    try {
    Taro.createSelectorQuery()
    .select(selector)
    .boundingClientRect((rect) => {
    resolve(rect as { width: number; height: number });
    })
    .exec();
    } catch (e) {
    reject(e);
    }
    });
    };

    const WaterfallList = ({ renderItem }: WaterfallListProps, ref: React.Ref<any>) => {
    // 左右两列数据状态
    const [leftColumn, setLeftColumn] = useState<any[]>([]);
    const [rightColumn, setRightColumn] = useState<any[]>([]);
    // 用于控制异步加载的标志
    const loadingFlagRef = useRef<symbol | null>(null);

    /**
    * @desc 更新指定 ID 项的数据
    * @param id - 要更新项的 ID
    * @param updatedItem - 更新后的数据
    */
    const updateItem = (id: string, updatedItem: any) => {
    setLeftColumn((prev) => prev.map((item) => (item.id === id ? updatedItem : item)));
    setRightColumn((prev) => prev.map((item) => (item.id === id ? updatedItem : item)));
    };

    /**
    * @desc 重置瀑布流列表状态
    */
    const reset = () => {
    loadingFlagRef.current = null;
    setLeftColumn([]);
    setRightColumn([]);
    };

    useImperativeHandle(ref, () => {
    return {
    loadMore,
    updateItem,
    reset,
    };
    });

    /**
    * @desc 加载更多数据
    * @param data - 要加载的数据数组
    * @description
    * 1. 创建新的加载标志,防止重复加载
    * 2. 遍历数据,对比左右列高度
    * 3. 将新数据添加到较短的一列
    */
    const loadMore = async (data: Array<any>) => {
    const currentLoadingFlag = Symbol('loading');
    loadingFlagRef.current = currentLoadingFlag;

    for (const item of data) {
    // 检查是否是最新的加载请求
    if (loadingFlagRef.current !== currentLoadingFlag) {
    break;
    }

    try {
    // 获取左右列的高度
    const leftRect = await getElementSize('#waterfallListLeftColumn');
    const rightRect = await getElementSize('#waterfallListRightColumn');

    // 再次检查加载标志,确保数据一致性
    if (loadingFlagRef.current !== currentLoadingFlag) {
    break;
    }

    // 比较左右列高度差,将新项添加到较短的一列
    if (leftRect.height - rightRect.height <= 5) {
    setLeftColumn((prevState) => [...prevState, item]);
    } else {
    setRightColumn((prevState) => [...prevState, item]);
    }
    } catch (error) {
    console.error('加载出错:', error);
    }
    }
    };

    return (
    <View className={styles.waterfallList}>
    <View
    id="waterfallListLeftColumn"
    className={`${styles.waterfallColumn} ${styles.leftColumn}`}
    >
    {leftColumn.map((item, index) => (
    <View key={item.id} className={styles.waterfallItem}>
    {renderItem(item, index)}
    </View>
    ))}
    </View>
    <View
    id="waterfallListRightColumn"
    className={`${styles.waterfallColumn} ${styles.rightColumn}`}
    >
    {rightColumn.map((item, index) => (
    <View key={index} className={styles.waterfallItem}>
    {renderItem(item, index)}
    </View>
    ))}
    </View>
    </View>
    );
    };

    export default forwardRef(WaterfallList);

  • css部分

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    .waterfallList {
    overflow: hidden;
    padding: 30px 30px;
    min-height: 100vh;
    }

    .waterfallColumn {
    width: 334px;
    }
    .leftColumn {
    float: left;
    }
    .rightColumn {
    float: right;
    }

    .waterfallItem {
    margin-bottom: 20px;
    }