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

NextJS Canvas拖拽SVG元素时出现闪烁问题求助

解决NextJS Canvas拖拽元素闪烁问题

核心问题分析

  • 无图片缓存:每次draw()都会重新创建Image对象并加载SVG,导致绘制延迟,加剧闪烁
  • 状态更新不规范:直接修改tablesState数组元素,未触发React状态更新,绘制逻辑异常
  • 无缓冲机制:直接在画布清空重绘,视觉上出现空白间隙引发闪烁

具体解决方案

1. 预缓存SVG图片

提前生成并缓存所有形状的SVG图片,避免每次绘制重复加载:

// 组件内或外部维护缓存对象
const svgCache = {};

// 生成对应形状的SVG Base64内容
function generateSVG(shape, tableNbr, nbrPlaces) {
  let svgContent = '';
  switch(shape) {
    case "square":
      svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="140" height="140">
        <rect x="20" y="20" fill="#CCC" width="100" height="100"/>
        <text x="70" y="70" text-anchor="middle" alignment-baseline="middle" fill="#000" font-size="26px">${tableNbr}</text>
        <circle cx="115" cy="25" r="25" fill="#000"/>
        <text x="115" y="27" text-anchor="middle" alignment-baseline="middle" fill="#FFF" font-weight="700" font-size="24px">-/${nbrPlaces}</text>
      </svg>`;
      break;
    case "oval":
      svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="190" height="140">
        <ellipse cx="100" cy="70" rx="80" ry="50" fill="#CCC"/>
        <text x="100" y="70" text-anchor="middle" alignment-baseline="middle" fill="#000" font-size="26px">${tableNbr}</text>
        <circle cx="160" cy="30" r="25" fill="#000"/>
        <text x="160" y="30" text-anchor="middle" alignment-baseline="middle" fill="#FFF" font-weight="700" font-size="24px">-/${nbrPlaces}</text>
      </svg>`;
      break;
    case "rectangular":
      svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="210" height="140">
        <rect width="150" height="100" x="20" y="20" fill="#CCC"/>
        <text x="95" y="70" text-anchor="middle" alignment-baseline="middle" fill="#000" font-size="26px">${tableNbr}</text>
        <circle cx="170" cy="30" r="25" fill="#000"/>
        <text x="170" y="30" text-anchor="middle" alignment-baseline="middle" fill="#FFF" font-weight="700" font-size="24px">-/${nbrPlaces}</text>
      </svg>`;
      break;
    default: // circle
      svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="140" height="140">
        <circle cx="70" cy="70" r="50" fill="#CCC"/>
        <text x="70" y="70" text-anchor="middle" alignment-baseline="middle" fill="#000" font-size="26px">${tableNbr}</text>
        <circle cx="110" cy="35" r="25" fill="#000"/>
        <text x="110" y="35" text-anchor="middle" alignment-baseline="middle" fill="#FFF" font-weight="700" font-size="24px">-/${nbrPlaces}</text>
      </svg>`;
  }
  return `data:image/svg+xml;base64,${window.btoa(svgContent)}`;
}

// 获取缓存的图片对象
function getCachedImage(el) {
  const key = `${el.shape}-${el.tableNbr}-${el.nbrPlaces}`;
  if (!svgCache[key]) {
    const img = new Image();
    img.src = generateSVG(el.shape, el.tableNbr, el.nbrPlaces);
    svgCache[key] = img;
  }
  return svgCache[key];
}

2. 规范React状态更新

禁止直接修改tablesState元素,使用setTablesState触发状态更新:

const handleMouseMove = (event) => {
  if (!isDragging || selectedElement === -1) return;
  event.preventDefault();

  const element = tablesState.find((el) => el.id === selectedElement);
  if (!element) return;

  const width = element.shape === "square" || element.shape === "circle" ? 80 : 150;
  const height = element.shape === "square" || element.shape === "circle" ? 80 : 100;

  // 正确更新状态
  setTablesState(prevState => prevState.map(item => {
    if (item.id === selectedElement) {
      return {...item, x: event.offsetX - width / 2, y: event.offsetY - height / 2};
    }
    return item;
  }));
};

3. 实现双缓冲消除闪烁

创建离屏Canvas,先完成所有绘制再一次性渲染到主画布:

let offscreenCanvas = null;
let offscreenCtx = null;

useEffect(() => {
  const canvas = canvasRef.current;
  if (!canvas) return;
  // 初始化离屏画布
  offscreenCanvas = document.createElement('canvas');
  offscreenCanvas.width = canvas.width;
  offscreenCanvas.height = canvas.height;
  offscreenCtx = offscreenCanvas.getContext('2d');
}, [canvasRef]);

