【前端词典】实现 Canvas 下雪背景引发的性能思考
前言
去年圣诞节产品提了一个活动需求,其中有一个下雪的背景动画。在做这个动画的过程中加深了对 canvas 动画的一些了解,在这里我仅是抛砖引玉的分享一下,欢迎各位大佬批评。
代码已上传至 github ,感兴趣的可以 clone 代码到本地运行。望给个 star 支持一下。
入题
需求给出的 UI 样式如下:
UI 的需求是雪花下落的方向有点倾斜角度,每片雪花的下落速度不一样但要保持在一个范围内。
需求了解的差不多就开始实现这个效果(在看这篇文章之前你需要对 canvas 的一些基本 API 了解)。
drawImage
drawImage
可传入 9 个参数,上图中的 5 个参数是比较常用的,另外几个参数是拿来剪切图片的。
直接使用
drawImage
来剪切图片,其性能不会太好,建议先将需要使用的部分用一个离屏canvas
保存起来,需要用到的时候直接使用即可。
requestAnimationFrame
requestAnimationFrame
相对于 setinterval
处理动画有以下几个优势:
- 经过浏览器优化,动画更流畅
- 窗口没激活时,动画将停止,省计算资源
- 更省电,尤其是对移动终端
这个 API 不需要传入动画间隔时间,这个方法会告诉浏览器以最佳的方式进行动画重绘。
由于兼容性问题,可以使用以下方法对 requestAnimationFrame
进行重写:
1 window.requestAnimationFrame = (function(){ 2 return window.requestAnimationFrame || 3 window.webkitRequestAnimationFrame || 4 window.mozRequestAnimationFrame || 5 window.oRequestAnimationFrame || 6 window.msRequestAnimationFrame || 7 function (callback) { 8 window.setTimeout(callback, 1000 / 60); 9 }; 10 })();
对于其他 API 烦请查阅文档。
第一次尝试
有一个大概想法后就开心的开始写代码了,基本思路就是使用 requestAnimationFrame
来刷新 canvas
画板。
由于雪花不规则,所以雪花是 UI 提供的图片,既然是图片我们就需要先将图片预加载好,要不然在转换图片的时候很可能影响性能。
使用的预加载方法如下:
1 function preloadImg(srcArr){ 2 if(srcArr instanceof Array){ 3 for(let i = 0; i < srcArr.length; i++){ 4 let oImg = new Image(); 5 oImg.src = srcArr[i]; 6 } 7 } 8 }
前前后后写了一个下午,算是写好了,在手机上查看的效果发现很是卡顿。100 片雪花 FPS
竟然才 40 多。而且在某些机型会出现抖动的情况。
要是产品看到这个效果,恐怕是又要召集相关人员开相关会议了。这么卡顿肯定是写了些开销大的代码,于是乎需要第二次尝试。
晚上还是需要按时下班的。不过下班回家后也不能闲着,开始找相关的资料,以便第二天快速的完成。
第二次尝试前的准备
经过一个晚上的查找学习,大概知道了以下几个优化 canvas
性能的方法:
1. 使用多层画布绘制复杂场景
分层的目的是降低完全不必要的渲染性能开销。
即:将变化频率高、幅度大的部分和变化频率小、幅度小的部分分成两个或两个以上的
canvas
对象。也就是说生成多个canvas
实例,把它们重叠放置,每个Canvas
使用不同的z-index
来定义堆叠的次序。
1 <canvas style="position: absolute; z-index: 0"></canvas> 2 <canvas style="position: absolute; z-index: 1"></canvas> 3 // js 代码
2. 使用 requestAnimationFrame 制作动画
上面有提到。
3. 清除画布尽量使用 clearRect
一般情况下的性能:clearRect
> fillRect
> canvas.width = canvas.width;
4. 使用离屏绘制进行预渲染
当时用 drawImage
绘制同样的一块区域:
- 若数据源(图片、canvas)和
canvas
画板的尺寸相仿,那么性能会比较好; - 若数据源只是大图上的一部分,那么性能就会比较差;因为每一次绘制还包含了裁剪工作。
第二种情况我们就可以先把待绘制的区域裁剪好,保存在一个离屏的
canvas
对象中。在绘制每一帧的时候,在将这个对象绘制到canvas
画板中。
drawImage
方法的第一个参数不仅可以接收 Image
对象,也可以接收另一个 Canvas
对象。而且,使用 Canvas
对象绘制的开销与使用 Image
对象的开销几乎完全一致。
当每一帧需要调用的对象需要多次调用 canvasAPI
时,我们也可以使用离屏绘制进行预渲染的方式来提高性能。
即:
1 let cacheCanvas = document.createElement("canvas"); 2 let cacheCtx = this.cacheCanvas.getContext("2d"); 3 4 cacheCtx.save(); 5 cacheCtx.lineWidth = 1; 6 for(let i = 1;i < 40; i++){ 7 cacheCtx.beginPath(); 8 cacheCtx.strokeStyle = this.color[i]; 9 cacheCtx.arc(this.r , this.r , i , 0 , 2*Math.PI); 10 cacheCtx.stroke(); 11 } 12 this.cacheCtx.restore(); 13 14 // 在绘制每一帧的时候,绘制这个图形 15 context.drawImage(cacheCtx, x, y);
cacheCtx
的宽高尽量设置成实际使用的宽高,否则过多空白区域也会造成性能的损耗。
下图显示了使用离屏绘制进行预渲染技术所带来的性能改善情况:
5. 尽量少调用 canvasAPI
,尽可能集中绘制
如下代码:
1 for (var i = 0; i < points.length - 1; i++) { 2 var p1 = points[i]; 3 var p2 = points[i + 1]; 4 context.beginPath(); 5 context.moveTo(p1.x, p1.y); 6 context.lineTo(p2.x, p2.y); 7 context.stroke(); 8 }
可以改成:
1 context.beginPath(); 2 for (var i = 0; i < points.length - 1; i++) { 3 var p1 = points[i]; 4 var p2 = points[i + 1]; 5 context.moveTo(p1.x, p1.y); 6 context.lineTo(p2.x, p2.y); 7 } 8 context.stroke();
tips: 写粒子效果时,可以使用方形替代圆形,因为粒子小,所以方和圆看上去差不多。有人问为什么?很容易理解,画一个圆需要三个步骤:先
beginPath
,然后用arc
画弧,再用fill
。而画方只需要一个fillRect
。当粒子对象达一定数量时性能差距就会显示出来了。
6. 像素级别操作尽量避免浮点运算
进行
canvas
动画绘制时,若坐标是浮点数,可能会出现CSS Sub-pixel
的问题.也就是会自动将浮点数值四舍五入转为整数,在动画的过程中就可能出现抖动的情况,同时也可能让元素的边缘出现抗锯齿失真情况。
虽然 javascript 提供了一些取整方法,像 Math.floor
,Math.ceil
,parseInt
,但 parseInt
这个方法做了一些额外的工作(比如检测数据是不是有效的数值、先将参数转换成了字符串等),所以,直接用 parseInt
的话相对来说比较消耗性能。
可以直接用以下巧妙的方法进行取整:
1 function getInt(num){ 2 var rounded; 3 rounded = (0.5 + num) | 0; 4 return rounded; 5 }
另 for 循环的效率是最高的,感兴趣的可以自行实验。
第二次尝试
通过昨天晚上的查阅,对这个动画做了以下几点优化:
- 使用离屏绘制进行预渲染
- 减少部分 API 的使用
- 浮点数取整
- 缓存变量
- 使用 for 循环,替代 forEach
- 将整体代码使用原型链方式改写了一遍
方案写好了就开始愉快的写代码了。
200 片雪花的时候 FPS
基本稳定在 60,而且抖动的情况也没了;
增加到 1000 片的时候,FPS
还是基本稳定在 60;
增加到 1500 片的时候,稍微有点零星的卡帧;
增加到 2000 片的时候,开始卡顿。
这说明这个动画还是没有优化好,还有优化空间,请各位大佬不吝指教。
推荐使用
stats.js
插件,这个插件可以显示动画运行时的 FPS。
主要代码

