HTML Canvas图像生成仅生效一次问题排查与修复
问题
使用Next.js + HTML Canvas实现图像生成按钮,点击可生成含多张小图的合成图,首次(或偶尔几次)生成正常,但后续点击时流程终止,排查发现background.onload回调从未触发,导致后续步骤无法执行。已用Promise管理字体、背景图、小图的加载流程。
原因分析
- 图片缓存导致事件丢失:当背景图被浏览器缓存后,设置
background.src时图片会立即加载完成,但此时onload回调还未绑定(原代码先设置src,再在FontFace加载完成后绑定onload),因此回调永远不会触发。 - 未处理图片已加载完成的状态:即使后续绑定了
onload,如果图片已经处于complete状态(缓存),浏览器不会再触发onload事件。 - 缺少错误处理:如果图片加载失败,原代码没有处理
onerror,会导致Promise永远无法resolve,流程卡死。
修复方案
- 调整事件绑定顺序:先绑定
onload和onerror回调,再设置图片src,确保不会错过缓存图片的加载事件。 - 检查图片
complete状态:绑定回调后,立即检查图片是否已经加载完成,如果是则直接执行回调逻辑。 - 添加错误处理:为图片添加
onerror回调,在加载失败时reject Promise,避免流程挂起。 - 可选:添加跨域属性:如果背景图是跨域资源,设置
background.crossOrigin = 'anonymous',避免Canvas绘制时的跨域问题。
完整修复后的代码
const downloadImage = () => { if (isGeneratingImage) return setIsGeneratingImage(true) // Define sizes for canvas components const canvasWidth = 1000; const logoHeight = 70; const logoMargin = 16; const symbolsPerRow = 6; const symbolCardWidth = 140; const symbolCardHeight = 175; const symbolCardGap = 8; const symbolImageSize = 96; // Calculate canvas height based on number of symbols // Symbols are arranged like a flexbox row with wrap const canvasHeight = Math.ceil(imageList.length / symbolsPerRow) * (symbolCardHeight + symbolCardGap) + symbolCardGap + logoHeight + (logoMargin * 2); const canvasMargin = Math.ceil((canvasWidth - (symbolsPerRow * (symbolCardWidth + symbolCardGap)) + symbolCardGap) / 2); // Create canvas element in the html document const canvas = document.createElement('canvas'); canvas.width = canvasWidth; canvas.height = canvasHeight; // Get 2d drawing context const ctx = canvas.getContext('2d')!; // Draw background image (same as the one used for the PageSection) const background = new Image(); // 跨域处理(如果背景图是外部资源需要开启) // background.crossOrigin = 'anonymous'; const RobotoBold = new FontFace('Roboto-Bold', 'url(/fonts/Roboto-Bold.ttf)') RobotoBold.load() .then(() => { document.fonts.add(RobotoBold); // 返回背景图加载的Promise,先绑定事件再设置src return new Promise<void>((resolve, reject) => { const handleLoad = () => { // Calculate scaling factors to cover the canvas while maintaining aspect ratio const scaleX = canvasWidth / background.width; const scaleY = canvasHeight / background.height; const scale = Math.max(scaleX, scaleY); // Calculate the new width and height of the image const newWidth = background.width * scale; const newHeight = background.height * scale; // Calculate the position to center the image on the canvas const x = (canvasWidth - newWidth) / 2; const y = (canvasHeight - newHeight) / 2; // Draw the background image with the calculated parameters ctx.filter = 'brightness(0.4) blur(10px)'; ctx.drawImage(background, x, y, newWidth, newHeight); // Reset filter ctx.filter = 'none'; resolve(); }; background.onload = handleLoad; background.onerror = () => reject(new Error('Background image load failed')); // 检查图片是否已缓存完成 if (background.complete) { handleLoad(); } // 最后设置src background.src = backgroundImageSrc; }); }) .then(() => { // List of promises for loading images const imagePromises: Promise<void>[] = []; // Load the logo image - 同样修复logo的加载逻辑 const logo = new Image(); // logo.crossOrigin = 'anonymous'; imagePromises.push(new Promise<void>((resolve, reject) => { const handleLoad = () => { // Calculate the scaled width to maintain aspect ratio const scaledWidth = (logoHeight / logo.naturalHeight) * logo.naturalWidth; // Draw logo horizontally centered with a margin at the top ctx.drawImage( logo, canvasWidth / 2 - scaledWidth / 2, logoMargin, scaledWidth, logoHeight ); resolve(); }; logo.onload = handleLoad; logo.onerror = () => reject(new Error('Logo image load failed')); if (logo.complete) { handleLoad(); } logo.src = FullLogo.src; })); // Calculate values for drawing symbols in the last row const symbolsInLastRow = imageList.length % symbolsPerRow; const lastRowOffset = (symbolsPerRow - symbolsInLastRow) * (symbolCardWidth + symbolCardGap) / 2 // Draw symbols with rounded backgrounds for (let i = 0; i < imageList.length; i++) { const imageReference = imageList[i]; // If the symbol is in the last row, we need to adjust the x position to center it const isLastRow = i >= imageList.length - symbolsInLastRow; const x = (i % symbolsPerRow) * (symbolCardWidth + symbolCardGap) + symbolCardGap + canvasMargin + (isLastRow ? lastRowOffset : 0); const y = Math.floor(i / symbolsPerRow) * (symbolCardHeight + symbolCardGap) + symbolCardGap + logoHeight + (logoMargin * 2); // Draw transparent gray background for symbol with rounded borders ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; roundRect(ctx, x, y, symbolCardWidth, symbolCardHeight, 16); // Draw symbol image - 修复小图的加载逻辑 const image = new Image(); // image.crossOrigin = 'anonymous'; imagePromises.push(new Promise<void>((resolve, reject) => { const handleLoad = () => { ctx.drawImage(image, x + (symbolCardWidth - symbolImageSize) / 2, y + (symbolCardHeight - symbolImageSize) / 4, symbolImageSize, symbolImageSize); resolve(); }; image.onload = handleLoad; image.onerror = () => reject(new Error(`Symbol image ${imageReference.url} load failed`)); if (image.complete) { handleLoad(); } image.src = imageReference.url; })); // Draw symbol name ctx.fillStyle = 'white'; ctx.font = '20px Roboto-Bold'; ctx.textAlign = 'center'; ctx.fillText(customNames[imageReference.id] ?? imageReference.name, x + symbolCardWidth / 2, y + symbolCardHeight - 24, symbolCardWidth - 16); } // Convert canvas to Blob and trigger download after all images are loaded return Promise.all(imagePromises); }) .then(() => { canvas.toBlob(blob => { // Trigger download const a = document.createElement('a'); a.download = `${calloutSet?.name}.png`; a.href = URL.createObjectURL(blob!); a.click(); setIsGeneratingImage(false); }); }) .catch(error => { console.error('Image generation failed:', error); setIsGeneratingImage(false); }); }
额外优化点
- 为所有图片加载添加错误处理,避免因单张图片加载失败导致整个流程挂起。
- 将Promise链扁平化,避免嵌套过深,提升代码可读性。
- 统一图片加载的逻辑,可以封装成一个工具函数,减少重复代码。
内容的提问来源于stack exchange,提问作者okay
相关产品推荐
相关产品推荐

