canvas 粒子效果 - 手残实践纪录

canvas 实践

粒子效果

首先确定开发的步骤

  1. 准备基础的 htmlcss 当背景
  2. 初始化 canvas
  3. 准备一个粒子类 Particle
  4. 编写粒子连线的函数 drawLine
  5. 编写动画函数 animate
  6. 添加鼠标和触摸移动事件、resize事件
  7. 离屏渲染优化、手机端的模糊处理
准备基础的 htmlcss 当背景

来这个网址随便找个你喜欢的渐变色

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="viewport"
          content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"/>
    <meta name="apple-mobile-web-app-status-bar-style" content="black"/>
    <meta name="format-detection" content="email=no"/>
    <meta name="apple-mobile-web-app-capable" content="yes"/>
    <meta name="format-detection" content="telephone=no"/>
    <meta name="renderer" content="webkit">
    <meta name="apple-mobile-web-app-status-bar-style" content="black">
    <meta name="apple-mobile-web-app-title" content="Amaze UI"/>
    <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"/>
    <meta http-equiv="Pragma" content="no-cache"/>
    <meta http-equiv="Expires" content="0"/>
    <title>canvas-粒子效果</title>
</head>
<body>
    <style>
        html,body {
            margin:0;
            overflow:hidden;
            width:100%;
            height:100%;
            background: #B993D6; 
            background: -webkit-linear-gradient(to left, #8CA6DB, #B993D6); 
            background: linear-gradient(to left, #8CA6DB, #B993D6); 
            }
    </style>
    <!--高清屏兼容的hidpi.js-->
    <script src="hidpi-canvas.min.js"></script>
    <!--业务代码-->
    <script src="canvas-particle.js"></script>
</body>
</html>
复制代码

这样之后你就得到了一个纯净的背景

初始化 canvas

首先准备一个可以将 context 变成链式调用的方法

// 链式调用
function Canvas2DContext(canvas) {
	if (typeof canvas === "string") {
		canvas = document.getElementById(canvas)
	}
	if (!(this instanceof Canvas2DContext)) {
		return new Canvas2DContext(canvas)
	}
	this.context = this.ctx = canvas.getContext("2d")
	if (!Canvas2DContext.prototype.arc) {
		Canvas2DContext.setup.call(this, this.ctx)
	}
}
Canvas2DContext.setup = function() {
	var methods = ["arc", "arcTo", "beginPath", "bezierCurveTo", "clearRect", "clip",
		"closePath", "drawImage", "fill", "fillRect", "fillText", "lineTo", "moveTo",
		"quadraticCurveTo", "rect", "restore", "rotate", "save", "scale", "setTransform",
		"stroke", "strokeRect", "strokeText", "transform", "translate"]
  
	var getterMethods = ["createPattern", "drawFocusRing", "isPointInPath", "measureText", 
	// drawFocusRing not currently supported
	// The following might instead be wrapped to be able to chain their child objects
		"createImageData", "createLinearGradient",
		"createRadialGradient", "getImageData", "putImageData"
	]
  
	var props = ["canvas", "fillStyle", "font", "globalAlpha", "globalCompositeOperation",
		"lineCap", "lineJoin", "lineWidth", "miterLimit", "shadowOffsetX", "shadowOffsetY",
		"shadowBlur", "shadowColor", "strokeStyle", "textAlign", "textBaseline"]
  
	for (let m of methods) {
		let method = m
		Canvas2DContext.prototype[method] = function() {
			this.ctx[method].apply(this.ctx, arguments)
			return this
		}
	}
  
	for (let m of getterMethods) {
		let method = m
		Canvas2DContext.prototype[method] = function() {
			return this.ctx[method].apply(this.ctx, arguments)
		}
	}
  
	for (let p of props) {
		let prop = p
		Canvas2DContext.prototype[prop] = function(value) {
			if (value === undefined)
			{return this.ctx[prop]}
			this.ctx[prop] = value
			return this
		}
	}
}
复制代码

接下来写一个 ParticleCanvas 函数

const ParticleCanvas = window.ParticleCanvas = function(){
    const canvas
    return canvas
}
const canvas = ParticleCanvas()
console.log(canvas)
复制代码

ParticleCanvas 方法可能会接受很多参数

  • 首先第一个参数必然是 id 啦,不然你怎么获取到 canvas
  • 还有宽高参数,我们把 canvas 处理一下宽高。
  • 可以使用 ES6 的函数默认参数跟解构赋值的方法。
  • 准备一个 init 方法初始化画布
const ParticleCanvas = window.ParticleCanvas = function({
    id = "p-canvas",
    width = 0,
    height = 0
}){
    //这里是获取到 canvas 对象,如果没获取到我们就自己创建一个插入进去
    const canvas = document.getElementById(id) || document.createElement("canvas")
    if(canvas.id !== id){ (canvas.id = id) && document.body.appendChild(canvas)}
    
    //通过调用上面的方法来获取到一个可以链式操作的上下文
    const context = Canvas2DContext(canvas)
    //这里默认的是网页窗口大小,如果传入则取传入的值
    width = width || document.documentElement.clientWidth
    height = height || document.documentElement.clientHeight
    
    //准备一个 init() 方法 初始化画布
    const init = () => {
        canvas.width = width
	    canvas.height = height
    }
    init()
    return canvas
}
const canvas = ParticleCanvas({})
console.log(canvas)
复制代码

写完之后就变成这样了

准备一个粒子类 Particle

接下来我们磨刀霍霍向粒子了,通过观察动画效果我们可以知道,首先这个核心就是粒子,且每次出现的随机的粒子,所以解决了粒子就可以解决了这个效果的 50% 啊 。那我们就开始来写这个类

我们先来思考一下,这个粒子类,目前最需要哪些参数初始化它

  • 第一个当然是,绘制上下文 context
  • 然后,这个粒子实际上其实就是画个圆,画圆需要什么参数?
    • arc(x, y, radius, startAngle, endAngle, anticlockwise)
  • 前三个怎么都要传进来吧,不然你怎么保证每个粒子实例 大小位置 不一样呢
  • 头脑风暴结束后我们目前确定了四个参数 context x y r
  • 所谓 万丈高楼平地起 要画一百个粒子,首先先画第一个粒子
class Particle {
	constructor({context, x, y, r}){
		context.beginPath()
    		.fillStyle("#fff")
    		.arc(x, y, r, 0, Math.PI * 2)
    		.fill()
    		.closePath()
	}
}
//准备一个 init() 方法 初始化画布
const init = () => {
	canvas.width = width
	canvas.height = height
	const particle = new Particle({
		context,
		x: 100,
		y: 100,
		r: 10
	})
}
init()
复制代码

好的,你成功迈出了第一步

我们接下来思考 现在我们的需求是画 N 个随机位置随机大小的粒子,那要怎么做呢

  • 首先,我们可以通过一个循环去绘制一堆粒子
  • 只要传值是随机的,那,不就是,随机的粒子吗!
  • 随机的 x y 应该在屏幕内,而大小应该在一个数值以内
  • 说写就写,用 Math.random 不就解决需求了吗
const init = () => {
	canvas.width = width
	canvas.height = height
	for (let i = 0; i < 50; i++) {
		new Particle({
			context,
			x: Math.random() * width,
			y: Math.random() * height,
			r: Math.round(Math.random() * (10 - 5) + 10)
		})
	}
}
init()
复制代码

好的,随机粒子也被我们撸出来了

接下来还有个问题,这样直接写虽然可以解决需求,但是其实不易于扩展。

  • 每次我们调用 Particle 类的构造函数的时候,我们就去绘制,这就显得有些奇怪。
  • 我们需要另外准备一个类的内部方法,让它去负责绘制,而构造函数存储这些参数值,各司其职
  • 然后就是我们初始化的粒子,我们需要拿一个数组来装住这些粒子,方便我们的后续操作
  • 然后机智的你又发现了,我们为什么不传个颜色,透明度进去让它更随机一点
  • 我们确定了要传入 parColor ,那我们分析一波这个参数,你有可能想传入的是一个十六进制的颜色码,也可能传一个 rgb 或者 rgba 形式的,我们配合透明度再来做处理,那就需要另外一个转换的函数,让它统一转换一下。
  • 既然你都能传颜色值了,那支持多种颜色不也是手到擒来的事情,不就是传个数组进去么?
  • 确定完需求就开写。
/*16进制颜色转为RGB格式 传入颜色值和透明度 */ 
const color2Rgb = (str, op) => {
	const reg = /^#([0-9a-fA-f]{3}|[0-9a-fA-f]{6})$/
	let sColor = str.toLowerCase()
	// 如果不传,那就随机透明度
	op = op || (Math.floor(Math.random() * 10) + 4) / 10 / 2
	let opStr = `,${op})`
	// 这里使用 惰性返回,就是存储一下转换好的,万一遇到转换过的就直接取值
	if (this[str]) {return this[str] + opStr}
	if (sColor && reg.test(sColor)) {
	    // 如果是十六进制颜色码
		if (sColor.length === 4) {
			let sColorNew = "#"
			for (let i = 1; i < 4; i += 1) {
				sColorNew += sColor.slice(i, i + 1).concat(sColor.slice(i, i + 1))
			}
			sColor = sColorNew
		}
		//处理六位的颜色值  
		let sColorChange = []
		for (let i = 1; i < 7; i += 2) {
			sColorChange.push(parseInt("0x" + sColor.slice(i, i + 2)))
		}
		let result = `rgba(${sColorChange.join(",")}`
		this[str] = result
		return result + opStr
	}
	// 不是我就不想管了
	return sColor
}
// 获取数组中随机一个值
const getArrRandomItem = (arr) => arr[Math.round(Math.random() * (arr.length - 1 - 0) + 0)]

//函数添加传入的参数
const ParticleCanvas = window.ParticleCanvas = function({
    id = "p-canvas",
    width = 0,
    height = 0,
    parColor = ["#fff","#000"],
    parOpacity,
    maxParR = 10, //粒子最大的尺寸
    minParR = 5, //粒子最小的尺寸
}){
    ...
    let particles = []
    class Particle {
		constructor({context, x, y, r, parColor, parOpacity}){
			this.context = context
			this.x = x
			this.y = y 
			this.r = r
			this.color = color2Rgb(typeof parColor === "string" ? parColor : getArrRandomItem(parColor), parOpacity) // 颜色
			this.draw()
		}
		draw(){
			this.context.beginPath()
				.fillStyle(this.color)
				.arc(this.x, this.y, this.r, 0, Math.PI * 2)
				.fill()
				.closePath()
		}
	}
	//准备一个 init() 方法 初始化画布
	const init = () => {
		canvas.width = width
		canvas.height = height
		for (let i = 0; i < 50; i++) {
			particles.push(new Particle({
				context,
				x: Math.random() * width,
				y: Math.random() * height,
				r: Math.round(Math.random() * (maxParR - minParR) + minParR),
				parColor,
				parOpacity
			}))
		}
	}
	init()
    return canvas
}
复制代码

接下来你的页面就会长成这样子啦,基础的粒子类已经写好了,接下来我们先把连线函数编写一下

drawLine

两个点要如何连成线?我们查一下就知道,要通过调用 moveTo(x, y)lineTo(x,y)

  • 观察效果,思考一下连线的条件,我们发现在一定的距离两个粒子会连成线
  • 首先线的参数就跟粒子的是差不多的,需要线宽 lineWidth, 颜色 lineColor, 透明度 lineOpacity
  • 那其实是不是再通过双层循环来调用 drawLine 就可以让他们彼此连线
  • drawLine 其实就需要传入另一个粒子进去,开搞
const ParticleCanvas = window.ParticleCanvas = function({
	id = "p-canvas",
	width = 0,
	height = 0,
	parColor = ["#fff","#000"],
	parOpacity,
	maxParR = 10, //粒子最大的尺寸
	minParR = 5, //粒子最小的尺寸
	lineColor = "#fff",
	lineOpacity,
	lineWidth = 1
}){
	...
	class Particle {
		constructor({context, x, y, r, parColor, parOpacity, lineWidth, lineColor, lineOpacity}){
			this.context = context
			this.x = x
			this.y = y 
			this.r = r
			this.color = color2Rgb(typeof parColor === "string" ? parColor : getArrRandomItem(parColor), parOpacity) // 颜色
			this.lineColor = color2Rgb(typeof lineColor === "string" ? lineColor : getArrRandomItem(lineColor), lineOpacity)  
			//这个判断是为了让线段颜色跟粒子颜色保持一致使用的,不影响整个逻辑
			if(lineColor != "#fff"){
				this.color = this.lineColor
			}else{
				this.lineColor = this.color
			}
			this.lineWidth = lineWidth
			this.draw()
		}
		draw(){
		    ...
		}
		drawLine(_round) {
			let dx = this.x - _round.x,
				dy = this.y - _round.y
			if (Math.sqrt(dx * dx + dy * dy) < 150) {
				let x = this.x,
					y = this.y,
					lx = _round.x,
					ly = _round.y
				this.context.beginPath()
					.moveTo(x, y)
					.lineTo(lx, ly)
					.closePath()
					.lineWidth(this.lineWidth)
					.strokeStyle(this.lineColor)
					.stroke()
			}
		}
	}
	//准备一个 init() 方法 初始化画布
	const init = () => {
		canvas.width = width
		canvas.height = height
		for (let i = 0; i < 50; i++) {
			particles.push(new Particle({
				context,
				x: Math.random() * width,
				y: Math.random() * height,
				r: Math.round(Math.random() * (maxParR - minParR) + minParR),
				parColor,
				parOpacity,
				lineWidth, 
				lineColor, 
				lineOpacity
			}))
		}
		for (let i = 0; i < particles.length; i++) {
			for (let j = i + 1; j < particles.length; j++) {
				particles[i].drawLine(particles[j])
			}
		}
	}
	...
}
复制代码

现在我们就得到一个连线的粒子了,接下来我们就要让我们的页面动起来了

animate

首先我们要认识到,canvas是通过我们编写的那些绘制函数绘制上去的,那么,我们如果使用一个定时器,定时的去绘制,不就是动画的基本原理了么

  • 首先我们要写一个 animate 函数,把我们的逻辑写进去,然后让定时器 requestAnimationFrame 去执行它

requestAnimationFrame是浏览器用于定时循环操作的一个接口,类似于setTimeout,主要用途是按帧对网页进行重绘。

设置这个API的目的是为了让各种网页动画效果(DOM动画、Canvas动画、SVG动画、WebGL动画)能够有一个统一的刷新机制,从而节省系统资源,提高系统性能,改善视觉效果。代码中使用这个API,就是告诉浏览器希望执行一个动画,让浏览器在下一个动画帧安排一次网页重绘。

  • 看不明白的话,那你就把他当成一个不用你去设置时间的 setInterval
  • 那我们要通过动画去执行绘制,粒子要动起来,我们必须要再粒子类上再扩展一个方法 move ,既然要移动了,那上下移动的偏移量必不可少 moveXmoveY
  • 逻辑分析完毕,开炮
const ParticleCanvas = window.ParticleCanvas = function({
	id = "p-canvas",
	width = 0,
	height = 0,
	parColor = ["#fff","#000"],
	parOpacity,
	maxParR = 10, //粒子最大的尺寸
	minParR = 5, //粒子最小的尺寸
	lineColor = "#fff",
	lineOpacity,
	lineWidth = 1,
	moveX = 0,
	moveY = 0,
}){
    ...
	class Particle {
		constructor({context, x, y, r, parColor, parOpacity, lineWidth, lineColor, lineOpacity, moveX, moveY}){
			this.context = context
			this.x = x
			this.y = y 
			this.r = r
			this.color = color2Rgb(typeof parColor === "string" ? parColor : getArrRandomItem(parColor), parOpacity) // 颜色
			this.lineColor = color2Rgb(typeof lineColor === "string" ? lineColor : getArrRandomItem(lineColor), lineOpacity) 
			
			this.lineWidth = lineWidth
			//初始化最开始的速度
			this.moveX = Math.random() + moveX
			this.moveY = Math.random() + moveY
            
			this.draw()
		}
		draw(){
			this.context.beginPath()
				.fillStyle(this.color)
				.arc(this.x, this.y, this.r, 0, Math.PI * 2)
				.fill()
				.closePath()
		}
		drawLine(_round) {
			let dx = this.x - _round.x,
				dy = this.y - _round.y
			if (Math.sqrt(dx * dx + dy * dy) < 150) {
				let x = this.x,
					y = this.y,
					lx = _round.x,
					ly = _round.y

				if(this.userCache){
					x = this.x + this.r / this._ratio
					y = this.y + this.r / this._ratio
					lx = _round.x + _round.r / this._ratio
					ly = _round.y + _round.r / this._ratio
				}

				this.context.beginPath()
					.moveTo(x, y)
					.lineTo(lx, ly)
					.closePath()
					.lineWidth(this.lineWidth)
					.strokeStyle(this.lineColor)
					.stroke()
			}
		}
		move() {
			//边界判断
			this.moveX = this.x + this.r * 2 < width && this.x > 0 ? this.moveX : -this.moveX
			this.moveY = this.y + this.r * 2 < height && this.y > 0 ? this.moveY : -this.moveY
			//通过偏移量,改变x y的值,绘制
			this.x += this.moveX
			this.y += this.moveY
			this.draw()
		}
	}
    
	//动画函数
	const animate = () => {
		//每次调用要首先清除画布,不然你懂的
		context.clearRect(0, 0, width, height)
		for (let i = 0; i < particles.length; i++) {
			//粒子移动
			particles[i].move()
			for (let j = i + 1; j < particles.length; j++) {
				//粒子连线
				particles[i].drawLine(particles[j])
			}
		}
		requestAnimationFrame(animate)
	}

	//准备一个 init() 方法 初始化画布
	const init = () => {
		canvas.width = width
		canvas.height = height
		for (let i = 0; i < 50; i++) {
			particles.push(new Particle({
				context,
				x: Math.random() * width,
				y: Math.random() * height,
				r: Math.round(Math.random() * (maxParR - minParR) + minParR),
				parColor,
				parOpacity,
				lineWidth, 
				lineColor, 
				lineOpacity,
				moveX,
				moveY,
			}))
		}
		//执行动画
		animate()
	}
	init()
	return canvas
}
复制代码

如果没有意外,你的页面应该动起来啦,是不是感觉很简单呢

添加鼠标和触摸移动事件

接下来我们要来添加鼠标和触摸移动的效果了

  • 首先鼠标移动会有一个粒子跟随,我们单独初始化一个孤单的粒子出来 currentParticle,这个粒子跟上来自己动的妖艳贱货不一样的点在于,currentParticle 的位置,我们需要通过监听事件返回的鼠标位置赋值给它,是的,这个需要你让他动。
  • 既然是个独特的粒子,那么样式也要支持自定义啦 isMove(是否开启跟随) targetColor targetPpacity targetR 看你也知道是什么意思啦, 不解释了。
  • resize 事件是监听浏览器窗口尺寸变化,这样子在用户变化尺寸的时候,我们的背景就不会变得不和谐
  • 实现的思路主要是通过监听 resize 事件,重新调用一波 init 方法,来重新渲染画布,由于 resize 这个在事件在变化的时候回调非常的频繁,频繁的计算会影响性能,严重可能会卡死,所以我们通过防抖 debounce 或者节流 throttle 的方式来限制其调用。
  • 了解完思路,那就继续写啦
/* 保留小数 */
const toFixed = (a, n) => parseFloat(a.toFixed(n || 1))
//节流,避免resize占用过多资源
const throttle = function (func,wait,options) {
	var context,args,timeout
	var previous = 0
	options = options || {}
	// leading:false 表示禁用第一次执行
	// trailing: false 表示禁用停止触发的回调
	var later = function(){
		previous = options.leading === false ? 0 : new Date().getTime()
		timeout = null
		func.apply(context, args)
	}
	var throttled = function(){
		var now = +new Date()
		if (!previous && options.leading === false) {previous = now}
		// 下次触发 func 的剩余时间
		var remaining = wait - (now - previous)
		context = this
		args = arguments
		// 如果没有剩余的时间了或者你改了系统时间
		if(remaining > wait || remaining <= 0){
			if (timeout) {
				clearTimeout(timeout)
				timeout = null
			}
			previous = now
			func.apply(context, args)
		}else if(!timeout && options.trailing !== false){
			timeout = setTimeout(later, remaining)
		}
	}
	throttled.cancel = function() {
		clearTimeout(timeout)
		previous = 0
		timeout = null
	}
	return throttled
}
//防抖,避免resize占用过多资源
const debounce = function(func,wait,immediate){
	//防抖
	//定义一个定时器。
	var timeout,result
	var debounced = function() {
		//获取 this
		var context = this
		//获取参数
		var args = arguments
		//清空定时器
		if(timeout){clearTimeout(timeout)}
		if(immediate){
			//立即触发,但是需要等待 n 秒后才可以重新触发执行
			var callNow = !timeout
			console.log(callNow)
			timeout = setTimeout(function(){
				timeout = null
			}, wait)
			if (callNow) {result = func.apply(context, args)}
		}else{
			//触发后开始定时,
			timeout = setTimeout(function(){
				func.apply(context,args)
			}, wait)
		}
		return result
	}
	debounced.cancel = function(){
		// 当immediate 为 true,上一次执行后立即,取消定时器,下一次可以实现立即触发
		if(timeout) {clearTimeout(timeout)}
		timeout = null
	}
	return debounced
}
const ParticleCanvas = window.ParticleCanvas = function({
	id = "p-canvas",
	width = 0,
	height = 0,
	parColor = ["#fff"],
	parOpacity,
	maxParR = 10, //粒子最大的尺寸
	minParR = 5, //粒子最小的尺寸
	lineColor = "#fff",
	lineOpacity,
	lineWidth = 1,
	moveX = 0,
	moveY = 0,
	isMove = true,
	targetColor = ["#000"],
	targetPpacity = 0.6,
	targetR = 10,
}){
	let currentParticle,
		isWResize = width,
		isHResize = height,
		myReq = null

	class Particle {
		...
	}
    
	//动画函数
	const animate = () => {
		//每次调用要首先清除画布,不然你懂的
		context.clearRect(0, 0, width, height)
		for (let i = 0; i < particles.length; i++) {
			//粒子移动
			particles[i].move()
			for (let j = i + 1; j < particles.length; j++) {
				//粒子连线
				particles[i].drawLine(particles[j])
			}
		}
            	/** 
                 * 这个放在外面的原因
                 * 我不开启isMove的时候,或者currentParticle.x 没有值的情况
                 * 放在上面的循环需要每次走循环都判断一次
                 * 而放在下面的话只需要执行一次就知道有没有必要再执行 N 次
                 * 当然你也可以放里面,问题也不大
                */
		if (isMove && currentParticle.x) {
			for (let i = 0; i < particles.length; i++) {
				currentParticle.drawLine(particles[i])
			}
			currentParticle.draw()
		}
		myReq = requestAnimationFrame(animate)
	}

	//准备一个 init() 方法 初始化画布
	const init = () => {
		canvas.width = width
		canvas.height = height
		//独立粒子
		if (isMove && !currentParticle) {
			currentParticle = new Particle({
				x: 0,
				y: 0, 
				r: targetR, 
				parColor: targetColor, 
				parOpacity: targetPpacity,
				lineColor,
				lineOpacity, 
				lineWidth,
				context
			}) //独立粒子
			
			const moveEvent = (e = window.event) => {
				//改变 currentParticle 的 x y
				currentParticle.x = e.clientX || e.touches[0].clientX
				currentParticle.y = e.clientY || e.touches[0].clientY
			}
			const outEvent = () => {currentParticle.x = currentParticle.y = null}
            
			const eventObject = {
				"pc": {
					move: "mousemove",
					out: "mouseout"
				},
				"phone": {
					move: "touchmove",
					out: "touchend"
				}
			}
			const event = eventObject[/Android|webOS|iPhone|iPod|BlackBerry/i.test(navigator.userAgent) ? "phone" : "pc"]

			canvas.removeEventListener(event.move,moveEvent)
			canvas.removeEventListener(event.out, outEvent)
			canvas.addEventListener(event.move,moveEvent)
			canvas.addEventListener(event.out, outEvent)
		}
		//自由粒子
		for (let i = 0; i < 50; i++) {
			particles.push(new Particle({
				context,
				x: Math.random() * width,
				y: Math.random() * height,
				r: Math.round(Math.random() * (maxParR - minParR) + minParR),
				parColor,
				parOpacity,
				lineWidth, 
				lineColor, 
				lineOpacity,
				moveX,
				moveY,
			}))
		}
		//执行动画
		animate()
                /*
                    这个判断在于,假设用户只需要一个 500*500 的画布的时候。其实是不需要 resize 的
                    而用户如果只是输入其中一个值,另一个值自适应,则认为其需要 resize。
                    如果全部都自适应,那则肯定是需要 resize 的
                    此逻辑是我自己瞎想的,其实不用也行,只是我觉得这样更符合我自己的需求。
                    全部 resize 也是可以的。
                */
		if(!isWResize || !isHResize){window.addEventListener("resize",debounce(resize, 100))}
	}
	const resize = () => {
		//清除 定时器
		if(this.timeout){clearTimeout(this.timeout)}
		//清除 AnimationFrame
		if(myReq){window.cancelAnimationFrame(myReq)}
		//清空 粒子数组
		particles = []
		//设置新的 宽高
		width = isWResize ? width : document.documentElement.clientWidth
		height = isHResize ? height : document.documentElement.clientHeight
		this.timeout = setTimeout(init, 20)
	}
	init()
	return canvas
}
复制代码

写到这里,这个东西差不多啦,接下来就是优化的问题了

离屏渲染优化和手机端的模糊处理
离屏渲染

其实是指用离屏canvas上预渲染相似的图形或重复的对象,简单点说就是,你现在其他canvas对象上画好,然后再通过 drawImage() 放进去目标画布里面

  • 我们需要提供一个方法,用于离屏渲染粒子,用于生成一个看不见的 canvas 然后在上面画画画
  • 最好能够提供一下缓存用过的 canvas 用于节省空间性能,提高复用率
  • 画的时候要注意,提供一个倍数,然后再缩小,看上去就比较清晰
  • 这里的注意点是,理解这种渲染方式,以及倍数之间的关系
//离屏缓存
const getCachePoint = (r,color,cacheRatio) => {
	let key = r + "cache" + color
	//缓存一个 canvas  如果遇到相同的,直接从缓存取
	if(this[key]){return this[key]}
	//离屏渲染
	const _ratio = 2 * cacheRatio,
		width = r * _ratio,
		cacheCanvas = document.createElement("canvas"),
		cacheContext = Canvas2DContext(cacheCanvas)
	cacheCanvas.width = cacheCanvas.height = width
	cacheContext.save()
		.fillStyle(color)
		.arc(r * cacheRatio, r * cacheRatio, r, 0, 360)
		.closePath()
		.fill()
		.restore()
	this[key] = cacheCanvas

	return cacheCanvas
}


const ParticleCanvas = window.ParticleCanvas = function({
    ...
    useCache = true //新增一个useCache表示是否开启离屏渲染
}){
	...
	class Particle {
		constructor({context, x, y, r, parColor, parOpacity, lineWidth, lineColor, lineOpacity, moveX, moveY, useCache}){
			...
			this.ratio = 3
			this.useCache = useCache
		}
		draw(){
			if(this.useCache){
				this.context.drawImage(
					getCachePoint(this.r,this.color,this.ratio), 
					this.x - this.r * this.ratio, 
					this.y - this.r * this.ratio
				)
			}else{
				this.context.beginPath()
					.fillStyle(this.color)
					.arc(toFixed(this.x), toFixed(this.y), toFixed(this.r), 0, Math.PI * 2)
					.fill()
					.closePath()
			}
		}
		...
	}
    ...
	//准备一个 init() 方法 初始化画布
	const init = () => {
	    ...
		if (isMove && !currentParticle) {
			currentParticle = new Particle({
				...
				useCache
			}) //独立粒子
			...
		}
		//自由粒子
		for (let i = 0; i < 50; i++) {
			particles.push(new Particle({
				...
				useCache
			}))
		}
        ...
	}
    ...
}
复制代码
高清屏的模糊处理

因为 canvas 绘制的图像并不是矢量图,而是跟图片一样的位图,所以在高 dpi 的屏幕上看的时候,就会显得比较模糊,比如 苹果的 Retina 屏幕,它会用两个或者三个像素来合成一个像素,相当于图被放大了两倍或者三倍,所以自然就模糊了

我们可以通过引入 hidpi-canvas.min.js 来处理在手机端高清屏绘制变得模糊的问题

这个插件的原理是通过这个方法来获取 dpi

getPixelRatio = (context) => {
	var backingStore = context.backingStorePixelRatio ||
            context.webkitBackingStorePixelRatio ||
            context.mozBackingStorePixelRatio ||
            context.msBackingStorePixelRatio ||
            context.oBackingStorePixelRatio ||
            context.backingStorePixelRatio || 1
	return (window.devicePixelRatio || 1) / backingStore
}
复制代码

然后通过放大画布,再通过CSS的宽高缩小画布

//兼容 Retina 屏幕
const setRetina = (canvas,context,width,height) => {
	var ratio = getPixelRatio(context)
	ratio = 2
	if(context._retinaRatio && context._retinaRatio !== ratio){window.location.reload()}
	canvas.style.width = width * ratio + "px"
	canvas.style.height = height * ratio + "px"
	// 缩放绘图
	context.setTransform(ratio, 0, 0, ratio, 0, 0)
	canvas.width = width * ratio
	canvas.height = height * ratio
	context._retinaRatio = ratio
	return ratio
}
复制代码

这个方法通过处理是可以兼容好手机模糊的问题,但是在屏幕比较好的电脑屏幕感觉还是有点模糊,所以我就改造了一下...

  • 如果是手机端,放大三倍,电脑端则放大两倍,再缩小到指定大小
  • 需要注意的是,drawImage 的倍数关系
  • 如果有更好更优雅的办法,希望能交流一下
const PIXEL_RATIO = /Android|webOS|iPhone|iPod|BlackBerry/i.test(navigator.userAgent) ? 3 : 2
//hidpi-canvas.min.js 核心代码
;(function(prototype) {

	var forEach = function(obj, func) {
			for (var p in obj) {
				if (obj.hasOwnProperty(p)) {
					func(obj[p], p)
				}
			}
		},

		ratioArgs = {
			"fillRect": "all",
			"clearRect": "all",
			"strokeRect": "all",
			"moveTo": "all",
			"lineTo": "all",
			"arc": [0,1,2],
			"arcTo": "all",
			"bezierCurveTo": "all",
			"isPointinPath": "all",
			"isPointinStroke": "all",
			"quadraticCurveTo": "all",
			"rect": "all",
			"translate": "all",
			"createRadialGradient": "all",
			"createLinearGradient": "all"
		}

	forEach(ratioArgs, function(value, key) {
		prototype[key] = (function(_super) {
			return function() {
				var i, len,
					args = Array.prototype.slice.call(arguments)

				if (value === "all") {
					args = args.map(function(a) {
						return a * PIXEL_RATIO
					})
				}
				else if (Array.isArray(value)) {
					for (i = 0, len = value.length; i < len; i++) {
						args[value[i]] *= PIXEL_RATIO
					}
				}

				return _super.apply(this, args)
			}
		})(prototype[key])
	})

	// Stroke lineWidth adjustment
	prototype.stroke = (function(_super) {
		return function() {
			this.lineWidth *= PIXEL_RATIO
			_super.apply(this, arguments)
			this.lineWidth /= PIXEL_RATIO
		}
	})(prototype.stroke)

	// Text
	//
	prototype.fillText = (function(_super) {
		return function() {
			var args = Array.prototype.slice.call(arguments)

			args[1] *= PIXEL_RATIO // x
			args[2] *= PIXEL_RATIO // y

			this.font = this.font.replace(
				/(\d+)(px|em|rem|pt)/g,
				function(w, m, u) {
					return m * PIXEL_RATIO + u
				}
			)

			_super.apply(this, args)

			this.font = this.font.replace(
				/(\d+)(px|em|rem|pt)/g,
				function(w, m, u) {
					return m / PIXEL_RATIO + u
				}
			)
		}
	})(prototype.fillText)

	prototype.strokeText = (function(_super) {
		return function() {
			var args = Array.prototype.slice.call(arguments)

			args[1] *= PIXEL_RATIO // x
			args[2] *= PIXEL_RATIO // y

			this.font = this.font.replace(
				/(\d+)(px|em|rem|pt)/g,
				function(w, m, u) {
					return m * PIXEL_RATIO + u
				}
			)

			_super.apply(this, args)

			this.font = this.font.replace(
				/(\d+)(px|em|rem|pt)/g,
				function(w, m, u) {
					return m / PIXEL_RATIO + u
				}
			)
		}
	})(prototype.strokeText)
})(CanvasRenderingContext2D.prototype)

//兼容 Retina 屏幕
const setRetina = (canvas,context,width,height) => {
	var ratio = PIXEL_RATIO
	canvas.style.width = width + "px"
	canvas.style.height = height + "px"
	// 缩放绘图
	context.setTransform(ratio, 0, 0, ratio, 0, 0)
	canvas.width = width * ratio
	canvas.height = height * ratio
	context._retinaRatio = ratio
	return ratio
}

// 链式调用
function Canvas2DContext(canvas) {
	if (typeof canvas === "string") {
		canvas = document.getElementById(canvas)
	}
	if (!(this instanceof Canvas2DContext)) {
		return new Canvas2DContext(canvas)
	}
	this.context = this.ctx = canvas.getContext("2d")
	if (!Canvas2DContext.prototype.arc) {
		Canvas2DContext.setup.call(this, this.ctx)
	}
}
Canvas2DContext.setup = function() {
	var methods = ["arc", "arcTo", "beginPath", "bezierCurveTo", "clearRect", "clip",
		"closePath", "drawImage", "fill", "fillRect", "fillText", "lineTo", "moveTo",
		"quadraticCurveTo", "rect", "restore", "rotate", "save", "scale", "setTransform",
		"stroke", "strokeRect", "strokeText", "transform", "translate"]
  
	var getterMethods = ["createPattern", "drawFocusRing", "isPointInPath", "measureText", 
	// drawFocusRing not currently supported
	// The following might instead be wrapped to be able to chain their child objects
		"createImageData", "createLinearGradient",
		"createRadialGradient", "getImageData", "putImageData"
	]
  
	var props = ["canvas", "fillStyle", "font", "globalAlpha", "globalCompositeOperation",
		"lineCap", "lineJoin", "lineWidth", "miterLimit", "shadowOffsetX", "shadowOffsetY",
		"shadowBlur", "shadowColor", "strokeStyle", "textAlign", "textBaseline"]
  
	for (let m of methods) {
		let method = m
		Canvas2DContext.prototype[method] = function() {
			this.ctx[method].apply(this.ctx, arguments)
			return this
		}
	}
  
	for (let m of getterMethods) {
		let method = m
		Canvas2DContext.prototype[method] = function() {
			return this.ctx[method].apply(this.ctx, arguments)
		}
	}
  
	for (let p of props) {
		let prop = p
		Canvas2DContext.prototype[prop] = function(value) {
			if (value === undefined)
			{return this.ctx[prop]}
			this.ctx[prop] = value
			return this
		}
	}
}

/*16进制颜色转为RGB格式 传入颜色值和透明度 */ 
const color2Rgb = (str, op) => {
	const reg = /^#([0-9a-fA-f]{3}|[0-9a-fA-f]{6})$/
	let sColor = str.toLowerCase()
	// 如果不传,那就随机透明度
	op = op || (Math.floor(Math.random() * 10) + 4) / 10 / 2
	let opStr = `,${op})`
	// 这里使用 惰性返回,就是存储一下转换好的,万一遇到转换过的就直接取值
	if (this[str]) {return this[str] + opStr}
	if (sColor && reg.test(sColor)) {
	// 如果是十六进制颜色码
		if (sColor.length === 4) {
			let sColorNew = "#"
			for (let i = 1; i < 4; i += 1) {
				sColorNew += sColor.slice(i, i + 1).concat(sColor.slice(i, i + 1))
			}
			sColor = sColorNew
		}
		//处理六位的颜色值  
		let sColorChange = []
		for (let i = 1; i < 7; i += 2) {
			sColorChange.push(parseInt("0x" + sColor.slice(i, i + 2)))
		}
		let result = `rgba(${sColorChange.join(",")}`
		this[str] = result
		return result + opStr
	}
	// 不是我就不想管了
	return sColor
}
// 获取数组中随机一个值
const getArrRandomItem = (arr) => arr[Math.round(Math.random() * (arr.length - 1 - 0) + 0)]
/* 保留小数 */
const toFixed = (a, n) => parseFloat(a.toFixed(n || 1))
//节流,避免resize占用过多资源
const throttle = function (func,wait,options) {
	var context,args,timeout
	var previous = 0
	options = options || {}
	// leading:false 表示禁用第一次执行
	// trailing: false 表示禁用停止触发的回调
	var later = function(){
		previous = options.leading === false ? 0 : new Date().getTime()
		timeout = null
		func.apply(context, args)
	}
	var throttled = function(){
		var now = +new Date()
		if (!previous && options.leading === false) {previous = now}
		// 下次触发 func 的剩余时间
		var remaining = wait - (now - previous)
		context = this
		args = arguments
		// 如果没有剩余的时间了或者你改了系统时间
		if(remaining > wait || remaining <= 0){
			if (timeout) {
				clearTimeout(timeout)
				timeout = null
			}
			previous = now
			func.apply(context, args)
		}else if(!timeout && options.trailing !== false){
			timeout = setTimeout(later, remaining)
		}
	}
	throttled.cancel = function() {
		clearTimeout(timeout)
		previous = 0
		timeout = null
	}
	return throttled
}
//防抖,避免resize占用过多资源
const debounce = function(func,wait,immediate){
	//防抖
	//定义一个定时器。
	var timeout,result
	var debounced = function() {
		//获取 this
		var context = this
		//获取参数
		var args = arguments
		//清空定时器
		if(timeout){clearTimeout(timeout)}
		if(immediate){
			//立即触发,但是需要等待 n 秒后才可以重新触发执行
			var callNow = !timeout
			console.log(callNow)
			timeout = setTimeout(function(){
				timeout = null
			}, wait)
			if (callNow) {result = func.apply(context, args)}
		}else{
			//触发后开始定时,
			timeout = setTimeout(function(){
				func.apply(context,args)
			}, wait)
		}
		return result
	}
	debounced.cancel = function(){
		// 当immediate 为 true,上一次执行后立即,取消定时器,下一次可以实现立即触发
		if(timeout) {clearTimeout(timeout)}
		timeout = null
	}
	return debounced
}

//离屏缓存
const getCachePoint = (r,color,cacheRatio) => {
	let key = r + "cache" + color
	if(this[key]){return this[key]}
	//离屏渲染
	const _ratio = 2 * cacheRatio,
		width = r * _ratio,
		cR = toFixed(r * cacheRatio),
		cacheCanvas = document.createElement("canvas"),
		cacheContext = Canvas2DContext(cacheCanvas)
	setRetina(cacheCanvas,cacheContext,width,width)
	// cacheCanvas.width = cacheCanvas.height = width
	cacheContext.save()
		.fillStyle(color)
		.arc(cR, cR, cR, 0, 360)
		.closePath()
		.fill()
		.restore()
	this[key] = cacheCanvas

	return cacheCanvas
}


const ParticleCanvas = window.ParticleCanvas = function({
	id = "p-canvas",
	num = 30,
	width = 0,
	height = 0,
	parColor = ["#fff"],
	parOpacity,
	maxParR = 4, //粒子最大的尺寸
	minParR = 8, //粒子最小的尺寸
	lineColor = ["#fff"],
	lineOpacity = 0.3,
	lineWidth = 1,
	moveX = 0,
	moveY = 0,
	isMove = true,
	targetColor = ["#fff"],
	targetPpacity = 0.6,
	targetR = 6,
	useCache = false
}){
	//这里是获取到 canvas 对象,如果没获取到我们就自己创建一个插入进去
	const canvas = document.getElementById(id) || document.createElement("canvas")
	if(canvas.id !== id){ (canvas.id = id) && document.body.appendChild(canvas)}
    
	//通过调用上面的方法来获取到一个可以链式操作的上下文
	const context = Canvas2DContext(canvas)
	let currentParticle,
		isWResize = width,
		isHResize = height,
		myReq = null
	let particles = []
	//这里默认的是网页窗口大小,如果传入则取传入的值
	width = width || document.documentElement.clientWidth
	height = height || document.documentElement.clientHeight

	class Particle {
		constructor({context, x, y, r, parColor, parOpacity, lineWidth, lineColor, lineOpacity, moveX, moveY, useCache}){
			this.context = context
			this.x = x
			this.y = y 
			this.r = toFixed(r)
			this.ratio = 3
			this.color = color2Rgb(typeof parColor === "string" ? parColor : getArrRandomItem(parColor), parOpacity) // 颜色
			this.lineColor = color2Rgb(typeof lineColor === "string" ? lineColor : getArrRandomItem(lineColor), lineOpacity)            
			if(lineColor === "#fff"){
				this.color = this.lineColor
			}else{
				this.lineColor = this.color
			}
			this.lineWidth = lineWidth
			//防止初始化越界
			this.x = x > this.r ? x - this.r : x
			this.y = y > this.r ? y - this.r : y
			//初始化最开始的速度
			this.moveX = Math.random() + moveX
			this.moveY = Math.random() + moveY
			this.useCache = useCache
			this.draw()
		}
		draw(){
			if(this.x >= 0 && this.y >= 0){
				if(this.useCache){
					this.context.drawImage(
						getCachePoint(this.r,this.color,this.ratio), 
						toFixed(this.x - this.r) * this.context._retinaRatio, 
						toFixed(this.y - this.r) * this.context._retinaRatio,
						this.r * 2 * this.context._retinaRatio,
						this.r * 2 * this.context._retinaRatio
					)
				}else{
					this.context.beginPath()
						.fillStyle(this.color)
						.arc(toFixed(this.x), toFixed(this.y), toFixed(this.r), 0, Math.PI * 2)
						.fill()
						.closePath()
				}
			}
			
		}
		drawLine(_round) {
			let dx = this.x - _round.x,
				dy = this.y - _round.y
			if (Math.sqrt(dx * dx + dy * dy) < 150) {
				let x = this.x,
					y = this.y,
					lx = _round.x,
					ly = _round.y

				
				if(this.userCache){
					x = this.x + this.r / this._ratio
					y = this.y + this.r / this._ratio
					lx = _round.x + _round.r / this._ratio
					ly = _round.y + _round.r / this._ratio
				}
				if(x >= 0 && y >= 0 && lx >= 0 && ly >= 0){
					this.context.beginPath()
						.moveTo(toFixed(x), toFixed(y))
						.lineTo(toFixed(lx), toFixed(ly))
						.closePath()
						.lineWidth(this.lineWidth)
						.strokeStyle(this.lineColor)
						.stroke()
				}
				
			}
		}
		move() {
			//边界判断
			this.moveX = this.x + this.r * 2 < width && this.x > 0 ? this.moveX : -this.moveX
			this.moveY = this.y + this.r * 2 < height && this.y > 0 ? this.moveY : -this.moveY
			//通过偏移量,改变x y的值,绘制
			this.x += this.moveX
			this.y += this.moveY
			this.draw()
		}
	}
    
	//动画函数
	const animate = () => {
		//每次调用要首先清除画布,不然你懂的
		context.clearRect(0, 0, width, height)
		for (let i = 0; i < particles.length; i++) {
			//粒子移动
			particles[i].move()
			for (let j = i + 1; j < particles.length; j++) {
				//粒子连线
				particles[i].drawLine(particles[j])
			}
		}
                /** 
                 * 这个放在外面的原因
                 * 我不开启isMove的时候,或者currentParticle.x 没有值的情况
                 * 放在上面的循环需要每次走循环都判断一次
                 * 而放在下面的话只需要执行一次就知道有没有必要再执行 N 次
                 * 当然你也可以放里面,问题也不大
                */
		if (isMove && currentParticle.x) {
			for (let i = 0; i < particles.length; i++) {
				currentParticle.drawLine(particles[i])
			}
			currentParticle.draw()
		}
		myReq = requestAnimationFrame(animate)
	}

	//准备一个 init() 方法 初始化画布
	const init = () => {
		// canvas.width = width
		// canvas.height = height
		setRetina(canvas, context, width, height)
		//独立粒子
		if (isMove && !currentParticle) {
			currentParticle = new Particle({
				x: 0,
				y: 0, 
				r: targetR, 
				parColor: targetColor, 
				parOpacity: targetPpacity,
				lineColor,
				lineOpacity, 
				lineWidth,
				context,
				useCache
			}) //独立粒子
			
			const moveEvent = (e = window.event) => {
				//改变 currentParticle 的 x y
				currentParticle.x = e.clientX || e.touches[0].clientX
				currentParticle.y = e.clientY || e.touches[0].clientY
			}
			const outEvent = () => {currentParticle.x = currentParticle.y = null}
            
			const eventObject = {
				"pc": {
					move: "mousemove",
					out: "mouseout"
				},
				"phone": {
					move: "touchmove",
					out: "touchend"
				}
			}
			const event = eventObject[/Android|webOS|iPhone|iPod|BlackBerry/i.test(navigator.userAgent) ? "phone" : "pc"]

			canvas.removeEventListener(event.move,moveEvent)
			canvas.removeEventListener(event.out, outEvent)
			canvas.addEventListener(event.move,moveEvent)
			canvas.addEventListener(event.out, outEvent)
		}
		//自由粒子
		for (let i = 0; i < num; i++) {
			particles.push(new Particle({
				context,
				x: Math.random() * width,
				y: Math.random() * height,
				r: Math.round(Math.random() * (maxParR - minParR) + minParR),
				parColor,
				parOpacity,
				lineWidth, 
				lineColor, 
				lineOpacity,
				moveX,
				moveY,
				useCache
			}))
		}
		//执行动画
		animate()
                /*
                    这个判断在于,假设用户只需要一个 500*500 的画布的时候。其实是不需要 resize 的
                    而用户如果只是输入其中一个值,另一个值自适应,则认为其需要 resize。
                    如果全部都自适应,那则肯定是需要 resize 的
                    此逻辑是我自己瞎想的,其实不用也行,只是我觉得这样更符合我自己的需求。
                    全部 resize 也是可以的。
                */
		if(!isWResize || !isHResize){window.addEventListener("resize",debounce(resize, 100))}
	}
	const resize = () => {
		//清除 定时器
		if(this.timeout){clearTimeout(this.timeout)}
		//清除 AnimationFrame
		if(myReq){window.cancelAnimationFrame(myReq)}
		//清空 粒子数组
		particles = []
		//设置新的 宽高
		width = isWResize ? width : document.documentElement.clientWidth
		height = isHResize ? height : document.documentElement.clientHeight
		this.timeout = setTimeout(init, 20)
	}
	init()
	return canvas
}

const canvas = ParticleCanvas({})
console.log(canvas)
复制代码

写到这里基本也就写完了...

溜了溜了

转载于:https://juejin.im/post/5bf506576fb9a049a62c329a

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值