在 React 应用上添加粒子效果

点击粒子效果与背景粒子效果实现分享

最近在维护基于 Vite + Electron + React 的开源项目时,突然觉醒了一个中二之魂 我们的应用怎么能没有酷炫的粒子特效! 经过一番探索,终于实现了几种截然不同的粒子效果。今天就把这些踩坑经验打包成干货大礼包,附带完整代码解析!

网页应用也可以使用的说

添加粒子效果前后的应用界面对比

点击绽放的星团

  1. Canvas的召唤仪式

动画需要一个舞台,Canvas 承担了这一角色,在 React 组件中初始化 Canvas 是我们的第一步。这里有个灵魂拷问:为什么要在 useEffect 里获取canvas元素? 因为我们要确保DOM已经完成渲染!

useEffect(() => {
  const canvas = canvasRef.current
  const ctx = canvas?.getContext('2d')
  // ...后续操作
}, [theme]) // 主题变化时,触发重新渲染
  1. 粒子工厂流水线

每个粒子都是独立的个体,每个个体都有它自己的个性,我们定义一个粒子接口,描述粒子特性:

// 定义粒子接口
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 }
}

HUE色环示意图

  1. 粒子运动的奥秘

更新循环是粒子效果的核心心脏。这个动画的核心公式其实就这几个:

  • 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更高效

永不停歇的萤火虫之舞

  1. 会拖尾的“萤火虫”粒子

这个粒子类明显比点击粒子复杂得多,因为它要记住自己走过的路

这里面采用的思路是将轨迹坐标记录下来,再由灵魂画师绘制出来

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()
  }
}

粒子运动轨迹示意图

  1. 粒子运动的微积分课

粒子的运动参数堪称精密仪器,我们需要做很多调参和限制:

  • 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: 通过一个较大的基础速度,让粒子始终大致朝一个方向运动
  1. 光影魔术手

这里有个性能优化血泪史:原本用 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.

MDN Reference

总之是个什么都会画的高手,在画布上的绘制工作全交给它就可以了。通过这行代码来请出灵魂画师:

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>
    </>
  )
}

欢迎在评论区留下你的调参趣事或深度问题!🎉