写在前面
辗转许久,终于决定拿起笔杆,记录下自己是如何克服所谓的恐惧,爱上写动画。本文面向JS基础不错的掘友,如果你的JS掌握的还不够好,记得一定要先打牢基础哦。
动画真的没有你想的那么复杂,这是我最近亲历亲为得出的结论。曾几何时,我以为动画需要非常扎实的数学、物理基础(基础好当然不错,没基础也不要怕),但其实不是这样。它需要的基础更多还是JS,是面向对象思想,是你的编程功底。数学物理基础固然要有,但这些主要影响的是你做出来的动画的复杂程度和精密程度。所以不要怕,万事开头难,等你找到了规律和诀窍,便会一发不可收拾地爱上它。
本文采用一个雪花飞舞的动画实例,带你走进JS动画的大门。 点击预览
何为动画
动画其实都是由无数的画面连续播放产生的效果,每一幅画面称作一帧,做动画其实就是画好每一帧,然后连续播放即可。我们主要关注的是如何画出每一帧,以及如何播放。
实例
说明
该实例的项目移步GitHub仓库,项目下的snow.js即为核心代码,觉得动画不错的掘友可以clone下来研究一下,也可直接将snow.js引入使用,通过一些配置快速实现特效哦。具体参见GitHub仓库。
技术
主要使用 canvas
绘图,通过window.requestAnimationFrame
实现刷新。通过浏览器的window.requestAnimationFrame方法发起动画帧请求,并传入一个回调函数,浏览器会在恰当的时机执行这个回调函数(也就是我们的重绘函数)。
思路
雪花
漫天飞舞的雪花,如何才能用程序的方式表现出来?利用面向对象的编程思想很容易分析出,每一片雪花都是一个对象,这个对象有大小,形状,速度,等等信息,自然而然的,我们可以抽象出一个Snowflake类,用于创建雪花对象,描述他的属性,用于动画中。雪花不可能完全相同,因此创建的时候,需要用到随机数。这里创建两个用来获取随机元素的函数便于开发:
// 生成随机数
const random = function (min, max, floor = false) {
// 是否取整?
if (floor) {
return Math.floor(min + Math.random() * (max - min))
} else {
return min + Math.random() * (max - min)
}
};
// 从数组中获取随机元素
// 由于Math.floor是向下取整,max = arr.length,而非arr.length - 1
const randomIn = arr => arr[random(0, arr.length, true)];
运动
雪花类(Snowflake)实际上是一个实体类,创建出来的个体虽然包含了雪花的各种属性,但是它并不能自己运动,我们需要一个控制器来操作雪花,让他运动起来,于是我们有了一个控制类——Snow。Snow主要负责利用canvas绘制每一帧,并连续播放帧,形成动画。
代码组织
Snowflake类
先放出整体代码,特地加了详细的注释:
class Snowflake {
constructor(config, image = null) {
this.config = config;
this.image = image;
this.load()
}
center () {
let x = this.x + this.radius / 2;
let y = this.y + this.radius / 2;
return {x, y} // ES6对象简写,实际是{x: x, y: y}
}
load () {
// 初始化绘图属性
this.x = random(0, window.innerWidth); // x轴起始位置
this.y = random(-window.innerHeight, 0); // y轴起始位置,在屏幕外
this.alpha = random(...this.config.alpha); // 透明度
this.radius = random(...this.config.radius); // 大小
this.color = randomIn(this.config.color); // 颜色
this.angle = 0; // 起始旋转角度
this.flip = 0; // 起始翻转参数,翻转是利用缩放和旋转模拟出来的
// 初始化变换属性
this.va = Math.PI / random(...this.config.va); // 旋转速度
this.va = Math.random() >= 0.5 ? this.va : -this.va; // 旋转方向
this.vx = random(...this.config.vx); // x轴移动速度
this.vy = random(...this.config.vy); // y轴移动速度
// 翻转速度,默认不翻转,这里做一下判断,!!转换布尔值
// vf 代表翻转速度,也就是缩放速度
!!this.config.vFlip && (this.vf = random(0, this.config.vFlip))
}
update(range) {
// 每调用一次都会导致相应的属性变化,变化速度取决于相应速度
this.x += this.vx;
this.y += this.vy;
this.angle += this.va;
this.flip += this.vf;
// 防止无限放大,缩放比例维持在0-1之间
if (this.flip > 1 || this.flip < 0) {
this.vf = -this.vf
}
// 当元素飞出范围则重置属性,复用元素
if (this.y >= range + this.radius ) {
this.load()
}
}
}
- 构造函数:传入雪花配置和可选的图片,无图片的话,则默认绘制圆形。
- load:用来初始化以及重置所有属性。
- center:返回元素当前的中点。
- update:绘制每一帧的时候都要调用一次,更新属性这样就相当于运动了。
Snow类
同样,先放出代码。代码较长,主要是因为增加了注释,耐心看完一定会有所收获:
class Snow {
constructor(container, config = {}) {
let {num} = config;
// 雪花数量,一般无需改动
this.num = num || window.innerWidth / 2;
delete config['num'];
// 这里是默认配置,通过Object.assign()使传入的配置覆盖默认配置
this.config = Object.assign({
image: [], // 可选的图片(网络或本地)
vx: [-3, 3], // 水平速度
vy: [2, 5], // 垂直速度
va: [45, 180], // 角速度范围,传入图片才会生效
vFlip: 0, //翻转速度,推荐:慢0.05/正常0.1/快0.2
radius: [5, 15], // 半径范围,传入图片需调整此项
color: ['white'], // 可选颜色,传入图片时会忽略该项
alpha: [0.1, 0.9] // 透明度范围
}, config);
this.init(container)
}
init (container) {
// 初始化基本配置
this.container = document.querySelector(container); // 获取dom
this.canvas = document.createElement('canvas');
// 获取dom元素实际宽高,并让canvas充满dom。视情况添加CSS代码
this.canvas.width = this.container.offsetWidth;
this.canvas.height = this.container.offsetHeight;
// 获取上下文
this.ctx = this.canvas.getContext('2d');
// 插入文档后才能显示出来
this.container.appendChild(this.canvas);
this.snowflakes = new Set(); // 用来存放创建的雪花
// 根据传入的配置确定画图还是画圆
// 两种模式,默认用大小形状不一的白色圆形代替雪花
// 若传入图片则绘制图片,例如自定义雪花图片
if (!!this.config.image.length) {
// 加载传入的图片地址,等待图片完全加载完成后开始创建雪花
this.loadImage(this.config.image).then(images => {
this.createSnowflakes(images);
// 发起动画请求,drawPicture返回一个绑定了了this的帧动画函数
requestAnimationFrame(this.drawPicture())
}).catch(e => console.error(e))
} else {
this.createSnowflakes();
requestAnimationFrame(this.drawCircle())
}
}
loadImage (images) {
// 定义一个加载图片的函数,使用Promise封装
let load = (src) => new Promise(resolve => {
let image = new Image();
image.src = src;
image.onload = () => resolve(image);
image.onerror = e => console.error('图片加载失败:' + e.path[0].src)
});
// 使用Promise.all()等待所有异步操作执行完毕
return Promise.all(images.map(src => load(src)))
}
createSnowflakes (image) {
if (image) {
for (let i = 0; i < this.num; i++) {
let img = randomIn(image);
let flake = new Snowflake(this.config, img);
this.snowflakes.add(flake)
}
} else {
for (let i = 0; i < this.num; i++) {
let flake = new Snowflake(this.config);
this.snowflakes.add(flake)
}
}
}
// 提取公共变换代码
transform (flake) {
// 所谓变化就是按照雪花的属性绘制,属性变了,绘制的东西自然就变了
// 串起来就形成了动画,所以这里需要调用update()更新属性
// 更新完属性后再进行绘制,串连起来就可以产生动画效果
flake.update(this.canvas.height);
let {x, y} = flake.center();
// 注意,canvas中旋转的是画布,移动的也是画布
// 先将画布移动到雪花中心再进行变换
this.ctx.translate(x, y);
this.ctx.rotate(flake.angle);
// 判断是否需要翻转(缩放)
!!flake.vFlip && this.ctx.scale(1, flake.flip);
// 变换结束记得移回原位
this.ctx.translate(-x, -y)
}
// 返回一个帧动画函数
drawCircle () {
// 因为window.requestAnimationFrame执行时this指向window
// 此函数里又使用了大量this,所以需要确保this指向不能变
// 这里使用箭头函数的话,this就会指向Snow,可以正常使用this了
let frame = () => {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
for (let flake of this.snowflakes) {
// 每次变化之前一定要调用save()保存状态,最后使用restore()恢复
// 否则画布的属性将会乱套
this.ctx.save();
this.transform(flake); // 公共变化
// 下来是画圆的过程,具体参考Canvas API
this.ctx.beginPath();
this.ctx.arc(flake.x, flake.y, flake.radius,0,2*Math.PI);
this.ctx.closePath();
this.ctx.globalAlpha = flake.alpha;
this.ctx.fillStyle = flake.color;
this.ctx.fill();
this.ctx.restore() // 恢复canvas上下文属性
}
// requestAnimationFrame 函数最后再讲
requestAnimationFrame(frame)
};
return frame
}
drawPicture() {
// 同上
let frame = () => {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
for (let flake of this.snowflakes) {
this.ctx.save();
this.transform(flake);
this.ctx.globalAlpha = flake.alpha;
this.ctx.drawImage(flake.image, flake.x, flake.y, flake.radius, flake.radius);
this.ctx.restore()
}
requestAnimationFrame(frame)
};
return frame
}
}
想了解箭头函数和更多ES6新特性,推荐阮一峰《ES6标准入门》
- constructor:构造函数主要处理默认参数的问题
- init:初始化控制类相关属性
- loadImage:使用Promise加载传入的图片
- createSnowflakes:根据是否传图片确定创建什么样的雪花
- transform:提取了两种绘图方式的公共变换代码,加以复用
- drawCircle:绘制圆形雪花。
- drawPicture:绘制图片,即自定义雪花
关于requestAnimationFrame
前面提到,传入一个回调函数,浏览器会在恰当的时机执行,但是只会执行一次。因此我们需要在回调函数里面再次使用requestAnimationFrame调用自身,如此,便能形成动画循环。
其实类似setInterval
,甚至更像链式setTimeout
,循环调用自身。浏览器判断的执行时机间隔大概是十几毫秒,这样的刷新速率,肉眼是不可能辨别的,于是就实现了动画效果。
那我们为什么要使用requestAnimationFrame
呢?因为浏览器判断时机的意思就是,如果你切换到了后台,动画就会停止渲染,直到你切换回来,以及类似的情况。而前者则会一直运行,无疑对性能是一种损耗。但损耗归损耗,老版本浏览器如果不支持的话,还是得用这些古老的方法,但大家要知道原理奥。
更多请参考MDN——window.requestAnimationFrame
总结
使用Canvas绘制动画的一般步骤如下:
- 分析动画元素,利用面向对象思想将动画元素变成对象,抽象出相应的类。
- 编写帧动画函数,以帧为单位绘制画面。
- 调用API刷新。
注意:
Canvas绘制时一定要先保存状态,每一个对象绘制完毕后恢复状态,避免画布配置错乱。
参考
引入使用
示例代码已完善,可投入使用,简单配置即可实现飞舞效果,可用作背景画面或玻璃窗效果。具体参考GitHub。
快速使用
// 传入id,默认配置下,雪花为大小、透明度不一的白色圆点
new Snow('#snow')
内置默认配置
// 配置相应项即可,不配置则应用默认配置
new Snow('#snow', {
image: [], // 可选的图片(网络或本地)
vx: [-3, 3], // 水平速度
vy: [2, 5], // 垂直速度
va: [45, 180], // 角速度范围,传入图片才会生效
vFlip: 0, // 翻转速度,推荐:慢0.05/正常0.1/快0.2
radius: [5, 15], // 半径范围,传入图片需调整此项
color: ['white'], // 可选颜色,传入图片时会忽略该项
alpha: [0.1, 0.9] // 透明度范围
num: window.innerWidth / 2, // 雪花数量,一般无需改动
})
GIF示例
示例代码:
new Snow('#snow', {
image: ['./snow.png'],
radius: [10, 80]
})
效果如下: