用JavaScript与Canvas实现图片翻转:从变换矩阵到导出实战
简介这是一份用 JavaScript 实现图片翻转效果的入门示例适合前端初学者快速理解交互式图片切换的写法。资源包含两个 HTML 页面与一个 JS 文件并配有 26 个 GIF、7 张 JPG 图片素材压缩包共 36 个文件、大小约 413KB。示例覆盖 CSS transform 的 rotateX/rotateY 翻转、click 事件监听、DOM 样式操作和 transition 平滑过渡通过简单点击即可让图片翻转并恢复也展示了如何在多次点击时维护状态。文件中不同 GIF 与 JPG 可用于按钮状态、横幅和翻转对象的替换测试便于在本地浏览器中直接运行并观察效果。该资源已有 136 人查看学习短小精悍适合作为 Web 交互开发的第一份动手练习。1. 用JavaScript写的图片翻转为什么说这不是一行CSS的事很多人在接到图片翻转需求时第一反应是写一句transform: scaleX(-1)然后告诉产品经理做完了。但如果你真的在一个需要导出、需要跨端、需要把翻转结果保存成新图片的项目里试过会发现CSS翻转只解决显示问题没解决数据问题。用JavaScript写的图片翻转本质上是把图片的像素信息读取出来经过几何变换后重新绘制到一个canvas或新图片上这样翻转结果才真正成为一份可用的资源。这个Demo的源码适合两类人一类是刚接触canvas的初级前端另一类是把图片当数据处理的工程师。往下读之前先记住一个结论翻转不是转一下而是重新画一遍。2. 翻转的几何基础坐标系、矩阵与canvas.drawImage2.1 从CSS transform到canvas两条技术路线的取舍CSS的transform: scaleX(-1)确实可以在浏览器里把图片镜像显示它走的是GPU合成层性能很好也不会改变原始图片的像素数据。如果你只是想让用户临时看一眼翻转效果这条路线完全够用。但一旦遇到把翻转后的图片上传到服务器导出成base64给后端这类操作CSS就无能为力了因为DOM节点上的transform不会影响canvas的绘制结果更不会改变图片文件的二进制内容。canvas的drawImage方法提供了另一种可能的实现路径把一张图片对象绘制到画布上并在绘制时传入目标画布上的偏移、宽高甚至通过ctx.transform或ctx.scale等接口来改变绘制时的坐标系。这个Demo采用的是canvas方案核心原因是它让翻转成为一个可计算的过程而不是一次视觉效果。你可以在翻转后调用canvas.toDataURL()拿到新的图片地址也可以直接读取像素数组做二次处理比如加滤镜、加文字、压缩尺寸。两条路线的选择其实取决于你要不要持久化翻转结果。如果只是UI交互用CSS就够了别把一个简单的需求复杂化。但如果项目里已经有canvas或者明确要求输出图片文件那直接用JavaScript操作canvas是更合理的选择代码量多不了几行却把边界撑开了很多。2.2 翻转矩阵的参数scale、translate与rotate的组合顺序canvas的变换矩阵是一个2x3的矩阵通过setTransform(a, b, c, d, e, f)可以一次性设置缩放、倾斜、旋转和平移。对于翻转来说最常用的是scale和translate的组合。举个例子水平翻转镜像可以用ctx.scale(-1, 1)实现但问题在于canvas的坐标原点默认在左上角缩放为负数后图像会跑到画布外所以你需要在缩放之前或之后配合translate把坐标原点移到合适位置。常见的正确写法是ctx.translate(width, 0); // 先把原点移到右边界 ctx.scale(-1, 1); // 再水平翻转 ctx.drawImage(img, 0, 0, width, height);这段代码的逻辑是先右移一个画布宽度让原点的x坐标落在画布右边缘接着执行水平翻转坐标系变成x轴向左此时drawImage的起点(0,0)从左上角变成了右上角绘制出来的图正好落在画布范围内。如果不做第一步translate翻转后的图像会整体偏移到左侧不可见区域这是新手最容易踩的坑。垂直翻转同理只是把translate搬到y方向ctx.translate(0, height); ctx.scale(1, -1); ctx.drawImage(img, 0, 0, width, height);旋转90度则不能用简单的scale解决需要借助rotate和translate而且画布的宽高要交换。后面动手实现的时候我会把scale和rotate的顺序写在一个统一的函数里让调用方不用关心底层矩阵怎么排列只需要传一个type参数horizontal、vertical、rotate90、rotate180等。这比让业务方自己拼ctx方法要安全得多也方便后续扩展新变换类型。3. 动手实现一个图片翻转组件事件绑定、参数与状态管理3.1 HTML结构与初始状态先画出一张正常图片动手之前先把HTML骨架搭好。这个Demo里我建议把canvas的尺寸绑定到图片的原始大小这样翻转前后不会出现画布被裁剪的情况。HTML只需要一个canvas元素和一个img元素img用来预加载图片资源canvas用来输出绘制结果两者都设置为不可见或重叠放置避免页面上出现一个原始图一个翻转图。div classflip-stage img idsourceImage srcphoto.jpg alt原图 styledisplay:none canvas idflipCanvas width800 height600/canvas /div div classcontrols button>let state { scaleX: 1, scaleY: 1, rotation: 0 }; const canvas document.getElementById(flipCanvas); const ctx canvas.getContext(2d); const img document.getElementById(sourceImage); img.onload function() { canvas.width img.naturalWidth; canvas.height img.naturalHeight; render(); }; function render() { ctx.save(); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.translate(canvas.width / 2, canvas.height / 2); ctx.rotate(state.rotation * Math.PI / 180); ctx.scale(state.scaleX, state.scaleY); ctx.drawImage(img, -img.naturalWidth / 2, -img.naturalHeight / 2); ctx.restore(); }这里的save和restore是canvas的后悔药机制save会把当前的变换矩阵、裁剪区域、样式等压入栈中restore则恢复到之前保存的状态避免多次操作后矩阵叠加产生混乱。我把原点平移到画布中心这样旋转和缩放都会围绕中心点进行比围着左上角转更符合人的直觉。绘制时用-自然宽/2作为起始坐标相当于把图片的左边缘放在中心的负方向一半处。3.2 水平翻转、垂直翻转与旋转代码与执行顺序有了render函数剩下的就是更新状态。水平翻转对应的操作是让state.scaleX取反垂直翻转让state.scaleY取反旋转90度则是让state.rotation增加90。但这里有一个细节先缩放再旋转和先旋转再缩放结果完全不同。如果用户先水平翻转然后顺时针旋转90度最终图片的方向是会被交换的。因此需要定义好操作顺序。我的习惯是固定顺序先旋转后缩放。这样rotate(rotation)先改变坐标系方向之后scale(scaleX, scaleY)沿着新的坐标轴进行缩放。在按钮事件里每个按钮只改一个维度但要注意旋转90度时图片的宽高视觉上会交换而canvas的尺寸不会自动变。所以用户旋转后画布还是原来的宽高图片会有一部分画到外面。const actions { horizontal() { state.scaleX * -1; render(); }, vertical() { state.scaleY * -1; render(); }, rotate90() { state.rotation 90; handleRotatedSize(); render(); }, reset() { state { scaleX: 1, scaleY: 1, rotation: 0 }; fitCanvas(); render(); } }; function handleRotatedSize() { if (state.rotation % 180 ! 0) { // 旋转90度或270度时需要交换canvas宽高 const temp canvas.width; canvas.width canvas.height; canvas.height temp; } } function fitCanvas() { canvas.width img.naturalWidth; canvas.height img.naturalHeight; }handleRotatedSize里面有一个判断只有旋转了90度或270度即rotation % 180 ! 0才交换宽高旋转180度时宽高不变。这个判断如果漏掉旋转90度后再转90度画布会变回原来的尺寸但图片已经累积了180度视觉上宽高是对的但画布尺寸却错乱了。我在这个Demo里加了一个简单的状态机每次操作后都检查rotation对180取模的结果保证画布尺寸始终跟图片实际方向一致。3.3 参数化把翻转角度、缩放比例暴露给调用方写死四个按钮容易但一个真正能复用的组件不应该把交互逻辑绑死在HTML里。所以我把翻转操作封装成一个类FlipController它接受canvas、图片对象和一个配置对象options。配置对象里可以定义fitMode是否自动适配画布尺寸、defaultRotation、minScale等参数还可以注册onChange回调让调用方在每次翻转后拿到当前的state快照。class FlipController { constructor(canvas, image, options {}) { this.canvas canvas; this.ctx canvas.getContext(2d); this.image image; this.options Object.assign({ fitMode: contain, minScale: 0.1, maxScale: 10, onChange: null }, options); this.state { scaleX: 1, scaleY: 1, rotation: 0 }; } transform(type) { switch (type) { case horizontal: this.state.scaleX * -1; break; case vertical: this.state.scaleY * -1; break; case rotate90CW: this.state.rotation 90; break; case rotate90CCW: this.state.rotation - 90; break; } this._handleSize(); this.render(); if (this.options.onChange) this.options.onChange(this.state); return this.state; } }参数化的好处是调用方可以自由组合操作比如水平翻转后旋转180度再导出只需要连续调用transform(horizontal)和transform(rotate180)就行。我在transform方法里没有写死组合而是让积累的状态决定最终结果。这样组件内部不需要关心业务逻辑只需要保证每次变换都在前一次的基础上正确执行。4. 避坑与排查翻转后图片模糊、白边、坐标错乱的四个典型问题4.1 现象翻转后图片边缘出现白边尤其是旋转90度时很多人第一次用canvas做旋转会发现在某些角度下图片边缘会出现1-2像素的白色线条。原因在于canvas的像素栅格和图片的物理像素不是完全对齐的旋转后边缘的像素采样可能落不到整数坐标上于是canvas用透明色填充了缺口看起来就是白边。解决这个问题我的做法是在drawImage之前对图片做一次ctx.imageSmoothingEnabled true并把imageSmoothingQuality设为high让边缘像素做插值而不是直接丢弃。同时在绘制时把坐标取整Math.round(-img.naturalWidth / 2)避免绘制到半像素位置。ctx.imageSmoothingEnabled true; ctx.imageSmoothingQuality high; const halfW Math.round(img.naturalWidth / 2); const halfH Math.round(img.naturalHeight / 2); ctx.drawImage(img, -halfW, -halfH);4.2 现象翻转后的图片在retina屏幕上非常模糊像被降采样过canvas的CSS尺寸和物理像素尺寸在retina屏上不是一比一。如果你只用canvas.width img.naturalWidth但CSS样式把canvas放大到2倍显示浏览器会强制拉伸图片自然模糊。正确做法是让canvas的物理尺寸乘上设备像素比window.devicePixelRatio然后用ctx.scale(dpr, dpr)把坐标系放大。const dpr window.devicePixelRatio || 1; canvas.width img.naturalWidth * dpr; canvas.height img.naturalHeight * dpr; canvas.style.width img.naturalWidth px; canvas.style.height img.naturalHeight px; ctx.scale(dpr, dpr);注意这里ctx.scale(dpr, dpr)必须在其他变换之前调用因为它是在设置基础坐标系。如果你把dpr缩放放在翻转操作之后矩阵会叠加导致翻转中心点偏移。这个坑的排查经验是先注释掉所有翻转逻辑单独画一张不翻转的图如果还是模糊那问题一定出在dpr处理上。4.3 现象点击翻转按钮后图片位置漂移甚至跑到画布外面漂移几乎都是因为translate的坐标和旋转中心不匹配。我在前面提到固定顺序先旋转后缩放但如果你在旋转前做了translate(canvas.width/2, canvas.height/2)然后在旋转后又做了一次translate就会叠加。另一个常见错误是旋转90度后画布宽高发生交换但旧的translate参数没变导致中心点错位。我建议要么统一用setTransform重置矩阵要么把所有变换都放在save/restore之间并且在每次render开头强制设置一个干净的状态ctx.setTransform(1, 0, 0, 1, 0, 0); // 重置为默认矩阵 ctx.clearRect(0, 0, canvas.width, canvas.height);这样即使上一次操作遗留下了奇怪的矩阵下一次render也会从零开始。注意setTransform和resetTransform是等价的但前者更通用一些。4.4 现象旋转90度后导出图片宽高虽然正确但内容被裁剪换画布尺寸时旧画布的内容会被清空如果你在交换宽高之前没有先绘制那内容自然丢失。很多新手会在按钮事件里直接写canvas.width canvas.height然后才调用render()此时画布已经被重置之前的内容全没了。解决方法是先记录当前画面的快照比如用ctx.getImageData缓存等画布尺寸调整后再重放或者直接让旋转操作和重绘在同一个同步代码块里完成不要在中间插入用户可见的异步操作。我一般会避免直接修改canvas.width而是通过一个_resizeCanvas(w, h)方法把新旧绘制逻辑都塞进去_resizeCanvas(w, h) { const imageData ctx.getImageData(0, 0, canvas.width, canvas.height); canvas.width w; canvas.height h; ctx.putImageData(imageData, 0, 0); }但注意getImageData拿到的数据是基于旧坐标系的如果只是单纯调整长宽而不改变坐标轴直接putImageData会错位。所以这个缓存只适合那些不涉及旋转的尺寸调整场景。要旋转还是老老实实先改state再render。5. 把翻转结果变成可下载的资源export与DataURL的坑5.1 导出图片toDataURL与toBlob的选择翻转完的canvas画面只能算临时结果真正交付给用户时得导出一张图片文件。canvas.toDataURL(image/png)是最直观的方式它返回base64字符串可以直接赋值给a标签的href实现下载也可以POST给后端。但base64字符串在图片尺寸较大时会很膨胀一张2MB的PNG转成base64可能接近2.7MB传输效率低。更推荐canvas.toBlob(callback, mimeType, quality)它返回一个Blob对象体积更接近原始文件可以直接用URL.createObjectURL生成临时下载链接。下面是Demo里使用的下载函数canvas.toBlob(function(blob) { const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download flipped-image.png; document.body.appendChild(a); a.click(); URL.revokeObjectURL(url); a.remove(); }, image/png);注意几个细节toBlob的回调是异步的不能在调用后立刻revokeObjectURL否则下载链接会失效a.click()之后要把a元素移除不然页面上会残留隐藏的下载节点revokeObjectURL在下载启动后调用没有什么问题但如果在老浏览器里最好延迟几十毫秒再回收避免下载中断。5.2 老浏览器的兼容方案exif-js与手动解析如果项目需要支持IE或老版Edgecanvas.toBlob是不存在的。通常的兼容方案是退回到toDataURL再用atob把base64转成二进制手动构造Blob。这里有个额外的坑某些老浏览器对canvas的toDataURL有像素污染限制如果图片是从跨域域名加载的canvas会被标记为脏调用toDataURL会直接抛SecurityError。处理跨域图片的正确姿势是在img标签上设置crossOriginanonymous并且确保图片服务器返回了正确的CORS响应头。如果服务器不支持CORS那就只能把图片先代理到同域再加载。这个Demo里没有内置代理服务器所以我在文档里特意标注如果遇到SecurityError先检查图片资源的跨域头再检查crossOrigin属性是否设置。很多开发者以为crossOrigin是万能的其实它只是浏览器发送带Origin头的请求最终能不能拿到图还是由服务器说了算。老浏览器没有Promise、没有Array.prototype.find这些API所以我写的组件代码没有用这些新特性而是用回调和switch分支保证在原生ES5环境下也能跑。如果你把代码扔到现代浏览器里当然没问题但在古董环境里这套保守写法的价值就体现出来了。6. 进阶技巧用requestAnimationFrame做平滑翻转动画前面所有的操作都是一次性跳变点击按钮图片瞬间完成翻转。但真实产品里用户往往希望看到翻过去的过程尤其在做相册、卡片翻转这类交互时。用requestAnimationFrame做动画的核心思路不是一帧到位而是把state里的rotation或scaleX从一个值逐渐过渡到目标值每一帧重新绘制。假设用户点击水平翻转最终目标是scaleX -1动画开始前scaleX 1。我们可以在一个动画循环里让scaleX从1变化到-1步长由duration和帧间隔决定。这里的关键是不要用ctx.scale直接叠加而是每帧都设置一个绝对的缩放值。function animateFlip(fromScaleX, toScaleX, duration) { const startTime performance.now(); const delta toScaleX - fromScaleX; function tick(currentTime) { const progress (currentTime - startTime) / duration; const eased progress 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress 2, 2) / 2; const currentScale fromScaleX delta * eased; state.scaleX currentScale; render(); if (progress 1) { requestAnimationFrame(tick); } else { state.scaleX toScaleX; render(); } } requestAnimationFrame(tick); }这个插值公式用的是缓动函数开头慢、中间快、结尾慢视觉效果比线性变化自然得多。你也可以自己写progress到currentScale的映射比如加上弹性回弹让scaleX先冲过-1再回到-1就会产生一种类似卡片弹回来的手感。在这个Demo里我把动画逻辑单独抽了一个animateTransform方法接收目标state内部用performance.now()计算时间戳兼容不支持performance.now的老浏览器时退回到Date.now()。从那以后我每次做canvas变换类需求都会强制把一次绘制和动画插值拆成两个函数一个只负责根据当前state画图另一个只负责改state。这样不管是按钮点击还是动画帧调用页面都不会出现状态不同步的问题。所有变换都集中在render里排查问题只用看两个地方——state对不对drawImage参数对不对。希望这篇笔记能帮你少走几个弯路把图片翻转这种小功能做得既干净又能落地。本文还有配套的精品资源点击获取