跳转到内容
关系图谱

image-preview:零依赖图片灯箱

2,277 字 8 分钟

属性 1
series
VaultKit 包指南

@vaultkit/vitepress-plugin-image-preview 是一个零运行时依赖的 VitePress 全屏图片查看器:点击正文图片即打开灯箱,支持滚轮缩放、1:1 复位、旋转、拖拽平移、同页画廊切换与下载,交互语义对齐 Element Plus Image Viewer。你现在就可以点击本站任意文章里的图片体验它 —— 比如 Markdown 语法示例 中的插图。

它与社区常见实现(vitepress-plugin-image-preview / ATQQ)最大的差异在安装方式:参考实现用 vite transform 改写 VitePress 内部 Layout.vue,依赖内部文件路径与 slot 标记,版本一升级就碎;本包改为 enhanceApp 一行 app.use(),在 body 上挂独立宿主 app,任意主题零侵入。

为什么需要它

VitePress 默认不提供图片放大能力,正文里的截图、架构图在窄容器里经常看不清细节。社区方案大致两类:

  • medium-zoom 系:轻量,但只有「放大居中」一个动作,没有旋转、画廊、下载
  • 改写内部布局系:功能全,但注入方式依赖 VitePress 内部实现,升级易碎

本包取两者之长:功能对齐 Element Plus Image Viewer(缩放/旋转/拖拽/画廊/键盘),注入方式却只是一行 app.use()——不碰主题布局、不碰 VitePress 内部文件,peerDependencies 只有 vue ^3.5.0,没有任何运行时依赖(滚动锁自实现、图标内联 lucide SVG)。

安装

sh
pnpm add -D @vaultkit/vitepress-plugin-image-preview

如果你用的是 @vaultkit/vitepress-theme-vault 主题,本插件开箱即带,无需单独安装。

快速上手

在主题入口注册客户端插件:

.vitepress/theme/index.ts
ts
import { ImagePreviewClient } from '@vaultkit/vitepress-plugin-image-preview'
import DefaultTheme from 'vitepress/theme'

export default {
  extends: DefaultTheme,
  enhanceApp({ app }) {
    app.use(ImagePreviewClient, {
      labels: { close: '关闭', prev: '上一张', next: '下一张' },
    })
  },
}
Important

本包 dist 内含 .vue 源文件,消费方必须把它加进 vite.ssr.noExternal,否则 SSR 构建会报错:

ts
// .vitepress/config.ts
import { defineConfig } from 'vitepress'

export default defineConfig({
  vite: { ssr: { noExternal: ['@vaultkit/vitepress-plugin-image-preview'] } },
})

就这两步。之后点击正文中任意命中选择器的图片即打开灯箱。

公开导出一览

导出形态说明
ImagePreviewClientVue 插件(默认导出同此)app.use() 安装,在 body 挂独立宿主 app
ImagePreviewHostVue 组件宿主组件:document 级点击委托 + 画廊收集
ImageViewerVue 组件全屏查看器本体,可编程式单独使用
ImagePreviewOptions类型插件配置项
ImagePreviewLabels类型按钮文案注入
ImagePreviewToolbarAction类型工具栏动作字面量联合

ImagePreviewOptions 逐项详解

selector

  • 类型:string,默认 '.vp-doc img:not(.no-preview)'
  • 语义:参与预览的图片选择器。同页命中集合即画廊序列——点开任意一张后,方向键切换的就是这批图片。

默认值有两个细节:只匹配 .vp-doc(VitePress 正文区)内的图片,导航栏 logo、头像等不会误入画廊;带 no-preview 类的图片被排除,给单张图开「免预览」白名单:

md
![这张图不参与预览](/diagram.png){.no-preview}

自定义站点结构时可以改选择器,比如把封面图也纳入:

ts
app.use(ImagePreviewClient, {
  selector: '.vp-doc img:not(.no-preview), .post-cover img',
})

