ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

用canvas打造动态相册:从零实现轮播、翻牌与粒子特效

用canvas打造动态相册:从零实现轮播、翻牌与粒子特效 这两天有个做展示类页面的需求找上我把一组相册图片做成动态展示效果要求自动轮播、切换时有动感、还要带点高级感。一开始我图省事直接用 CSS 动画堆结果越写越别扭——多个图片的交叉淡化、位移、缩放要精确同步的时候CSS 的 transition 和 keyframes 明显不够灵活。最后我老老实实把实现方案换成了 canvas用一套 rAF 渲染循环把所有效果统一管理反而清爽得多。这篇文章就把我做动态 canvas 相册简单效果展示的完整过程、核心代码和踩过的坑都写出来。如果你也需要做一个轻量级的相册/图片展示效果或者想搞明白 canvas 这类手动绘制方案到底怎么落地这篇应该能帮到你。1. 为什么最后选了 canvas 而不是 CSS 动画堆效果1.1 需求其实很简单但简单最考验选型先说需求本身一个展示位轮播若干张图片切换时带过渡动效能手动切也能自动播。听起来确实不难市面上也有很多现成轮播组件。但为什么这种简单效果反而容易翻车因为一旦你开始追求动态感比如图片移动方向不一致、当前图淡出同时下一张带点缩放进入、或者切图瞬间撒几个光斑粒子CSS 写起来就开始痛苦了。CSS 不是不能做transition加opacity、transform确实能实现基础轮播。但多个元素的状态同步是个大问题。比如我想做一个当前图向左移出 下一张从右侧移入 同时整体透明度渐变的组合效果用 CSS 你得保证两三个动画的时长、缓动曲线、延迟完全一致稍有不慎就会出现跳帧感或者错位。更麻烦的是如果你想把动画进度暂停、反转或者中途改变方向CSS 动画的状态控制能力很弱得靠 Web Animations API 或者反复切换 class 才能勉强做到。1.2 canvas 这个方案的核心优势在哪canvas 的好处是它把所有图片的绘制权都交给你自己。你想让哪张图在什么时间出现在什么位置、透明度和缩放是多少全都在一个requestAnimationFrame循环里计算状态完全可控。尤其是多个动效叠加的场景——位移、缩放、透明度、粒子所有参数都在同一套时间进度下推进协调性远好于 CSS 分散的动画声明。这里补充一句不是所有相册场景都该用 canvas。如果你的需求只是简单的淡入淡出轮播、没有复杂的组合动效用纯 CSS 加一个成熟的轮播组件反而更省事。canvas 适合的是效果有定制要求、需要统一控制时间轴的场景。另外一个容易被忽略的点是canvas 的渲染结果是一帧一帧画出来的天然适合跟后续更复杂的动效接轨。我这个项目做完之后同事又提了能不能在切图的时候加几个飘散的小光点CSS 方案下得额外插一堆 DOM 节点canvas 方案里就是一个粒子数组的事。这也是我最后坚定用 canvas 的原因——它不是性能最优解但它是扩展性最优解。2. 开工前准备画布初始化、高分屏适配和图片预加载2.1 HTML 结构和一个能用的画布页面结构很直接一个容器加一个 canvas 元素宽高由外部容器决定div idalbumContainer canvas idalbumCanvas/canvas /div第一步是拿到 canvas 的 2D 上下文并把画布尺寸设置成和容器一致。这里有个最常见的坑如果你直接把canvas.width设置成 CSS 宽度在 Retina 屏上画面会糊成一团因为物理像素和逻辑像素不对等。正确做法是把 canvas 的实际像素尺寸乘以devicePixelRatio再通过ctx.scale把坐标系还原成 CSS 像素。这样画图的时候你仍然用逻辑尺寸思考但渲染出来的清晰度是适配高分辨率屏幕的。const canvas document.getElementById(albumCanvas); const ctx canvas.getContext(2d); const dpr window.devicePixelRatio || 1; const rect canvas.parentElement.getBoundingClientRect(); canvas.width rect.width * dpr; canvas.height rect.height * dpr; canvas.style.width rect.width px; canvas.style.height rect.height px; ctx.scale(dpr, dpr);这段代码里canvas.style.width也不能省否则 canvas 的显示尺寸会跟着像素尺寸一起变大布局就崩了。我在这里卡过一次查了半天才发现是忘了设置 style 尺寸。2.2 图片预加载从 onload 地狱到 Promise.allcanvas 本身不关心图片从哪来但你用drawImage绘制一张还没加载完的图片时结果是直接空白的。所以加载逻辑必须放在绘制之前。我最早写的时候用的是单个Image对象加onload回调图片一多就退化成回调嵌套非常难受。后来统一改成 Promise 包装再用Promise.all等待全部加载完成再开始动效。function loadImage(src) { return new Promise((resolve, reject) { const img new Image(); img.onload () resolve(img); img.onerror () reject(new Error(图片加载失败: src)); img.src src; }); } async function initAlbum(imageUrls) { try { const images await Promise.all(imageUrls.map(loadImage)); startPlayer(images); } catch (err) { console.error(err); } }用async/await之后整个初始化流程清晰很多。加载失败的情况我也建议单独处理比如给一个占位图而不是让整个相册白屏。实际项目中图片挂掉是常态尤其是内容来自用户上传的时候这个错误处理一定要留好。2.3 绘制前的最后一步cover 模式的 drawImage图片加载好之后还有一个细节要处理每张图片的尺寸比例和画布通常不一致直接drawImage会把图片拉伸变形。我参考 CSS 的object-fit: cover语义写了一个通用的绘制函数按比例裁剪填充画布多余的部分裁掉保证任何尺寸的图片都能铺满画面且不变形。function drawImageCover(ctx, img, offsetX 0, offsetY 0, w, h) { const imgRatio img.width / img.height; const canvasRatio w / h; let dw, dh; if (imgRatio canvasRatio) { dh h; dw h * imgRatio; } else { dw w; dh w / imgRatio; } ctx.drawImage(img, (w - dw) / 2 offsetX, (h - dh) / 2 offsetY, dw, dh); }这个函数后面所有动效都会用到所以写成独立工具函数。drawImage支持九个参数把计算好的目标尺寸和位置传进去即可。注意这里的offsetX、offsetY是留给动效平移用的绘制时加上移动量就能让图片在 cover 基础上还能平滑移动。3. 核心动效拆解淡入淡出轮播、缩放、翻牌与粒子点缀3.1 淡入淡出轮播先从时间轴说起整个相册的核心是一个时间轴驱动的渲染循环。我用一个progress变量表示当前动画的进度从 0 到 1每帧根据实际经过的时间累加。当 progress 达到 1 时说明一次切换动画完成就把索引切换到下一张progress 归零重新开始。class AlbumPlayer { constructor(canvas, images) { this.canvas canvas; this.ctx canvas.getContext(2d); this.images images; this.index 0; this.progress 0; this.duration 1500; this.running false; this.lastTime 0; this.onFrame null; } start() { this.running true; this.lastTime performance.now(); this.loop(); } loop() { if (!this.running) return; const now performance.now(); const delta now - this.lastTime; this.lastTime now; this.progress Math.min(this.progress delta / this.duration, 1); this.render(); this.onFrame this.onFrame(this.progress); requestAnimationFrame(() this.loop()); } }为什么用requestAnimationFrame而不是setInterval因为 rAF 会自动跟随屏幕刷新率并且在标签页切到后台时自动暂停不消耗额外性能。你不需要手动处理用户切走了页面还在空转的问题。进度累加用经过的时间除以总时长这样不管帧率是 60fps 还是 30fps动画总时长都是稳定的 1.5 秒不会因为掉帧而变慢或变快。3.2 给切换动画加缓动easeInOutCubic 的直观理解直接线性累加 progress 会让动画看起来非常机械所有物体匀速移动缺少起步-加速-减速的自然感。解法是加一个缓动函数。我常用easeInOutCubic它让动画在开始和结束时速度慢、中间速度快视觉上最舒服function easeInOutCubic(t) { return t 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t 2, 3) / 2; }你可以把这个函数的输出理解成一个加工后的进度。原始 progress 是匀速增长的但喂给渲染逻辑的t是非匀速的前半段缓入、后半段缓出。所有动效参数位移距离、透明度、缩放比例都应该基于t而不是原始 progress 计算这样整套动画才协调。3.3 淡入淡出 位移最经典的轮播组合效果现在来实现核心的轮播效果。逻辑很简单当前图从原位淡出并向左移动一段距离下一张图从右侧淡入并移动到中间。两段动画共享同一个t所以视觉上它们是严格同步的render() { const { ctx, images, index } this; const w this.canvas.width / (window.devicePixelRatio || 1); const h this.canvas.height / (window.devicePixelRatio || 1); ctx.clearRect(0, 0, w, h); const t easeInOutCubic(this.progress); const currentImg images[index]; const nextImg images[(index 1) % images.length]; ctx.globalAlpha 1 - t; drawImageCover(ctx, currentImg, -t * 60, 0, w, h); ctx.globalAlpha t; drawImageCover(ctx, nextImg, (1 - t) * 60, 0, w, h); ctx.globalAlpha 1; }这里有个细节值得说位移 60 像素是我试出来的比较舒服的距离太近动感不够太远会感觉图片跳来跳去。如果你想做出当前图缩小退出、下一张放大进入的层次感就把位移改成缩放ctx.scale加在drawImage前后即可。我用过一个组合当前图透明度渐降 向左移出 轻微缩到 0.95下一张透明度渐升 从右侧移入 从 1.05 缩到 1整体切换会显得非常流畅。3.4 翻牌效果一张图在 3D 翻转中切换轮播做完了我又加了一个翻牌效果也就是图片绕纵轴翻转 180 度背面是下一张图。实现思路是在渲染时用ctx.scale把 x 轴方向压扁模拟翻转过程中的透视收缩renderFlipFrame(t) { const { ctx, images, index } this; const w this.canvas.width / (window.devicePixelRatio || 1); const h this.canvas.height / (window.devicePixelRatio || 1); ctx.clearRect(0, 0, w, h); ctx.save(); ctx.translate(w / 2, h / 2); const angle Math.PI * easeInOutCubic(t); ctx.scale(Math.cos(angle), 1); if (t 0.5) { ctx.drawImage(images[index], -w / 2, -h / 2, w, h); } else { ctx.drawImage(images[(index 1) % images.length], -w / 2, -h / 2, w, h); } ctx.restore(); }Math.cos(angle)在翻转角度从 0 到 90 度时从 1 缩到 0再继续转到 180 度时从 0 变回 1正好模拟卡片翻转的视觉宽度变化。角度超过 90 度后切换到下一张图看起来就是翻到背面已经是新图了。唯一的瑕疵是没有做真正的 3D 透视但作为简单效果展示这个程度已经够用。如果追求真实透视需要上 WebGL 或者 CSStransform-style: preserve-3d复杂度会陡增。3.5 粒子点缀最简单的高级感来源这种动态展示最容易出彩的点不是图片本身而是细节。我加了几个会在切换时从中心飘散的小光点实现起来非常简单维护一个粒子数组每次切换时生成一批粒子每帧更新位置和透明度超过生命周期就移除。class Particle { constructor(x, y) { this.x x; this.y y; this.vx (Math.random() - 0.5) * 3; this.vy -Math.random() * 2 - 1; this.size Math.random() * 3 1; this.alpha 1; this.decay 0.015 Math.random() * 0.02; } update() { this.x this.vx; this.y this.vy; this.vy 0.03; this.alpha - this.decay; } draw(ctx) { ctx.save(); ctx.globalAlpha Math.max(this.alpha, 0); ctx.fillStyle #ffffff; ctx.beginPath(); ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } }粒子效果千万别做多我建议一次最多 20 个左右多了反而显得廉价。而且粒子的颜色最好跟随图片主色调或者干脆用白色加透明度适配性最好。4. 交互与自动播放点击切图、拖拽滑动和播放状态管理4.1 点击和拖拽两种最自然的切图方式动态展示不能只有自动播放用户得有手动控制的能力。我实现了两种交互点击画布左右两侧切上一张/下一张以及鼠标拖拽滑动切图。canvas.addEventListener(click, (e) { const rect canvas.getBoundingClientRect(); const x e.clientX - rect.left; if (x rect.width / 2) { this.next(); } else { this.prev(); } }); let dragStartX 0; canvas.addEventListener(mousedown, (e) { dragStartX e.clientX; }); canvas.addEventListener(mouseup, (e) { const delta e.clientX - dragStartX; if (Math.abs(delta) 50) { delta 0 ? this.next() : this.prev(); } });点击区域按左右半边划分是最直观的设计用户习惯不用学习成本。拖拽判定阈值设为 50 像素小于这个值不触发切换因为那更可能是普通的点击。移动端的touchstart、touchend事件也是同样的写法把clientX换成touches[0].clientX即可。手动切换时有一个关键点必须把当前动画打断并重置。我的做法是next()和prev()里直接让progress 0并把index更新为目标索引这样下一次渲染会从头开始播放新切换动画。如果用户连续快速点击也不用担心状态错乱因为所有状态都集中在index和progress两个变量上覆盖式更新即可。4.2 自动播放与等待时间别让切换太赶自动播放的逻辑我在前面的AlbumPlayer里已经写进去了切换动画时长duration是 1500ms但每张图停留的时间应该更长比如 4 秒。所以我把切换进度和停留计时分开处理先停留 4000ms然后进入 1500ms 的切换动画动画结束后再进入下一次停留。if (this.state waiting) { this.waitTimer delta; if (this.waitTimer this.holdTime) { this.state switching; this.progress 0; this.waitTimer 0; } } else if (this.state switching) { this.progress delta / this.duration; if (this.progress 1) { this.progress 1; this.index (this.index 1) % this.images.length; this.state waiting; } }这个状态机模型比单纯用setInterval靠谱得多。setInterval在标签页切后台后会堆积回调回来时可能出现连续快速切换的诡异现象。用基于performance.now()的时间差累加天然免疫这个问题。用户手动切换时我直接把状态切回waiting并重置等待时间这样用户操作完图片会先安静地展示一会儿再自动继续。4.3 页面生命周期切后台暂停、回来继续canvas 动画最忌讳的就是用户切到别的标签页后还一直跑。虽然requestAnimationFrame在后台会自动暂停但如果你用了独立计时器或者把逻辑写在setInterval里后台就会白白耗电。我加上visibilitychange监听页面隐藏时暂停、显示时恢复并且重置时间基准防止回来时出现一大段 delta 导致动画跳变。document.addEventListener(visibilitychange, () { if (document.hidden) { this.running false; } else { this.lastTime performance.now(); this.start(); } });另外一个容易漏掉的点是销毁。如果这个相册组件挂在某个单页应用里路由切换后组件虽然卸载了但requestAnimationFrame循环可能还在跑。一定要提供destroy()方法把running置为 false 并cancelAnimationFrame同时移除所有事件监听。我见过不少项目因为这个原因导致页面上挂着几十个不可见的 rAF 循环页面越来越卡。5. 性能与兼容性实测模糊、卡顿、内存泄漏三座大山5.1 高分屏模糊dpr 没适配的典型症状这个坑前面已经提过但值得单独拿出来说因为它太常见了。现象是图片在普通屏上看起来正常在 MacBook 或者高分屏手机上明显发虚。根因就是 canvas 的实际像素尺寸小于物理显示尺寸浏览器拉伸放大导致的模糊。解决方法是canvas.width cssWidth * dpr并ctx.scale(dpr, dpr)。注意适配dpr之后所有用到画布宽高的地方都要除以 dpr或者统一用 CSS 像素尺寸否则绘制坐标会偏。5.2 卡顿排查drawImage 大图和尺寸压缩如果图片原图是 4000x3000 的像素直接丢进 canvas 每帧绘制GPU 压力不小尤其是低端手机上。我实测下来加载原图直接渲染在移动端的帧率会从 60 掉到 30 左右。处理办法是加载完成后先做一次压缩用一个离屏 canvas 把图片缩放到画布尺寸的 1.5 倍左右后续动画都用压缩后的图来画。这样视觉上几乎没有损失因为图片本身就铺满画布多余的像素反正被裁掉了但性能提升非常明显。function createResizedImage(img, maxWidth, maxHeight) { const ratio Math.min(maxWidth / img.width, maxHeight / img.height, 1); const w Math.round(img.width * ratio); const h Math.round(img.height * ratio); const offscreen document.createElement(canvas); offscreen.width w; offscreen.height h; const octx offscreen.getContext(2d); octx.drawImage(img, 0, 0, w, h); return offscreen; }关键在Math.min(..., 1)这个 1 保证只缩小不放大避免小图被强行放大导致更模糊。5.3 内存与绘制区域别反复创建对象canvas 项目里的内存泄漏通常不是 canvas 自己造成的而是开发者不小心在每帧里堆积对象。比如每帧都new一个数组、每帧都调用ctx.createLinearGradient这些都会让垃圾回收器频繁工作造成肉眼可见的卡顿。优化思路是把不依赖时间的对象放到初始化时创建渲染循环里只做状态更新和绘制。// 推荐初始化时创建一次 const gradient ctx.createLinearGradient(0, 0, 0, h); // 避免每帧都重新创建 function render() { const gradient ctx.createLinearGradient(0, 0, 0, h); }另外一点是clearRect的范围。如果画面只在中间区域有变化就没必要清空整个画布。不过相册这种全屏展示场景每帧全量绘制是不可避免的能做的就是减少绘制次数、压缩图片尺寸、避免无意义的 canvas 状态切换比如频繁save/restore。我实测做了一轮优化前后的对比优化项优化前优化后效果图片加载原图直接绘制离屏 canvas 压缩到 1.5 倍低端机帧率从 30 提升到 55粒子对象每帧创建新对象对象池复用内存曲线平稳clearRect 范围全画布清除按需清除高分辨率下省 20% 绘制时间缓动计算每帧重复 Math 运算预计算或缓存可忽略但习惯保持5.4 兼容性canva 2D 上下文全平台通用canvas 2D 的兼容性在现代浏览器里几乎没有问题IE11 也支持大部分 API只是没有requestAnimationFrame的原生实现需要 polyfill。如果项目面向的是老旧的 WebView 或低版本安卓机建议先判断canvas.getContext(2d)是否返回 null返回 null 就降级成普通的图片轮播展示保证基本功能可用。我在一个面向政企客户的内部系统里就是这么处理的稳得很。6. 简单效果之外还能延展的相册玩法和选型建议6.1 从简单出发能扩展出哪些方向这套基于 canvas 的相册框架核心就是一个渲染循环 一组绘制函数 一套状态管理所以扩展新效果非常自然。我列几个我觉得比较值得做、难度又不算高的方向极坐标旋转画廊图片绕中心点环形排列通过旋转角度滚动切换视觉效果比平面轮播更抓眼球。相册堆叠散落模拟一叠照片放在桌上点击时最上面一张飞出去露出下面一张物理感靠简单的速度加摩擦力模拟。镜面倒影在图片下方用ctx.scale(1, -1)加渐变遮罩画一个倒影瞬间提升质感成本极低。缩略图导航条在画布下方用另一个 canvas 或者 DOM 画一排缩略图点击跳转对应图片与主画布联动。WebGL 3D 相册如果后续效果复杂到 2D 无法满足可以考虑 Three.js 或者 PixiJS但性能开销和代码复杂度是另一个量级不建议一上来就上。6.2 什么情况下建议换其他方案虽然这篇文章在讲 canvas我还是得说句公道话canvas 不是万能的。如果你的相册需要对图片做精确的文本标注、可点击的热区、或者对无障碍访问有要求canvas 的劣势会很明显——它绘制出来的东西对屏幕阅读器不可见元素语义也没有。这种场景下DOM 加 CSS 的方案或者 SVG 更合适。另外如果只是做首页 Banner 这种固定位置、效果单一的轮播直接上 Swiper 这类现成库效率高得多完全没必要自己造轮子。我做这个项目最大的体会是选型不是比谁技术更酷而是看在可维护性和扩展性之间怎么平衡。canvas 这套方案的开发成本比 CSS 高一些但在动态感的定制上几乎是无限自由而且一旦把这套渲染循环写顺了后面加任何效果都是往里塞函数的事。如果你也在做类似的动态展示需求建议先搭好时间轴和状态管理再考虑具体动效——骨架稳了皮囊怎么换都好看。最后分享一个我踩过几次的坑写这类动画组件时一定要在一开始就把暂停、恢复、销毁三个生命周期方法留好哪怕当前需求根本用不到。因为你永远不知道产品经理明天会不会提出悬浮暂停播放离开页面回来继续播放SPA 路由切换后销毁这些要求。提前留好接口到时候就是几十行代码的事不然就得在别人写烂的组件上动刀那才叫痛苦。
返回列表