JS动画?其实很简单。150行代码,带你制作雪花飞舞特效

2,521 阅读9分钟

写在前面

辗转许久,终于决定拿起笔杆,记录下自己是如何克服所谓的恐惧,爱上写动画。本文面向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()
        }
    }
}
  1. 构造函数:传入雪花配置和可选的图片,无图片的话,则默认绘制圆形。
  2. load:用来初始化以及重置所有属性。
  3. center:返回元素当前的中点。
  4. 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标准入门》

  1. constructor:构造函数主要处理默认参数的问题
  2. init:初始化控制类相关属性
  3. loadImage:使用Promise加载传入的图片
  4. createSnowflakes:根据是否传图片确定创建什么样的雪花
  5. transform:提取了两种绘图方式的公共变换代码,加以复用
  6. drawCircle:绘制圆形雪花。
  7. drawPicture:绘制图片,即自定义雪花

关于requestAnimationFrame

前面提到,传入一个回调函数,浏览器会在恰当的时机执行,但是只会执行一次。因此我们需要在回调函数里面再次使用requestAnimationFrame调用自身,如此,便能形成动画循环。

其实类似setInterval,甚至更像链式setTimeout,循环调用自身。浏览器判断的执行时机间隔大概是十几毫秒,这样的刷新速率,肉眼是不可能辨别的,于是就实现了动画效果。

那我们为什么要使用requestAnimationFrame呢?因为浏览器判断时机的意思就是,如果你切换到了后台,动画就会停止渲染,直到你切换回来,以及类似的情况。而前者则会一直运行,无疑对性能是一种损耗。但损耗归损耗,老版本浏览器如果不支持的话,还是得用这些古老的方法,但大家要知道原理奥。

更多请参考MDN——window.requestAnimationFrame

总结

使用Canvas绘制动画的一般步骤如下:

  1. 分析动画元素,利用面向对象思想将动画元素变成对象,抽象出相应的类。
  2. 编写帧动画函数,以帧为单位绘制画面。
  3. 调用API刷新。

注意:

Canvas绘制时一定要先保存状态,每一个对象绘制完毕后恢复状态,避免画布配置错乱。

参考

  1. 阮一峰《ES6标准入门》
  2. MDN——window.requestAnimationFrame

引入使用

示例代码已完善,可投入使用,简单配置即可实现飞舞效果,可用作背景画面或玻璃窗效果。具体参考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]
})

效果如下:

GIF示例