Vue3瀑布流布局实现

本文介绍了如何在Vue3中使用TypeScript实现瀑布流布局,包括使用Hook技术、列数控制、断点响应、懒加载和动画效果。作者提供了详细的代码示例和封装的工具函数。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Vue3瀑布流布局实现

介绍

以下是使用Vue3TypeScript实现的瀑布流,采用hook方式。如果这篇文章启发到你,你可以尝试使用class重构一下实现,加深理解。废话不多说,直接上代码!
未完成的功能(待更新):
1. 断点响应 🚀
2. 数据懒加载
3. Loading加载层效果
4. 拖拽自定义布局

思路

  1. 获取容器的宽度,根据列计算出每一个item的宽度,并初始化每一项的宽。
  2. 根据列数,将第一行column个排列(先排一行),并将这一行的每一个元素的高度存储到colHeight中。
  3. 找出colHeight中高度最小的一个,并获取最小高度值和索引值。索引值和宽度得出left,最小高度和间距得到top,最后排列一个item
  4. 重复步骤3。

准备

为了代码更简洁优雅一点,所以必须要封装几个工具函数,以及类型的定义

/**
 * @description: 获取元素样式
 * @param {HTMLElement} element 元素
 * @param {string} styleName 样式名称
 * @return {any}
 */
export const getStyle = (element: HTMLElement, styleName: string): any => {
    return getComputedStyle ? getComputedStyle(element).getPropertyValue(styleName) : element.style[styleName as any];
}

/**
 * @description: 设置元素样式
 * @param {HTMLElement} el 元素
 * @param {string} key 样式名称 | 样式对象
 * @param {string} value 样式值
 * @return {void}
 */
export const injectStyle = (el: HTMLElement, key: string | CSSProperties, value?: string):void => {
    if(typeof key === 'string') {
        el.style[key as any] = value;
    }else {
        Object.keys(key).forEach((k) => {
            el.style[k as any] = key[k];
        })
    }
}

/**
 * @description: 设置元素属性
 * @param {HTMLElement} el  元素
 * @param {string} key 属性名称 | 属性对象
 * @param {string} value 属性值
 * @return {void}
 */
export const injectProp = (el: HTMLElement, key: string | CSSProperties, value?: string):void => {
    if(typeof key === 'string') {
        el.setAttribute(key, value);
    }else {
        Object.keys(key).forEach((k) => {
            el.setAttribute(k, key[k]);
        })
    }
}
export interface IUseWaterfallOptionsBreakpoint {
    point: number // 断点
    column: number // 列数
    gap: number // 间隙
}

export interface IUseWaterfallOptions {
    container: string | HTMLElement | null // 容器
    column: number // 列数
    gap: number // 间隙
    isResize: boolean // 是否使用resize监听
    isLazy: boolean // 是否使用懒加载
    isLoadingLayer: boolean // 是否使用loading层

    resizeDelay: number // resize延迟时间
    resizeTimer: number // resize定时器

    scrollDelay: number // scroll延迟时间  懒加载使用
    scrollTimer: number // scroll定时器  懒加载使用

    leftAnimateDelay: number // 左侧动画延迟时间
    topAnimateDelay: number // 上侧动画延迟时间
    animateEase: import('vue').CSSProperties['animation-timing-function'] // 动画缓动函数

    breakpoint: Partial<IUseWaterfallOptionsBreakpoint> & Pick<IUseWaterfallOptionsBreakpoint, 'point'>[] // 断点
}

export interface IUseWaterfallResult {
    raw: Partial<IUseWaterfallOptions>,
    colWidth: number, // 列宽
    colHeight: number[], // 列高度
    children: HTMLCollection, // 子元素集合

    getColWidth: () => number, // 获取列宽
}

实现

export const useWaterfall = (options: Partial<IUseWaterfallOptions>): IUseWaterfallResult => {

    if (!options.container) throw new Error('Waterfall:container is required!');

    const defaultOpts: Partial<IUseWaterfallOptions> = {
        column: 4,
        gap: 20,
        isResize: true,
        resizeDelay: 500,
        leftAnimateDelay: 300,
        topAnimateDelay: 300,
        animateEase: 'ease'
    }
    // options保留默认设置和用户参数设置
    options = { ...defaultOpts, ...options };

    // opts用于动态更改设置
    const opts: Partial<IUseWaterfallOptions> = reactive({
        ...JSON.parse(JSON.stringify(options)), ...defaultOpts
    })

    let children: HTMLCollection = null; // 子元素集合
    let colWidth = 0; // 列宽
    let containerObserver: MutationObserver = null; // 容器监测
    const colHeight: number[] = []; // 列高度

    /**
     * @description 获取列宽
     * @return {number} 列宽
     */
    const getColWidth = (): number => {
        const containerWidth = parseInt(getStyle(opts.container as HTMLElement, 'width'), 10);
        const { column, gap } = opts;
        return (containerWidth - (column - 1) * gap) / column;
    }

    onMounted(() => {
        if (typeof opts.container === 'string') {
            opts.container = document.querySelector(opts.container) as HTMLElement;
        }
        colWidth = getColWidth();
        const containerStyle: CSSProperties = {
            position: 'relative',
            width: '100%',
            height: '100%',
            overflow: 'hidden',
            boxSizing: 'content-box',
            paddingBottom: `${opts.gap}px`,
        }
        const itemStyle: CSSProperties = {
            position: 'absolute',
            top: 0,
            left: 0,
            transition: `left ${opts.leftAnimateDelay}ms ${opts.animateEase}, top ${opts.topAnimateDelay}ms ${opts.animateEase}`,
            zIndex: 1,
            width: `${colWidth}px`,
        }

        children = opts.container.children;
        injectStyle(opts.container, containerStyle);
        Array.from(children).forEach((item: any, index: number) => {
            injectStyle(item, itemStyle);
            injectProp(item, 'data-loaded', 'false');
            injectProp(item, 'data-index', index.toString());
        });
    })

    /**
     * @description 初始化布局
     * @param {object} o - 参数
     * @param {boolean} o.force - 是否强制执行 默认false 为true时会重新计算整个瀑布流
     * @return {void}
     */
    const initLayout = (
        o: { force: boolean; column?: number; gap?: number } = { force: false }): void => {

        const { force, column = opts.column, gap = opts.gap } = o;

        // 强制执行,表示重新计算整个瀑布流
        if (force) colHeight.length = 0;

        colWidth = getColWidth();

        let top = 0;
        let left = 0;

        for (let i = 0; i < children.length; i++) {
            const el = children[i] as HTMLElement;

            // eslint-disable-next-line no-continue
            if (el.dataset.loaded === 'true' && !force) continue;

            injectStyle(el, 'width', `${colWidth}px`);  // 需要预先设置宽度,高度才能被正确计算

            // eslint-disable-next-line no-use-before-define
            setTimeout(() => layout(el, i), 300);
        }

        function layout(el: HTMLElement, i: number) {
            const height = el.clientHeight || parseInt(getStyle(el, 'height'), 10);
            if (i >= column) {
                const minIndex = colHeight.indexOf(Math.min(...colHeight));
                top = colHeight[minIndex] + gap;
                left = minIndex * colWidth + gap * minIndex;
                colHeight[minIndex] = top + height;
            } else {
                colHeight[i] = height;
                top = 0;
                left = i * colWidth + gap * i;
            }
            injectStyle(el, {
                position: 'absolute',
                top: `${top}px`,
                left: `${left}px`,
            })
            injectProp(el, 'data-loaded', 'true');
            injectStyle(opts.container as HTMLElement, 'height', `${Math.max(...colHeight)}px`);
        }
    }


    /**
     * @description: 处理响应宽度断点
     * @return {void}
     */
    const processBreakpoint = (): void => {
        if (opts.breakpoint.length) {
            opts.breakpoint.forEach(item => {
                if (window.innerWidth <= item.point) {
                    opts.column = item.column || options.column;
                    opts.gap = item.gap || options.gap;
                }
            })
            // 如果当前宽度比断点最大值还大,就恢复默认
            if (window.innerWidth > opts.breakpoint[0].point) {
                opts.column = options.column;
                opts.gap = options.gap;
            }
        }
    }

    /**
     * @description: 监测容器的子项,处理新增项
     * @return {void}
     */
    const processContainerObserver = (): void => {
        containerObserver = new MutationObserver(() => {
            initLayout();
        });
        containerObserver.observe(opts.container as HTMLElement, {
            subtree: false,
            attributes: false,
            characterData: false,
            childList: true
        });
    }


    /**
     * @description resize事件处理函数
     * @return {void}
     */
    const handleResize = (): void => {
        clearTimeout(opts.resizeTimer);
        opts.resizeTimer = setTimeout(() => {
            processBreakpoint();
            initLayout({ force: true });
        }, opts.resizeDelay);
    }

    onMounted(() => {
        // 等待300ms,保证元素完全渲染
        setTimeout(() => {
            processContainerObserver();
            if (opts.breakpoint.length) processBreakpoint();
            initLayout();
            if (opts.isResize) window.addEventListener('resize', handleResize);
        }, 300);
    })

    onUnmounted(() => {
        if (opts.isResize) window.removeEventListener('resize', handleResize);
        if(containerObserver) containerObserver.disconnect();
    });

    return {
        raw: opts,
        colWidth,
        colHeight,
        children,

        getColWidth,
    }
}

使用

