1. 组件介绍
PAGAnimation 是一个基于 libpag 实现的 Vue 3 动画组件,用于在网页中播放高性能的 PAG (Portable Animated Graphics) 格式动画。组件封装了 PAG 初始化、加载和播放逻辑,提供了简单易用的 API,能够轻松集成高质量的动画效果到 Vue 项目中。
2. 核心功能
2.1 基础动画播放
- 支持单个 PAG 文件的加载和播放
- 自动管理 PAG 实例和资源生命周期
- 支持 Canvas 尺寸自定义
2.2 高级动画特性
- 组合动画:支持多个 PAG 文件叠加组合播放
- 动态替换图片:可在运行时替换 PAG 动画中的指定图片资源
- 延迟播放:支持设置动画延迟开始时间
- 响应式控制 :通过
visible属性控制动画的显示与隐藏
2.3 性能优化
- 单例模式:全局共享 PAG 实例,减少资源占用
- 缓存机制:对加载的 PAG 文件进行缓存,避免重复加载
- 资源自动销毁:在组件卸载或隐藏时自动销毁资源,防止内存泄漏
3. 技术实现原理
3.1 核心工具函数 (utils/pag.ts)
PAGInit
- 初始化 libpag 库,加载 WebAssembly 二进制文件
- 采用单例模式,确保全局只有一个 PAG 实例
- 支持 file:// 协议,方便开发调试
loadPagFile
- 加载 PAG 文件并缓存
- 自动检查缓存文件状态,销毁的文件会重新加载
- 内部调用
loadFile获取文件对象
loadFile
- 加载 PAG 文件资源
- 使用 XMLHttpRequest 兼容 file:// 协议
- 缓存 File 对象,提高性能
3.2 组件工作流程
- 初始化阶段 :组件挂载时,调用
PAGInit初始化 PAG 实例 - 加载阶段 :根据
pagName或composition属性加载对应的 PAG 文件 - 渲染阶段:将 PAG 文件渲染到 Canvas 元素上
- 播放阶段:调用 PAGView 的 play() 方法开始播放动画
- 更新阶段:监听 props 变化,重新初始化或更新动画
- 销毁阶段:组件卸载或隐藏时,销毁 PAGView 实例,释放资源
4. 使用方法
4.1 安装依赖
bash
npm install libpag
4.2 导入组件
vue
<template>
<PagAnimation pagName="example.pag" :width="300" :height="300" />
</template>
<script setup lang="ts">
import PagAnimation from '@/components/PagAnimation.vue';
</script>
4.3 基础用法
单个 PAG 文件播放
vue
<PagAnimation pagName="example.pag" :width="300" :height="300" />
带延迟的 PAG 文件播放
vue
<PagAnimation pagName="example.pag" :delay="1000" />
动态替换图片
vue
<PagAnimation
pagName="example.pag"
:replaceIndex="0"
replaceImage="https://example.com/image.jpg"
/>
组合动画
vue
<PagAnimation
:composition="{
pagNames: ['background.pag', 'foreground.pag'],
width: 1080,
height: 1920,
layers: [
{ pagName: 'foreground.pag', replaceIndex: 0, replaceImage: 'https://example.com/image.jpg' }
]
}"
/>
4.4 Props 说明
| 参数名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| pagName | string | - | PAG 文件名 |
| width | number | 199 | 画布宽度 |
| height | number | 199 | 画布高度 |
| replaceIndex | number | - | 要替换的图片索引 |
| replaceImage | string | - | 替换图片的 URL |
| visible | boolean | true | 控制动画显示/隐藏 |
| delay | number | 0 | 延迟开始动画的时间(毫秒) |
| composition | object | - | 组合动画配置 |
composition 对象结构
typescript
{
pagNames: string[]; // 多个 PAG 文件名
width?: number; // 组合画布宽度
height?: number; // 组合画布高度
layers?: Array<{
pagName: string; // 图层 PAG 文件名
replaceIndex?: number; // 替换图片索引
replaceImage?: string; // 替换图片 URL
}>; // 图层配置
}
5. 组件优点
5.1 高性能
- 基于 WebAssembly 实现,动画播放流畅
- 单例模式和缓存机制减少资源占用
- 自动销毁资源,避免内存泄漏
5.2 易用性
- 封装了复杂的 PAG 初始化和播放逻辑
- 提供简洁的 API,易于集成到项目中
- 支持 Vue 3 的 Composition API
5.3 灵活性
- 支持单个和组合动画
- 支持动态替换图片
- 支持延迟播放
- 响应式设计,参数变化时自动更新
5.4 兼容性
- 支持 file:// 协议,方便开发调试
- 基于标准 Canvas API,浏览器兼容性好
5.5 可维护性
- 代码结构清晰,易于扩展
- 类型安全,使用 TypeScript 开发
- 良好的错误处理机制
6. 注意事项
- PAG 文件路径 :确保 PAG 文件存放在正确的位置(默认是
/pag/或./pag/目录) - 图片替换索引:替换图片时需要知道正确的索引,可通过 PAG 文件编辑工具查看
- 组合动画图层顺序 :组合动画的图层顺序与
pagNames数组顺序一致 - 大文件加载:大尺寸的 PAG 文件可能会影响加载速度,建议优化 PAG 文件大小
- 内存管理:虽然组件会自动销毁资源,但频繁切换动画时仍需注意性能
- 跨域问题:替换的图片需要支持 CORS,否则会加载失败
7. 最佳实践
7.1 预加载常用动画
vue
<template>
<!-- 预加载动画 -->
<PagAnimation
v-if="false"
pagName="common.pag"
/>
<!-- 实际使用的动画 -->
<PagAnimation
v-if="showAnimation"
pagName="common.pag"
/>
</template>
7.2 动态控制动画播放
vue
<template>
<div>
<button @click="toggleAnimation">
{{ showAnimation ? '隐藏动画' : '显示动画' }}
</button>
<PagAnimation
v-model:visible="showAnimation"
pagName="example.pag"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import PagAnimation from '@/components/PagAnimation.vue';
const showAnimation = ref(false);
const toggleAnimation = () => {
showAnimation.value = !showAnimation.value;
};
</script>
7.3 使用组合动画创建复杂效果
vue
<template>
<PagAnimation
:composition="composition"
:width="1080"
:height="1920"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import PagAnimation from '@/components/PagAnimation.vue';
const composition = ref({
pagNames: ['background.pag', 'character.pag', 'effects.pag'],
width: 1080,
height: 1920,
layers: [
{
pagName: 'character.pag',
replaceIndex: 0,
replaceImage: 'https://example.com/character.jpg'
}
]
});
</script>
组件源码
ini
<template>
<div class="pag-animation-container">
<canvas
v-if="internalVisible"
ref="canvasRef"
class="pag-canvas"
:width="width"
:height="height"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue';
import { PAGInit, loadPagFile } from '@/utils/pag';
interface Props {
pagName: string;
width?: number;
height?: number;
replaceIndex?: number;
replaceImage?: string;
visible?: boolean;
delay?: number; // 延迟开始动画的时间(毫秒)
// 组合功能配置
composition?: {
pagNames: string[]; // 多个 PAG 文件名
width?: number; // 组合画布宽度
height?: number; // 组合画布高度
layers?: Array<{
pagName: string;
replaceIndex?: number;
replaceImage?: string;
}>; // 图层配置
};
}
const props = withDefaults(defineProps<Props>(), {
width: 199,
height: 199,
visible: true,
delay: 0, // 默认不延迟
});
const canvasRef = ref<HTMLCanvasElement | null>(null);
let PAG: any = null;
let pagFile: any = null;
let pagView: any = null;
const internalVisible = ref(props.visible);
// 加载图片
const loadImage = async (src: string) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = src;
try {
await img.decode();
return img;
} catch (err) {
console.error('Load image failed:', src, err);
return null;
}
};
// 加载单个 PAG 文件并应用替换图片
const loadPagFileWithReplace = async (pagName: string, replaceIndex?: number, replaceImage?: string) => {
if (!PAG) return null;
const pagFile = await loadPagFile(pagName);
if (replaceImage && replaceIndex !== undefined) {
const img = await loadImage(replaceImage);
if (img) {
const pagImg = PAG.PAGImage.fromSource(img);
pagFile.replaceImage(replaceIndex, pagImg);
}
}
return pagFile;
};
// 创建组合动画
const createCompositionAnimation = async () => {
if (!canvasRef.value || !props.composition) return;
try {
PAG = PAG || (await PAGInit());
const { pagNames, width = 1080, height = 1920, layers = [] } = props.composition;
// 加载所有 PAG 文件
const pagFiles: any[] = [];
for (const pagName of pagNames) {
// 查找图层配置
const layerConfig = layers.find(layer => layer.pagName === pagName);
const replaceIndex = layerConfig?.replaceIndex;
const replaceImage = layerConfig?.replaceImage;
const pagFile = await loadPagFileWithReplace(pagName, replaceIndex, replaceImage);
if (pagFile) {
pagFiles.push(pagFile);
}
}
if (pagFiles.length === 0) {
console.error('No valid PAG files loaded for composition');
return;
}
// 创建组合
const composition = PAG.PAGComposition.make(width, height);
// 添加所有图层到组合
pagFiles.forEach(pagFile => {
composition.addLayer(pagFile);
});
// 初始化 PAGView 并设置组合
pagView = await PAG.PAGView.init(composition, canvasRef.value);
// 添加延迟开始动画
if (props.delay > 0) {
setTimeout(() => {
pagView.play().catch(() => {});
}, props.delay);
} else {
pagView.play().catch(() => {});
}
} catch (err) {
console.error('createCompositionAnimation error:', err);
}
};
// 销毁 PAG 动画
const destroyPagAnimation = () => {
try {
if (pagView) {
pagView.destroy?.();
pagView = null;
}
} catch (err) {
console.warn('destroyPagAnimation error:', err);
}
};
// 初始化 PAG 动画
const initPagAnimation = async () => {
if (!canvasRef.value) return;
destroyPagAnimation();
try {
// 检查是否使用组合功能
if (props.composition) {
await createCompositionAnimation();
} else {
// 单个 PAG 文件模式
PAG = PAG || (await PAGInit());
pagFile = await loadPagFile(props.pagName);
if (props.replaceImage) {
const img = await loadImage(props.replaceImage);
if (img) {
const pagImg = PAG.PAGImage.fromSource(img);
console.log('pagImg====', pagImg);
pagFile.replaceImage(props.replaceIndex, pagImg);
}
}
pagView = await PAG.PAGView.init(pagFile, canvasRef.value);
// 添加延迟开始动画
if (props.delay > 0) {
setTimeout(() => {
pagView.play().catch(() => {});
}, props.delay);
} else {
pagView.play().catch(() => {});
}
}
} catch (err) {
console.error('initPagAnimation error:', err);
}
};
// watch visible
watch(
() => props.visible,
async (val) => {
if (!val) {
destroyPagAnimation();
internalVisible.value = false;
} else {
internalVisible.value = true;
await nextTick();
initPagAnimation();
}
},
{ immediate: true }
);
// watch pagName / replaceImage / composition
watch([() => props.pagName, () => props.replaceImage, () => props.composition], () => {
if (props.visible) {
initPagAnimation();
}
});
onMounted(() => {
if (props.visible) initPagAnimation();
});
onUnmounted(() => {
destroyPagAnimation();
});
</script>
<style scoped>
.pag-animation-container {
display: flex;
justify-content: center;
align-items: center;
}
.pag-canvas {
display: block;
}
</style>
pag.ts 源码
typescript
import { PAGInit as _PAGInit } from 'libpag';
type PAGInstance = Awaited<ReturnType<typeof _PAGInit>>;
// PAG 实例单例
let pagInstance: PAGInstance | null = null;
let pagPromise: Promise<PAGInstance> | null = null;
// PAGFile 缓存
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pagFileCache = new Map<string, any>();
// File 对象缓存
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fileCache = new Map<string, any>();
// 使用 XMLHttpRequest 加载二进制数据,兼容 file:// 协议
const loadArrayBuffer = (url: string): Promise<ArrayBuffer> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'arraybuffer';
xhr.onload = () => {
if (xhr.status === 200 || xhr.status === 0) {
// status 0 是 file:// 协议的成功状态
resolve(xhr.response);
} else {
reject(new Error(`Failed to load ${url}: ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error(`Failed to load ${url}`));
xhr.send();
});
};
// 使用 XMLHttpRequest 加载 Blob,兼容 file:// 协议
const loadBlob = (url: string): Promise<Blob> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'blob';
xhr.onload = () => {
if (xhr.status === 200 || xhr.status === 0) {
resolve(xhr.response);
} else {
reject(new Error(`Failed to load ${url}: ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error(`Failed to load ${url}`));
xhr.send();
});
};
export const PAGInit = async (): Promise<PAGInstance> => {
if (pagInstance) return pagInstance;
if (pagPromise) return pagPromise;
pagPromise = (async () => {
// 手动加载 wasm 二进制
// 使用 XMLHttpRequest 兼容 file:// 协议(fetch 在 file:// 下不工作)
const wasmUrl = import.meta.env.DEV ? '/libpag.wasm' : './libpag.wasm';
console.log('wasmUrl', wasmUrl);
const wasmBinary = await loadArrayBuffer(wasmUrl);
pagInstance = await _PAGInit({ wasmBinary });
return pagInstance;
})();
return pagPromise;
};
// 加载并缓存 PAGFile(适用于不需要修改的静态动画)
export const loadPagFile = async (fileName: string) => {
if (pagFileCache.has(fileName)) {
const cachedFile = pagFileCache.get(fileName);
// 检查缓存的文件是否已被销毁
try {
// 尝试调用一个简单的方法来检查文件状态
cachedFile?.numImages?.();
return cachedFile;
} catch (error) {
// 如果文件已被销毁,从缓存中移除并重新加载
pagFileCache.delete(fileName);
}
}
const PAG = await PAGInit();
const file = await loadFile(fileName);
const pagFile = await PAG.PAGFile.load(file);
pagFileCache.set(fileName, pagFile);
return pagFile;
};
// 加载 File 对象(适用于需要每次修改内容的动画)
export const loadFile = async (fileName: string) => {
if (fileCache.has(fileName)) {
return fileCache.get(fileName)!;
}
const pagUrl = import.meta.env.DEV ? `/pag/${fileName}` : `./pag/${fileName}`;
const blob = await loadBlob(pagUrl);
const file = new File([blob], fileName);
fileCache.set(fileName, file);
return file;
};