Skip to content

@punish/masonry-wall

一个功能强大的 Vue 3 瀑布流组件,支持自动适应布局、响应式列数、以及运行时动态追加数据。

✨ 功能特性

  • 🚀 轻量级,仅依赖 Vue 3
  • 🔄 自动适应布局(RTL,LRT)
  • 🔄 响应式设计(
  • ➕ 运行时动态追加数据(addItems
  • 🖼️ 媒体就绪感知
  • 🧬 完整的 TypeScript 泛型支持

📦 安装

bash
npm install @punish/masonry-wall

样式自动按需引入,无需手动引入 CSS 文件。

🚀 快速开始

全局注册

ts
import { createApp } from "vue";
import MasonryWall from "@punish/masonry-wall";
import "@punish/masonry-wall/dist/style.css"; // 可选:SSR/SSG 下预引入避免样式闪烁

createApp(App).use(MasonryWall).mount("#app");

按需引入

vue
<script setup lang="ts">
import { MasonryWall } from "@punish/masonry-wall";
</script>

基础用法

vue
<template>
    <MasonryWall :items="items" :columns="3" :gap="12">
        <template #default="{ item }">
            <div class="card">
                <img :src="item.url" :width="item.w" :height="item.h" alt="" />
                <div class="card-body">{{ item.title }}</div>
            </div>
        </template>
    </MasonryWall>
</template>

<script setup lang="ts">
import { MasonryWall } from "@punish/masonry-wall";

interface SizedItem {
    url: string;
    title: string;
    w: number;
    h: number;
}

const items: SizedItem[] = [
    { url: "https://picsum.photos/seed/sx1/300/200", title: "矮图", w: 300, h: 200 },
    { url: "https://picsum.photos/seed/sx2/300/420", title: "高图", w: 300, h: 420 },
    { url: "https://picsum.photos/seed/sx3/300/300", title: "方图", w: 300, h: 300 },
];
</script>

🎬 演示

下面是组件的实际运行效果,每个示例都可在侧边栏「示例」分组中打开独立页面查看完整代码。

图片瀑布流(媒体感知)

开启 with-media 后,组件会等待每张图片加载完成。

查看源码
vue
<template>
    <MasonryWall :items="items" :columns="3" :gap="12" :with-media="true" :media-timeout="10000">
        <template #default="{ item }">
            <div class="card">
                <img :src="item.url" class="card-img" alt="" />
                <div class="card-body">
                    <div class="card-title">{{ item.title }}</div>
                    <div class="card-desc">{{ item.desc }}</div>
                </div>
            </div>
        </template>
    </MasonryWall>
</template>

<script setup lang="ts">
import { MasonryWall } from "@punish/masonry-wall";

interface PhotoItem {
    url: string;
    title: string;
    desc: string;
}

const items: PhotoItem[] = [
    { url: "https://picsum.photos/seed/rox1/300/200", title: "山间晨雾", desc: "清晨的山谷被薄雾笼罩" },
    { url: "https://picsum.photos/seed/rox2/300/420", title: "都市霓虹", desc: "夜幕下的城市灯火" },
    { url: "https://picsum.photos/seed/rox3/300/300", title: "海岸线", desc: "潮起潮落的海边" },
    { url: "https://picsum.photos/seed/rox4/300/520", title: "林间小径", desc: "阳光穿过树叶的缝隙" },
    { url: "https://picsum.photos/seed/rox5/300/240", title: "湖心倒影", desc: "平静湖面的对称之美" },
    { url: "https://picsum.photos/seed/rox6/300/380", title: "雪山之巅", desc: "高海拔的纯净世界" },
    { url: "https://picsum.photos/seed/rox7/300/260", title: "麦田黄昏", desc: "风吹麦浪的金色海洋" },
    { url: "https://picsum.photos/seed/rox8/300/460", title: "星空银河", desc: "无光害下的璀璨星河" },
    { url: "https://picsum.photos/seed/rox9/300/320", title: "老城巷弄", desc: "斑驳墙壁讲述旧时光" },
];
</script>

<style scoped>
.card {
    background: #fff;
    border-radius: 10px;
    overflow: hidden;
    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.card-img {
    width: 100%;
    display: block;
    object-fit: cover;
    background: #eee;
}
.card-body {
    padding: 10px 12px 14px;
}
.card-title {
    font-size: 14px;
    font-weight: 600;
    color: #1f2329;
}
.card-desc {
    margin-top: 4px;
    font-size: 12px;
    color: #8a919f;
}
</style>

预设尺寸图片

当图片宽高已知(with-media 关闭)时,首屏更快、无等待。适用于你能提前拿到图片尺寸的场景(如接口返回了宽高字段)。

查看源码
vue
<template>
    <MasonryWall :items="items" :columns="3" :gap="12" :with-media="false">
        <template #default="{ item }">
            <div class="card">
                <img :src="item.url" class="card-img" :width="item.w" :height="item.h" alt="" />
                <div class="card-body">{{ item.title }}</div>
            </div>
        </template>
    </MasonryWall>
</template>

<script setup lang="ts">
import { MasonryWall } from "@punish/masonry-wall";

interface SizedItem {
    url: string;
    title: string;
    w: number;
    h: number;
}

const items: SizedItem[] = [
    { url: "https://picsum.photos/seed/sx1/300/200", title: "已设尺寸 · 矮图", w: 300, h: 200 },
    { url: "https://picsum.photos/seed/sx2/300/420", title: "已设尺寸 · 高图", w: 300, h: 420 },
    { url: "https://picsum.photos/seed/sx3/300/300", title: "已设尺寸 · 方图", w: 300, h: 300 },
    { url: "https://picsum.photos/seed/sx4/300/520", title: "已设尺寸 · 长图", w: 300, h: 520 },
    { url: "https://picsum.photos/seed/sx5/300/240", title: "已设尺寸 · 横图", w: 300, h: 240 },
    { url: "https://picsum.photos/seed/sx6/300/380", title: "已设尺寸 · 中图", w: 300, h: 380 },
    { url: "https://picsum.photos/seed/sx7/300/260", title: "已设尺寸 · 窄图", w: 300, h: 260 },
    { url: "https://picsum.photos/seed/sx8/300/460", title: "已设尺寸 · 竖图", w: 300, h: 460 },
    { url: "https://picsum.photos/seed/sx9/300/320", title: "已设尺寸 · 标准", w: 300, h: 320 },
];
</script>

<style scoped>
.card {
    background: #fff;
    border-radius: 10px;
    overflow: hidden;
    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.card-img {
    width: 100%;
    display: block;
    object-fit: cover;
    background: #eee;
}
.card-body {
    padding: 10px 12px 14px;
    font-size: 13px;
    color: #1f2329;
}
</style>

纯文本卡片

瀑布流不局限于图片,任何高度不一的内容(如卡片、段落、标签)都能自适应排布。

查看源码
vue
<template>
    <MasonryWall :items="items" :columns="3" :gap="12">
        <template #default="{ item }">
            <div class="card">
                <div class="card-tag">{{ item.tag }}</div>
                <div class="card-title">{{ item.title }}</div>
                <div class="card-desc">{{ item.desc }}</div>
            </div>
        </template>
    </MasonryWall>
</template>

<script setup lang="ts">
import { MasonryWall } from "@punish/masonry-wall";

interface TextItem {
    tag: string;
    title: string;
    desc: string;
}

const items: TextItem[] = [
    {
        tag: "前端",
        title: "响应式布局",
        desc: "通过 flex 与 grid 的组合,在不同断点下自动调整列数,无需编写媒体查询即可获得良好的多端体验。",
    },
    { tag: "设计", title: "色彩对比", desc: "高对比文字在弱光环境下依然清晰可读。" },
    { tag: "工程", title: "构建提速", desc: "使用持久化缓存与按需编译,冷启动时间从 12s 降到 2s。" },
    { tag: "算法", title: "最短列优先", desc: "这是瀑布流的核心:每次把新元素放入当前最矮的一列,从而保持整体视觉平衡。" },
    { tag: "体验", title: "骨架屏", desc: "首屏占位,减少等待焦虑。" },
    { tag: "前端", title: "组合式函数", desc: "useXxx 让逻辑可跨组件复用,配合 shallowRef 还能避免深响应式的性能损耗。" },
    { tag: "产品", title: "信息密度", desc: "在有限空间内呈现更多内容,同时保留呼吸感。" },
    { tag: "工程", title: "SSR 安全", desc: "在服务器端渲染时跳过媒体等待逻辑,避免阻塞。" },
    { tag: "设计", title: "圆角与留白", desc: "10px 圆角搭配 12px 间距,整体更轻盈。" },
];
</script>

<style scoped>
.card {
    background: #fff;
    border-radius: 10px;
    padding: 14px 16px;
    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.card-tag {
    display: inline-block;
    font-size: 12px;
    color: #4f7cff;
    background: #eef3ff;
    border-radius: 6px;
    padding: 2px 8px;
}
.card-title {
    margin-top: 8px;
    font-size: 15px;
    font-weight: 600;
    color: #1f2329;
}
.card-desc {
    margin-top: 6px;
    font-size: 13px;
    line-height: 1.6;
    color: #8a919f;
}
</style>

提示:items 的类型会被自动推导为 SizedItem[],因此插槽内的 item 也拥有完整的类型提示。

🎯 Props

参数类型默认值说明
itemsArray[]数据数组(必填)
columnsnumber3列数。至少 1 列;传 0/负数会被兜底为 1
gapnumber10行列间距(px),当未单独设置 rowGap / colGap 时生效
rowGapnumberundefined行间距(px),优先级高于 gap
colGapnumberundefined列间距(px),优先级高于 gap
withMediabooleanfalse是否等待每张卡片内的图片/视频加载完成后再排布(避免高度跳动)
mediaTimeoutnumber10000withMedia 模式下等待媒体的超时时间(ms),超时后不再阻塞
rePaintDueToSizeChangebooleanfalse容器尺寸变化时是否重新排布

🎪 插槽

名称参数说明
default{ item: any, index: number, column: number }每个数据项的内容
  • item:当前数据项。
  • index:该项在当前列中的索引。
  • column:该项所在的列索引(从 0 开始)。
vue
<template #default="{ item, index, column }">
    <div>第 {{ column }} 列 · 第 {{ index }} 项:{{ item.title }}</div>
</template>

🧩 暴露的方法(defineExpose)

通过组件 ref 可调用以下方法:

方法签名说明
addItems(items: any[]) => Promise<void>向瀑布流追加一批数据。

动态追加示例

vue
<template>
    <MasonryWall ref="wallRef" :columns="3" :gap="12">
        <template #default="{ item }">
            <img :src="item.url" alt="" />
        </template>
    </MasonryWall>
    <button @click="loadMore">加载更多</button>
</template>

<script setup lang="ts">
import { ref } from "vue";
import { MasonryWall } from "@punish/masonry-wall";

interface Photo {
    url: string;
}
const wallRef = ref<InstanceType<typeof MasonryWall<Photo>>>();
// 或更精确地拿到暴露的方法类型:
// const wallRef = ref<ComponentExposed<typeof MasonryWall<Photo>>>();

async function loadMore() {
    const more: Photo[] = await fetchPhotos();
    await wallRef.value?.addItems(more);
}
</script>

注意:addItems 接收的数据。若你完全不通过 items 传入数据、全部走 addItems,建议如下:

vue
<MasonryWall<Photo> ref="wallRef">
    <template #default="{ item }">{{ item.url }}</template>
</MasonryWall>

⚠️ 注意事项

  1. withMedia 与性能:开启后每次排布都会等待媒体就绪,数据量很大时建议配合 mediaTimeout 控制最大等待时间。
  • 只有当布局中存在的媒体资源是未知宽高或者媒体资源父容器未设置宽高时,才需要设置 withMedia 为 true。否则,可以设置为 false 以提升性能。

🌐 浏览器兼容性

  • Chrome / Edge(最新版本)
  • Firefox(最新版本)
  • Safari(最新版本)
  • iOS >= 10 / Android >= 5 的移动端浏览器

📄 License

MIT

基于 MIT 协议开源