想给自己的网站写一个404、500之类的错误页,光放仨数字太丑,想着有什么办法漂亮地动起来,于是想起来以前做过一个破碎的粒子合并成文字的效果,就拿来使了。但是当初做的时候没有考虑性能上的问题,于是这次重构了一遍,所以本篇博客的重点不是动画效果,而是性能优化。
动画的原理很简单,相信大家都能想到:
① 就是先在一个临时的画布上绘制出文字
② 然后遍历画布中的每个像素
③ 如果不是透明的则记录下来作为例子结束的位置
④ 然后随机生成例子的初始位置和颜色,设置初始大小为0
⑤ 创建动画循环,粒子放大并移动到结束位置
核心的API是getImageData方法。如果按照这种方式实现,当你生成的粒子较多,你会发现动画过程中会明显卡顿的时候。主要的原因是getImageData和遍历像素是一个十分耗时的操作,会阻塞页面的绘制,这时可以使用Web Worker和离屏渲染OffscreenCanvas一定程度上避免该问题。
主线程代码:
<html lang="zh-cn">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>粒子</title>
<style>
* {
padding: 0;
margin: 0;
}
html, body {
width: 100%;
height: 100%;
}
#particle-canvas {
display: block;
width: 100%;
height: 100%;
}
#input-word {
font-family: Tahoma, sans-serif;
position: absolute;
bottom: 10px;
left: 10px;
height: 40px;
padding: 10px;
z-index: 1;
width: 200px;
border: 1px solid #aaa;
line-height: 30px;
box-sizing: border-box;
}
#btn-generate {
font-family: Tahoma, sans-serif;
position: absolute;
height: 40px;
bottom: 10px;
left: 240px;
padding: 0 10px;
box-sizing: border-box;
}
</style>
</head>
<body>
<canvas id="particle-canvas" width="1600" height="849"></canvas>
<input type="text" placeholder="please input words" id="input-word">
<button id="btn-generate">Generate</button>
<script>
(function () {
// const blob = new Blob([document.querySelector('#worker').textContent], { type: 'text/javascript' })
// const url = window.URL.createObjectURL(blob)
const worker = new Worker('./worker.js')
let pageWidth, pageHeight
let particleList = []
let particleSize = 0
let timer = -1
let canvas = document.getElementById('particle-canvas')
let ctx = canvas.getContext('2d')
let unDoneCount = 0
worker.onmessage = function (e) {
if (e.data.signal === 'initialized') {
// 倒计时十秒,每秒绘制一次
let count = 10
timer = setInterval(function () {
if (count <= 0) {
clearInterval(timer)
}
worker.postMessage({ signal: 'generate', word: String(count), fontSize: pageHeight * 0.8, })
count--
}, 1000)
}
else if (e.data.signal === 'generated') {
particleList = e.data.particleList
particleSize = e.data.particleSize
unDoneCount = particleList.length
tick()
}
// else if (e.data.signal === 'resized') {
// particleList = e.data.data
// tick()
// }
}
let tick = function () {
ctx.clearRect(0, 0, pageWidth, pageHeight)
let particle
for (let i = 0; i < particleList.length; i++) {
particle = particleList[i]
if (!particle.done) {
particle.size = particle.size + (particleSize - particle.size) * particle.speed
particle.from.x = particle.from.x + (particle.to.x - particle.from.x) * particle.speed
particle.from.y = particle.from.y + (particle.to.y - particle.from.y) * particle.speed
if (Math.abs(particle.to.x - particle.from.x) < 0.5 && Math.abs(particle.to.y - particle.from.y) < 0.5 && particleSize - particle.size < 0.1) {
particle.done = true
particle.from.x = particle.to.x
particle.from.y = particle.to.y
unDoneCount--
}
}
ctx.fillStyle = particle.color
ctx.fillRect(particle.from.x, particle.from.y, particle.size, particle.size)
}
if (unDoneCount > 0) {
requestAnimationFrame(tick)
}
else {
console.log('绘制结束')
}
}
pageWidth = document.documentElement.clientWidth
pageHeight = document.documentElement.clientHeight
canvas.width = pageWidth
canvas.height = pageHeight
worker.postMessage({
signal: 'init',
width: pageWidth,
height: pageHeight,
particleSpacing: 4,
})
const $inputWord = document.getElementById('input-word')
const $btnGenerate = document.getElementById('btn-generate')
$btnGenerate.onclick = function () {
const word = $inputWord.value
clearInterval(timer)
worker.postMessage({ signal: 'generate', word, fontSize: 'auto' })
}
})()
</script>
</body>
</html>
worker线程(worker.js)代码:
let offscreen
let ctx
const colors = [
'#a09d1d',
'#84b826',
'#168a30',
'#155fbf',
'#40148c',
'#5f168b',
'#93148c',
'#970c0d',
'#af2e15',
'#ab4913',
'#a45a12',
'#514e0e',
]
let cvsWidth, cvsHeight
let particleSize
let particleSpacing
let particleList
let wordLeft
let wordTop
let generating
addEventListener('message', function (e) {
if (e.data.signal === 'init') {
cvsWidth = e.data.width
cvsHeight = e.data.height
particleSpacing = e.data.particleSpacing
offscreen = new OffscreenCanvas(cvsWidth, cvsHeight)
ctx = offscreen.getContext('2d')
postMessage({ signal: 'initialized' })
}
else if (e.data.signal === 'generate') {
generate(e.data.word, e.data.fontSize)
}
}, false)
function generate (word, fontSize) {
generating = true
offscreen = new OffscreenCanvas(cvsWidth, cvsHeight)
// 自动计算字体大小
if (fontSize === 'auto') {
fontSize = cvsWidth / word.length
fontSize = Math.min(cvsHeight * 0.8, fontSize)
}
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.fillStyle = '#000'
ctx.font = 'bold ' + fontSize + 'px Tahoma'
const wordWidth = ctx.measureText(word).width
particleSize = Math.max(wordWidth / 140, 5)
wordLeft = (cvsWidth - wordWidth) / 2
wordTop = cvsHeight / 2
ctx.clearRect(0, 0, cvsWidth, cvsHeight)
ctx.fillText(word, (cvsWidth - ctx.measureText(word).width) / 2, cvsHeight / 2) // 轻微调整绘制字符位置
particleList = []
let imageData = ctx.getImageData(0, 0, cvsWidth, cvsHeight).data
let i, j // 采样的座标
const sampleOffset = Math.floor(particleSize + particleSpacing)
for (i = 0; i < cvsWidth; i += sampleOffset) {
for (j = 0; j < cvsHeight; j += sampleOffset) {
// 若采样点alpha通道的值不是0
if (imageData[4 * (j * cvsWidth + i) + 3]) {
particleList.push({
from: { x: cvsWidth * Math.random(), y: cvsHeight * Math.random() }, // 动画随机起始位置
to: { x: i, y: j },
color: colors[Math.floor(Math.random() * colors.length)], // 随机选取颜色
speed: 0.08 + 0.04 * Math.random(),
size: 0, // 初始大小为0
done: false, // 是否完成动画
})
}
}
}
postMessage({ signal: 'generated', particleList, particleSize })
generating = false
}
最终效果如下: