custom-svga
v1.0.11
Published
A lightweight, tree-shakable SVGA animation player for Vue 3 & modern web. Supports dynamic imports, OffscreenCanvas + Web Worker, and in-flight sprite/text replacement. ~30KB gzipped, zero deps on UI frameworks.(面向 Vue3 与现代浏览器的轻量 SVGA 动画播放器。支持动态导入懒加载、Off
Maintainers
Readme
🎬 Custom SVGA
A lightweight, tree-shakable SVGA animation player for Vue 3 & modern web.
支持按需懒加载 · OffscreenCanvas 离屏渲染 · 动态精灵/文本替换 · TypeScript 全量类型
✨ Features
| 特性 | 说明 |
| --- | --- |
| 📦 极致轻量 | gzip 后核心 ~30KB,无任何 UI 框架依赖 |
| 🌳 Tree-shakable | 完整 ESM 支持,按需引入方法,未使用代码自动剔除 |
| 🚀 按需懒加载 | Parser / ShapeRenderer / Loader 全部支持动态 import(),首屏最小化 |
| ⚡ OffscreenCanvas 渲染 | 主流渲染管线 0 主线程占用,60fps 不卡顿 |
| 👷 共享 Web Worker | 多 Player 实例复用同一 Worker,解析结果缓存共享 |
| 🧠 LRU 智能缓存 | 自动管理解析后的 MovieData,避免重复解析 |
| 🎨 动态形状渲染 | 内置 Path2D 矢量绘制,支持 rect / ellipse / SVG path |
| 🖼️ 运行时图片替换 | 不重编 SVGA 即可替换图层(URL / Base64 / ImageBitmap) |
| 📝 动态文本注入 | 在动画播放中实时替换文本图层 |
| 🎯 TypeScript 一等公民 | 完整 .d.ts 类型定义,IDE 智能提示开箱即用 |
| 🌐 多产物输出 | ESM / CJS / IIFE 全场景覆盖(npm、script 标签、CDN) |
📥 Installation
# npm
npm install custom-svga
# yarn
yarn add custom-svga
# pnpm
pnpm add custom-svga浏览器 CDN(无构建工具)
<!-- unpkg -->
<script src="https://unpkg.com/custom-svga@latest/dist/index.global.js"></script>
<!-- jsDelivr -->
<script src="https://cdn.jsdelivr.net/npm/custom-svga@latest/dist/index.global.js"></script>
<script>
const { Player, Parser } = CustomSvga;
</script>🚀 Quick Start
Vue 3 组件用法
<template>
<div ref="container" class="svga-stage"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, shallowRef } from 'vue';
import { Player, Parser } from 'custom-svga';
const container = ref<HTMLDivElement>();
const player = shallowRef<Player>();
onMounted(async () => {
player.value = new Player(container.value!, {
useOffscreenCanvas: true,
});
player.value.onStart(() => console.log('播放开始'));
player.value.onProcess((frame) => console.log('当前帧:', frame));
player.value.onLoopEnd(() => console.log('循环结束'));
const videoItem = await Parser.loadUrl('https://example.com/anim.svga');
await player.value.setVideoItem(videoItem);
player.value.play();
});
onBeforeUnmount(() => {
player.value?.destroy();
});
</script>
<style scoped>
.svga-stage {
width: 300px;
height: 300px;
}
</style>原生 JavaScript 用法
import { Player, Parser } from 'custom-svga';
const player = new Player(document.getElementById('stage'));
const item = await Parser.loadUrl('/assets/anim.svga');
await player.setVideoItem(item);
player.play();直接传入 ArrayBuffer
import { Player, Parser } from 'custom-svga';
const buffer = await fetch('/anim.svga').then((r) => r.arrayBuffer());
const item = await Parser.parseBuffer(buffer);
const player = new Player(document.getElementById('stage'));
await player.setVideoItem(item);
player.play();📚 API 文档
new Player(element, config?)
创建播放器实例。根据 config.useOffscreenCanvas 自动选择主线程渲染器或 Worker 离屏渲染器。
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| element | HTMLCanvasElement \| HTMLDivElement | ✅ | 绘图容器节点,传入 div 时会自动创建 canvas |
| config | PlayConfig | ❌ | 初始化配置 |
PlayConfig 配置项
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| loop | number | 0 | 循环次数(0 = 无限循环) |
| fps | number | - | 覆盖 SVGA 自身帧率 |
| fillMode | 'forwards' \| 'backwards' \| 'clear' | 'forwards' | 播放结束时的行为 |
| startFrame | number | - | 起始播放帧 |
| endFrame | number | - | 结束播放帧 |
| direction | 'forward' \| 'backward' | 'forward' | 播放方向 |
| useOffscreenCanvas | boolean | false | 启用 OffscreenCanvas 离屏渲染 |
| useIntersectionObserver | boolean | true | 启用 IntersectionObserver 可见性优化 |
| invisibleBehavior | 'pause' \| 'skip' | 'pause' | 不可见时的行为 |
| useSharedRAF | boolean | true | 多实例共享 requestAnimationFrame |
| cacheFrames | boolean | false | 缓存已绘制帧为位图 |
| defaultPoster | string | - | 默认外链静态海报图 |
Player 实例方法
| 方法 | 说明 |
| --- | --- |
| setVideoItem(item) | 设置视频数据,返回 Promise |
| play(config?) | 触发播放 |
| playRange(start, end, config?) | 播放指定帧范围 |
| pause() | 暂停 |
| stop() | 停止并回到第一帧 |
| stepToFrame(frame, andPlay?) | 跳转到指定帧 |
| stepToPercentage(p, andPlay?) | 跳转到指定进度(0~1) |
| setImage(image, key) | 动态替换图片图层 |
| setText(text, key) | 动态替换文本图层 |
| clearDynamicObject(key) | 清除单个动态对象 |
| clearDynamicObjects() | 清除所有动态对象 |
| renderPoster(imageUrl) | 渲染外链海报图 |
| getMemoryInfo() | 获取内存占用信息 |
| clear() | 清空画布和内存引用 |
| destroy() | 彻底销毁实例 |
Player 事件回调
| 事件 | 签名 | 触发时机 |
| --- | --- | --- |
| onStart(cb) | () => void | 播放开始 |
| onPause(cb) | () => void | 播放暂停 |
| onStop(cb) | () => void | 播放停止 |
| onLoopEnd(cb) | () => void | 单次循环结束 |
| onProcess(cb) | (frame: number) => void | 每渲染一帧触发 |
Parser 静态方法
| 方法 | 说明 | 适用场景 |
| --- | --- | --- |
| Parser.loadUrl(url, options?) | 从网络 URL 加载并解析 | 传统主线程渲染 |
| Parser.loadFile(file, options?) | 从本地 File / Blob 加载 | 用户上传文件 |
| Parser.parseBuffer(buffer, options?) | 直接解析 ArrayBuffer | 已下载好的二进制 |
| Parser.load(url, options?) | 专为 OffscreenCanvas 设计,返回轻量元数据 | 离屏渲染专用 |
| Parser.getCachedVideoItem(url) | 同步获取已缓存的解析结果 | 命中缓存时 |
| Parser.clearCache() | 清空主线程缓存 | 内存压力时 |
| Parser.clearOffscreenCache() | 清空 Worker 缓存 | 内存压力时 |
| Parser.destroy(url?) | 销毁指定/全部缓存 | 主动释放 |
Parser.loadUrl / Parser.load 选项
{
useImageBitmap?: boolean; // 默认 false,true 时 GPU 加速
cache?: boolean; // 默认 true
movieDataId?: string; // 自定义缓存 key(默认用 URL)
useWorker?: boolean; // 默认 true,是否使用 Web Worker 解析
}🎯 高级用法
1. 动态替换图片图层
// 替换为在线图片
await player.setImage('https://example.com/new-avatar.png', 'avatar');
// 替换为 ImageBitmap(最佳性能)
const bitmap = await createImageBitmap(blob);
await player.setImage(bitmap, 'avatar');
// 替换为 Canvas 内容
await player.setImage(myOffscreenCanvas, 'avatar');2. 动态注入文本
// 简单字符串
player.setText('Hello, CustomSvga!', 'greeting');
// 完整样式配置
player.setText({
text: 'VIP 用户',
size: 28,
color: '#FFD700',
bold: true,
family: 'Arial',
offset: { x: 0, y: -10 },
}, 'username');3. 帧范围播放与反向播放
// 只播放第 10~50 帧
player.playRange(10, 50, { loop: 3 });
// 跳转到 50% 进度后自动播放
player.stepToPercentage(0.5, true);
// 反向播放
player.play({ direction: 'backward' });
// 开启帧缓存(适合复杂矢量动画)
player.play({ cacheFrames: true });4. 可见性优化
const player = new Player(container, {
useIntersectionObserver: true, // 默认开启
invisibleBehavior: 'pause', // 移出视口时暂停
// invisibleBehavior: 'skip', // 移出视口时跳过绘制但保持帧同步
});5. 内存管理
// 查看内存占用
const info = player.getMemoryInfo();
console.log(info);
// {
// fileSize: 102400,
// imageMemory: 5242880,
// dictionaryMemory: 204800,
// version: '2.0',
// fps: 30,
// frames: 60,
// width: 750,
// height: 1334,
// dynamicKeys: ['avatar'],
// desc: '源文件大小: 100.00 KB | 预估显存: 5.20 MB'
// }
// 释放指定 URL 缓存
Parser.destroy('https://example.com/anim.svga');
// 清空所有缓存
Parser.destroy();👷 Web Worker 使用场景与示例
场景一:离线离屏渲染(核心推荐)
将解析、解码、绘制全部移至 Worker 线程,主线程零压力。适用于对动画流畅度要求高的场景(如直播间礼物动画)。
import { Player, Parser } from 'custom-svga';
const player = new Player(canvasElement, {
useOffscreenCanvas: true, // 开启离屏渲染
});
// 必须使用 Parser.load 而非 Parser.loadUrl
const metadata = await Parser.load('https://example.com/anim.svga');
// 返回轻量元数据:{ movieDataId, isOffscreen: true, params: { viewBoxWidth, viewBoxHeight, fps, frames } }
// 仅传入 movieDataId,Worker 内部从缓存获取完整数据
await player.setVideoItem(metadata);
player.play();数据流示意:
主线程 Web Worker
───────── ──────────
Parser.load(url) ──fetch──▶ 解码 Protobuf
│ 解码 ImageBitmap
│ 存入 sharedMovieCache
◀──返回轻量 metadata ────
│
Player.setVideoItem(meta) ──▶ 从 sharedMovieCache 读取
│ 离屏 Canvas 渲染
│ requestAnimationFrame 循环
◀──事件通知 (onProcess等) ─限制:开启
useOffscreenCanvas后,setVideoItem只能传入Parser.load()返回的轻量元数据或缓存 key 字符串,禁止传入Parser.loadUrl()返回的完整 MovieData。
场景二:多 Player 实例共享同一 Worker
多个播放器实例复用同一个共享 Web Worker,Worker 内部维护全局 LRU 缓存,相同 URL 的动画数据只解析一次。
import { Player, Parser } from 'custom-svga';
// 三个播放器共享同一个 Worker 和缓存
const player1 = new Player(container1, { useOffscreenCanvas: true });
const player2 = new Player(container2, { useOffscreenCanvas: true });
const player3 = new Player(container3, { useOffscreenCanvas: true });
// 同一 URL 只会在 Worker 内解析一次
const meta1 = await Parser.load('https://example.com/gift.svga');
const meta2 = await Parser.load('https://example.com/gift.svga');
const meta3 = await Parser.load('https://example.com/gift.svga');
await Promise.all([
player1.setVideoItem(meta1),
player2.setVideoItem(meta2),
player3.setVideoItem(meta3),
]);
player1.play();
player2.play({ loop: 2 });
player3.play({ loop: 1 });场景三:Worker 解析 + 主线程渲染
利用 Worker 进行高性能解析,解析完成后图片在主线程渲染。适用于需要精细控制 Canvas 绘制或需要兼容不支持 OffscreenCanvas 浏览器的场景。
import { Player, Parser } from 'custom-svga';
// 不开启 useOffscreenCanvas,走主线程渲染
const player = new Player(document.getElementById('stage'), {
useOffscreenCanvas: false, // 默认值
});
// 使用 useWorker: true(默认)在 Worker 中解析
const videoItem = await Parser.loadUrl('https://example.com/anim.svga', {
useWorker: true, // Worker 解析,主线程渲染
useImageBitmap: false, // 主线程使用 HTMLImageElement
cache: true,
});
await player.setVideoItem(videoItem);
player.play();场景四:禁用 Worker 回退到主线程解析
在不支持 Worker 的环境(如部分小程序 WebView)或调试时,可以强制降级。
import { Parser } from 'custom-svga';
// 全局关闭 Worker,所有解析在主线程进行
const videoItem = await Parser.loadUrl('https://example.com/anim.svga', {
useWorker: false,
});场景五:Worker 中的内存回收
Worker 内部维护 sharedMovieCache + 引用计数机制,当所有渲染器实例释放后自动回收。
// 手动释放 Worker 中缓存的指定动画数据
Parser.destroyOffscreenCache('https://example.com/anim.svga');
// 或使用通用 destroy 方法(同时清理主线程 + Worker 缓存)
Parser.destroy('https://example.com/anim.svga');
// 精细控制仅清理 Worker 离屏缓存
Parser.destroy({
url: 'https://example.com/anim.svga',
mainThread: false,
offscreen: true,
});
// 清空 Worker 全部离屏缓存
Parser.clearOffscreenCache();Worker 生命周期概览
┌─────────────────────────────────────────────────────────────┐
│ 主线程 (Main Thread) │
│ │
│ Parser.loadUrl(url) ───────┐ │
│ Parser.load(url) ───────┤ │
│ │ postMessage │
│ Player.setVideoItem() ────┼──▶ WORKER_ACTION.SET_MOVIE │
│ Player.play() ────┼──▶ WORKER_ACTION.PLAY │
│ Player.pause() ────┼──▶ WORKER_ACTION.PAUSE │
│ Player.destroy() ────┼──▶ WORKER_ACTION.DESTROY │
│ │ │
│ ◀── WORKER_RESPONSE_TYPE │ postMessage │
│ .SUCCESS (解析结果) │ │
│ .EVENT (播放事件) │ │
└──────────────────────────────┼──────────────────────────────┘
│
┌──────────────────────────────┼──────────────────────────────┐
│ Web Worker (共享子线程) │
│ │
│ sharedMovieCache (Map<movieDataId, MovieData + refCount>) │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ WorkerParser │ │ WorkerRenderer[] │ │
│ │ .parse() │ │ .setMovie() │ │
│ │ .parseOffscreen │ │ .play() │ │
│ └──────────────────┘ │ .drawFrame() │ │
│ │ (OffscreenCanvas)│ │
│ └──────────────────┘ │
│ │
│ tickerManager (共享 RAF 调度器) │
└─────────────────────────────────────────────────────────────┘Worker 模式自动降级机制
Custom SVGA 内置三层降级策略,无需开发者干预:
| 层级 | 条件 | 行为 |
| --- | --- | --- |
| 🥇 Blob Worker | 浏览器支持 Worker + Blob | 将 worker 代码通过 Blob URL 实例化,零网络请求 |
| 🥈 Module Worker | 开发环境 / Blob 失败 | 通过 new URL('./svga.worker.ts', import.meta.url) 加载 |
| 🥉 Mock Worker | Worker API 不可用 | 同构 setTimeout 宏任务模拟,功能完全等价 |
📊 性能对比
| 指标 | 官方 svga-web | Custom SVGA | | --- | --- | --- | | 核心包体积(gzip) | ~85KB | ~30KB ⬇️ 65% | | Tree-shaking 支持 | ❌ | ✅ | | 按需懒加载 | ❌ | ✅ | | OffscreenCanvas | 需手动配置 | ✅ 一键启用 | | Worker 缓存共享 | ❌ | ✅ | | 动态图片/文本替换 | 需重编 SVGA | ✅ 运行时热替换 | | Vue 3 集成 | 需包装 | ✅ 原生支持 | | TypeScript 类型 | 部分 | ✅ 100% 覆盖 |
🌐 浏览器兼容性
| 浏览器 | 最低版本 | OffscreenCanvas 支持 | | --- | --- | --- | | Chrome / Edge | 88+ | ✅ | | Firefox | 105+ | ✅ | | Safari | 16.4+ | ✅ | | 移动端 Chrome (Android) | 88+ | ✅ | | 移动端 Safari (iOS) | 16.4+ | ✅ |
在不支持 OffscreenCanvas 的浏览器中,播放器自动回退到主线程 Canvas 渲染,业务代码无需任何改动。
🔧 常见问题
开启 useOffscreenCanvas: true 时,setVideoItem 只能传 Parser.load() 返回的轻量元数据,不能传 Parser.loadUrl() 返回的完整 MovieData。
// ✅ 正确
const meta = await Parser.load(url);
await player.setVideoItem(meta);
// ❌ 错误
const item = await Parser.loadUrl(url);
await player.setVideoItem(item); // 抛错!- 关闭
useImageBitmap(iOS 兼容性差) - 检查是否启用了
useOffscreenCanvas(iOS 16.4+ 才支持) - 减少同屏 SVGA 实例数量
await player.setVideoItem(item);
player.stepToFrame(0, false); // 跳到第 0 帧,不自动播放🤝 贡献
欢迎 PR 和 Issue!请确保:
- 代码风格一致(TypeScript strict)
- 提交前
npm run build:tsup能成功 - 重大变更请先开 Issue 讨论
📄 License
MIT © 2026 DreamLife(Zhou)
🙏 致谢
- SVGA - 跨平台动画格式
- fflate - 高性能 zip 解压
- protobufjs - protobuf 解码
