纯 JavaScript Canvas 实现图片放大预览、多张切换、动画效果等
2022-08-19
阅读 {{counts.readCount}}
评论 {{counts.commentCount}}
<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: 已更新到最终代码和在线演示