Vue3图片(Image)
2026/9/8 8:24:46 网站建设 项目流程

本图片预览组件主要包括以下功能:

  • 展示图片时,可设置鼠标悬浮时的预览文本;图像无法加载时要显示的描述;自定义图像高度和宽度;设置图像如何适应容器高度和宽度(fill | contain | cover | none | scale-down),参考 object-fit;传入单张图像或图像数组;设置图像缩放比率;设置最大最小缩放比例;

  • 单张图片预览时,左上角展示图片名称:水平镜像或垂直镜像;可顺时针旋转或逆时针旋转;还原图片;放大缩小;鼠标任意拖动;鼠标双击图片还原;使用触摸板或鼠标滚轮控制图片缩放;

  • 多张图片预览时,除了单张展示的功能以外:可点击左右切换按钮预览多张图片;使用键盘上下左右按键进行图片切换;设置是否可以循环切换图片;

可自定义设置以下属性:

  • 图像地址或图像地址数组(src),类型:string | Array<{src: string, alt?: string}>,默认 undefined

  • 图像名称(name),没有传入图片名时自动从图像地址 src 中读取,类型:string,默认 undefined

  • 图像宽度(width),类型:string | number | (string | number)[],单位 px,默认 100

  • 图像高度(height),类型:string | number | (string | number)[],单位 px,默认 100

  • 是否禁用图像预览(disabled),类型:boolean,默认 false

  • 是否显示边框(bordered),类型:boolean,默认 true

  • 图形如何适应容器高度和宽度(fit),类型:'contain' | 'fill' | 'cover' | 'none' | 'scale-down',默认 'contain',参考 object-fit

  • 预览文本(preview),类型:string | slot,默认 '预览'

  • 自定义预览图片时 img 元素的样式(previewImageStyle),类型:CSSProperties,默认 {}

  • Space 组件属性配置(spaceProps),用于配置多张展示图片时的排列方式,类型:object,默认 {}

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

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

  • 每次缩放比率(zoomRatio),类型:number,默认 0.1

  • 最小缩放比例(minZoomScale),类型:number,默认 0.1

  • 最大缩放比例(maxZoomScale),类型:number,默认 10

  • 缩放移动旋转图片后,是否可以双击还原(resetOnDbclick),类型:boolean,默认 true

  • 是否可以拖动图片(draggable),类型:boolean,默认 false

  • 是否可以循环切换图片(loop),类型:boolean,默认 false

  • 相册模式,即从一张展示图片点开相册(album),类型:boolean,默认 false

  • 图片下载配置(downloadOptions),透传给内置 downloadFile 工具函数的第三个参数 options,默认 auto 策略(同源 anchor / 跨域 iframe),类型:{ target?: '_self' | '_blank', strategy?: 'auto' | 'anchor' | 'iframe' },默认值 undefined

  • 自定义下载方法(customDownload),提供时优先于内置 downloadFile,用于解决跨域图床下载受限等内置策略无法满足的场景,类型:(url: string, fileName?: string) => void | Promise<void>,默认 undefined

效果如下图:

预览时样式:

正常展示时样式:

图片加载时样式:

鼠标悬浮时样式:

在线预览

①创建图片预览组件Image.vue:

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

  • Vue3加载中(Spin)
  • Vue3间距(Space)
  • 加法 add
  • 下载文件 downloadFile
  • 获取依赖注入 useInject

