写100个前端效率工具(2):帧动画绘制 frame-animator

387 阅读5分钟

像平时开发中可能会遇到需要绘制动画的需求:loading加载过渡、开屏动画、抽奖动画等等

frame-animator 是一个高性能的帧动画工具库,使用双屏canvas进行预绘制,支持播放、暂停、停止、改变帧率等,不妨一试。

📦 安装指南

选择你喜欢的包管理器安装:

# npm
npm install frame-animator@latest

# yarn
yarn add frame-animator@latest

# pnpm
pnpm add frame-animator@latest

✨ 核心优势:

  • 离屏(双屏)画布绘制
  • 简化动画帧迭代循环
  • 事件机制,如动画开始、结束、每帧事件
  • 支持多种绘制适配模式(cover、contain、width、height)、支持多种定位模式(cc、tl、tr、tc、bl、br、bc、cl、cr)
  • 提供简单易用的 API

1、离屏画布绘制

CanvasRenderingContext2D 是一个状态机的处理,几乎所有的绘制操作都会造成它的状态变化,

CanvasRenderingContext2D.drawImage(
  image,
  sX,
  sY,
  sWidth,
  sHeight,
  dX,
  dY,
  dWidth,
  dHeight
)

众所周知,drawImage 用于实现图片绘制到画布上,当需要图片与画布的尺寸绘制裁切时,频繁地变更 context 是非常恶心的,性能开销大

而通过离屏画布实现单独绘制操作,可以避免在显示画布上频繁绘制操作导致的回流与重绘,直接去中间态,一步到位。

this.offscreenCtx.drawImage(
  image,
  sX,
  0,
  frameWidth,
  frameHeight,
  this.frameOffsetX,
  this.frameOffsetY,
  this.frameScaledWidth,
  this.frameScaledHeight
)

// 将离屏画布绘制到屏幕画布
this.onscreenCtx.drawImage(this.offscreenCanvas, 0, 0)

2、简化动画帧迭代循环

通过ES6 生成器,优雅管理复杂的帧状态变化,无需维护迭代变量。

/**
 * 帧序列生成器
 */
private *_frameGenerator(
  start: number,
  total: number,
  loopIndexRange?: [number, number],
): Generator<number> {
  let frame = start

  const [loopIndexStart, loopIndexEnd] = loopIndexRange ?? [-1, -1]
  const isLoopSupported =
    loopIndexStart > -1 && loopIndexEnd > -1 && loopIndexStart < loopIndexEnd

  while (this.isPlaying) {
    if (isLoopSupported && frame > loopIndexEnd) {
      frame = loopIndexStart
    }

    if (frame >= total) break

    yield frame++
  }
}

3、状态管理

引入 事件机制,可以在动画的不同阶段注册回调函数,并在相应事件触发时调用。另外明确动画的播放、暂停、停止状态,通过状态管理来控制动画的流动,结合 事件机制 可以实现几乎 100% 的控制。

// 动画事件类型
export enum FrameAnimationEvent {
  START = 'start',
  END = 'end',
  FPS = 'fps',
}

🛠️ 配置介绍

参数介绍

// 帧动画初始化配置
export interface FrameAnimationOptions {
  container: string | HTMLCanvasElement
  width: number
  height: number
  spriteConfig: SpriteConfig
  autoPlay?: boolean // 是否自动播放
}

export interface SpriteConfig {
  // 精灵图(从左到右)的 URL 或 Image 对象
  sprites: { url: string | HTMLImageElement; frames: number }[]

  // 绘制模式配置
  fit?: FitMode
  position?: PositionMode

  // 帧尺寸配置
  frameWidth: number
  frameHeight: number
  framePadding?: number // 雪碧图间距

  // 动画控制参数
  total: number // 总帧数
  loopIndexRange?: [number, number] // 循环区间
  frameRate?: number // 帧率,默认30
}

sprites

url 雪碧图(也叫精灵图)组或者图片组,用于后续逐帧绘制;frames 则是对应的帧总数或者叫做组合图的总数。比如说:

// 第一种,使用雪碧图(从左到右)
const sprites = [
  {
    url: 'https://xxx.com/30帧组合的雪碧图.png',
    frames: 30 // 对应上边 url 30帧组合
  }
]

// 第二种,使用连续图片组
const sprites = Array.from({ length: 30 }).map((_, index) => {
  return {
    url: `https://xxx.com/第${index + 1}帧的图.png`,
    frames: 1
  }
})

fit 和 position

提供 fitposition 实现各种模式的绘制绘制,默认是 fit: 'cover'position: 'cc',也就是居中将图片的比例缩放到充满容器。

export type FitMode = 'cover' | 'contain' | 'width' | 'height'
export type PositionMode = 'cc' | 'tl' | 'tr' | 'tc' | 'bl' | 'br' | 'bc' | 'cl' | 'cr'

frameWidth、frameHeight 和 framePadding

frameWidthframeHeight 用来表示每帧绘制的宽高大小;而 framePadding 是雪碧图每帧的间隔,确保可以准确取出每一帧进行绘制。

total

total,也就是 sprites 中的所有 frames 的总数。为什么不直接逻辑计算得到呢?因为有时候并不需要完全绘制,接下去看就知道啦。

