You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

HTML Canvas图像生成仅生效一次问题排查与修复

问题

使用Next.js + HTML Canvas实现图像生成按钮,点击可生成含多张小图的合成图,首次(或偶尔几次)生成正常,但后续点击时流程终止,排查发现background.onload回调从未触发,导致后续步骤无法执行。已用Promise管理字体、背景图、小图的加载流程。

原因分析
  1. 图片缓存导致事件丢失:当背景图被浏览器缓存后,设置background.src时图片会立即加载完成,但此时onload回调还未绑定(原代码先设置src,再在FontFace加载完成后绑定onload),因此回调永远不会触发。
  2. 未处理图片已加载完成的状态:即使后续绑定了onload,如果图片已经处于complete状态(缓存),浏览器不会再触发onload事件。
  3. 缺少错误处理:如果图片加载失败,原代码没有处理onerror,会导致Promise永远无法resolve,流程卡死。
修复方案
  1. 调整事件绑定顺序:先绑定onload和onerror回调,再设置图片src,确保不会错过缓存图片的加载事件。
  2. 检查图片complete状态:绑定回调后,立即检查图片是否已经加载完成,如果是则直接执行回调逻辑。
  3. 添加错误处理:为图片添加onerror回调,在加载失败时reject Promise,避免流程挂起。
  4. 可选:添加跨域属性:如果背景图是跨域资源,设置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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.09 21:05:22