1 let snowBox = function () { 2 let canvasEl = document.getElementById("snowFall"); 3 let ctx = canvasEl.getContext('2d'); 4 canvasEl.width = window.innerWidth; 5 canvasEl.height = window.innerHeight; 6 let lineList = []; // 雪的容器 7 let snow = function () { 8 let _this = this; 9 _this.cacheCanvas = document.createElement("canvas"); 10 _this.cacheCtx = _this.cacheCanvas.getContext("2d"); 11 _this.cacheCanvas.width = 10; 12 _this.cacheCanvas.height = 10; 13 _this.speed = [1, 1.5, 2][Math.floor(Math.random()*3)]; // 雪花下落的三种速度,便于取整 14 _this.posx = Math.round(Math.random() * canvasEl.width); // 雪花x坐标 15 _this.posy = Math.round(Math.random() * canvasEl.height); // 雪花y坐标 16 _this.img = `./img/snow_(${Math.ceil(Math.random() * 9)}).png`; // img 17 _this.w = _this.getInt(5 + Math.random() * 6); 18 _this.h = _this.getInt(5 + Math.random() * 6); 19 _this.cacheSnow(); 20 }; 21 22 snow.prototype = { 23 cacheSnow: function () { 24 let _this = this; 25 // _this.cacheCtx.save(); 26 let img = new Image(); // 创建img元素 27 img.src = _this.img; 28 _this.cacheCtx.drawImage(img, 0, 0, _this.w, _this.h); 29 // _this.cacheCtx.restore(); 30 }, 31 fall: function () { 32 let _this = this; 33 if (_this.posy > canvasEl.height + 5) { 34 _this.posy = _this.getInt(0 - _this.h); 35 _this.posx = _this.getInt(canvasEl.width * Math.random()); 36 } 37 if (_this.posx > canvasEl.width + 5) { 38 _this.posx = _this.getInt(0 - _this.w); 39 _this.posy = _this.getInt(canvasEl.height * Math.random()); 40 } 41 // 如果雪花在可视区域 42 if (_this.posy <= canvasEl.height || _this.posx <= canvasEl.width) { 43 _this.posy = _this.posy + _this.speed; 44 _this.posx = _this.posx + _this.speed * .5; 45 } 46 _this.paint(); 47 }, 48 paint: function () { 49 ctx.drawImage(this.cacheCanvas, this.posx, this.posy) 50 }, 51 getInt: function(num){ 52 let rounded; 53 rounded = (0.5 + num) | 0; 54 return rounded; 55 } 56 }; 57 58 let control; 59 control = { 60 start: function (num) { 61 for (let i = 0; i < num; i++) { 62 let s = new snow(); 63 lineList.push(s); 64 } 65 (function loop() { 66 ctx.clearRect(0, 0, canvasEl.width, canvasEl.height); 67 for (let i = 0; i < num; i++) { 68 lineList[i].fall(); 69 } 70 requestAnimationFrame(loop) 71 })(); 72 } 73 }; 74 return control; 75 }(); 76 77 window.onload = function(){ 78 snowBox.start(2000) 79 };
建议从 github clone 代码到本地运行。
后话
这篇文章虽然说是关于 canvas 动画的性能优化。一些大佬也已经看出,其他方面的性能优化方案和这个大抵相同,无非是:
- 减少 API 的使用
- 使用缓存(重点)
- 合并频繁使用的 API
- 避免使用高耗能的 API
- 用 webWorker 来处理一些比较耗时的计算
- ……
希望通过阅读这篇文章,可以在性能优化方面给你作一个参考,多谢阅读。
https://blog.csdn.net/weixin_33854644/article/details/91387593
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?