loopIndexRange

loopIndexRange 表示不断循环绘制的帧区间,这也就是为什么 total 是传值而不是自动计算的原因。

举个例子: 需求:比如有两组雪碧图,一组有90帧是用于常态不断循环播放,一组有10帧是用于当用户点击后单次播放。 解法:那么就可以这样子设置 loopIndexRange: [0, 89]total: 100,使用后边提到的 playWithIndex 就可以控制实现上边需求啦。

方法介绍

play、pause、stop、destroy

play 播放动画 pause 暂停动画 stop 停止动画,但是没有清除缓存数据,后续可以使用 play 恢复播放 destroy 完全停止动画,会清除缓存数据,后续无法使用 play 恢复播放 destroy 完全停止动画,会清除缓存数据,后续无法

playWithIndex

playWithIndex 就比较好玩。startIndex 设置从指定帧开始播放,newLoopIndexRange 设置接下来的循环播放区间。

/**
 * 从指定帧开始播放
 */
public async playWithIndex(startIndex: number, newLoopIndexRange?: [number, number]) {
  if (this.requestId) {
    cancelAnimationFrame(this.requestId)
    this.requestId = null
  }

  this.isPlaying = true // 设置动画播放状态

  this._animate(startIndex, newLoopIndexRange) // 启动动画并指定起始帧和循环范围
}

resize

有时候设备的尺寸在使用过程中发生变化,比如缩放、旋转,那么就可以使用 resize,将更新后的宽高传递。

addEventListener 和 removeEventListener

事件监听器,用于在动画的不同阶段注册回调函数,并在相应事件触发时调用

/**
  * 添加事件监听器
  */
public addEventListener(eventName: FrameAnimationEvent, callback: (param1?: any) => void) {
  if (!this.eventListeners.has(eventName)) {
    this.eventListeners.set(eventName, new Set())
  }
  const listeners = this.eventListeners.get(eventName)!
  if (!listeners.has(callback)) {
    listeners.add(callback)
  }
}

/**
  * 移除事件监听器
  */
public removeEventListener(eventName: FrameAnimationEvent, callback: (param1?: any) => void) {
  const listeners = this.eventListeners.get(eventName)
  if (listeners && listeners.has(callback)) {
    listeners.delete(callback)
    if (listeners.size === 0) {
      this.eventListeners.delete(eventName)
    }
  }
}

💡 来个实际例子

例子图片,gif太大了!!!

image.png

<!-- AnimationFrame.vue -->
<template>
  <canvas
    ref="AnimationFrameCanvasRef"
    id="frame-animator-canvas"
    relative
    w-full
    h-full
  />
</template>

<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRootStore } from '@/store'
import {
  FrameAnimation,
  FrameAnimationOptions,
  FrameAnimationEvent
} from 'frame-animator'

const emit = defineEmits(['start', 'end'])

const useDraw = (
  options: FrameAnimationOptions,
  callback?: Partial<Record<FrameAnimationEvent, () => void>>
) => {
  const frameAnimation = ref<InstanceType<typeof FrameAnimation>>(null!)

  onUnmounted(() => {
    if (callback) {
      if (callback?.start) {
        frameAnimation.value.removeEventListener(
          FrameAnimationEvent.START,
          callback?.start
        )
      }
      if (callback?.end) {
        frameAnimation.value.removeEventListener(
          FrameAnimationEvent.END,
          callback?.end
        )
      }
    }
  })

  return {
    frameAnimation,
    init: async (width, height) => {
      options.width = width
      options.height = height
      frameAnimation.value = new FrameAnimation(options)
      if (callback) {
        if (callback?.start) {
          frameAnimation.value.addEventListener(
            FrameAnimationEvent.START,
            callback?.start
          )
        }
        if (callback?.end) {
          frameAnimation.value.addEventListener(
            FrameAnimationEvent.END,
            callback?.end
          )
        }
      }
    },
    play: () => {
      frameAnimation.value?.play?.()
    },
    playWithIndex: (startIndex, newLoopIndexRange?: [number, number]) => {
      frameAnimation.value?.playWithIndex?.(startIndex, newLoopIndexRange)
    },
    pause: () => {
      frameAnimation.value?.pause?.()
    },
    stop: () => {
      frameAnimation.value?.stop?.()
    },
    stopLoop: () => {
      frameAnimation.value?.stopLoop?.()
    },
    resize: (width, height) => {
      frameAnimation.value?.resize?.(width, height)
    }
  }
}

const parentWidth = ref(0)
const parentHeight = ref(0)

const AnimationFrameCanvasRef = ref<HTMLCanvasElement>()

const {
  frameAnimation,
  init,
  play,
  playWithIndex,
  pause,
  stop,
  stopLoop,
  resize
} = useDraw(
  {
    container: '#frame-animator-canvas',
    spriteConfig: props.spriteConfig,
    width: 375,
    height: 750,
    autoPlay: true
  },
  {
    start: () => {
      emit('start')
    },
    end: () => {
      emit('end')
    }
  }
)


onMounted(() => {
  const { clientWidth, clientHeight } = AnimationFrameCanvasRef.value || {}
  init(clientWidth, clientHeight)
})

onUnmounted(() => {
  stop()
})
</script>