想給自己的網站寫一個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
}
最終效果如下: