封装了一个vue版本 Pag组件

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 组件工作流程

  1. 初始化阶段 :组件挂载时,调用 PAGInit 初始化 PAG 实例
  2. 加载阶段 :根据 pagNamecomposition 属性加载对应的 PAG 文件
  3. 渲染阶段:将 PAG 文件渲染到 Canvas 元素上
  4. 播放阶段:调用 PAGView 的 play() 方法开始播放动画
  5. 更新阶段:监听 props 变化,重新初始化或更新动画
  6. 销毁阶段:组件卸载或隐藏时,销毁 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. 注意事项

  1. PAG 文件路径 :确保 PAG 文件存放在正确的位置(默认是 /pag/./pag/ 目录)
  2. 图片替换索引:替换图片时需要知道正确的索引,可通过 PAG 文件编辑工具查看
  3. 组合动画图层顺序 :组合动画的图层顺序与 pagNames 数组顺序一致
  4. 大文件加载:大尺寸的 PAG 文件可能会影响加载速度,建议优化 PAG 文件大小
  5. 内存管理:虽然组件会自动销毁资源,但频繁切换动画时仍需注意性能
  6. 跨域问题:替换的图片需要支持 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;
};
相关推荐
Stirner2 小时前
A2UI : 以动态 UI 代替 LLM 文本输出的方案
前端·llm·agent
Code知行合壹2 小时前
Vue.js进阶
前端·javascript·vue.js
我叫唧唧波2 小时前
【微前端】qiankun基础
前端·前端框架
摸鱼的春哥2 小时前
企业自建低代码平台正在扼杀AI编程的生长
前端·javascript·后端
-凌凌漆-2 小时前
【JS】var与let的区别
开发语言·前端·javascript
火车叼位2 小时前
使ast-grep-vscode 识别Vue组件
前端·visual studio code
YAY_tyy2 小时前
综合实战:基于 Turfjs 的智慧园区空间管理系统
前端·3d·cesium·turfjs
生活在一步步变好i2 小时前
模块化与包管理核心知识点详解
前端
午安~婉2 小时前
整理Git
前端·git