zoomRatio

  • 类型:number,默认 1.2
  • 语义:滚轮 / 按钮的缩放速率。每滚一格或点一次按钮,缩放比乘以(放大)或除以(缩小)该值。调大则缩放更「跳」,调小则更细腻:
ts
app.use(ImagePreviewClient, { zoomRatio: 1.1 }) // 更细腻的缩放步进

minScale 与 maxScale

  • 类型:number,默认 0.2 / 7
  • 语义:缩放比的上下界。滚轮和按钮都会被钳制在 [minScale, maxScale] 区间内,源码里经 Math.min/Math.max 双向钳制并保留三位小数,避免浮点误差累积。

高分辨率长图站点可以放宽上界:

ts
app.use(ImagePreviewClient, { maxScale: 12 })

infinite

  • 类型:boolean,默认 false
  • 语义:画廊是否循环切换。默认到第一张 / 最后一张时对应箭头禁用(is-disabled 半透明 + not-allowed 光标);开启后到尾再切回到头,取模运算实现环形索引。

hideOnClickModal

  • 类型:boolean,默认 true
  • 语义:点击遮罩是否关闭查看器。这是本包与 Element Plus 的刻意差异:EP 缺省 false,但博客场景「点空白处关闭」是主流预期(与 medium-zoom 行为对齐),所以本包缺省 true。若想强制用户走关闭按钮:
ts
app.use(ImagePreviewClient, { hideOnClickModal: false })

closeOnPressEscape

  • 类型:boolean,默认 true
  • 语义:按 Esc 是否关闭。键盘监听挂在 window 上,随查看器 onMounted 绑定、onBeforeUnmount 解绑,不会泄漏。

showProgress

  • 类型:boolean,默认 true
  • 语义:多图时顶部居中显示「n / N」进度胶囊。单图时无论如何都不显示(连左右箭头也一并隐藏)。

toolbar

  • 类型:ImagePreviewToolbarAction[],默认全部六项
  • 语义:底部工具栏按钮及顺序。可用动作:'zoomOut' | 'zoomIn' | 'reset' | 'rotateLeft' | 'rotateRight' | 'download'。数组顺序即渲染顺序,传空数组则整条工具栏消失:
ts
// 只留缩放与下载,去掉旋转
app.use(ImagePreviewClient, {
  toolbar: ['zoomOut', 'zoomIn', 'download'],
})

labels

  • 类型:ImagePreviewLabels,默认英文文案
  • 语义:全部按钮的 aria-label / title 注入,共九个字段,逐一说明:
字段默认值对应控件
close'Close'右上角关闭按钮
prev'Previous image'左侧上一张箭头
next'Next image'右侧下一张箭头
zoomOut'Zoom out'工具栏缩小
zoomIn'Zoom in'工具栏放大
reset'Reset'工具栏 1:1 复位(清空平移/缩放/旋转)
rotateLeft'Rotate left'工具栏逆时针旋转 90°
rotateRight'Rotate right'工具栏顺时针旋转 90°
download'Download'工具栏下载当前图

中文站点的完整注入示例:

ts
app.use(ImagePreviewClient, {
  labels: {
    close: '关闭',
    prev: '上一张',
    next: '下一张',
    zoomOut: '缩小',
    zoomIn: '放大',
    reset: '还原',
    rotateLeft: '向左旋转',
    rotateRight: '向右旋转',
    download: '下载图片',
  },
})

这些文案同时写进 aria-labeltitle,屏幕阅读器与鼠标悬停提示同源,不需要分开配。

设计机制

document 级事件委托:路由切换免重挂

宿主组件把点击监听绑在 document 上而不是每张图片上。VitePress 是 SPA,路由切换后正文 DOM 整体重建——若逐图绑定,每次切页都要重新扫描绑定;委托到 document 则天然免重挂,新页面的图片点了照样能命中 target.matches(selector)

