一、方案背景
目前我们系统使用的素材包类型包含(图片、视频、音频、spine等文件)
整体系统的数据结构设计,多有深层嵌套和频繁修改,新增、删除(相关文件类型)字段等、所以在开发中,需要频繁的对齐每次改动,很容易出现遗漏和冗余,所以我们制定了一套比较适合于我们系统的整体素材包整合方案
二、存在问题
● 结构多变:需频繁改动和对齐改动,开发层面 - 易遗漏和冗余-开发成本高。
● 冗余重复:同一素材多次上传、引用,导致后续打包素材巨大-打包速度慢-下载体积大-整体体验差
● 素材本身体积过大:导致整体包体积过大
三、核心目标
1. 整合:设置一套全局的素材整合方案,无需关心数据结构和改动
2. 精简:精简包体积,避免冗余素材
3. 压缩:优化文件体积,平衡质量与存储需求
四、整体方案
一、整合
1. 发布订阅模式-监听所有的上传事件,素材库接收上传文件
// 创建事件总线(Event Bus)
class EventBus {
constructor() {
this.events = {};
}
// 订阅事件
on(eventName, callback) {
if (!this.events[eventName]) {
this.events[eventName] = [];
}
this.events[eventName].push(callback);
}
// 发布事件
emit(eventName, data) {
if (this.events[eventName]) {
this.events[eventName].forEach(callback => {
callback(data);
});
}
}
// 取消订阅
off(eventName, callback) {
if (this.events[eventName]) {
this.events[eventName] = this.events[eventName].filter(
cb => cb !== callback
);
}
}
}
// 创建全局事件总线实例
const eventBus = new EventBus();
// 素材库模块 - 订阅上传事件
eventBus.on('fileUploaded', (fileData) => {
console.log('素材库接收到上传文件:', fileData);
// 处理上传的文件,如保存到数据库等
});
// 上传组件 - 触发上传事件
function uploadFile(file) {
// cos上传 TODO
// 发布上传完成事件
eventBus.emit('fileUploaded', fileData);
console.log('文件上传完成:', fileData);
}, 1000);
}
二、 去重
1、文件上传去重复
1.思路
1. 哈希生成:使用SHA-256算法为上传的文件生成哈希值。
2. 哈希库查找:将生成的哈希值与存储在哈希库中的值进行比对。
3. 匹配处理:
a. 如果哈希值匹配,则直接返回对应素材的URL。
b. 如果不匹配,则将素材上传到对象存储服务(如COS)。
4. 上传与存储:上传成功后,将新素材的哈希值和URL存储到哈希库中。
2.详细实现步骤
● 计算文件哈希:使用FileReader读取文件内容,并通过CryptoJS.SHA256计算哈希值。
● 查询已存在的文件:通过API查询哈希库,找出已存在的文件信息。
● 上传文件匹配:校验文件唯一性,找出需要上传的新文件。
● 上传到COS:使用putCos.uploadFiles方法上传新文件,并处理上传进度和结果。
3.注意事项
● 哈希匹配:对于普通文件(图片、视频、音频),只需匹配单个文件的哈希值。对于spine文件,由于它包含多个内部文件,需要进行二维数组hash的全匹配(无序)。
● 文件名处理:哈希值不受文件名影响,因此匹配时不需要考虑文件名。但在实际应用中,可能需要记录文件名以便后续使用。
● 错误处理:在文件读取、哈希计算、API查询和文件上传过程中,处理可能出现的错误。
1. 上传侧处理:
a. 生成哈希值:在用户上传素材时,首先根据素材文件的内容生成一个唯一的哈希值(例如使用SHA-256算法)。
2. 哈希库查找:
a. 查询哈希库:将生成的哈希值与存储在素材维度哈希库中的哈希值进行比对。
b. 匹配成功:如果哈希库中存在相同的哈希值,则直接返回对应素材的URL,避免重复上传。
c. 匹配失败:如果哈希库中没有匹配的哈希值,则将素材上传到对象存储服务(如COS)。
3. 上传与存储:
a. COS上传成功后:将新上传素材的哈希值和对应的URL存储到哈希库中,以便后续查询使用
// 文件上传去重处理器
class FileUploadDeduplicator {
constructor(cosService, hashLibraryService) {
this.cosService = cosService; // COS上传服务
this.hashLibraryService = hashLibraryService; // 哈希库服务
}
/**
* 处理文件上传(带去重逻辑)
* @param {File} file - 要上传的文件
* @returns {Promise<{url: string, isNewUpload: boolean}>} - 返回文件URL和是否是新上传的标志
*/
async uploadFileWithDeduplication(file) {
try {
// 1. 计算文件哈希值
const fileHash = await this.calculateFileHash(file);
// 2. 查询哈希库
const existingFile = await this.hashLibraryService.queryByHash(fileHash);
if (existingFile) {
// 3. 如果已存在,直接返回已有URL
console.log(`文件已存在,直接返回URL: ${existingFile.url}`);
return { url: existingFile.url, isNewUpload: false };
} else {
// 4. 如果不存在,上传到COS
console.log(`文件不存在,开始上传: ${file.name}`);
const uploadResult = await this.cosService.uploadFile(file);
// 5. 将新文件信息存入哈希库
await this.hashLibraryService.addHashRecord({
hash: fileHash,
url: uploadResult.url,
fileName: file.name,
fileType: file.type,
size: file.size
});
return { url: uploadResult.url, isNewUpload: true };
}
} catch (error) {
console.error('文件上传去重处理失败:', error);
throw error;
}
}
/**
* 计算文件SHA-256哈希值
* @param {File} file - 要计算哈希的文件
* @returns {Promise<string>} - 返回文件的SHA-256哈希值
*/
async calculateFileHash(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (event) => {
try {
const fileContent = event.target.result;
// 使用浏览器内置的crypto API计算SHA-256
const hashBuffer = await crypto.subtle.digest('SHA-256', fileContent);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
resolve(hashHex);
} catch (err) {
reject(err);
}
};
reader.onerror = () => {
reject(new Error('文件读取失败'));
};
reader.readAsArrayBuffer(file);
});
}
}
// COS上传服务模拟实现
class CosService {
/**
* 模拟COS文件上传
* @param {File} file - 要上传的文件
* @returns {Promise<{url: string}>} - 返回上传后的URL
*/
async uploadFile(file) {
// 这里应该是实际的COS上传逻辑
console.log(`上传文件到COS: ${file.name}`);
// 模拟上传延迟
await new Promise(resolve => setTimeout(resolve, 1000));
// 生成模拟URL
const randomId = Math.random().toString(36).substring(2, 10);
const url = ` https://cos.example.com/files/${randomId}/${file.name}`;
return { url };
}
}
// 哈希库服务模拟实现
class HashLibraryService {
constructor() {
// 模拟哈希库存储
this.hashRecords = new Map();
}
/**
* 根据哈希值查询文件记录
* @param {string} hash - 文件哈希值
* @returns {Promise<{url: string} | null>} - 返回文件记录或null
*/
async queryByHash(hash) {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 200));
return this.hashRecords.get(hash) || null;
}
/**
* 添加哈希记录
* @param {object} record - 文件记录
* @returns {Promise<void>}
*/
async addHashRecord(record) {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 200));
this.hashRecords.set(record.hash, record);
}
}
// 使用示例
async function main() {
// 初始化服务
const cosService = new CosService();
const hashLibraryService = new HashLibraryService();
const uploadProcessor = new FileUploadDeduplicator(cosService, hashLibraryService);
// 模拟文件上传
const mockFile = new File(['file content'], 'example.jpg', { type: 'image/jpeg' });
// 第一次上传(应该是新上传)
const result1 = await uploadProcessor.uploadFileWithDeduplication(mockFile);
console.log('第一次上传结果:', result1);
// 第二次上传相同内容文件(应该返回已有URL)
const mockFileSameContent = new File(['file content'], 'different-name.jpg', { type: 'image/jpeg' });
const result2 = await uploadProcessor.uploadFileWithDeduplication(mockFileSameContent);
console.log('第二次上传结果:', result2);
}
main().catch(console.error);
特殊文件:spine文件处理
/**
* 处理Spine文件上传(包含多个内部文件)
* @param {File[]} files - Spine文件及其相关文件
* @returns {Promise<{urls: string[], isNewUpload: boolean}>}
*/
async uploadSpineFilesWithDeduplication(files) {
// 1. 计算所有文件的哈希值(无序)
const fileHashes = await Promise.all(
files.map(file => this.calculateFileHash(file))
);
// 2. 查询哈希库(需要所有哈希值都匹配)
const allExist = await this.hashLibraryService.querySpineFiles(fileHashes);
if (allExist) {
// 3. 如果全部已存在,直接返回已有URL
return { urls: allExist.urls, isNewUpload: false };
} else {
// 4. 上传所有文件到COS
const uploadResults = await Promise.all(
files.map(file => this.cosService.uploadFile(file))
);
// 5. 将新文件信息存入哈希库
await this.hashLibraryService.addSpineRecords({
hashes: fileHashes,
urls: uploadResults.map(r => r.url),
fileNames: files.map(f => f.name)
});
return { urls: uploadResults.map(r => r.url), isNewUpload: true };
}
}
2. 保存数据时去重
1.思路
对保存时候的整体数据结构,和素材包保存的数据进行 对比, 从素材包中移除保存时候没有的数据
2.详细实现步骤
反序列化保存数据结构(因为保存的数据结构不定,大多会存在深层嵌套)
map 整体素材包结构
从素材包中移除 保存时候没有的数据
/**
* 素材包清理工具
*/
class MaterialPackageCleaner {
/**
* 清理素材包中未被引用的素材
* @param {Object|string} savedData - 保存的数据结构(可以是对象或JSON字符串)
* @param {Object} materialPackage - 素材包数据 {key: cdnPath}
* @returns {Object} 清理后的素材包
*/
cleanUnusedMaterials(savedData, materialPackage) {
// 1. 反序列化保存数据
const parsedData = this._parseSavedData(savedData);
// 2. 收集保存数据中所有引用的素材key
const usedMaterialKeys = this._collectMaterialKeys(parsedData);
// 3. 过滤素材包,只保留被引用的素材
return this._filterMaterialPackage(materialPackage, usedMaterialKeys);
}
/**
* 解析保存的数据
* @private
* @param {Object|string} data - 输入数据
* @returns {Object} 解析后的对象
*/
_parseSavedData(data) {
if (typeof data === 'string') {
try {
return JSON.parse(data);
} catch (e) {
console.error('数据解析失败:', e);
return {};
}
}
return data || {};
}
/**
* 递归收集所有素材key
* @private
* @param {Object} data - 要遍历的数据
* @param {Set} [keys=new Set()] - 存储key的集合
* @returns {Set<string>} 所有素材key的集合
*/
_collectMaterialKeys(data, keys = new Set()) {
if (!data || typeof data !== 'object') {
return keys;
}
// 处理数组
if (Array.isArray(data)) {
data.forEach(item => this._collectMaterialKeys(item, keys));
return keys;
}
// 处理对象
for (const key in data) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
const value = data[key];
// 假设包含materialKey字段的是素材引用
if (key === 'materialKey' && typeof value === 'string') {
keys.add(value);
}
// 递归处理嵌套结构
if (typeof value === 'object') {
this._collectMaterialKeys(value, keys);
}
}
}
return keys;
}
/**
* 过滤素材包
* @private
* @param {Object} materialPackage - 原始素材包
* @param {Set<string>} usedKeys - 被引用的key集合
* @returns {Object} 过滤后的素材包
*/
_filterMaterialPackage(materialPackage, usedKeys) {
const cleanedPackage = {};
for (const key in materialPackage) {
if (usedKeys.has(key)) {
cleanedPackage[key] = materialPackage[key];
} else {
console.log(`移除未使用的素材: ${key}`);
}
}
return cleanedPackage;
}
}
// 使用示例
const cleaner = new MaterialPackageCleaner();
// 模拟保存的数据结构(包含深层嵌套)
const savedData = {
pages: [
{
elements: [
{ type: 'image', materialKey: 'img1' },
{
type: 'group',
children: [
{ type: 'text', materialKey: 'text1' },
{ type: 'video', materialKey: 'video1' }
]
}
]
}
],
settings: {
background: { materialKey: 'bg1' }
}
};
// 模拟素材包数据
const materialPackage = {
img1: ' https://cdn.example.com/img1.jpg',
img2: ' https://cdn.example.com/img2.jpg', // 未被引用
text1: ' https://cdn.example.com/text1.json',
video1: ' https://cdn.example.com/video1.mp4',
bg1: ' https://cdn.example.com/bg1.png',
audio1: ' https://cdn.example.com/audio1.mp3' // 未被引用
};
// 执行清理
const cleanedPackage = cleaner.cleanUnusedMaterials(savedData, materialPackage);
console.log('清理后的素材包:', cleanedPackage);
三、 压缩
我们使用的上传为cos上传,图片开启了cos的极智压缩,将图片转为webp格式
五、总结
1. 发布订阅模式:通过事件总线机制实现素材上传的统一管理,解耦上传组件与素材库,使系统更易扩展和维护。
2. 智能去重机制:
a. 文件级去重:基于SHA-256哈希算法识别重复文件,避免重复上传和存储
b. Spine文件特殊处理:支持多文件组合的去重判断
c. 数据结构去重:清理素材包中未被引用的冗余素材
3. 高效压缩策略:
a. 图片自动转换为WebP格式
b. 利用COS极智压缩功能优化存储空间