纯 JavaScript Canvas 实现图片放大预览、多张切换、动画效果等 home 编辑时间 2022/08/02 ![](/api/file/getImage?fileId=62e885e7da74050013012910) <br><br> ## 前言 先说下正确打开方式是用JS+CSS3实现,例如baguettebox,只需要几行代码,且兼容性极高,动效极流畅。我这里用Canvas实现纯属瞎折腾,不但要从零开始,代码量大复杂度高还费CPU资源,低兼容性。 <br><br> ## 折腾 <br> 先放最终效果和最终代码。 <br> 最终源码 [https://gitee.com/tczmh/canvas-album](https://gitee.com/tczmh/canvas-album) <br> 在线演示 [https://tczmh.gitee.io/canvas-album](https://tczmh.gitee.io/canvas-album) <br> 然后讲讲折腾过程 <br><br> #### 先搞个空白HTML ```html <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width,initial-scale=1.0"> <title>album-step1</title> <style> html, body { width: 100%; height: 100%; margin: 0; padding: 0; } body { overflow: hidden; } canvas { width: 100%; height: 100%; background: #333; } .toolbar { position: fixed; bottom: 1rem; left: 0; right: 0; text-align: center; } button { font-size: large; padding: .7rem 2.2rem; margin: 0 1rem; } </style> </head> <body> <canvas></canvas> <div class="toolbar"> <button onclick="prev()">上一张</button> <button onclick="next()">下一张</button> </div> </body> </html> ``` <br><br> #### 实现基础的图片展示功能 ```javascript <script> const images = [ 'images/1.jpg', 'images/2.png', 'images/3.jpg', 'images/4.jpg', 'images/5.jpg', 'images/6.jpg', 'images/7.jpg', 'images/8.jpg', ] const canvas = document.querySelector('canvas'); const ctx = canvas.getContext('2d'); const image = new Image(); image.src = images[0]; image.onload = function () { ctx.drawImage(image, 0, 0); } </script> ``` <br> 原图大概长这样 ![](/api/file/getImage?fileId=62e8892dda74050013012917) <br> 页面展示效果长这样 ![](/api/file/getImage?fileId=62e8892dda74050013012918) <br> 原因是原图分辨率是4000x2828 显示器分辨率不够,只展示了一个角,所以这里需要用到缩放,且缩放存在比例问题,不能粗暴的获取显示区域高宽,否则会拉伸,需要等比缩放且计算长宽边,最后在上下或者左右显示黑边。 <br><br> #### 等比缩放 ```javascript <script> const images = [ 'images/1.jpg', 'images/2.png', 'images/3.jpg', 'images/4.jpg', 'images/5.jpg', 'images/6.jpg', 'images/7.jpg', 'images/8.jpg', ] const canvas = document.querySelector('canvas'); const ctx = canvas.getContext('2d'); const image = new Image(); image.src = images[0]; image.onload = function () { const size = resize(); ctx.drawImage(image, size.dx, size.dy, size.width, size.height); } /** 根据显示区域分辨率和原图片分辨率,计算图片缩放后左边和宽高,这段代码只是随手写的,还有很大优化空间,仅做示范懒得修改了 */ function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; let dx = 0; let dy = 0; let width = image.naturalWidth; let height = image.naturalHeight; if (canvas.width / canvas.height <= width / height) { height = height / width * canvas.width; width = canvas.width; dy = (canvas.height - height) / 2 } else { width = width / height * canvas.height; height = canvas.height; dx = (canvas.width - width) / 2 } return { dx: dx, dy: dy, width: width, height: height } } </script> ``` <br> 长屏效果和宽屏效果如下 ![](/api/file/getImage?fileId=62e88a51da74050013012919) <br> ![](/api/file/getImage?fileId=62e88a51da7405001301291a) <br><br> #### 自适应分辨率和图片切换 自适应分辨率需要做2步,上一段落实现了第一步,不同高宽比的显示区域,可以自适应显示锁定比例的图片,但由于只渲染一次,如果用户改变windows窗口大小,内容不会跟着改变,如果需要实现跟着窗口实时改变,则需要监听onresize方法,并实时渲染。也就是本段落实现的第二步。由于图片切换也很简单在这段一起实现了。 ```javascript <script> let index = 0; const images = [ 'images/1.jpg', 'images/2.png', 'images/3.jpg', 'images/4.jpg', 'images/5.jpg', 'images/6.jpg', 'images/7.jpg', 'images/8.jpg', ] const canvas = document.querySelector('canvas'); const ctx = canvas.getContext('2d'); const image = new Image(); function init() { load(function () { draw(image, resize(image)); }); } function load(callback) { image.src = images[index]; image.onload = function () { callback(); } } function draw(image, size) { ctx.drawImage(image, size.dx, size.dy, size.width, size.height); } /** 根据显示区域分辨率和原图片分辨率,计算图片缩放后左边和宽高,这段代码只是随手写的,还有很大优化空间,仅做示范懒得修改了 */ function resize(image) { canvas.width = window.innerWidth; canvas.height = window.innerHeight; let dx = 0; let dy = 0; let width = image.naturalWidth; let height = image.naturalHeight; if (canvas.width / canvas.height <= width / height) { height = height / width * canvas.width; width = canvas.width; dy = (canvas.height - height) / 2 } else { width = width / height * canvas.height; height = canvas.height; dx = (canvas.width - width) / 2 } return { dx: dx, dy: dy, width: width, height: height } } /** 后期还需要加入防超出范围限制 */ function next() { index++; init(); } /** 后期还需要加入防超出范围限制 */ function prev() { index--; init(); } window.onload = function () { // 加载第一张图片 init(); } window.onresize = function (){ // 使用缓存数据避免加载延迟导致黑屏闪屏 draw(image,resize(image)); } </script> ``` 关键代码就是onresize,窗口改变大小后,会立刻执行draw方法,这里需要注意onresize会在瞬间反复被调用,image如果每次都去服务器请求,会卡成狗,这里必须把image对象缓存,并每次用变量中缓存的对象。才能实现纵享丝滑。 <br> 自适应窗口效果 ![](/api/file/getImage?fileId=62e88fc7da74050013012923) <br> 图片切换太简单了不多介绍略过 图片切换效果 ![](/api/file/getImage?fileId=62e891a0da74050013012924) <br><br> #### 最后简单实现一下切换动画 截止到上一步,切换是直接替换图片,下一步是希望做到和手机相册类似的切换有图片滑入滑出效果。 <br> 切换动画示意图 ![](/api/file/getImage?fileId=62e89c16da74050013012930) ![](/api/file/getImage?fileId=62e8b5ffda74050013012940) ![](/api/file/getImage?fileId=62e8b5ffda7405001301293f) <br><br> 即将用到一个核心方法 requestAnimationFrame 类似于Java的递归,如果动画没完成就接着用requestAnimationFrame 调用动画方法本身 ```javascript <script> let index = 0; const speed = 25; const images = [ 'images/1.jpg', 'images/2.png', 'images/3.jpg', 'images/4.jpg', 'images/5.jpg', 'images/6.jpg', 'images/7.jpg', 'images/8.jpg', ] const canvas = document.querySelector('canvas'); const ctx = canvas.getContext('2d'); let temp = new Image(); function init() { load(function (image) { draw(image, resize(image)); temp = image; }); } function load(callback) { const image = new Image(); image.src = images[index]; image.onload = function () { callback(image); } } function draw(image, size) { ctx.drawImage(image, size.dx, size.dy, size.width, size.height); } /** 根据显示区域分辨率和原图片分辨率,计算图片缩放后左边和宽高,这段代码只是随手写的,还有很大优化空间,仅做示范懒得修改了 */ function resize(image) { canvas.width = window.innerWidth; canvas.height = window.innerHeight; let dx = 0; let dy = 0; let width = image.naturalWidth; let height = image.naturalHeight; if (canvas.width / canvas.height <= width / height) { height = height / width * canvas.width; width = canvas.width; dy = (canvas.height - height) / 2 } else { width = width / height * canvas.height; height = canvas.height; dx = (canvas.width - width) / 2 } return { dx: dx, dy: dy, width: width, height: height } } /** 动画部分代码重点你看这里 这个功能写起来累,只先写一个简单示范,代码有大量需要优化的地方,自行判断 */ function next() { index++; // init(); load(function (image) { const size1 = resize(temp); const size2 = resize(image); const end = size2.dx; // size1是旧图,起始位置不变,慢慢滑出屏幕 // size2是新图,默认从屏幕外滑入,起始位置是原来dx再加一个屏幕宽度 // end为最终位置 size2.dx += window.innerWidth; animation(image, size1, size2, end); }); } /** 上一张的动画懒得写了,累了 */ function prev() { index--; init(); } /** 这个功能写起来累,只先写一个简单示范 */ function animation(image, size1, size2, end) { // 这里思路是temp作为上一张图,传入的image作为下一张图,目前只考虑下一张按钮 // 新图从右侧屏幕外开始进入,旧图从屏幕中间向左滑出屏幕 // 开始之前清屏,避免上次动画留下什么残影 ctx.clearRect(0, 0, canvas.width, canvas.height); draw(temp, size1); draw(image, size2); size1.dx -= speed; size2.dx -= speed; if (size2.dx > end) { // 继续动画 requestAnimationFrame(function () { // 层层递归 animation(image, size1, size2, end); }); } else { // 最后结尾肯定是 draw image size2.dx = end; // 开始之前清屏,避免上次动画留下什么残影 ctx.clearRect(0, 0, canvas.width, canvas.height); draw(image, size2); // 由于requestAnimationFrame存在线程问题,必须确认最后一次执行才赋image给temp缓存 temp = image; } } window.onload = function () { // 加载第一张图片 init(); } window.onresize = function () { // 使用缓存数据避免加载延迟导致黑屏闪屏 draw(temp, resize(temp)); } </script> ``` <br> 效果演示 ![](/api/file/getImage?fileId=62e8c302da74050013012944) <br> 我这里特地把speed调到50,目的是能看清楚效果。。。 另外还有几个地方存在改进空间,我只是写个demo就懒得调了 1. speed不能匀速,否则看着感觉很傻,凭感觉应该是先快后慢会舒服点 2. 画面放大后放慢速度后,会感觉卡顿,目前还没找到原因,可能和渲染次数、浏览器性能有关,目前还没有解决思路,待定 <br><br> #### 最终打死不改版 比上一段落优化了这几个地方 1. 加入了2个方向的切换效果 2. 加入下标判断防止越界 3. 加快动画速度,加入简单的先慢后快效果 还没解决的剩下卡顿问题,心累了,以后再说 完整源码如下 <br> ```javascript <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width,initial-scale=1.0"> <title>album-step1</title> <style> html, body { width: 100%; height: 100%; margin: 0; padding: 0; } body { overflow: hidden; } canvas { width: 100%; height: 100%; background: #333; } .toolbar { position: fixed; bottom: 1rem; left: 0; right: 0; text-align: center; } button { font-size: large; padding: .7rem 2.2rem; margin: 0 1rem; } </style> </head> <body> <canvas></canvas> <div class="toolbar"> <button onclick="prev()">上一张</button> <button onclick="next()">下一张</button> </div> <script> let index = 0; /** 最后加个方向调整2个方向的差异 */ let orientation = -1; let factor = 1; const speed = 50; const images = [ 'images/1.jpg', 'images/2.png', 'images/3.jpg', 'images/4.jpg', 'images/5.jpg', 'images/6.jpg', 'images/7.jpg', 'images/8.jpg', ] const canvas = document.querySelector('canvas'); const ctx = canvas.getContext('2d'); let temp = new Image(); function init() { load(function (image) { draw(image, resize(image)); temp = image; }); } function load(callback) { const image = new Image(); image.src = images[index]; image.onload = function () { callback(image); } } function draw(image, size) { ctx.drawImage(image, size.dx, size.dy, size.width, size.height); } /** 根据显示区域分辨率和原图片分辨率,计算图片缩放后左边和宽高,这段代码只是随手写的,还有很大优化空间,仅做示范懒得修改了 */ function resize(image) { canvas.width = window.innerWidth; canvas.height = window.innerHeight; let dx = 0; let dy = 0; let width = image.naturalWidth; let height = image.naturalHeight; if (canvas.width / canvas.height <= width / height) { height = height / width * canvas.width; width = canvas.width; dy = (canvas.height - height) / 2 } else { width = width / height * canvas.height; height = canvas.height; dx = (canvas.width - width) / 2 } return { dx: dx, dy: dy, width: width, height: height } } /** 算了累就累了 一起写了 */ function next() { // 加入下标判断 防止越界 if (index == images.length - 1) { alert('已经是最后一张了') } else if (index < images.length - 1) { orientation = -1; factor = 0.7; index++; // init(); load(function (image) { const size1 = resize(temp); const size2 = resize(image); const end = size2.dx; // size1是旧图,起始位置不变,慢慢滑出屏幕 // size2是新图,默认从屏幕外滑入,起始位置是原来dx再加一个屏幕宽度 // end为最终位置 size2.dx += window.innerWidth; animation(image, size1, size2, end); }); } } /** 算了累就累了 一起写了 */ function prev() { // 加入下标判断 防止越界 if (index == 0) { alert('已经是第一张了') } else if (index > 0) { orientation = 1; factor = 0.7; index--; // init(); load(function (image) { const size1 = resize(temp); const size2 = resize(image); const end = size2.dx; // size1是旧图,起始位置不变,慢慢滑出屏幕 // size2是新图,默认从屏幕外滑入,起始位置是原来dx再加一个屏幕宽度 // end为最终位置 size2.dx -= window.innerWidth; animation(image, size1, size2, end); }); } } /** 这个功能写起来累,只先写一个简单示范 */ function animation(image, size1, size2, end) { // 这里思路是temp作为上一张图,传入的image作为下一张图,目前只考虑下一张按钮 // 新图从右侧屏幕外开始进入,旧图从屏幕中间向左滑出屏幕 // 开始之前清屏,避免上次动画留下什么残影 ctx.clearRect(0, 0, canvas.width, canvas.height); draw(temp, size1); draw(image, size2); size1.dx += speed * orientation * factor; size2.dx += speed * orientation * factor; // 用于前慢后快的系数,这样写不准确,就意思一下,实际 factor += 0.15; console.log(factor) if (orientation > 0 ? size2.dx < end : size2.dx > end) { // 继续动画 requestAnimationFrame(function () { // 层层递归 animation(image, size1, size2, end); }); } else { // 最后结尾肯定是 draw image size2.dx = end; // 开始之前清屏,避免上次动画留下什么残影 ctx.clearRect(0, 0, canvas.width, canvas.height); draw(image, size2); // 由于requestAnimationFrame存在线程问题,必须确认最后一次执行才赋image给temp缓存 temp = image; } } window.onload = function () { // 加载第一张图片 init(); } window.onresize = function () { // 使用缓存数据避免加载延迟导致黑屏闪屏 draw(temp, resize(temp)); } </script> </body> </html> ``` <br> **需要注意**:这里的先慢后快的代码,我只是意思一下加了一个factor,简单示意了一下,其实是不准确的,准确写法需要参考css的ease-in的贝塞尔曲线,如下图 ![](/api/file/getImage?fileId=62e8c7eada74050013012945) <br> 这次不录gif演示效果了,需要看最终效果的,直接点链接看线上展示吧 <br> 最终源码 [https://gitee.com/tczmh/canvas-album](https://gitee.com/tczmh/canvas-album) <br> 在线演示 [https://tczmh.gitee.io/canvas-album](https://tczmh.gitee.io/canvas-album) ## END 事后反复测试发现卡顿是源于图片过大,每一帧都要重新加载图片渲染,导致占用资源太大卡顿。本来这动画如果是css实现就还好,canvas就特别吃资源,图片一大就必然受不了。第一反应想的是用canvas第一次加载成功后,把图片转成0.7质量webp的base64,分辨率取浏览器显示区域,再转回image继续渲染。理论上可以在不损失太多画质的前提下改善卡顿问题,but,这里面有太多异步操作,我前端水平有限,异步转同步还不会搞,于是曲线救国,图片手动转为1920宽度webp质量0.7,图均从1mb+下降到100kb+,瞬间就如丝般顺滑了,如果以后项目到线上,可以用cdn的图片处理来解决这个问题。 PS: 已更新到最终代码和在线演示 送人玫瑰,手留余香 赞赏 Wechat Pay Alipay CCS3 立体箱子 页面载入动画 从零新建一个 Springboot 2.7.1 项目搭配 Swagger 3.0 Knife4j MyBatisPlus 等