简介:这是一份用 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.drawImage
2.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 class="flip-stage"> <img id="sourceImage" src="photo.jpg" alt="原图" style="display:none"> <canvas id="flipCanvas" width="800" height="600"></canvas> </div> <div class="controls"> <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或老版Edge,canvas.toBlob是不存在的。通常的兼容方案是退回到toDataURL,再用atob把base64转成二进制,手动构造Blob。这里有个额外的坑:某些老浏览器对canvas的toDataURL有像素污染限制,如果图片是从跨域域名加载的,canvas会被标记为"脏",调用toDataURL会直接抛SecurityError。
处理跨域图片的正确姿势是在img标签上设置crossOrigin='anonymous',并且确保图片服务器返回了正确的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参数对不对。希望这篇笔记能帮你少走几个弯路,把图片翻转这种小功能做得既干净又能落地。
本文还有配套的精品资源,点击获取