在 React 应用上添加粒子效果
点击粒子效果与背景粒子效果实现分享
最近在维护基于 Vite + Electron + React 的开源项目时,突然觉醒了一个中二之魂 我们的应用怎么能没有酷炫的粒子特效! 经过一番探索,终于实现了几种截然不同的粒子效果。今天就把这些踩坑经验打包成干货大礼包,附带完整代码解析!
网页应用也可以使用的说
点击绽放的星团
- Canvas的召唤仪式
动画需要一个舞台,Canvas 承担了这一角色,在 React 组件中初始化 Canvas 是我们的第一步。这里有个灵魂拷问:为什么要在 useEffect
里获取canvas元素? 因为我们要确保DOM已经完成渲染!
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
// ...后续操作
}, [theme]) // 主题变化时,触发重新渲染
- 粒子工厂流水线
每个粒子都是独立的个体,每个个体都有它自己的个性,我们定义一个粒子接口,描述粒子特性:
// 定义粒子接口
interface Particle {
x: number
y: number
radius: number
color: string
speedX: number
speedY: number
}
接着,我们通过 createParticle
工厂函数赋予它们生命。每个粒子长啥样完全由我们说了算,这里我们用随机膜法决定粒子的样貌:
// 创建随机粒子,并根据主题分配随机颜色
const createParticle = (x: number, y: number, hexColor: string): Particle => {
const radius = Math.random() * radiusBase + 2
const speedX = (Math.random() - 0.5) * speedBase
const speedY = (Math.random() - 0.5) * speedBase
// 基于 HUE 色环计算随机颜色
const nowColor = (hexStringToHue(hexColor) + Math.random() * colorOffset) % 360
const color = `hsl(${nowColor}, 90%, 50%)`
return { x, y, radius, color, speedX, speedY }
}
- 粒子运动的奥秘
更新循环是粒子效果的核心心脏。这个动画的核心公式其实就这几个:
p.radius *= radiusDecay
粒子尺寸衰减if (p.radius < 0.1) particles.splice(i, 1)
回收掉过小的粒子p.x += p.speedX
通过坐标变化,让粒子动起来
以及 JS 最重要的方法 requestAnimateFrame
const animate = (): void => {
// ...
updateParticles() // 让粒子动起来
drawParticles(ctx!) // 粒子交给灵魂画手
requestAnimationFrame(animate) // 激活动画循环
// ...
}
🚨 避坑警报
- 内存泄漏陷阱:一定要在组件卸载时清空粒子数组!
- 事件监听器黑洞:别忘了在
useEffect
清理函数中移除监听器 - 性能刺客:
ctx.clearRect()
比反复创建Canvas更高效
永不停歇的萤火虫之舞
- 会拖尾的“萤火虫”粒子
这个粒子类明显比点击粒子复杂得多,因为它要记住自己走过的路
这里面采用的思路是将轨迹坐标记录下来,再由灵魂画师绘制出来
class Particle {
trail: { x: number; y: number }[] = []
addTrail(x: number, y: number) {
this.trail.push({ x, y })
if (this.trail.length > MAX_TRAIL_LENGTH) this.trail.shift()
}
}
- 粒子运动的微积分课
粒子的运动参数堪称精密仪器,我们需要做很多调参和限制:
this.vx = Math.min(Math.max(this.vx / BASE_SPEED_X, -SPEED_RANGE), SPEED_RANGE) * BASE_SPEED_X
: 给予粒子自由运动的自由,但自由是有限的,放置粒子发飙乱跑this.ax = (Math.random() - 0.5) * ACCELERATION
: 模拟加速度机制,让粒子运动时更具有“活性”(速度变化更丝滑,防止单纯随机速度带来的突然变化和震动)this.x += this.vx + BASE_SPEED_X
: 通过一个较大的基础速度,让粒子始终大致朝一个方向运动
- 光影魔术手
这里有个性能优化血泪史:原本用 shadowBlur
实现模糊光斑效果,结果帧率直接跳水!改用半透明叠加才是正道:
ctx.globalAlpha = this.alpha
ctx.globalCompositeOperation = 'lighter' // 叠加模式YYDS!
💡 终极秘籍:粒子系统的调参哲学
- 黄金密度公式:最大粒子数不是越大越好,建议根据屏幕分辨率动态计算
- 色彩心理学:灰色主题下改用明度替代色相变化
- 运动守恒定律:加速度参数与基础速度需要保持微妙平衡,不妨调整一下试试效果(笑)
- 拖尾宽度:拖尾宽度为 1 太细?这个最好还是不要调整,否则灵魂画手会忙不过来(FPS显著下降),或者用透明度叠加的方式绘制
最后有请灵魂画师
interface CanvasRenderingContext2D
官方的说明如下:
The CanvasRenderingContext2D interface, part of the Canvas API, provides the 2D rendering context for the drawing surface of a element. It is used for drawing shapes, text, images, and other objects.
总之是个什么都会画的高手,在画布上的绘制工作全交给它就可以了。通过这行代码来请出灵魂画师:
const ctx = canvas?.getContext('2d')
闲话莫说,放码过来
什么?不想看文章,不想自己写一遍?
完整代码放置在 Github src/renderer/src/effects
目录下(项目正在开发,如果没有尝试切换到 graph
分支),欢迎查看并解锁更多内容
什么?不想去 Github 翻代码?
代码清单如下(有删减):
// 点击粒子效果:点击鼠标时,会在点击位置生成自动消失的光斑
import { useEffect, useRef } from 'react'
import C, { ThemeTypeEnum, getMidColor, hexStringToHue } from '@renderer/common/colors' // 颜色跟随主题变化
import timer from '../timer' // FPS 监控
const colorOffset = 50
const particlesOneClick = 10
const speedShake = 0.2
const speedBase = 1.5
const radiusBase = 20
const radiusDecay = 0.98
interface Particle {
x: number
y: number
radius: number
color: string
speedX: number
speedY: number
}
const createParticle = (x: number, y: number, colorType: ThemeTypeEnum): Particle => {
const radius = Math.random() * radiusBase + 2
const speedX = (Math.random() - 0.5) * speedBase
const speedY = (Math.random() - 0.5) * speedBase
const nowColor =
(hexStringToHue(getMidColor(0.5, C(colorType).main, C(colorType).sub)) +
Math.random() * colorOffset) %
360
const color =
colorType !== ThemeTypeEnum.GRAY
? `hsl(${nowColor}, 90%, 50%)`
: `hsl(0, 0%, ${nowColor % 100}%)`
return { x, y, radius, color, speedX, speedY }
}
export default function ClickGranule({
theme = ThemeTypeEnum.SKY
}: {
theme: ThemeTypeEnum
}): JSX.Element {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const particles: Particle[] = []
const updateParticles = (): void => {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i]
p.x += p.speedX
p.y += p.speedY
p.speedX += (Math.random() - 0.5) * speedShake
p.speedY += (Math.random() - 0.5) * speedShake
p.radius *= radiusDecay
if (p.radius < 0.1) {
particles.splice(i, 1)
}
}
}
const drawParticles = (ctx: CanvasRenderingContext2D): void => {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
for (const p of particles) {
ctx.fillStyle = p.color
ctx.beginPath()
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2)
ctx.fill()
}
}
const handleClick = (e: MouseEvent): void => {
const canvas = canvasRef.current
if (canvas) {
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
for (let i = 0; i < particlesOneClick; i++) {
particles.push(createParticle(x, y, theme))
}
}
}
const handleResize = (): void => {
const canvas = canvasRef.current
if (canvas) {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
}
}
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
let unmounted = false
const animate = (): void => {
if (unmounted) {
return
}
timer('ClickGranule')
updateParticles()
drawParticles(ctx!)
requestAnimationFrame(animate)
}
if (ctx) {
canvas!.width = window.innerWidth
canvas!.height = window.innerHeight
animate()
window.addEventListener('mousedown', handleClick)
window.addEventListener('resize', handleResize)
}
return (): void => {
// Clear particles on unmount
particles.length = 0
window.removeEventListener('mousedown', handleClick)
window.removeEventListener('resize', handleResize)
unmounted = true
}
}, [theme])
return (
<div>
<canvas
id="clickGranule"
className="size-full absolute top-0 left-0 pointer-events-none z-50"
ref={canvasRef}
/>
</div>
)
}
// 动画:绘制向右上角随机移动的,具有拖尾效果的粒子
import { getMidColor, hexStringToHue, ThemeTypeEnum } from '@renderer/common/colors'
import { useEffect, useRef } from 'react'
import C from '@renderer/common/colors'
import timer from '../timer'
const BASE_SPEED_X = 0.5
const BASE_SPEED_Y = -0.28125
const SPEED_RANGE = 0.5
const ACCELERATION = 0.1
const PARTICLE_RATE = 0.0002
const MAX_TRAIL_LENGTH = 100
const STROKE_WIDTH = 1
const MAX_ALPHA = 0.25
const MIN_ALPHA = 0.1
const ALPHA_CHANGE_RATE = 0.01
const MAX_END_POINT_RADIUS_RATE = 0.025
const MIN_END_POINT_RADIUS_RATE = 0.005
// const BLUR_RADIUS = 10
const MAX_PARTICLE_CNT = 200
const COLOR_OFFSET = 80
class Particle {
x: number
y: number
vx: number
vy: number
ax: number
ay: number
alpha: number
endPointRadius: number
color: string
trail: { x: number; y: number }[]
constructor(x: number, y: number, vx: number, vy: number, color: string, endPointRadius: number) {
this.x = x
this.y = y
this.vx = vx
this.vy = vy
this.ax = 0
this.ay = 0
this.endPointRadius = endPointRadius
this.color = color
this.trail = []
this.alpha = MAX_ALPHA
}
addTrail(x: number, y: number): void {
this.trail.push({ x, y })
if (this.trail.length > MAX_TRAIL_LENGTH) {
this.trail.shift()
}
}
draw(ctx: CanvasRenderingContext2D, color: string): void {
// 主体光斑
ctx.globalAlpha = this.alpha
ctx.beginPath()
ctx.moveTo(this.x, this.y)
ctx.arc(this.x, this.y, this.endPointRadius, 0, Math.PI * 2)
ctx.fillStyle = color
ctx.closePath()
ctx.fill()
// 拖尾
if (this.trail.length === 0) {
return
}
ctx.beginPath()
for (let i = this.trail.length - 1; i >= 0; i--) {
const p = this.trail[i]
ctx.lineTo(p.x, p.y)
}
ctx.strokeStyle = color
ctx.lineWidth = STROKE_WIDTH
ctx.setLineDash([STROKE_WIDTH, STROKE_WIDTH * 4])
ctx.stroke()
}
update(canvas: HTMLCanvasElement): void {
this.x += this.vx + BASE_SPEED_X
this.y += this.vy + BASE_SPEED_Y
this.vx += this.ax
this.vy += this.ay
this.ax = (Math.random() - 0.5) * ACCELERATION
this.ay = (Math.random() - 0.5) * ACCELERATION
this.vx = Math.min(Math.max(this.vx / BASE_SPEED_X, -SPEED_RANGE), SPEED_RANGE) * BASE_SPEED_X
this.vy = Math.min(Math.max(this.vy / BASE_SPEED_Y, -SPEED_RANGE), SPEED_RANGE) * BASE_SPEED_Y
const overflowX = this.x < -this.endPointRadius || this.x > canvas.width + this.endPointRadius
const overflowY = this.y < -this.endPointRadius || this.y > canvas.height + this.endPointRadius
if (MAX_TRAIL_LENGTH > 0) {
if (overflowX || overflowY) {
this.trail.shift()
} else {
this.addTrail(this.x, this.y)
}
}
if (this.trail.length === 0) {
this.x = overflowX ? -this.endPointRadius : this.x
this.y = overflowY ? canvas.height + this.endPointRadius : this.y
}
this.alpha = Math.max(
Math.min(this.alpha + (Math.random() - 0.5) * ALPHA_CHANGE_RATE, MAX_ALPHA),
MIN_ALPHA
)
}
static createRandom(
canvas: HTMLCanvasElement,
theme: ThemeTypeEnum,
radiusBase: number
): Particle {
const x = Math.random() * canvas.width
const y = Math.random() * canvas.height
const vx = Math.random() * BASE_SPEED_X
const vy = Math.random() * BASE_SPEED_Y
const endPointRadius =
(Math.random() * (MAX_END_POINT_RADIUS_RATE - MIN_END_POINT_RADIUS_RATE) +
MIN_END_POINT_RADIUS_RATE) *
radiusBase
const nowColor =
(hexStringToHue(getMidColor(0.5, C(theme).main, C(theme).sub)) +
Math.random() * COLOR_OFFSET) %
360
const color =
theme !== ThemeTypeEnum.GRAY ? `hsl(${nowColor}, 80%, 40%)` : `hsl(0, 0%, ${nowColor % 100}%)`
return new Particle(x, y, vx, vy, color, endPointRadius)
}
}
export default function Firefiles({
theme = ThemeTypeEnum.SKY
}: {
theme: ThemeTypeEnum
}): JSX.Element {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
const particles: Particle[] = []
let unmounted = false
const refreshAll = (): void => {
particles.length = 0
const particleCnt = Math.min(canvas!.width * canvas!.height * PARTICLE_RATE, MAX_PARTICLE_CNT)
for (let i = 0; i < particleCnt; i++) {
addParticle()
}
}
const resize = (): void => {
canvas!.width = window.innerWidth
canvas!.height = window.innerHeight
refreshAll()
}
const draw = (): void => {
ctx!.clearRect(0, 0, canvas!.width, canvas!.height)
ctx!.globalCompositeOperation = 'lighter'
particles.forEach((p) => {
p.draw(ctx!, p.color)
})
}
const update = (): void => {
particles.forEach((p) => {
p.update(canvas!)
})
}
const loop = (): void => {
if (unmounted) {
return
}
timer('Fireflies')
update()
draw()
requestAnimationFrame(loop)
}
const addParticle = (): void => {
const radiusBase = Math.sqrt(canvas!.width * canvas!.width + canvas!.height * canvas!.height)
particles.unshift(Particle.createRandom(canvas!, theme, radiusBase))
}
window.addEventListener('resize', resize)
resize()
loop()
// 组件销毁时,清理事件监听、退出循环
return (): void => {
window.removeEventListener('resize', resize)
unmounted = true
}
}, [theme])
return (
<>
<canvas
ref={canvasRef}
className="size-full absolute top-0 left-0 pointer-events-none z-40"
></canvas>
</>
)
}
欢迎在评论区留下你的调参趣事或深度问题!🎉