另一个巧思是防「套娃」:查看器自身也渲染 <img>,如果它挂在 .vp-doc 里,会命中默认选择器、点击后再开一层查看器。本包把宿主挂在 body 直属节点(#vp-image-preview-host)下,天然不在 .vp-doc 里,结构上杜绝了递归。

独立变换属性:translate / scale / rotate 互不覆写

传统写法是拼一个 transform: translate(...) scale(...) rotate(...) 字符串,三个分量互相覆写,还必须一起过渡。本包用 CSS 独立变换属性:

css
.ivp-img {
  transition:
    translate 0.3s,
    scale 0.3s,
    rotate 0.3s;
}

.ivp-img.is-dragging {
  cursor: grabbing;
  transition: none;
}

三个属性各自独立过渡:旋转时平移不动,缩放时旋转角不变。拖拽期间加 is-dragging 类禁掉全部过渡——否则图片会「追着」指针跑而不是跟手。

Pointer Events + setPointerCapture

拖拽用 pointerdown 里的 setPointerCapture:捕获之后 pointermove / pointerup 天然跟随该指针,即使指针移出图片区域也持续接收事件,无需在 window 上加兜底监听。松手(含 pointercancel)即释放。

其余细节

  • 滚轮缩放经 @wheel.prevent——Vue 会把带 .prevent 的 wheel 编译为非 passive 监听,才能阻止页面随滚轮滚动
  • 打开期锁 body 滚动:保存旧的 overflow 值、关闭时恢复,自实现免引 @vueuse/core
  • 切图后变换自动复位(watch(index, reset)),上一张的缩放旋转不会带到下一张
  • 悬浮控件固定深色半透明配色,独立于站点明暗——这是图片查看器的行业惯例,遮罩本来就是黑的

高级与边界

编程式使用 ImageViewer

ImageViewer 单独导出,可以脱离点击委托、自己控制打开时机:

vue
<script setup lang="ts">
import { ImageViewer } from '@vaultkit/vitepress-plugin-image-preview'
import { ref } from 'vue'

const show = ref(false)
const items = [
  { src: '/a.png', alt: '图 A' },
  { src: '/b.png', alt: '图 B' },
]
</script>

<template>
  <button @click="show = true">
    打开相册
  </button>
  <ImageViewer
    v-if="show"
    :items="items"
    :initial-index="0"
    infinite
    @close="show = false"
    @switch="i => console.log('切到第', i + 1, '张')"
  />
</template>

ImageViewer 支持事件两枚:close(请求关闭,显隐由宿主控制)与 switch(切图后携带新索引)。props 与插件 options 同名同义,另有 items{ src, alt? }[])与 initialIndex(初始索引,默认 0,越界自动钳到最后一张)。

图片源的取值

画廊收集时取 img.currentSrc || img.src——响应式图片(srcset)场景下预览的是浏览器实际选用的那个源,而不是 src 兜底值。

下载行为

下载按钮创建临时 <a download target="_blank"> 触发。注意:跨域图片若响应头没有 Access-Control-Allow-Origin 配合,浏览器会忽略 download 属性、退化为新窗口打开,这是浏览器安全策略而非插件缺陷。

常见问题

Q:某些图片不想被点开怎么办? 给图片加 no-preview 类,或自定义 selector 排除。

Q:SSR 构建报 Unknown file extension ".vue" 没配 vite.ssr.noExternal,回看上文安装一节的醒目提示。

Q:插件安装了但点图没反应? 检查图片是否在 .vp-doc 容器内(自定义布局可能没有这个类),必要时改 selectorImagePreviewClient.install 内有幂等保护(检测 #vp-image-preview-host 已存在则跳过),重复 app.use 不会坏,但也不会应用第二份配置。

Q:移动端可以用吗? 可以。拖拽走 Pointer Events,触摸设备天然支持;但双指捏合缩放暂未实现,触屏缩放依赖工具栏按钮。

相关链接