function draw() {
  if (!offscreenCtx || !context) return;
  // 清空离屏画布
  offscreenCtx.clearRect(0, 0, offscreenCanvas.width, offscreenCanvas.height);

  for (const el of tablesState) {
    const img = getCachedImage(el);
    if (img.complete) {
      const [w, h] = el.shape === "square" || el.shape === "circle" 
        ? [80, 80] 
        : el.shape === "oval" 
          ? [140, 100] 
          : [150, 100];
      offscreenCtx.drawImage(img, el.x, el.y, w, h);
    }
  }

  // 一次性绘制到主画布
  context.drawImage(offscreenCanvas, 0, 0);
}

4. 优化绘制触发时机

将draw绑定到tablesState状态更新,移除拖拽时手动调用:

useEffect(() => {
  if (!offscreenCtx || tablesState.length === 0) return;
  draw();
}, [tablesState, offscreenCtx]);

修正后的核心组件代码

import { useState, useEffect, useRef } from 'react';

export default function CanvasTable() {
  const canvasRef = useRef(null);
  const [tablesState, setTablesState] = useState([]);
  const isDraggingRef = useRef(false);
  const selectedElementRef = useRef(-1);
  let offscreenCanvas = null;
  let offscreenCtx = null;
  const svgCache = {};

  // 省略generateSVG、getCachedImage、draw函数...

  useEffect(() => {
    // 假设tables是传入的初始数据
    setTablesState(tables);
  }, []);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    offscreenCanvas = document.createElement('canvas');
    offscreenCanvas.width = canvas.width;
    offscreenCanvas.height = canvas.height;
    offscreenCtx = offscreenCanvas.getContext('2d');
    const context = canvas.getContext('2d');
    
    const handleMouseDown = (event) => {
      event.preventDefault();
      isDraggingRef.current = true;
      const prevX = event.offsetX;
      const prevY = event.offsetY;

      // 命中检测
      for (let i = 0; i < tablesState.length; i++) {
        const el = tablesState[i];
        const height = el.shape === "square" || el.shape === "circle" ? 80 : 100;
        const width = el.shape === "square" || el.shape === "circle" ? 70 : 150;
        if (prevX >= el.x && prevX <= el.x + width && prevY >= el.y && prevY <= el.y + height) {
          selectedElementRef.current = el.id;
          break;
        }
      }
    };

    const handleMouseUp = (e) => {
      e.preventDefault();
      isDraggingRef.current = false;
      selectedElementRef.current = -1;
    };

    const handleMouseOut = (e) => {
      e.preventDefault();
      isDraggingRef.current = false;
      selectedElementRef.current = -1;
    };

    const handleMouseMove = (event) => {
      if (!isDraggingRef.current || selectedElementRef.current === -1) return;
      event.preventDefault();

      const element = tablesState.find((el) => el.id === selectedElementRef.current);
      if (!element) return;

      const width = element.shape === "square" || element.shape === "circle" ? 80 : 150;
      const height = element.shape === "square" || element.shape === "circle" ? 80 : 100;

      setTablesState(prev => prev.map(el => {
        if (el.id === selectedElementRef.current) {
          return {...el, x: event.offsetX - width / 2, y: event.offsetY - height / 2};
        }
        return el;
      }));
    };

    canvas.addEventListener("mousedown", handleMouseDown);
    canvas.addEventListener("mouseup", handleMouseUp);
    canvas.addEventListener("mousemove", handleMouseMove);
    canvas.addEventListener("mouseout", handleMouseOut);

    return () => {
      canvas.removeEventListener("mousedown", handleMouseDown);
      canvas.removeEventListener("mouseup", handleMouseUp);
      canvas.removeEventListener("mousemove", handleMouseMove);
      canvas.removeEventListener("mouseout", handleMouseOut);
    };
  }, [tablesState]);

  useEffect(() => {
    if (!offscreenCtx || tablesState.length === 0) return;
    draw();
  }, [tablesState, offscreenCtx]);

  return (
    <div style={{ width: "100%", height: "calc(100% - 40px)", overflow: "scroll" }}>
      <canvas
        width={1210}
        height={495}
        style={{ backgroundColor: "#FFF" }}
        ref={canvasRef}
      ></canvas>
    </div>
  );
}

额外优化点

  • 用useRef存储isDragging和selectedElement,避免状态更新导致事件重新绑定
  • 移除不必要的canvas和context状态,直接通过canvasRef.current获取
  • 确保SVG内容转义正确,避免特殊字符导致加载失败

内容的提问来源于stack exchange,提问作者Boulanfad Mustapha

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.17 07:39:53