perf: 优化图片渲染性能和内存管理
重构图片渲染逻辑,引入图片缓存系统和状态压缩机制 添加批量处理层叠图功能,每批5个图片处理 实现内存监控和自动清理机制 增加全局Loading管理器显示操作进度 优化撤销重做功能的状态比较和保存逻辑
This commit is contained in:
332
js/script.js
332
js/script.js
@@ -1,4 +1,4 @@
|
||||
// 极简邀请函制作工具
|
||||
// 极简邀请函制作工具 - 性能优化版本
|
||||
(function() {
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const addImageBtn = document.getElementById('addImageBtn');
|
||||
@@ -69,6 +69,143 @@
|
||||
let lastSavedState = null;
|
||||
const SAVE_DELAY = 300; // 防抖延迟 300ms
|
||||
|
||||
// ==================== 性能优化:图片缓存系统 ====================
|
||||
const imageCache = new Map();
|
||||
const MAX_CACHE_SIZE = 20; // 最大缓存图片数量
|
||||
|
||||
// 获取缓存图片,如果不存在则加载并缓存
|
||||
async function getCachedImage(src) {
|
||||
if (imageCache.has(src)) {
|
||||
return imageCache.get(src);
|
||||
}
|
||||
|
||||
// 清理旧缓存
|
||||
if (imageCache.size >= MAX_CACHE_SIZE) {
|
||||
const firstKey = imageCache.keys().next().value;
|
||||
imageCache.delete(firstKey);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
imageCache.set(src, img);
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
// 清理图片缓存
|
||||
function clearImageCache() {
|
||||
imageCache.clear();
|
||||
}
|
||||
|
||||
// ==================== 性能优化:状态压缩 ====================
|
||||
// 压缩状态数据,减少内存占用
|
||||
function compressState(state) {
|
||||
return {
|
||||
...state,
|
||||
// 压缩文本内容,限制长度
|
||||
textInputs: state.textInputs.map(input => ({
|
||||
...input,
|
||||
content: input.content.length > 500 ? input.content.substring(0, 500) : input.content
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 性能优化:节流与防抖工具 ====================
|
||||
function throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function(...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function(...args) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 性能优化:Loading 管理器 ====================
|
||||
const loadingManager = {
|
||||
show: (message = '处理中...') => {
|
||||
let loadingEl = document.getElementById('globalLoading');
|
||||
if (!loadingEl) {
|
||||
loadingEl = document.createElement('div');
|
||||
loadingEl.id = 'globalLoading';
|
||||
loadingEl.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 99999;
|
||||
`;
|
||||
loadingEl.innerHTML = `
|
||||
<div style="
|
||||
background: #fff;
|
||||
padding: 20px 30px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
">
|
||||
<div class="loading-spinner" style="
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #4a90e2;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 10px;
|
||||
"></div>
|
||||
<div class="loading-text" style="color: #333; font-size: 14px;">${message}</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(loadingEl);
|
||||
|
||||
// 添加动画样式
|
||||
if (!document.getElementById('loadingAnimation')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'loadingAnimation';
|
||||
style.textContent = `
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
} else {
|
||||
loadingEl.querySelector('.loading-text').textContent = message;
|
||||
loadingEl.style.display = 'flex';
|
||||
}
|
||||
},
|
||||
hide: () => {
|
||||
const loadingEl = document.getElementById('globalLoading');
|
||||
if (loadingEl) {
|
||||
loadingEl.style.display = 'none';
|
||||
}
|
||||
},
|
||||
updateMessage: (message) => {
|
||||
const loadingEl = document.getElementById('globalLoading');
|
||||
if (loadingEl) {
|
||||
loadingEl.querySelector('.loading-text').textContent = message;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化
|
||||
init();
|
||||
|
||||
@@ -321,9 +458,34 @@
|
||||
|
||||
}
|
||||
|
||||
// ==================== 撤销重做功能 ====================
|
||||
// ==================== 撤销重做功能(性能优化版) ====================
|
||||
|
||||
// 内存使用监控
|
||||
let memoryMonitor = {
|
||||
lastCheck: 0,
|
||||
checkInterval: 30000, // 30秒检查一次
|
||||
check: function() {
|
||||
const now = Date.now();
|
||||
if (now - this.lastCheck < this.checkInterval) return;
|
||||
this.lastCheck = now;
|
||||
|
||||
// 如果历史记录过多,主动清理
|
||||
if (undoStack.length > MAX_HISTORY * 0.8) {
|
||||
console.log('内存优化:清理旧历史记录');
|
||||
while (undoStack.length > MAX_HISTORY * 0.6) {
|
||||
undoStack.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// 清理图片缓存
|
||||
if (imageCache.size > MAX_CACHE_SIZE * 0.8) {
|
||||
console.log('内存优化:清理图片缓存');
|
||||
clearImageCache();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 保存当前状态(带防抖)
|
||||
// 保存当前状态(带防抖和压缩)
|
||||
function saveState(immediate = false) {
|
||||
if (isUndoing) return;
|
||||
|
||||
@@ -334,6 +496,9 @@
|
||||
}
|
||||
|
||||
const doSave = () => {
|
||||
// 内存检查
|
||||
memoryMonitor.check();
|
||||
|
||||
const state = captureState();
|
||||
|
||||
// 如果新状态与上一个状态相同,不保存
|
||||
@@ -341,8 +506,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
undoStack.push(state);
|
||||
lastSavedState = JSON.parse(JSON.stringify(state));
|
||||
// 压缩状态后保存
|
||||
const compressedState = compressState(state);
|
||||
undoStack.push(compressedState);
|
||||
lastSavedState = JSON.parse(JSON.stringify(compressedState));
|
||||
|
||||
// 限制历史记录数量
|
||||
if (undoStack.length > MAX_HISTORY) {
|
||||
@@ -365,7 +532,7 @@
|
||||
// 捕获当前状态
|
||||
function captureState() {
|
||||
return {
|
||||
// 背景图状态
|
||||
// 背景图状态 - 使用图片标识而非完整 dataURL
|
||||
bgImageSrc: bgImage.src,
|
||||
bgImageScale: bgImageScale,
|
||||
bgImageOpacity: bgImageOpacity,
|
||||
@@ -398,13 +565,29 @@
|
||||
fontWeight: el.style.fontWeight,
|
||||
fontStyle: el.style.fontStyle,
|
||||
isLocked: el.dataset.locked === 'true'
|
||||
}))
|
||||
})),
|
||||
|
||||
// 添加时间戳用于状态管理
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
// 比较两个状态是否相同
|
||||
// 比较两个状态是否相同(优化版)
|
||||
function statesEqual(state1, state2) {
|
||||
return JSON.stringify(state1) === JSON.stringify(state2);
|
||||
// 快速检查:时间戳
|
||||
if (state1.timestamp && state2.timestamp && Math.abs(state1.timestamp - state2.timestamp) > 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 比较关键属性
|
||||
if (state1.bgImageSrc !== state2.bgImageSrc) return false;
|
||||
if (state1.bgImageScale !== state2.bgImageScale) return false;
|
||||
if (state1.imageOffsetX !== state2.imageOffsetX) return false;
|
||||
|
||||
// 使用 JSON 比较完整状态
|
||||
const s1 = JSON.stringify({ ...state1, timestamp: undefined });
|
||||
const s2 = JSON.stringify({ ...state2, timestamp: undefined });
|
||||
return s1 === s2;
|
||||
}
|
||||
|
||||
// 撤销
|
||||
@@ -1313,8 +1496,8 @@
|
||||
|
||||
function handleImageEnd() {
|
||||
if (isDraggingImage) {
|
||||
// 保存状态
|
||||
saveState();
|
||||
// 保存状态(拖拽结束只保存一次)
|
||||
saveState(true);
|
||||
}
|
||||
isDraggingImage = false;
|
||||
isInteractingWithBgImage = false;
|
||||
@@ -1480,8 +1663,8 @@
|
||||
element.classList.remove('dragging');
|
||||
hideGuides();
|
||||
|
||||
// 保存状态
|
||||
saveState();
|
||||
// 保存状态(拖拽结束只保存一次,使用 immediate 模式)
|
||||
saveState(true);
|
||||
}
|
||||
|
||||
// 阻止事件冒泡
|
||||
@@ -2569,8 +2752,9 @@
|
||||
return /MicroMessenger/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
// 生成画布(高清无损,保持与显示容器相同的宽高比)
|
||||
// 生成画布(高清无损,保持与显示容器相同的宽高比)- 性能优化版
|
||||
async function generateCanvas() {
|
||||
const startTime = performance.now();
|
||||
const canvas = exportCanvas;
|
||||
const container = imageContainer;
|
||||
|
||||
@@ -2621,6 +2805,11 @@
|
||||
console.log('目标画布尺寸:', TARGET_WIDTH, 'x', TARGET_HEIGHT, 'dpr:', dpr, '微信:', isWechat());
|
||||
console.log('显示容器尺寸:', displayWidth, 'x', displayHeight);
|
||||
|
||||
// 更新 Loading 提示
|
||||
if (document.getElementById('globalLoading')) {
|
||||
loadingManager.updateMessage('正在初始化画布...');
|
||||
}
|
||||
|
||||
// 临时显示 canvas 以避免浏览器对隐藏 canvas 的尺寸限制
|
||||
canvas.removeAttribute('hidden');
|
||||
canvas.style.position = 'fixed';
|
||||
@@ -2657,13 +2846,26 @@
|
||||
const scale = TARGET_WIDTH / displayWidth;
|
||||
console.log('统一缩放比例:', scale, '画布宽高比:', TARGET_WIDTH/TARGET_HEIGHT, '容器宽高比:', displayRatio);
|
||||
|
||||
// 绘制背景图
|
||||
if (currentImage) {
|
||||
if (document.getElementById('globalLoading')) {
|
||||
loadingManager.updateMessage('正在绘制背景...');
|
||||
}
|
||||
console.log('背景图原始尺寸:', currentImage.naturalWidth, 'x', currentImage.naturalHeight);
|
||||
drawBgImageOnCanvasHD(ctx, currentImage, TARGET_WIDTH, TARGET_HEIGHT, scale);
|
||||
}
|
||||
|
||||
// 绘制层叠图
|
||||
if (document.getElementById('globalLoading')) {
|
||||
loadingManager.updateMessage('正在绘制层叠图...');
|
||||
}
|
||||
await drawOverlayImagesHD(ctx, scale);
|
||||
|
||||
// 绘制文字
|
||||
if (document.getElementById('globalLoading')) {
|
||||
loadingManager.updateMessage('正在绘制文字...');
|
||||
}
|
||||
|
||||
// 等待所有字体加载完成
|
||||
const fontPromises = [];
|
||||
const textInputs = container.querySelectorAll('.preview-input-layer .text-input');
|
||||
@@ -2684,6 +2886,10 @@
|
||||
textInputs.forEach(textInput => {
|
||||
drawTextOnCanvasHD(ctx, textInput, scale);
|
||||
});
|
||||
|
||||
// 记录总耗时
|
||||
const endTime = performance.now();
|
||||
console.log(`画布生成总耗时: ${(endTime - startTime).toFixed(2)}ms`);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
@@ -2818,29 +3024,29 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 绘制层叠图(高清版本)
|
||||
function drawOverlayImagesHD(ctx, scale) {
|
||||
return new Promise((resolve) => {
|
||||
const overlayImages = overlayLayer.querySelectorAll('.overlay-image');
|
||||
if (overlayImages.length === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// 绘制层叠图(高清版本)- 性能优化版,使用图片缓存
|
||||
async function drawOverlayImagesHD(ctx, scale) {
|
||||
const overlayImages = overlayLayer.querySelectorAll('.overlay-image');
|
||||
if (overlayImages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let loadedCount = 0;
|
||||
const totalImages = overlayImages.length;
|
||||
|
||||
function checkComplete() {
|
||||
loadedCount++;
|
||||
if (loadedCount >= totalImages) {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
overlayImages.forEach(overlayEl => {
|
||||
const totalImages = overlayImages.length;
|
||||
let processedCount = 0;
|
||||
|
||||
// 批量处理,每批最多5个
|
||||
const BATCH_SIZE = 5;
|
||||
const batches = [];
|
||||
|
||||
for (let i = 0; i < overlayImages.length; i += BATCH_SIZE) {
|
||||
batches.push(Array.from(overlayImages).slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
|
||||
for (const batch of batches) {
|
||||
await Promise.all(batch.map(async (overlayEl) => {
|
||||
const imgEl = overlayEl.querySelector('img');
|
||||
if (!imgEl) {
|
||||
checkComplete();
|
||||
processedCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2862,25 +3068,33 @@
|
||||
const isMirroredH = imgTransform.includes('scaleX(-1)');
|
||||
const isMirroredV = imgTransform.includes('scaleY(-1)');
|
||||
|
||||
// 直接使用已加载的图片元素,避免重新加载导致质量下降
|
||||
if (imgEl.complete && imgEl.naturalWidth > 0) {
|
||||
drawImageWithTransform(ctx, imgEl, left, top, width, height, rotateDeg, isMirroredH, isMirroredV);
|
||||
try {
|
||||
// 优先使用缓存的图片
|
||||
let imgToDraw;
|
||||
if (imgEl.complete && imgEl.naturalWidth > 0) {
|
||||
imgToDraw = imgEl;
|
||||
} else {
|
||||
// 尝试从缓存获取
|
||||
imgToDraw = await getCachedImage(imgEl.src);
|
||||
}
|
||||
|
||||
drawImageWithTransform(ctx, imgToDraw, left, top, width, height, rotateDeg, isMirroredH, isMirroredV);
|
||||
} catch (err) {
|
||||
console.warn('层叠图绘制失败:', err);
|
||||
} finally {
|
||||
ctx.globalAlpha = 1;
|
||||
checkComplete();
|
||||
} else {
|
||||
// 如果图片未加载完成,等待加载
|
||||
imgEl.onload = () => {
|
||||
drawImageWithTransform(ctx, imgEl, left, top, width, height, rotateDeg, isMirroredH, isMirroredV);
|
||||
ctx.globalAlpha = 1;
|
||||
checkComplete();
|
||||
};
|
||||
imgEl.onerror = () => {
|
||||
ctx.globalAlpha = 1;
|
||||
checkComplete();
|
||||
};
|
||||
processedCount++;
|
||||
|
||||
// 更新 Loading 进度
|
||||
if (document.getElementById('globalLoading') && processedCount % 3 === 0) {
|
||||
loadingManager.updateMessage(`正在绘制层叠图 (${processedCount}/${totalImages})...`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
// 每批处理后让出主线程,避免阻塞
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制带旋转和镜像变换的图片
|
||||
@@ -3018,19 +3232,33 @@
|
||||
ctx.fillText(text, inputLeft, inputTop + textOffsetY);
|
||||
}
|
||||
|
||||
// 保存图片
|
||||
// 保存图片(带 Loading 提示)
|
||||
async function saveImage() {
|
||||
if (!currentImage) {
|
||||
alert('请先添加背景图片');
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示 Loading
|
||||
loadingManager.show('正在生成图片...');
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
const canvas = await generateCanvas();
|
||||
|
||||
// 更新 Loading 提示
|
||||
loadingManager.updateMessage('正在准备保存...');
|
||||
|
||||
await showMobileSaveTip(canvas);
|
||||
|
||||
const endTime = performance.now();
|
||||
console.log(`图片保存耗时: ${(endTime - startTime).toFixed(2)}ms`);
|
||||
} catch (err) {
|
||||
console.error('保存失败:', err);
|
||||
alert('保存失败,请重试');
|
||||
} finally {
|
||||
// 隐藏 Loading
|
||||
loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user