useWaterfall({
  containerClass: 'y-waterfall__container',
});
<think>嗯,用户想要了解在Vue3中如何实现瀑布流布局。我需要先回顾一下瀑布的基本概念,然后结合Vue3的特性来思考实现方法。首先,瀑布流布局的特点是等宽不等高,元素按照垂直方向排列,填满容器后再换列。常见的实现方式有CSS多列布局、Flex布局配合绝对定位,或者使用JavaScript动态计算位置。 考虑到Vue3的响应式特性,特别是Composition API,可能需要用到ref和reactive来管理状态,同时监听窗口大小变化来动态调整布局。用户提供的引用中提到了使用onMounted钩子和监听resize事件,这点很重要。另外,示例代码里用了绝对定位,通过计算每个元素的位置来确定left和top的值,这样可以在数据变化时重新排列。 接下来需要确定具体步骤。比如,如何计算列数?根据容器宽度和列宽,列数应该是容器宽度除以列宽,向下取整。然后,将数据分配到各列,并记录每列的当前高度,以便新元素添加到最短的那一列。在Vue组件中,可能需要一个计算属性来处理这些逻辑,或者使用watchEffect自动响应数据变化。 另外,图片加载可能影响高度计算,因为图片未加载时无法获取实际高度。解决方法可以是预先知道图片的宽高比,或者在图片加载完成后再计算位置。或者使用Intersection Observer来延迟加载图片,避免布局抖动。 关于性能优化,动态计算位置可能引起频繁的重排重绘,需要防抖处理resize事件。还有,使用CSS transform代替top和left属性可能会更高效,但需要测试实际效果。 用户提供的示例中使用了绝对定位,这可能需要在父容器设置position: relative,子元素设置position: absolute。同时,列宽可以通过props传入,或者根据容器宽度动态计算。响应式设计方面,当窗口大小变化时,重新计算列数和元素位置是必须的。 是否需要第三方库?比如使用vue-waterfall-plugin,但用户可能更倾向于原生实现,所以应该重点讲解手动实现的方法。代码示例部分需要展示如何构建组件,包括模板结构、样式和脚本逻辑。 最后,生成相关问题时要考虑用户可能的后续疑问,比如性能优化、动态加载数据、图片处理等。确保回答覆盖了实现步骤、代码示例、注意事项和优化技巧,帮助用户全面理解。</think>### Vue3 瀑布流布局实现方法详解 #### 一、核心实现原理 瀑布流布局的核心是通过动态计算每个元素的位置坐标实现错位排列。在Vue3中结合响应式API实现以下程: 1. 容器宽度监听:通过`ResizeObserver`监听容器宽度变化 2. 列数计算:根据容器宽度与预设列宽计算实际列数 $$列数 = \lfloor{容器宽度 / (列宽 + 间隔)}\rfloor$$ 3. 位置计算:采用列高记录法,始终将新元素插入当前最短列 #### 二、组件实现步骤(参考示例代码优化) ```vue <template> <div ref="container" class="waterfall-container"> <div v-for="(col, index) in columns" :key="index" class="waterfall-column" :style="{ width: columnWidth + &#39;px&#39;, marginRight: gap + &#39;px&#39; }" > <div v-for="item in col" :key="item.id" class="waterfall-item" > <img :src="item.image" :style="{ height: item.height + &#39;px&#39; }" @load="handleImageLoad" > <div class="caption">{{ item.text }}</div> </div> </div> </div> </template> <script setup> import { ref, reactive, computed, onMounted, onUnmounted } from &#39;vue&#39; const props = defineProps({ data: Array, columnWidth: { type: Number, default: 200 }, gap: { type: Number, default: 15 } }) const container = ref(null) const state = reactive({ columns: [], columnHeights: [] }) // 响应式列数计算 const columnCount = computed(() => { const containerWidth = container.value?.offsetWidth || 0 return Math.max(1, Math.floor(containerWidth / (props.columnWidth + props.gap))) }) // 布局重建方法 const rebuildLayout = () => { state.columns = Array.from({ length: columnCount.value }, () => []) state.columnHeights = Array(columnCount.value).fill(0) props.data.forEach(item => { const minHeight = Math.min(...state.columnHeights) const targetCol = state.columnHeights.indexOf(minHeight) state.columns[targetCol].push(item) state.columnHeights[targetCol] += item.height + props.gap }) } // 图片加载完成处理 const handleImageLoad = (e) => { const img = e.target const realHeight = (img.naturalHeight * props.columnWidth) / img.naturalWidth img.style.height = `${realHeight}px` } // 响应式监听 onMounted(() => { const resizeObserver = new ResizeObserver(() => rebuildLayout()) if(container.value) resizeObserver.observe(container.value) onUnmounted(() => resizeObserver.disconnect()) }) watch([columnCount, () => props.data], rebuildLayout, { immediate: true }) </script> <style> .waterfall-container { position: relative; display: flex; justify-content: center; } .waterfall-column { display: flex; flex-direction: column; } .waterfall-item { margin-bottom: 15px; break-inside: avoid; } .waterfall-item img { width: 100%; object-fit: cover; } </style> ``` #### 三、关键优化点 1. **图片自适应处理**:通过`naturalWidth/naturalHeight`计算实际显示高度 2. **响应式布局**:使用`ResizeObserver`替代窗口resize监听,更精准检测容器变化 3. **虚拟滚动支持**:可结合`vue-virtual-scroller`实现大数据量优化[^2] 4. **加载状态处理**:添加`IntersectionObserver`实现懒加载
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值