frame-animator
v1.0.0
Published
一个高性能的 H5 序列帧动画渲染库
Readme
像平时开发中可能会遇到需要绘制动画的需求:loading加载过渡、开屏动画、抽奖动画等等
[frame-animator](https://github.com/shenzhongchaoii/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
提供 fit、position 实现各种模式的绘制绘制,默认是 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
frameWidth 和 frameHeight 用来表示每帧绘制的宽高大小;而 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)
}
}
}💡 来个实际例子

<!-- 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>