简介这是一份面向 Web 前端的图像查看器 JavaScript 库资源核心文件 viewer.min.js 经过压缩优化体积更小能让网页快速获得图片缩放、旋转、平移、全屏预览等交互能力非常适合商城图集、产品展示、相册浏览、后台管理等多图展示场景。ZIP 压缩包共包含 177 个文件整体大小约 3.14MB文件类型以 121 个 JS 脚本为主既有可直接部署的压缩构建版本也保留了便于调试的源码同时配有 18 张 JPG 示例图片、7 个 CSS 样式文件、6 个 HTML 演示页面以及 Markdown 文档、JSON 配置、TypeScript 声明等目录结构完整方便按需取用。目前已有 349 人学习使用。对于需要快速集成图片轻量查看器的开发者来说压缩包内不仅提供了可直接引用的 viewer.min.js还通过未压缩的源码版本、示例页面和配套样式展示了完整的初始化及配置流程能够帮助理解 API 调用方式和样式定制逻辑同时文档与示例图片也降低了上手门槛适合希望为网站增加专业级图片预览模块的前端工程师。1. viewer.min.js一个 30KB 的图片查看器为什么我还留着它做后台审批系统时甲方要求图片列表里每张缩略图点开都能放大、旋转、翻转还要能切换上一张下一张。第一反应是上组件库的 Preview 组件结果发现它依赖整套组件框架弹层一打开就掉帧。后来换成 viewer.min.js 这个单文件核心逻辑加样式不到 30KB不依赖任何框架原生 JavaScript 直接就能跑。它解决的核心问题是把一组图片变成带缩放、旋转、拖拽、键盘操作的查看器几十行代码就能接入。适合不打算为一张图片预览引入重型框架的前端从业者也适合老项目里临时要加看图功能的场景。2. 先搞清 viewer.min.js 在链路上的位置引入方式与初始化机制2.1 三种引入姿势CDN、npm 和手动拷贝viewer.min.js 是 Viewer.js 库的压缩产物完整配套文件还有一个 viewer.min.css。很多第一次接触的人只引了 JS 没引 CSS看到图片功能正常但布局全乱后面专门讲这个坑。项目里常见的引入方式有三种选择不同后续打包和维护方式也完全不同。第一种是 CDN 引入适合快速验证或老页面直接加一段脚本。HTML 里必须先引 CSS 再引 JSlink relstylesheet hrefhttps://unpkg.com/viewerjs/dist/viewer.css script srchttps://unpkg.com/viewerjs/dist/viewer.min.js/script这样引入后全局会挂一个Viewer构造函数直接用就行。需要注意版本锁定问题CDN 路径如果写成viewerjs不带版本号以后升级可能会突然改变行为生产环境最好固定到具体版本例如viewerjs1.10.5。第二种是 npm 安装适合现代构建项目npm install viewerjs然后在组件里引入import Viewer from viewerjs; import viewerjs/dist/viewer.css;这样做的优点是能参与构建流程按需打包也方便统一管理依赖。缺点是必须配合打包器使用不能直接在浏览器里跑。第三种是手动拷贝把 viewer.min.js 和 viewer.min.css 下载下来丢进项目的 static 或 public 目录用相对路径引入。这种方式最稳不受 CDN 可用性影响也无需构建工具。很多内网部署的后台系统就是这么用的。三种方式的选择建议新项目走 npm 配合按需加载纯粹老项目或离线环境直接手动拷贝文件临时调试用 CDN。不管哪种文件本身是一样的API 完全一致切换引入方式不需要改业务代码。2.2 它为什么是“监听”而不是“包裹”初始化机制初始化一个查看器只需要一行代码const viewer new Viewer(document.getElementById(gallery));这里传入的gallery是容器元素Viewer 会自动查找容器内所有img标签。它并不会把每张图片都绑上一个点击事件而是在容器上做事件委托监听容器的click判断点击目标是不是img命中后打开查看器。这个机制直接决定了动态添加的图片很多时候点不开——因为委托在容器上理论上新图片也能被监听到但 Viewer 内部还维护了一份图片索引必须调用update()方法刷新这点在避坑章节详细说。Viewer 打开后会在body末尾插入一个固定定位的viewer-container节点原容器里的图片信息被复制到这个层里展示。注意是“复制”不是“移动”。也就是说原图片 DOM 始终在页面里查看器展示的是基于同一图片地址的另一个渲染层。旋转、翻转这些操作实际是作用于新层的 CSS transform这也是为什么用 canvas 导出时会发现方向不对原因到第 4 章展开。整个查看器的生命周期可以概括为new Viewer()创建实例但不显示点击图片触发show()图片切换会触发view事件缩放、旋转会触发zoom和rotated事件关闭走hide()彻底删除用destroy()。理解这个流程后面用方法调用和事件回调就不会混乱。小技巧初始化后马上调用viewer.view(index)可以跳过点击直接显示第几张这在做列表页的“查看大图”入口时很有用。3. 把一张图片变成可交互查看器最小可用实现与参数详解3.1 最小可用代码从页面结构到查看器启动先准备一个最基本的图片列表容器ul idgallery liimg src./photos/01.jpg alt照片 1/li liimg src./photos/02.jpg alt照片 2/li liimg src./photos/03.jpg alt照片 3/li /ul然后在一个 script 里初始化const gallery new Viewer(document.getElementById(gallery), { url: src, toolbar: true, navbar: true, title: true, transition: true, });这段代码的逻辑是把gallery里所有img收集起来点击任意一张打开全屏遮罩层顶部显示工具栏底部显示缩略图导航窗口标题显示alt属性文字。url: src表示大图地址取自img的src属性。如果你的页面结构是缩略图用小图、点击要看原图通常会把原图地址放在>toolbar: { zoomIn: 1, zoomOut: 2, oneToOne: 3, reset: 4, prev: 0, play: 0, next: 0, rotateLeft: 1, rotateRight: 1, flipHorizontal: 1, flipVertical: 1, }注意这个对象里数字相同的按钮共享按钮组样式主要用于做分组不用太纠结按顺序排即可。3.3 method 调用与事件回调像操作对象一样控制查看器除了用户手动点击业务代码里经常需要主动控制查看器。Viewer 实例上暴露了一套方法常用如下gallery.zoom(0.5); // 在当前缩放基础上放大 50% gallery.zoomTo(1); // 直接缩放到原始比例 gallery.rotate(90); // 顺时针旋转 90 度 gallery.rotateTo(0); // 回到初始角度 gallery.flipX(); // 水平镜像 gallery.flipY(); // 垂直镜像 gallery.view(1); // 切换到第 2 张图片索引从 0 开始 gallery.show(); // 打开查看器 gallery.hide(); // 关闭查看器 gallery.destroy(); // 销毁实例并释放监听这里最容易搞错的是zoom和zoomTo的区别zoom(0.5)是在当前缩放比例上乘以 1.5也就是说如果当前已经是 2 倍调用后变成 3 倍而zoomTo(0.5)是直接设成 0.5 倍。做“一键还原”按钮时必须用zoomTo(1)配合rotateTo(0)不能用zoom(-1)因为zoom的参数不支持负数。事件回调用于在特定时机插入业务逻辑gallery.on(shown, function (event) { console.log(查看器已打开); }); gallery.on(view, function (event) { console.log(当前切换到第, event.detail.index, 张); }); gallery.on(zoom, function (event) { console.log(缩放比例变化, event.detail.ratio); });Viewer 的on方法直接挂在实例上事件对象里的event.detail会携带关键数据。值得强调的是事件绑定在实例上destroy()之后绑定自动解除不会造成内存泄漏。如果你需要某个回调只执行一次可以加gallery.one(shown, fn)这在初始化后自动打开查看器时很常用。4. viewer.min.js 避坑六个我踩过的坑现象、原因、解决4.1 动态加载新图片但点击没有反应现象页面初始化时通过innerHTML或insertAdjacentHTML往容器里追加了新的img点击新图片查看器没打开旧图片正常。原因Viewer 初始化时会把容器内的图片索引、事件、DOM 引用都建立在一份内部列表里。虽然它用的是容器事件委托理论上能感知新元素但这张新图片不在内部索引中所以点击后被过滤掉了。解决每次往容器里添加图片后必须手动调用一次update()const container document.getElementById(gallery); container.insertAdjacentHTML(beforeend, img src./new.jpg alt新图); // 关键通知 Viewer 重新收集图片 gallery.update();update()会重新遍历容器把新图片加入索引并且自动绑定好相关属性。注意如果图片本身是在查看器打开状态时加入的建议先hide()再update()避免内部状态错乱。我一般封装一个addImage(imgHtml)函数内部固定执行container.insertAdjacentHTML和gallery.update()两步业务层不再感知 Viewer 的存在。4.2 打开查看器后页面依然能滚动现象在长列表页打开查看器鼠标滚轮滚动时背景页面也跟着滚动遮罩层形同虚设。原因Viewer 默认不会去锁定body的滚动条。它自身的滚动事件被处理了但body的滚动通道没有堵住。查阅文档会发现没有提供scrollLock这样的选项需要我们自己补。解决监听show和hide事件动态切换body的overflow样式gallery.on(show, function () { document.body.style.overflow hidden; }); gallery.on(hidden, function () { document.body.style.overflow ; });这段代码要注意如果页面上还有其他弹层组件也在控制body的overflowhidden事件里直接重置成空字符串会覆盖其他弹层的状态。稳妥做法是记录进入前的原始值关闭时恢复let prevOverflow ; gallery.on(show, function () { prevOverflow document.body.style.overflow; document.body.style.overflow hidden; }); gallery.on(hidden, function () { document.body.style.overflow prevOverflow; });4.3 旋转后导出图片方向不对现象用户在查看器里把图片旋转了 90 度点击“导出当前图片”按钮生成的图片还是原始方向。原因Viewer 的旋转是用 CSStransform作用在查看器层的 DOM 上原图片数据完全没有变化。导出时如果用canvas直接画原图自然不带任何旋转信息。这算 Viewer 的设计边界不是 bug。解决导出时需要手动读取 Viewer 记录的旋转和翻转状态在 canvas 里做对应变换。实例上有getImageData()方法返回包含rotate、scaleX、scaleY字段的数据function exportCurrentImage(viewer) { const imageData viewer.getImageData(); const img new Image(); img.onload function () { const canvas document.createElement(canvas); const angle (imageData.rotate || 0) % 360; const radian angle * Math.PI / 180; canvas.width img.naturalWidth; canvas.height img.naturalHeight; const ctx canvas.getContext(2d); ctx.translate(canvas.width / 2, canvas.height / 2); ctx.rotate(radian); if (imageData.scaleX -1) ctx.scale(-1, 1); if (imageData.scaleY -1) ctx.scale(1, -1); ctx.drawImage(img, -img.naturalWidth / 2, -img.naturalHeight / 2); }; img.src imageData.src; }这里有一个更容易踩的细节旋转 90 度后canvas 的宽高也应该交换否则导出图会被截掉一部分。简单处理是判断angle % 180 ! 0时交换宽高。我在实际项目里还遇到getImageData()返回的src是相对路径必须拿new URL(imageData.src, location.href)转成绝对路径才能让img正常加载。4.4 功能全部正常但界面样式全乱现象查看器能打开图片也能缩放但是按钮位置错乱、遮罩层不透明、导航条挤在一起。原因几乎都是漏引了样式文件。Viewer 的结构样式全部写在viewer.css里JS 只负责行为和结构不负责视觉。我见过有人为了省一个请求把 CSS 内容复制进自己的样式文件但选择器写错结果同样表现。解决确保引入顺序是这样的link relstylesheet hrefpath/to/viewer.min.css script srcpath/to/viewer.min.js/scriptCSS 必须在 JS 之前。如果用了打包工具import viewerjs/dist/viewer.css这行也不可省略。排查方式很简单打开开发者工具检查查看器容器看它的getComputedStyle里有没有viewer-container应该有的position: fixed和背景色没有就是样式缺失。4.5 keyboard 选项设了 true 但键盘没反应现象配置里写了keyboard: true打开查看器按左右方向键不切图按加减号不缩放。原因Viewer 的键盘事件监听绑定在document上但只有在查看器内部元素获得焦点时才响应。很多页面里点击打开查看器后焦点还在原来的按钮上键盘事件被其他组件拦截了。解决最有效的方式是打开后手动把焦点移到查看器容器gallery.on(shown, function () { viewerInstance.$container viewerInstance.$container.focus(); });这里$container是 Viewer 内部暴露的元素引用也可以改成在shown回调里用document.querySelector(.viewer-container)实现。需要注意如果页面里用了 iframe键盘事件可能被 iframe 吞掉这种情况建议放弃原生keyboard由外层自己做keydown监听然后调用viewer.view()方法反而更可控。4.6 Vue/React 组件中多次初始化导致事件重复绑定现象在 Vue 组件里每次进mounted都new Viewer()离开时不销毁第二次进组件后打开查看器图片切换一次会触发两次业务请求。原因destroy()没有被调用旧实例的容器和事件监听还挂在body上。重新创建实例后两张事件的监听叠在一起所有回调执行两遍。解决严格在组件卸载时调用mounted() { this.viewer new Viewer(this.$refs.gallery); }, beforeDestroy() { if (this.viewer) { this.viewer.destroy(); this.viewer null; } }在 React 函数组件里对应useEffect的清理函数。需要额外注意如果你在destroy()之前先调了hide()页面上的查看器层会立刻移除顺序应该是hide()后再destroy()直接destroy()其实内部也会做清理但为了控制过渡动画我一般先hide()再destroy()。5. 把 viewer.min.js 嵌入业务系统自定义工具栏与本地预览5.1 自定义“下载原图”按钮业务系统里最常见的诉求是用户看完图直接下载原图。Viewer 的工具栏默认没有下载按钮官方也没提供相关配置需要自己扩展。好在toolbar选项支持自定义项写法如下const viewer new Viewer(imageContainer, { toolbar: { zoomIn: 1, zoomOut: 2, oneToOne: 3, reset: 4, rotateLeft: 5, rotateRight: 6, flipHorizontal: 7, flipVertical: 8, download: { icon: download, title: 下载原图, show: true, click: (viewer) { const imageData viewer.getImageData(); const a document.createElement(a); a.href imageData.src; a.download imageData.alt || image; document.body.appendChild(a); a.click(); document.body.removeChild(a); } } } });这段代码里的download就是我们自定义的按钮。icon对应的图标来自 Viewer 内置字体如果不想用默认图标可以传入一个 HTML 字符串作为图标比如icon: svg.../svg。click回调接收当前 viewer 实例因此能拿到当前查看图片的完整信息。需要注意download这个自定义名不要和未来的内置方法冲突业务里稳妥一点可以改成downloadOrigin。点击后的下载动作依赖浏览器的download属性如果图片是跨域且服务器没给Access-Control-Allow-Origin头浏览器会忽略download属性直接打开图片这种情况下要做服务端代理下载或转 blob这是题外话。5.2 上传场景先预览本地图片再提交内容审核后台常要在一个查看器里预览本地上传的图片我倾向的流程是先生成临时 URL再初始化查看器预览结束销毁后再释放 URLconst fileInput document.getElementById(fileInput); const previewContainer document.getElementById(previewContainer); fileInput.addEventListener(change, function (e) { const file e.target.files[0]; if (!file) return; let currentViewer this._viewer; if (currentViewer) { currentViewer.destroy(); currentViewer null; } const objectUrl URL.createObjectURL(file); previewContainer.innerHTML img src objectUrl alt本地预览; currentViewer new Viewer(previewContainer, { inline: true, title: false, }); this._viewer currentViewer; // 预览结束后 currentViewer.on(hidden, function () { if (currentViewer) { currentViewer.destroy(); URL.revokeObjectURL(objectUrl); } }); });这里有一个顺序陷阱URL.revokeObjectURL()必须在图片不再被任何地方引用时调用否则 DOM 里的图片会变成空白。所以销毁实例的代码必须放在revokeObjectURL之前。如果你用inline: true把查看器直接嵌在页面区域里没有弹层那关闭按钮事件就不是hidden而是没有对应事件此时应该由你的业务逻辑来决定何时销毁比如点击“替换图片”按钮时先销毁再重来。5.3 多实例管理让每个弹窗有独立的查看器一个页面里可能同时存在多个入口比如左侧列表点开大图、右侧详情页点开另一张图。如果不管实例每次new Viewer都会往body塞一个遮罩层多个实例叠加时关闭一个会出现第二个露底的问题。我的做法是维护一个全局唯一的查看器实例let globalViewer null; function openOneImage(imgElement) { if (globalViewer) { globalViewer.destroy(); globalViewer null; } globalViewer new Viewer(imgElement, { toolbar: true, viewed() { const instance this; globalViewer instance; } }); }注意new Viewer(imgElement)的imgElement可以直接传一个单独的img元素Viewer 会把它当作只有一张图片的列表。但这样初始化后原图片会被挂上点击事件吗不会因为viewed回调里我们刚才没有 show需要手动调globalViewer.show()。另一种做法是构造一个临时容器并放入img初始化后调用viewer.show()。多实例场景下最好统一走一个入口函数避免业务代码里散落各种new Viewer导致无法管控。6. 进阶用法让 viewer.min.js 和图库资源只在需要时加载6.1 用 Vite 或 webpack 做动态 importviewer.min.js 本身体积不大但在某些首屏很敏感的项目里还是希望用户真正点开图片时才加载这个库。Vite 和 webpack 都支持动态导入写法如下let viewerCache null; async function openImageViewer(container) { if (!viewerCache) { const [{ default: Viewer }, style] await Promise.all([ import(viewerjs), import(viewerjs/dist/viewer.css), ]); viewerCache { Viewer, style }; } const { Viewer } viewerCache; const viewer new Viewer(container, { title: true }); viewer.show(); }第一次点击图片时代码会异步拉取 viewer.min.js 和 viewer.css之后的点击直接复用缓存的Viewer构造函数。如果项目使用的是 CDN 手动引入方式那就用动态创建script标签的办法加载完成后再初始化。动态 import 节省的是首屏字节注意这里的 Promise.all 并不是并行加载两个模块的必需语法而只是为了拿到 CSS 的加载完成信号确保初始化前样式就位。6.2 用 Performance 面板确认懒加载是否真正生效写好动态加载之后不要只看控制台不报错我习惯用浏览器工具验证一次。打开页面先不看 Effect 面板直接按 F12 进入 Performance点录制然后操作图片预览停止录制。在 Network 标签里查找viewer.min.js和viewer.css两条请求如果它们出现在点击图片之后的时间线上说明懒加载成功如果出现在首屏加载瀑布流的开头说明构建配置里还是被提前打包了。额外观察一点打开查看器后在 Performance 面板里会有一次明显的Parse和Style计算这对应动态加载的 JS 和 CSS 被浏览器解析。如果这个时间段超过 200ms 且页面卡顿考虑把viewer.min.js放进link relpreload预取而不是完全按需加载。从那以后我每次接入这个库都会先花五分钟在 Performance 面板里把“脚本加载—初始化—销毁”的链路完整跑一遍确认没有在首屏抢资源也确认关闭查看器后实例引用被清空。希望帮到你。本文还有配套的精品资源点击获取