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

HTML5 Canvas绘制图形重绘全画布致卡顿的优化方案咨询

优化协作白板实时绘制卡顿问题

核心卡顿原因

你当前的问题根源在于每次鼠标移动都清空整个画布、重新加载DataURL图片再绘制临时形状:

  • DataURL转Image是异步操作,会导致绘制延迟累积
  • 全量重绘整个画布的像素数据,画布尺寸越大性能开销越高
  • 铅笔工具无卡顿是因为它不需要清空画布,直接在原有内容上追加绘制,没有重绘成本

优化方案

1. 双画布分层架构

用两层画布拆分绘制逻辑,彻底避免全量重绘:

  • 底层画布(持久层):渲染所有已确认的绘制内容(本地完成的图形、其他客户端同步的内容)
  • 顶层画布(临时层):仅用于实时预览当前正在绘制的临时形状(比如拖动中的直线),鼠标松开后再合并到底层画布

实时绘制时只需要操作顶层画布的小范围区域,不用动底层,性能会大幅提升。

2. 传输绘制元数据而非全量图片

放弃用DataURL同步整个画布,改为存储每个图形的元数据(类型、坐标、样式):

  • 元数据体积远小于DataURL,传输更快更省带宽
  • 本地用Canvas API直接绘制图形,比加载图片高效得多
  • 图形始终保持矢量清晰度,缩放不会模糊

3. 优化实时绘制流程

  • 鼠标按下时记录起点,初始化临时图形元数据
  • 鼠标移动时仅清空顶层画布,重新绘制当前临时形状
  • 鼠标松开时,将临时图形元数据加入持久列表,合并顶层到底层,同时发送元数据给其他客户端

修改后的代码示例

const Whiteboard = ({ open }) => {
  const canvasRef = useRef(null); // 底层持久画布
  const tempCanvasRef = useRef(null); // 顶层临时画布
  const [shapes, setShapes] = useState([]); // 存储所有图形元数据
  const ctxRef = useRef(null);
  const tempCtxRef = useRef(null);

  // 初始化画布上下文与尺寸
  useEffect(() => {
    if (!open) return;
    const canvas = canvasRef.current;
    const tempCanvas = tempCanvasRef.current;
    if (!canvas || !tempCanvas) return;

    const rect = canvas.getBoundingClientRect();
    canvas.width = rect.width;
    canvas.height = rect.height;
    tempCanvas.width = rect.width;
    tempCanvas.height = rect.height;

    ctxRef.current = canvas.getContext('2d');
    tempCtxRef.current = tempCanvas.getContext('2d');

    // 设置默认绘制样式
    const setDefaultStyle = (ctx) => {
      ctx.lineWidth = 5;
      ctx.lineJoin = 'round';
      ctx.lineCap = 'round';
      ctx.strokeStyle = 'black';
    };
    setDefaultStyle(ctxRef.current);
    setDefaultStyle(tempCtxRef.current);

    // 初始重绘所有持久图形
    redrawShapes();

    // 窗口 resize 时调整画布尺寸并重绘
    const handleResize = () => {
      const newRect = canvas.getBoundingClientRect();
      canvas.width = newRect.width;
      canvas.height = newRect.height;
      tempCanvas.width = newRect.width;
      tempCanvas.height = newRect.height;
      setDefaultStyle(ctxRef.current);
      setDefaultStyle(tempCtxRef.current);
      redrawShapes();
    };
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, [open, shapes]);

  // 重绘所有持久图形
  const redrawShapes = () => {
    const ctx = ctxRef.current;
    if (!ctx) return;
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    shapes.forEach(shape => drawShape(ctx, shape));
  };

  // 根据元数据绘制单个图形
  const drawShape = (ctx, shape) => {
    ctx.strokeStyle = shape.color || 'black';
    ctx.lineWidth = shape.lineWidth || 5;
    ctx.beginPath();
    switch (shape.type) {
      case 'line':
        ctx.moveTo(shape.start.x, shape.start.y);
        ctx.lineTo(shape.end.x, shape.end.y);
        break;
      // 可扩展其他形状,比如矩形、圆形
      case 'rect':
        ctx.rect(shape.start.x, shape.start.y, shape.end.x - shape.start.x, shape.end.y - shape.start.y);
        break;
    }
    ctx.stroke();
  };

  // Socket接收其他客户端的图形元数据
  useEffect(() => {
    socket.on('shape-data', (shape) => {
      setShapes(prev => [...prev, shape]);
    });
    return () => socket.off('shape-data');
  }, []);

  // 绑定鼠标事件处理实时绘制
  useEffect(() => {
    if (!open) return;
    const tempCanvas = tempCanvasRef.current;
    if (!tempCanvas) return;

    let isDrawing = false;
    let currentShape = null;
    const rect = tempCanvas.getBoundingClientRect();

    // 获取鼠标在画布内的坐标
    const getMousePos = (e) => ({
      x: e.pageX - rect.x,
      y: e.pageY - rect.y
    });

    const handleMouseDown = (e) => {
      isDrawing = true;
      const pos = getMousePos(e);
      currentShape = {
        type: 'line', // 可根据工具切换动态修改类型
        start: pos,
        end: pos,
        color: 'black',
        lineWidth: 5
      };
    };

    const handleMouseMove = (e) => {
      if (!isDrawing || !currentShape) return;
      const tempCtx = tempCtxRef.current;
      if (!tempCtx) return;

      // 清空临时画布,重新绘制当前临时形状
      tempCtx.clearRect(0, 0, tempCtx.canvas.width, tempCtx.canvas.height);
      currentShape.end = getMousePos(e);
      drawShape(tempCtx, currentShape);
    };

    const handleMouseUp = () => {
      if (!isDrawing || !currentShape) return;
      isDrawing = false;
      const tempCtx = tempCtxRef.current;
      if (!tempCtx) return;

      // 清空临时画布
      tempCtx.clearRect(0, 0, tempCtx.canvas.width, tempCtx.canvas.height);
      // 将临时图形加入持久列表
      setShapes(prev => [...prev, currentShape]);
      // 发送元数据给其他客户端
      socket.emit('shape-data', currentShape);
    };

    tempCanvas.addEventListener('mousedown', handleMouseDown);
    tempCanvas.addEventListener('mousemove', handleMouseMove);
    tempCanvas.addEventListener('mouseup', handleMouseUp);
    tempCanvas.addEventListener('mouseleave', handleMouseUp); // 鼠标移出画布时结束绘制

    return () => {
      tempCanvas.removeEventListener('mousedown', handleMouseDown);
      tempCanvas.removeEventListener('mousemove', handleMouseMove);
      tempCanvas.removeEventListener('mouseup', handleMouseUp);
      tempCanvas.removeEventListener('mouseleave', handleMouseUp);
    };
  }, [open]);

  return (
    <div style={{ position: 'relative', width: '100%', height: '80vh' }}>
      {/* 底层持久画布 */}
      <canvas 
        ref={canvasRef} 
        style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }} 
      />
      {/* 顶层临时画布,覆盖在底层 */}
      <canvas 
        ref={tempCanvasRef} 
        style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }} 
      />
    </div>
  );
};

额外优化建议

  • 节流处理:给handleMouseMove加节流函数,减少高频鼠标移动时的绘制次数
  • 批量同步:连续绘制多条图形时,批量发送元数据,降低Socket请求频率
  • 样式复用:将颜色、线宽等样式存入React状态,避免重复设置Canvas上下文属性
  • 撤销/重做:基于元数据列表可轻松实现撤销重做功能,只需操作数组增删即可

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.16 12:55:09