<script setup lang="ts"> import { computed, ref, watchEffect, nextTick } from 'vue' import type { CSSProperties } from 'vue' import Space from 'components/space' import Spin from 'components/spin' import { add, downloadFile, useInject } from 'components/utils' export interface Image { src: string // 图像地址 name?: string // 图像名称 } export interface Props { src?: string | Image[] // 图像地址或图像地址数组 name?: string // 图像名称,没有传入图片名时自动从图像地址 src 中读取 width?: string | number | (string | number)[] // 图像宽度,单位 px height?: string | number | (string | number)[] // 图像高度,单位 px disabled?: boolean // 是否禁用图像预览 bordered?: boolean // 是否显示边框 fit?: 'contain' | 'fill' | 'cover' | 'none' | 'scale-down' // 图片在容器内的的适应类型 preview?: string // 预览文本 string | slot previewImageStyle?: CSSProperties // 自定义预览图片时 img 元素的样式 spaceProps?: object // Space 组件属性配置,用于配置多张展示图片时的排列方式 spinProps?: object // Spin 组件属性配置,用于配置图片加载中样式 previewSpinProps?: object // Spin 组件属性配置,用于配置预览图片加载中样式 zoomRatio?: number // 每次缩放比率 minZoomScale?: number // 最小缩放比例 maxZoomScale?: number // 最大缩放比例 resetOnDbclick?: boolean // 缩放移动旋转图片后,是否可以双击还原 draggable?: boolean // 是否可以拖动图片 loop?: boolean // 是否可以循环切换图片 album?: boolean // 是否相册模式,即从一张展示图片点开相册 downloadOptions?: { target?: '_self' | '_blank' // 打开方式 strategy?: 'auto' | 'anchor' | 'iframe' // 下载策略 } // 图片下载配置,透传给内置 downloadFile 的第三个参数 options;默认 auto 策略(同源 anchor / 跨域 iframe) customDownload?: (url: string, fileName?: string) => void | Promise<void> // 自定义下载方法,提供时优先于内置 downloadFile,用于解决跨域图床下载受限等内置策略无法满足的场景 } const props = withDefaults(defineProps<Props>(), { src: undefined, name: undefined, width: 100, height: 100, disabled: false, bordered: true, fit: 'contain', preview: '预览', previewImageStyle: () => ({}), spaceProps: () => ({}), spinProps: () => ({}), previewSpinProps: () => ({}), zoomRatio: 0.1, minZoomScale: 0.1, maxZoomScale: 10, resetOnDbclick: true, draggable: false, loop: false, album: false, downloadOptions: undefined, customDownload: undefined }) const images = ref<Image[]>([]) // 图片数组 const previewRef = ref<HTMLElement | null>(null) // 预览 DOM 引用 const previewIndex = ref<number>(0) // 当前预览的图片索引 const showPreview = ref<boolean>(false) // 是否显示预览 const imagesRef = ref<HTMLImageElement[]>([]) // 图片 DOM 引用 const imagesCompleted = ref<boolean[]>([]) // 图片是否加载完成 const previewImagesRef = ref<HTMLImageElement[]>([]) // 预览图片 DOM 引用 const previewCompleted = ref<boolean[]>([]) // 预览图片是否加载完成 const rotate = ref<number>(0) // 预览图片旋转角度 const scale = ref<number>(1) // 缩放比例 const swapX = ref<number>(1) // 水平镜像数值符号 const swapY = ref<number>(1) // 垂直镜像数值符号 const sourceX = ref<number>(0) // 拖动开始时位置 const sourceY = ref<number>(0) // 拖动开始时位置 const dragX = ref<number>(0) // 拖动横向距离 const dragY = ref<number>(0) // 拖动纵向距离 const sourceDragX = ref<number>(0) // 鼠标按下时图片的X轴偏移量 const sourceDragY = ref<number>(0) // 鼠标按下时图片的Y轴偏移量 const top = ref<number>(0) // 图片上边缘距浏览器窗口上边界的距离 const bottom = ref<number>(0) // 图片下边缘距浏览器窗口上边界的距离 const right = ref<number>(0) // 图片右边缘距浏览器窗口左边界的距离 const left = ref<number>(0) // 图片左边缘距浏览器窗口左边界的距离 const viewportWidth = ref<number>(0) // 视口宽度 const viewportHeight = ref<number>(0) // 视口高度 const { colorPalettes } = useInject('Image') // 主题色注入 const imageAmount = computed(() => { return images.value.length }) const dragTransitionDuration = computed(() => { return props.draggable ? '100ms' : '200ms' }) watchEffect(() => { images.value = getImages() }) function getImages(): Image[] { if (Array.isArray(props.src)) { return props.src } else { return [ { src: props.src || '', name: props.name } ] } } // 图片加载完成 function onImageLoaded(index: number): void { imagesCompleted.value[index] = true } // 预览图片加载完成(例如相册模式,可在预览中切换到未加载完成的图片) function onPreviewLoaded(index: number): void { previewCompleted.value[index] = true } // 从地址中提取路径末段作为原始文件名 function getRawNameFromUrl(src: string): string { // 以当前页面地址为 base 解析,兼容相对路径;解析失败时降级为手工切分 let pathname = '' try { pathname = new URL(src, location.href).pathname } catch { pathname = src.split('?')[0].split('#')[0] } const segments = pathname.split('/') return segments[segments.length - 1] || '' } // 从图像地址 src 中获取图像名称 // 优先使用显式传入的 name,否则从 src 中提取: // 用 URL.pathname 天然剥离查询参数(?)与哈希(#),再取末段并做 URL 解码 function getImageName(image: Image): string | undefined { if (image) { if (image.name) { return image.name } const rawName = getRawNameFromUrl(image.src) try { return decodeURIComponent(rawName) } catch { // 路径段含非法百分号编码时 decodeURIComponent 会抛异常,此时原样返回 return rawName } } } function getImageSize(size: string | number | (string | number)[], index: number): string { if (Array.isArray(size)) { if (typeof size[index] === 'number') { return `${size[index]}px` } return size[index] } else { if (typeof size === 'number') { return `${size}px` } return size } } // 使用键盘切换图片 function onKeyboard(e: KeyboardEvent): void { if (showPreview.value && imageAmount.value > 1) { if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { onSwitchLeft() } if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { onSwitchRight() } } } // 预览 async function onPreview(index: number): Promise<void> { scale.value = 1 rotate.value = 0 dragX.value = 0 dragY.value = 0 showPreview.value = true previewIndex.value = index await nextTick() previewRef.value?.focus() } defineExpose({ preview: onPreview }) // 关闭 function onClose(): void { showPreview.value = false } // 下载 function onDownload(): void { const image = images.value[previewIndex.value] // 提供自定义下载方法时优先使用,绕开内置下载方法 if (props.customDownload) { props.customDownload(image.src, image.name) return } // 未自定义时走内置 downloadFile,透传下载配置 downloadFile(image.src, image.name, props.downloadOptions) } // 放大 function onZoomin(): void { if (scale.value + props.zoomRatio > props.maxZoomScale) { scale.value = props.maxZoomScale } else { scale.value = add(scale.value, props.zoomRatio) } } // 缩小 function onZoomout(): void { if (scale.value - props.zoomRatio < props.minZoomScale) { scale.value = props.minZoomScale } else { scale.value = add(scale.value, -props.zoomRatio) } } // 重置图片为初始状态 function onResetOrigin(): void { scale.value = 1 swapX.value = 1 swapY.value = 1 rotate.value = 0 dragX.value = 0 dragY.value = 0 } // 顺时针旋转 function onClockwiseRotate(): void { rotate.value += 90 } // 逆时针旋转 function onAnticlockwiseRotate(): void { rotate.value -= 90 } // 水平镜像 function onHorizontalMirror(): void { swapX.value *= -1 } // 垂直镜像 function onVerticalMirror(): void { swapY.value *= -1 } // 鼠标滚轮缩放 function onWheel(e: WheelEvent) { // e.preventDefault() // 禁止浏览器捕获滑动事件 const scrollZoom = e.deltaY * props.zoomRatio * 0.1 // 滚轮的纵向滚动量 if (scale.value === props.minZoomScale && scrollZoom > 0) { return } if (scale.value === props.maxZoomScale && scrollZoom < 0) { return } if (scale.value - scrollZoom < props.minZoomScale) { scale.value = props.minZoomScale } else if (scale.value - scrollZoom > props.maxZoomScale) { scale.value = props.maxZoomScale } else { scale.value = add(scale.value, -scrollZoom) } } function handlePointerDown(e: PointerEvent) { // event.preventDefault() // 消除拖动元素时的阴影 if (!e.target) return const el = e.target // 当前点击的元素 const imageRect = (el as Element).getBoundingClientRect() top.value = imageRect.top // 图片上边缘距浏览器窗口上边界的距离 bottom.value = imageRect.bottom // 图片下边缘距浏览器窗口上边界的距离 right.value = imageRect.right // 图片右边缘距浏览器窗口左边界的距离 left.value = imageRect.left // 图片左边缘距浏览器窗口左边界的距离 viewportWidth.value = window.innerWidth // 视口宽度 viewportHeight.value = window.innerHeight // 视口高度 sourceX.value = e.clientX // 指针按下时相对于视口左边缘的X坐标 sourceY.value = e.clientY // 指针按下时相对于视口上边缘的Y坐标 sourceDragX.value = dragX.value // 指针按下时图片的X轴偏移量 sourceDragY.value = dragY.value // 指针按下时图片的Y轴偏移量 // 捕获指针,确保即使移出元素也能继续跟踪 ;(el as HTMLElement).setPointerCapture(e.pointerId) document.addEventListener('pointermove', handlePointerMove) document.addEventListener('pointerup', handlePointerUp) document.addEventListener('pointercancel', handlePointerUp) handlePointerMove(e) } function handlePointerMove(e: PointerEvent) { // e.clientX 返回事件被触发时指针相对于浏览器可视窗口的水平坐标 dragX.value = sourceDragX.value + e.clientX - sourceX.value dragY.value = sourceDragY.value + e.clientY - sourceY.value } function handlePointerUp() { if (props.draggable) { if (dragX.value > sourceDragX.value + viewportWidth.value - right.value) { // 溢出视口右边缘 dragX.value = sourceDragX.value + viewportWidth.value - right.value } if (dragX.value < sourceDragX.value - left.value) { // 溢出视口左边缘 dragX.value = sourceDragX.value - left.value } if (dragY.value > sourceDragY.value + viewportHeight.value - bottom.value) { // 溢出视口下边缘 dragY.value = sourceDragY.value + viewportHeight.value - bottom.value } if (dragY.value < sourceDragY.value - top.value) { // 溢出视口上边缘 dragY.value = sourceDragY.value - top.value } } else { dragX.value = 0 dragY.value = 0 } document.removeEventListener('pointermove', handlePointerMove) document.removeEventListener('pointerup', handlePointerUp) document.removeEventListener('pointercancel', handlePointerUp) } // 切换到上一张 function onSwitchLeft(): void { if (props.loop) { previewIndex.value = (previewIndex.value - 1 + imageAmount.value) % imageAmount.value } else { if (previewIndex.value > 0) { previewIndex.value-- } } onResetOrigin() } // 切换到下一张 function onSwitchRight(): void { if (props.loop) { previewIndex.value = (previewIndex.value + 1) % imageAmount.value } else { if (previewIndex.value < imageAmount.value - 1) { previewIndex.value++ } } onResetOrigin() } </script> <template> <div class="image-wrap" :style="`--image-primary-color: ${colorPalettes[5]};`"> <Space gap="small" v-bind="spaceProps"> <div v-for="(image, index) in images" :key="index" v-show="!album || (album && index === 0)" class="image-container" :class="{ 'image-bordered': bordered, 'image-hover-mask': !disabled && (imagesCompleted[index] || imagesRef[index]?.naturalWidth) }" :style="`width: ${getImageSize(props.width, index)}; height: ${getImageSize(props.height, index)};`" > <Spin :spinning="!(imagesCompleted[index] || imagesRef[index]?.naturalWidth)" color="var(--image-primary-color)" indicator="dynamic-circle" size="small" v-bind="spinProps" > <img ref="imagesRef" class="image-item" :style="`object-fit: ${fit};`" @load="onImageLoaded(index)" :src="image.src" :alt="getImageName(image)" /> </Spin> <div v-if="!disabled" class="image-mask" @click="onPreview(index)"> <div class="image-mask-info"> <svg class="eye-svg" focusable="false" ><script setup lang="ts"> import Image from './Image.vue' import { ref } from 'vue' import type { ImageItem } from 'vue-amazing-ui' const images = ref<ImageItem[]>([ { src: 'https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.1.2/1.jpg', name: 'image-1.jpg' }, { src: 'https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.1.2/2.jpg', name: 'image-2.jpg' }, { src: 'https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.1.2/3.jpg', name: 'image-3.jpg' }, { src: 'https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.1.2/4.jpg', name: 'image-4.jpg' }, { src: 'https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.1.2/5.jpg', name: 'image-5.jpg' } ]) /** * 自定义下载示例:用 XHR + Blob 下载跨域图床图片 * 内置 downloadFile 的 iframe 策略受图床 X-Frame-Options 限制,改用 XHR 取二进制后通过 Blob 触发下载,不受该限制 */ function customDownload(url: string, fileName?: string) { // 以二进制流方式请求图片,需要图床开启 CORS 才能跨域读取 const xhr = new XMLHttpRequest() xhr.open('GET', url, true) xhr.responseType = 'blob' xhr.onload = () => { if (xhr.status === 200) { // 将二进制流转为本地 Blob URL,再通过 a 标签的 download 属性强制下载(blob 同源,download 始终生效) const blobUrl = URL.createObjectURL(xhr.response) const a = document.createElement('a') a.href = blobUrl a.download = fileName || url.split('?')[0].split('/').pop() || 'download' document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(blobUrl) // 释放 Blob URL,避免内存泄漏 } } xhr.send() } </script> <template> <div> <h1>{{ $route.name }} {{ $route.meta.title }}</h1> <Alert class="mt10 mb10" type="warning" message="关于示例图片的下载功能" show-icon> <template #description> <p >示例图片虽以 <code>cdn.jsdelivr.net</code> 为入口,但下载时会 301 重定向到 <code>raw.githubusercontent.com</code>,导致默认的「下载」按钮失效,原因如下:</p > <p class="mb10 mt10" >内置 <code>downloadFile</code> 对跨域地址走 <code>iframe</code> 策略,而 <code>raw.githubusercontent.com</code> 设置了 <code>X-Frame-Options: deny</code>,浏览器拒绝在 iframe 内加载,控制台报 <code>Refused to display ... in a frame</code>;即便不重定向,<code>cdn.jsdelivr.net</code> 作为普通 CDN 也不支持 iframe 策略依赖的 <code>response-content-disposition</code> 参数。</p > <p class="mt10" >下方的「自定义下载」小节演示了可用的 <code>XHR + Blob</code> 方案:以二进制流读取图片(需图床开启 CORS,<code >raw.githubusercontent.com</code > 返回 <code>Access-Control-Allow-Origin: *</code>)后转 Blob URL,再用 <code>a</code> 标签 <code>download</code> 属性强制下载,不受上述限制。</p > </template> </Alert> <h2 class="mt30 mb10">基本使用</h2> <Image src="https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.1.2/1.jpg" /> <h2 class="mt30 mb10">多张图片预览</h2> <p class="mb10" >可循环切换图片,并支持键盘 (<code>left</code> / <code>right</code> / <code>up</code> / <code>down</code>) 按键切换</p > <Image :src="images" loop /> <h2 class="mt30 mb10">禁用预览</h2> <Image src="https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.1.2/1.jpg" disabled /> <h2 class="mt30 mb10">自定义样式</h2> <p class="mb10">自定义宽高;同时图片覆盖容器;预览文本设为 <code>preview</code></p> <Image :src="images" :width="[100, 200, 100, 200, 100]" :space-props="{ width: 416 }" fit="cover" loop> <template #preview> <p class="preview-txt">preview</p> </template> </Image> <h2 class="mt30 mb10">自定义预览图片样式</h2> <Image :src="images" :preview-image-style="{ background: '#fff', padding: '12px', borderRadius: '8px' }" loop /> <h2 class="mt30 mb10">自定义排列方式 & 加载中样式</h2> <Image :src="images" :space-props="{ width: 332, gap: 16 }" :spin-props="{ tip: 'loading', indicator: 'spin-line', color: '#fadb14' }" loop /> <h2 class="mt30 mb10">无边框</h2> <Image :src="images" :bordered="false" fit="cover" loop /> <h2 class="mt30 mb10">相册模式</h2> <Image :src="images" album loop /> <h2 class="mt30 mb10">自定义预览配置</h2> <p class="mb10">更改缩放比率和最大最小缩放比例</p> <Image :zoom-ratio="0.2" :min-zoom-scale="0.5" :max-zoom-scale="2" src="https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.1.2/1.jpg" /> <h2 class="mt30 mb10">下载配置</h2> <p class="mb10"> 通过 <code>downloadOptions</code> 配置内置下载策略(<code>anchor</code> / <code>iframe</code>)与打开方式 (<code >_self</code > / <code>_blank</code>)。 </p> <p class="mb10"> 下方示例强制 <code>anchor</code> 策略并设 <code>target="_blank"</code>,跨域下 <code>download</code> 属性失效,点击后在新窗口打开图片。 </p> <Image album :src="images" :download-options="{ strategy: 'anchor', target: '_blank' }" loop /> <h2 class="mt30 mb10">自定义下载</h2> <p class="mb10" >跨域图床(如设置 <code>X-Frame-Options</code> 拒绝 <code>iframe</code> 的 CDN)下载受限时,内置 <code>downloadFile</code> 可能无法满足:可通过 <code>downloadOptions</code> 调整内置下载策略,或通过 <code>customDownload</code> 提供完全自定义的下载方法。 </p> <p class="mb10"> 下方示例演示了用 XHR + Blob 绕过<code>X-Frame-Options</code> 限制下载跨域图床图片 </p> <Image album :src="images" :custom-download="customDownload" loop /> </div> </template> <style lang="less" scoped> .preview-txt { display: inline-block; font-size: 16px; } </style>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询