news 2026/8/26 13:39:36

Vue3瀑布流(Waterfall)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue3瀑布流(Waterfall)

Vue2瀑布流(Waterfall)

瀑布流_百度百科

可自定义设置以下属性:

  • 图片数组(images),类型:Image[],默认 []

  • 要划分的列数(columnCount),类型:number,默认 3

  • 各列之间的间隙(columnGap),类型:number,单位 px,默认 30

  • 瀑布流区域的总宽度(width),类型:number | string,单位 px,默认 '100%'

  • 瀑布流区域和图片圆角(borderRadius),类型:number,单位 px,默认 8

  • 瀑布流区域背景填充色(backgroundColor),类型:string,默认 '#F2F4F8'

  • Spin 组件属性配置(spinProps),类型:object,默认 {},参考 Spin Props,用于配置图片加载中样式

效果如下图:在线预览

在线预览

①创建瀑布流组件Waterfall.vue:

其中引用使用了以下组件和工具函数:

  • Vue3加载中(Spin)
  • 监听DOM尺寸 useResizeObserver
<script setup lang="ts"> import { ref, computed, onMounted, watch } from 'vue' import Spin from 'components/spin' import { useResizeObserver } from 'components/utils' /* 宽度固定,图片等比例缩放;使用JS获取每张图片宽度和高度,结合 `relative` 和 `absolute` 定位 计算每个图片的位置 `top`,`left`,保证每张新的图片都追加在当前高度最小的那列末尾 */ export interface Image { name?: string // 图片名称 src: string // 图片地址 link?: string // 图片跳转链接 target?: '_self' | '_blank' // 如何打开跳转链接 } export interface Props { images?: Image[] // 图片数组 columnCount?: number // 要划分的列数 columnGap?: number // 各列之间的间隙,单位 px width?: string | number // 瀑布流区域的总宽度,单位 px borderRadius?: number // 瀑布流区域和图片圆角,单位 px backgroundColor?: string // 瀑布流区域背景填充色 spinProps?: object // Spin 组件属性配置,参考 Spin Props,用于配置图片加载中样式 } const props = withDefaults(defineProps<Props>(), { images: () => [], columnCount: 3, columnGap: 20, width: '100%', borderRadius: 8, backgroundColor: '#F2F4F8', spinProps: () => ({}) }) const waterfallRef = ref() // 瀑布流容器引用 const waterfallWidth = ref<number>(0) // 瀑布流区域宽度 const imagesLoaded = ref<boolean[]>([]) // 图片是否加载完成 const imagesSize = ref<{ width: number; height: number }[]>([]) // 所有图片原始尺寸 const imagesProperty = ref<{ width: number; height: number; top: number; left: number }[]>([]) // 瀑布流图片的宽高和位置信息 const preColumnHeight = ref<number[]>([]) // 每列的高度 const flag = ref<number>(0) // 更新瀑布流标识 // 瀑布流区域总宽度 const totalWidth = computed(() => { if (typeof props.width === 'number') { return `${props.width}px` } else { return props.width } }) // 瀑布流区域总高度 const totalHeight = computed(() => { return Math.max(...preColumnHeight.value) + props.columnGap }) // 每列的图片宽度 const imageWidth = computed(() => { return (waterfallWidth.value - (props.columnCount + 1) * props.columnGap) / props.columnCount }) // 图片总数量 const imagesAmount = computed(() => { return props.images.length }) watch( () => props.images, (to, from) => { // 只有当图片数组长度变化或图片文件变化时才清空图片尺寸数组并重新初始化 if (to.length !== to.length || to.some((image, index) => image.src !== from[index]?.src)) { imagesSize.value = [] initWaterfall() } }, { deep: true, flush: 'post' } ) watch( [() => props.columnCount, () => props.columnGap, () => props.width], () => { initWaterfall() }, { flush: 'post' } ) onMounted(() => { initWaterfall() }) // 窗口宽度改变时重新计算瀑布流布局 useResizeObserver(waterfallRef, () => { const currentWidth = waterfallRef.value.offsetWidth if (props.images.length && currentWidth !== waterfallWidth.value) { initWaterfall() } }) // 初始化瀑布流 function initWaterfall() { if (!waterfallRef.value) return waterfallWidth.value = waterfallRef.value.offsetWidth preColumnHeight.value = Array(props.columnCount).fill(0) imagesProperty.value.splice(0) flag.value++ updateWaterfall(flag.value) } // 更新瀑布流 async function updateWaterfall(symbol: number): Promise<boolean | void> { for (let i = 0; i < imagesAmount.value; i++) { if (symbol === flag.value) { if (!imagesSize.value[i]) { await loadImage(props.images[i].src, i) } else { getImagesProperty(i) } } else { return false } } } // 加载图片并计算宽高和位置信息 function loadImage(url: string, n: number): void | Promise<string> { const loadedImageSize = imagesSize.value[n] if (loadedImageSize) { // 图片已被加载过 getImagesProperty(n) } else { // 图片未被加载过 return new Promise((resolve, reject) => { const image = new Image() image.src = url // 图片加载完成后,通过 image.naturalWidth 和 image.naturalHeight 获取图片原始宽高 image.onload = function () { imagesSize.value[n] = { // 保存已加载图片的尺寸信息 width: image.naturalWidth, height: image.naturalHeight } getImagesProperty(n) resolve('loaded') } image.onerror = function (err) { reject(new Error(`Failed to load image at ${url}, err: ${err}`)) } }) } } // 获取并存储实际渲染图片的宽高和位置信息 function getImagesProperty(n: number): void { const loadedImageSize = imagesSize.value[n] const height = loadedImageSize.height / (loadedImageSize.width / imageWidth.value) imagesProperty.value[n] = { width: imageWidth.value, height: height, ...getPosition(n, height) } } // 获取图片位置信息 function getPosition(i: number, height: number): { top: number; left: number } { if (i < props.columnCount) { preColumnHeight.value[i] = props.columnGap + height return { top: props.columnGap, left: (imageWidth.value + props.columnGap) * i + props.columnGap } } else { const top = Math.min(...preColumnHeight.value) let index = 0 for (let n = 0; n < props.columnCount; n++) { if (preColumnHeight.value[n] === top) { index = n break } } preColumnHeight.value[index] = top + props.columnGap + height return { top: top + props.columnGap, left: (imageWidth.value + props.columnGap) * index + props.columnGap } } } function onLoaded(index: number): void { imagesLoaded.value[index] = true } // 从图像地址 src 中获取图像名称 function getImageName(image: Image): string { if (image) { if (image.name) { return image.name } else { const res = image.src.split('?')[0].split('/') return res[res.length - 1] } } return '' } </script> <template> <div ref="waterfallRef" class="waterfall-wrap" :style="` --waterfall-border-radius: ${borderRadius}px; --waterfall-bg-color: ${backgroundColor}; --waterfall-width: ${totalWidth}; --waterfall-height: ${totalHeight}px; `" > <Spin class="waterfall-image" :style="`width: ${property.width}px; height: ${property.height}px; top: ${property && property.top}px; left: ${property && property.left}px;`" :spinning="!imagesLoaded[index]" size="small" indicator="dynamic-circle" v-bind="spinProps" v-for="(property, index) in imagesProperty" :key="index" > <a class="image-link" :class="{ 'link-cursor': images[index].link }" :href="images[index].link" :target="images[index].target ? images[index].target : '_blank'" > <img class="image-item" :src="images[index].src" :alt="getImageName(images[index])" @load="onLoaded(index)" /> </a> </Spin> </div> </template> <style lang="less" scoped> .waterfall-wrap { position: relative; width: var(--waterfall-width); height: var(--waterfall-height); border-radius: var(--waterfall-border-radius); background-color: var(--waterfall-bg-color); .waterfall-image { position: absolute; .image-link { display: block; height: 100%; cursor: default; .image-item { width: 100%; height: 100%; border-radius: var(--border-radius); display: inline-block; vertical-align: bottom; } } .link-cursor { cursor: pointer; } } } </style>

②在要使用的页面引入:

其中引入使用了以下组件:

  • Vue3 栅格(Grid)
  • Vue3弹性布局(Flex)
  • Vue3 滑动输入条(Slider)
  • Vue3颜色选择器(ColorPicker)
<script setup lang="ts"> import Waterfall from './Waterfall.vue' import { ref, onBeforeMount, reactive } from 'vue' import type { WaterfallImage } from 'vue-amazing-ui' const images = ref<WaterfallImage[]>([]) const state = reactive({ columnCount: 3, columnGap: 20, backgroundColor: '#e6f4ff', borderRadius: 12 }) function loadImages() { for (let i = 1; i <= 10; i++) { images.value.push({ name: `image-${i}`, src: `https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.1.2/${i}.jpg` // link: `https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.1.2/${i}.jpg` }) } } onBeforeMount(() => { // 组件已完成响应式状态设置,但未创建DOM节点 loadImages() }) </script> <template> <div> <h1>{{ $route.name }} {{ $route.meta.title }}</h1> <h2 class="mt30 mb10">基本使用</h2> <Waterfall :images="images" /> <h2 class="mt30 mb10">瀑布流配置器</h2> <Row :gutter="24"> <Col :span="6"> <Flex vertical gap="middle"> columnCount: <Slider :min="1" :max="6" v-model:value="state.columnCount" /> </Flex> </Col> <Col :span="6"> <Flex vertical gap="middle"> columnGap: <Slider :min="10" :max="100" v-model:value="state.columnGap" /> </Flex> </Col> <Col :span="6"> <Flex vertical gap="middle"> borderRadius: <Slider :min="0" :max="100" v-model:value="state.borderRadius" /> </Flex> </Col> <Col :span="6"> <Flex vertical> backgroundColor: <ColorPicker v-model:value="state.backgroundColor" /> </Flex> </Col> </Row> <Waterfall class="mt30" :images="images" :column-count="state.columnCount" :column-gap="state.columnGap" :background-color="state.backgroundColor" :border-radius="state.borderRadius" /> </div> </template>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/26 13:35:53

OpenAI王冠松动:多模型时代开发者选型与API接入实战

最近这半年&#xff0c;AI 圈最明显的感受不是某个模型又刷了多少分&#xff0c;而是 OpenAI 不再拥有绝对的“王冠”式统治力。Claude 在编程场景里强势反超&#xff0c;Gemini 在长上下文和原生多模态上持续发力&#xff0c;开源模型也在快速逼近闭源第一梯队。标题里说 Open…

作者头像 李华
网站建设 2026/8/26 13:35:26

如何使用界面控件DevExpress WinForms自带的UI模板?其实很简单

DevExpress WinForms拥有180组件和UI库&#xff0c;能为Windows Forms平台创建具有影响力的业务解决方案。DevExpress WinForms能完美构建流畅、美观且易于使用的应用程序&#xff0c;无论是Office风格的界面&#xff0c;还是分析处理大批量的业务数据&#xff0c;它都能轻松胜…

作者头像 李华
网站建设 2026/8/26 13:34:07

云Agent选型与部署实践:从控制面模型到运维自动化

如果你经常逛 Hacker News&#xff0c;大概率见过一种很典型的提问方式&#xff1a;Ask HN: What cloud agents do you use? 下面密密麻麻的回复里&#xff0c;有人推荐某云厂商的原生 Agent&#xff0c;有人坚持开源方案&#xff0c;也有人直接说“别用 Agent&#xff0c;SSH…

作者头像 李华
网站建设 2026/8/26 13:31:30

Python+Demucs实战:仅凭贝斯声轨识别BEYOND歌曲

之前在整理音频素材时&#xff0c;我冒出一个有点挑战性的想法&#xff1a;把BEYOND的经典歌曲做一次“去人声、去吉他、去鼓”&#xff0c;只保留贝斯轨&#xff0c;然后不看任何提示&#xff0c;只听贝斯声音猜歌。结果发现&#xff0c;这个玩法比想象中难&#xff0c;也比想…

作者头像 李华
网站建设 2026/8/26 13:30:42

加密算法笔记(哈希算法、base64,md5,aes,rsa等)、签名校验、bcrypt、数字信封

开发中经常要用到加解密&#xff0c;熟悉它很有好处。 文章目录哈希算法(hash)(消息摘要算法)单向散列哈希算法哈希算法可逆吗?哈希算法的时间复杂度哈希算法是加密算法吗?md5DigestUtils(spring)实现md5DigestUtils验证密码(登录、修改密码)DigestUtils新增用户时加密对称加…

作者头像 李华
网站建设 2026/8/26 13:29:16

半人马机器人“小橙”技术拆解:轮腿融合与运动控制

各位开发者和机器人爱好者&#xff0c;大家好。 最近航天领域公开的“半人马机器人‘小橙’”成了一个热门话题。很多人第一眼看到它&#xff0c;会觉得外观很科幻&#xff1a;四条腿加上轮子&#xff0c;既能像足式机器人一样跨越障碍&#xff0c;又能像轮式平台一样高速移动…

作者头像 李华