1. Element Plus 图片预览的默认边界,以及为什么下载按钮需要自己动手
在 Vue3 + Element Plus 的后台项目里,el-image的preview-src-list几乎是处理图片预览的默认选择。点缩略图、出大图、能缩放能切换,确实省力。但真到了交付的时候,运营同事一句"这里能不能加个下载按钮",往往让你重新审视这个看似完整的功能。
Element Plus 官方的图片预览能力已经很完善:点击图片进入查看器,支持鼠标滚轮缩放、PageUp/PageDown 切换、旋转、放大缩小,甚至可以通过hide-on-click-modal控制点击遮罩是否关闭。但它没有提供任何"自定义操作栏"的插槽位,更别说内置下载按钮了。你去翻el-image-viewer的源码就会发现,它的操作区el-image-viewer__actions是固定写死的几组图标,不支持外部扩展。
所以实际做项目时,解决思路基本有三种:
- 自己重写一个完整图片查看器组件,把所有交互轮子重造一遍——不是说不行,但工作量至少翻两倍,而且后续 Element Plus 升级带来的新特性你都享受不到。
- 直接操作 DOM 往查看器里塞按钮——粗暴,但依赖组件内部 DOM 结构类名,版本一变就崩,而且 Vue3 的响应式状态同步非常别扭。
- 写一个独立的底部菜单操作栏,用 Teleport 或者绝对定位挂在查看器外层,通过事件监听来感知预览的开关和图片索引变化——这也是我最终采用并要详细展开的方案。
第三种方式的核心理念是"最小侵入":不修改 Element Plus 组件内部,只利用它暴露的 props 和 events,外加z-index和弹层定位来叠加我们自己的 UI。这套方案的好处是稳定、升级友好、代码量可控,而且下载功能可以独立沉淀为可复用的工具函数。
这篇文章面向的是已经基本掌握el-image用法、但希望在预览场景中追加自定义能力的开发者。我会从原理拆解讲到完整实现,再把我实际踩过的坑和排查思路一并交代清楚,全程基于 Vue3 Composition API。
2. preview-src-list 的触发机制与弹层可侵入点拆解
先弄明白一件事:当我们传入preview-src-list时,Element Plus 到底做了什么。
preview-src-list的类型是string[],它只负责提供"点击缩略图后弹出的大图地址列表"。真正承载预览能力的组件是内部的ElImageViewer,它会渲染成一个全屏的 fixed 覆盖层。默认情况下,这个覆盖层是放在组件当前 DOM 流里的,还是直接append到 body,取决于preview-teleported这个属性。
这里有个很关键的版本差异:早期 Element Plus 用的是append-to-body来决定是否将预览挂载到 body,现在则统一改成了preview-teleported。如果你在嵌套对话框、下拉菜单这类有独立定位上下文的场景里使用el-image,建议显式设置preview-teleported为true,否则弹层可能被父级容器的transform或overflow: hidden影响,导致预览位置错乱。
2.1 查看器内部结构:操作按钮与画布、关闭按钮
我拆过el-image-viewer的 DOM 结构,关键节点包括:
.el-image-viewer__wrapper:最外层的固定蒙层容器,承载背景遮罩和当前图片。.el-image-viewer__canvas:图片画布,缩放、切换、旋转都作用在这里。.el-image-viewer__actions:内置工具栏,包含缩放、旋转、切换按钮,HTML 里是几个<i>图标。.el-image-viewer__close:右上角关闭按钮。
我之所以要拆这些类名,是因为后面自定义操作栏的定位依赖它们。当我们通过 Teleport 把底部菜单渲染到 body 时,需要确保操作栏的层级高于查看器覆盖层。而查看器默认的z-index是受z-indexprop 控制的,默认值通常不高,在el-dialog内部打开时容易出现层级打架的问题。提前把这个关系理顺,能省掉后面一大半的调试时间。
2.2 通过 props 和 events 拿到预览状态
要实现自定义底部菜单,必须解决一个问题:如何知道预览当前打开了、当前展示第几张图、以及关闭时如何通知外层。
Element Plus 的el-image提供了几个关键 props 和 events:
| 名称 | 类型 | 作用 |
|---|---|---|
preview-src-list | string[] | 预览大图地址列表 |
preview-teleported | boolean | 预览弹层是否插入 body |
initial-index | number | 预览默认展示第几张图 |
z-index | number | 预览弹层层级 |
hide-on-click-modal | boolean | 点击遮罩是否关闭预览 |
preview-indicator | boolean | 是否显示右上角第几张/共几张指示器 |
@show | (e) | 首次打开预览触发 |
@hide | (e) | 关闭预览触发 |
@switch | (index) | 切换图片时触发,参数是当前索引 |
你注意到没有,这里没有v-model类型的绑定,所以预览的开和关只能通过事件去感知。我的建议是:写一个包装组件CustomImage.vue,内部维护一份当前索引和开关状态,把el-image的@show、@hide、@switch事件转成自己的响应式变量。这样一来,底部菜单要用的当前图片地址、文件名就都有地方拿了。
以实际代码为例:
<script setup lang="ts"> import { ref, computed } from 'vue' const props = defineProps<{ images: { url: string; name?: string }[] }>() const isPreviewOpen = ref(false) const activeIndex = ref(0) const handleShow = () => { isPreviewOpen.value = true } const handleHide = () => { isPreviewOpen.value = false } const handleSwitch = (index: number) => { activeIndex.value = index } const currentUrl = computed(() => props.images[activeIndex.value]?.url || '') const currentName = computed(() => props.images[activeIndex.value]?.name || '') </script> <template> <el-image :src="images[0]?.url" :preview-src-list="images.map(item => item.url)" preview-teleported :initial-index="activeIndex" fit="cover" @show="handleShow" @hide="handleHide" @switch="handleSwitch" /> </template>注意initial-index这里存在一个细节:如果你希望点击缩略图时从上次浏览的索引继续,需要把activeIndex同步回去。但反过来,当你点击不同缩略图重新打开预览时,Element Plus 会按你点击的那张索引重新初始化,initial-index不总是生效。实际调试时,更多人选择直接用事件返回的索引,而不是死盯这个 prop。
3. 底部菜单组件设计与定位逻辑
现在进入正题:如何在查看器下方加一个自定义操作栏。这一步我分为两件事来做:一是渲染位置的挂载方案,二是与el-image的状态联动。
3.1 Teleport 挂载与 z-index 控制
如果你的el-image开启了preview-teleported,那么查看器实际上已经挂到了document.body。此时,自定义操作栏最稳妥的做法也是通过Teleport挂载到document.body,并用position: fixed定位到底部中间。
为什么不用绝对定位依赖查看器容器?因为查看器内部 DOM 并不是固定不变的,而且挂载位置受preview-teleported影响,你在业务组件里很难用一个稳定的父级容器来对齐。固定定位是绕开复杂的 DOM 结构关系最直接的办法。
操作栏的z-index必须高于查看器。如果查看器给的是默认值,在容器属性未显式设置z-index时,操作栏直接给3000以上也能压住。更好的做法是把z-index作为 prop 传入,通过主组件统一控制:
<template> <Teleport to="body"> <div v-if="visible" class="preview-download-bar" :style="{ zIndex: zIndex }" @click.stop > <slot /> <el-button type="primary" size="small" @click="handleDownload">下载当前图片</el-button> </div> </Teleport> </template> <style scoped> .preview-download-bar { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); display: flex; align-items: center; gap: 12px; padding: 8px 14px; background: rgba(20, 20, 20, 0.78); border-radius: 10px; backdrop-filter: blur(6px); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); } </style>@click.stop是关键防护:如果不加,当你点击操作栏空白区域时,事件会冒泡到查看器的遮罩层,可能触发关闭预览。这是个非常容易踩的小坑,我后面还会再提。
3.2 多实例场景下的状态管理问题
一个页面常常有多个el-image,比如商品图列表、合同附件列表。如果每个实例都独立监听@show、@hide,那么在某个预览打开时,其他未打开预览的图片组件也可能渲染出同样的底部菜单。解决起来其实很简单:把预览开关状态和相关回调聚合到一个共享的模块里,或者用一个全局事件总线。
我的习惯是用一个轻量的组合式函数来管理:
// usePreviewBar.ts import { reactive, readonly } from 'vue' interface PreviewState { visible: boolean currentSrc: string currentName: string zIndex: number token?: string } const state = reactive<PreviewState>({ visible: false, currentSrc: '', currentName: '', zIndex: 3001, }) export function usePreviewBar() { function showPreview(src: string, name?: string) { state.visible = true state.currentSrc = src state.currentName = name || '' } function hidePreview() { state.visible = false state.currentSrc = '' state.currentName = '' } return { previewState: readonly(state), showPreview, hidePreview, } }然后在自定义图片组件里这样用:
const { previewState, showPreview, hidePreview } = usePreviewBar() const handleShow = () => { showPreview(currentUrl.value, currentName.value) } const handleHide = () => { hidePreview() } const handleSwitch = (index: number) => { showPreview( props.images[index]?.url || '', props.images[index]?.name || '' ) }这样一来,无论页面有多少个图片入口,最终都只有一个底部操作栏实例,通过全局状态决定是否显示。只要它的visible为 false,就不渲染。把"多个图片任选一个打开预览"和"页面上只有一个下载操作栏"这对矛盾,用共享状态很自然地化解掉了。
3.3 操作栏内容扩展:除了下载还能放什么
底部菜单默认适合放下载按钮,但因为你用了 Teleport + 插槽,这块操作栏完全可以扩展成通用工具栏。我通常会在里面放三个动作:
- 下载当前图片:调第 4 节实现的下载工具函数。
- 新窗口打开:
window.open(src)适合快速核对原图。 - 复制图片来源:把图片 URL 写入剪贴板,方便运营同事贴到工单里。
复制功能单独说一下。浏览器navigator.clipboard.writeText在 HTTPS 和 localhost 下可用,但在 HTTP 内网环境下可能被禁用。降级方案是用一个临时textarea加execCommand('copy')。我实际写完顺手也封装进去了,因为后台系统常常部署在内网 IP。
4. 下载功能实现:从 a 标签直链到 fetch blob 的升级之路
底部菜单的核心动作是"下载当前图片"。这一步看着简单,实际藏了不少边界情况。
4.1 最直接的实现:a 标签 download
一开始我图省事,直接用动态创建<a>标签的方式:
function downloadByAnchor(url: string, filename: string) { const link = document.createElement('a') link.href = url link.download = filename document.body.appendChild(link) link.click() link.remove() }这个方法在同源图片、且响应头没有特殊Content-Disposition的情况下挺好用。但问题也很明显:如果图片部署在另一个域名(比如 OSS 或 CDN),多数浏览器会忽略download属性,直接在当前窗口打开图片,甚至可能因为跨域策略直接没反应。你在后台管理里最常见的场景恰恰是附件存在对象存储而不是本服务,所以这个方案只能算玩具。
4.2 可靠方案:fetch 转 Blob 再触发下载
更可控的做法是用fetch拿二进制数据,生成 Blob URL 后触发下载。这样文件名可以完全由前端控制,也能绕过某些浏览器对跨域download属性的限制。但前提是目标服务允许跨域请求,或者你通过后端代理接口转发。
一个比较稳的封装长这样:
async function downloadImageByFetch( url: string, filename?: string, options?: { headers?: Record<string, string>; credentials?: RequestCredentials } ) { if (!url) { throw new Error('图片地址为空') } let response: Response try { response = await fetch(url, { method: 'GET', credentials: options?.credentials || 'include', headers: options?.headers, }) } catch (error) { // 网络层失败,降级为打开新窗口 window.open(url, '_blank') return } if (!response.ok) { throw new Error(`请求失败:HTTP ${response.status}`) } const blob = await response.blob() const objectUrl = URL.createObjectURL(blob) const link = document.createElement('a') link.href = objectUrl link.download = normalizeFilename(filename, url, response) document.body.appendChild(link) link.click() link.remove() URL.revokeObjectURL(objectUrl) }这里credentials: 'include'很关键。很多后台系统的图片接口是带鉴权的,如果直接通过<img>标签加载,浏览器会自动带上 Cookie,但fetch默认的credentials是same-origin,跨域时就不会携带 Cookie。如果不加这行,你 fetch 回来可能是 401 或者一张登录页的图片。
4.3 文件名推导:从 URL 到 Content-Disposition
下载时文件名怎么定?我见过不少教程直接写死成image.jpg,这不实用。合理的优先级应该是:
- 前端接口返回的元数据里有明确的文件名,比如商品图存储时有一个
name字段。 - 响应头
Content-Disposition里带的filename。 - 从 URL 最后一段路径提取文件名,去掉 query 参数。
- 都不行,兜底用时间戳生成一个。
写个简单的提取函数:
function getFilenameFromUrl(url: string, fallback = 'image.jpg') { try { const cleanUrl = url.split('?')[0] const parts = cleanUrl.split('/') const last = parts[parts.length - 1] || '' if (last && last.includes('.')) { return decodeURIComponent(last) } } catch (e) { // ignore } return fallback } function normalizeFilename(filenameFromProps: string | undefined, url: string, response: Response) { if (filenameFromProps) return filenameFromProps const disposition = response.headers.get('Content-Disposition') || '' const match = disposition.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i) if (match && match[1]) { return decodeURIComponent(match[1]) } return getFilenameFromUrl(url) }Content-Disposition的解析是顺手写的正则,覆盖不了所有编码情况,但对常见filename=\"xxx.jpg\"和filename*=UTF-8''xxx.jpg基本够用。如果你的图片接口没有返回这个头,那调用前面两层的优先级就够了。
4.4 下载后的内存回收
每次fetch都会生成一个 Blob URL,如果不及时revokeObjectURL,内存占用会随着用户反复下载而不断累加。我在代码里下载完成后立即URL.revokeObjectURL(objectUrl),但有个细节:在某些浏览器上,立即 revoke 可能导致下载被中断。稳妥点是延迟几毫秒再 revoke。实际项目里我用的是:
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000)5. 实测踩坑整理:版本差异、事件时序、层级覆盖
这部分是我最想写的内容,因为真正让功能跑起来和能用是两回事,我几乎每个项目都遇到下面这些问题。
5.1 点击操作栏导致预览关闭
这个坑出现概率最高。原因很直白:操作栏是覆盖在查看器上方的,点击事件会穿透到.el-image-viewer__wrapper上,而 wrapper 默认会在点击时关闭预览。解决办法在操作的根节点上加上@click.stop。如果你操作栏内部用了el-button,那el-button本身也可能会向外冒泡,因此最外层挡一次就够。
5.2 图片切换时 src 不更新
如果操作栏只在打开时保存了一次src,那么当你用左右箭头切换图片时,底部的"下载当前图片"拿到的还是第一张图的地址。所以必须在包装组件里每次都从@switch事件拿到最新索引,再重新计算当前src。我前面已经给出了handleSwitch的实现,原理就是"每一次展示动作都刷新一遍当前图信息"。
展开说一个容易忽略的点:@switch事件的参数是索引数字,不是图片 URL,所以不要在事件回调里直接判断evt === url。如果列表很长,建议直接通过索引读取列表项,拿地址、拿文件名,这样不需要额外校验。
5.3 预览层级低于对话框导致的显示错位
当你把el-image放在el-dialog里时,预览框不一定盖在对话框上方,需要手动给el-image传z-index。Element Plus 弹窗的 z-index 是动态累加的,默认从 2000 起步。这时如果你使用底部操作栏,也要把操作栏的z-index提上去,否则会出现"预览框打开了,但底部菜单跑到对话框后面去了"的情况。
我的做法是给预览框一个明确的z-index,比如3000,操作栏则通过 prop 传3001:
<el-image :preview-src-list="images.map(item => item.url)" preview-teleported :z-index="3000" /> <PreviewDownloadBar :visible="previewState.visible" :src="previewState.currentSrc" :filename="previewState.currentName" :z-index="3001" />如果你的项目里有多个不同层级的弹窗,更精致的做法是动态监听弹窗的 z-index,然后加一。但在实际使用中,固定给一个高于常规弹窗的值往往已经够用,别把简单方案复杂化。
5.4 preview-teleported 与 teleport 的双重作用
如果你的el-image设置了preview-teleported,但浏览器环境不满足(比如某些老的内嵌 WebView),预览框可能仍在原位置。这时候底部菜单依旧用position: fixed定位,看起来也还行,因为它相对视口固定,不会跟随页面滚动。但层级问题需要额外检查。
如果你开发的是基于cefsharp这类内嵌浏览器环境,还要留意一个情况:老版本 Chromium 内核的 WebView 对backdrop-filter支持不完整,操作栏背景很可能变成纯透明。为了兼容,我在样式里给了两层背景:一层半透明深色底,一层backdrop-filter可选。就算滤镜不生效,也至少保证文字可读。
5.5 下载功能在跨域时的兜底
fetch拿 blob 需要服务端允许跨域。如果你控制不了 OSS 的 CORS 配置,或者图片是私有鉴权接口,前端直接 fetch 大概率拿不到数据。此时有两条路:
- 走后端代理接口:后端请求图片服务,将二进制流转给前端。后端做好鉴权,同时生成正确的
Content-Disposition。 - 前端降级:
window.open(url, '_blank'),让浏览器新标签页直接展示图片,用户自己另存为。
我更推荐结合使用:先尝试fetch,失败后提示"图片鉴权失败,请在浏览器中打开查看",同时给出新窗口打开的按钮。这样虽然不如一键下载顺畅,但至少有一个可用的备选路径。实际项目里,我遇到过登录态从页面穿越到 fetch 后丢失的情况,排查半天发现是浏览器第三方 Cookie 策略拦截,最后直接在鉴权请求头里手动塞了 token 才解决。
6. 一圈做完后的最终代码形态与优化建议
把上面所有逻辑整合到一起,我这里给一个相对完整的最小可运行版本,方便你直接抄作业。
6.1 项目文件结构
src/ components/ CustomImage.vue PreviewDownloadBar.vue composables/ usePreviewBar.ts utils/ downloadImage.ts6.2 CustomImage.vue 完整示例
<script setup lang="ts"> import { ref, computed } from 'vue' import { usePreviewBar } from '@/composables/usePreviewBar' import PreviewDownloadBar from '@/components/PreviewDownloadBar.vue' import { downloadImageByFetch } from '@/utils/downloadImage' const props = defineProps<{ images: { url: string; name?: string }[] }>() const { previewState, showPreview, hidePreview } = usePreviewBar() const currentUrl = ref('') const currentName = ref('') const isDownloading = ref(false) const imageUrls = computed(() => props.images.map(item => item.url)) const handleShow = () => { currentUrl.value = props.images[0]?.url || '' currentName.value = props.images[0]?.name || '' showPreview(currentUrl.value, currentName.value) } const handleHide = () => { hidePreview() } const handleSwitch = (index: number) => { currentUrl.value = props.images[index]?.url || '' currentName.value = props.images[index]?.name || '' showPreview(currentUrl.value, currentName.value) } const handleDownload = async () => { if (isDownloading.value) return isDownloading.value = true try { await downloadImageByFetch(currentUrl.value, currentName.value) } finally { isDownloading.value = false } } </script> <template> <div class="custom-image"> <el-image :src="imageUrls[0]" :preview-src-list="imageUrls" preview-teleported :z-index="3000" fit="cover" @show="handleShow" @hide="handleHide" @switch="handleSwitch" /> <PreviewDownloadBar :visible="previewState.visible" :src="currentUrl" :filename="currentName" :z-index="3001" :loading="isDownloading" @download="handleDownload" /> </div> </template>6.3 PreviewDownloadBar.vue
<script setup lang="ts"> defineProps<{ visible: boolean src: string filename?: string zIndex?: number loading?: boolean }>() const emit = defineEmits<{ (e: 'download'): void }>() </script> <template> <Teleport to="body"> <div v-if="visible" class="preview-download-bar" :style="{ zIndex: zIndex }" @click.stop > <span class="preview-download-bar__filename">{{ filename || '未命名图片' }}</span> <el-button type="primary" size="small" :loading="loading" @click="emit('download')" > 下载 </el-button> </div> </Teleport> </template> <style scoped> .preview-download-bar { position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%); display: flex; align-items: center; gap: 14px; padding: 8px 16px; background: rgba(17, 17, 17, 0.82); border-radius: 8px; color: #fff; font-size: 13px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); backdrop-filter: blur(6px); } .preview-download-bar__filename { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } </style>6.4 进一步优化思考
功能跑通之后,你还可以再往下走几步:
- 给操作栏加一个
transition过渡动画,避免它生硬地出现和消失。Element Plus 的el-transition或单纯的 CSS 动画都行。 - 大图下载时给出进度反馈。如果图片动辄十几兆,用户点击后页面毫无反应会让人觉得按钮坏了。可以在下载按钮里加
loading状态,或者写一个轻量的顶部进度条。 - 如果你是后来才接手别人代码的小白,记住这句话:不要用
querySelector去查.el-image-viewer__wrapper再往里面塞东西。那种做法一旦遇到 Element Plus 更新,类名或被压缩或调整,你就得连夜改。
我在不同项目里反复用了这套方案。从最早的append-to-body时代到现在的preview-teleported,核心思路都没变:把自定义 UI 作为"外层挂件"持续叠加,而不是去动 Element Plus 内部逻辑。下次你再遇到"预览时加个下载按钮"的需求,先别急着改源码,照着这个思路走,严格执行@click.stop、同步好@switch索引、控制好z-index,很快就能交出一版让运营满意的功能。