Skip to content

从0到1实现OffscreenCanvas+Worker电子签名

一、背景技术介绍

在Web应用中,canvas绘图通常运行在主线程上,当执行复杂的渲染任务时,很容易造成界面卡顿,影响用户体验,为解决整个问题,HTML引入了OffscreenCanvas和Web Worker技术。本文将详细讲解如何从0到1实现一个基于OffscreenCanvas和Web Worker的高性能电子签名系统。希望您读完也能有所收获~

1.1 相关概念

  • OffscreenCanvas:一种可以在非主线程中渲染的Canvas技术,能够将绘图操作从主线程中分离出来。
  • Web Worker:允许在后台线程中运行JavaScript代码,不会阻塞UI线程。 两者结合的优势: 绘图计算完全不阻塞主线程 更高的绘图性能和更流畅的用户体验 适合处理复杂的图形计算和大量绘图操作

基础环境搭建

2.1 项目结构

结构大致如下

├── index.html              // 主页面
├── main.js                 // 主线程代码
├── style.css               // 样式文件
├── worker.js               // Worker线程代码
└── utils/                  // 工具函数
    └── bezier.js           // 贝塞尔曲线算法

2.2 HTML结构

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <title>OffscreenCanvas+Worker</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="signature-container">
    <div class="signature-header">
      <h2>电子签名</h2>
    </div>
    
    <div class="canvas-container">
      <!-- 显示用的Canvas -->
      <canvas id="displayCanvas"></canvas>
    </div>
    
    <div class="action-buttons">
      <button id="clearBtn" class="btn">清除签名</button>
      <button id="saveBtn" class="btn primary">保存签名</button>
    </div>
  </div>
  
  <script src="main.js" type="module"></script>
</body>
</html>

2.3 基础样式

css
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  background-color: #f5f5f5;
  touch-action: none;
}

.signature-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  max-width: 100%;
  margin: 0 auto;
}

.signature-header {
  padding: 16px;
  background-color: #fff;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  text-align: center;
}

.canvas-container {
  flex: 1;
  padding: 20px;
  display: flex;
  justify-content: center;
  align-items: center;
  overflow: hidden;
}

#displayCanvas {
  background-color: white;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  touch-action: none;
}

.action-buttons {
  padding: 16px;
  background-color: #fff;
  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
  display: flex;
  justify-content: space-around;
}

.btn {
  padding: 10px 20px;
  border: 1px solid #ddd;
  border-radius: 4px;
  background-color: #f5f5f5;
  font-size: 16px;
  cursor: pointer;
  flex: 1;
  margin: 0 8px;
}

.btn.primary {
  background-color: #1890ff;
  color: white;
  border-color: #1890ff;
}

三、主线程代码实现

3.1 初始化与设置

在 main.js 中,我们先初始化主线程代码

js
// main.js
document.addEventListener('DOMContentLoaded', () => {
  // 获取DOM元素
  const displayCanvas = document.getElementById('displayCanvas');
  const clearBtn = document.getElementById('clearBtn');
  const saveBtn = document.getElementById('saveBtn');
  
  // 设置Canvas尺寸
  let canvasWidth = Math.min(window.innerWidth * 0.9, 800);
  let canvasHeight = Math.min(window.innerHeight * 0.6, 600);
  
  // 处理设备像素比
  const setupHiDPICanvas = (canvas) => {
    const dpr = window.devicePixelRatio || 1;
    
    // 设置CSS尺寸
    canvas.style.width = `${canvasWidth}px`;
    canvas.style.height = `${canvasHeight}px`;
    
    // 设置实际尺寸,考虑DPR
    // dpr是设备像素比(Device Pixel Ratio),表示物理像素与CSS像素的比例
    // 在高分辨率屏幕(如Retina显示屏)上,dpr通常为2或3,普通屏幕为1
    // 乘以dpr可以确保在高分辨率屏幕上绘制的内容更加清晰
    canvas.width = canvasWidth * dpr;
    canvas.height = canvasHeight * dpr;
    
    // 获取2D上下文
    const ctx = canvas.getContext('2d');
    
    // 根据DPR缩放上下文
    ctx.scale(dpr, dpr);
    
    return { ctx, dpr };
  };
  
  // 设置高DPI Canvas
  const { ctx: displayCtx, dpr } = setupHiDPICanvas(displayCanvas);
  
  // 初始化Worker
  let drawingWorker;
  let offscreenCanvas;
  
  // 创建Worker和OffscreenCanvas
  initializeWorker();
  
  // 更多代码将在下面继续...

3.2 初始化Worker和OffscreenCanvas

js
  // 初始化Worker和OffscreenCanvas
  function initializeWorker() {
    // 检查是否支持OffscreenCanvas
    if (!window.OffscreenCanvas) {
      alert('您的浏览器不支持OffscreenCanvas,将回退到标准模式');
      fallbackToStandardCanvas();
      return;
    }
    
    try {
      // 创建Worker
      drawingWorker = new Worker('worker.js');
      
      // 创建OffscreenCanvas
      offscreenCanvas = new OffscreenCanvas(canvasWidth * dpr, canvasHeight * dpr);
      
      // 将OffscreenCanvas传递给Worker
      const transferableOffscreen = offscreenCanvas.transferToOffscreen();
      
      // 发送初始化消息给Worker
      drawingWorker.postMessage({
        type: 'init',
        canvas: transferableOffscreen,
        width: canvasWidth,
        height: canvasHeight,
        dpr: dpr
      }, [transferableOffscreen]); // 这里使用数组是因为transferableOffscreen是一个可转移对象,
      // 通过将其放入数组中作为postMessage的第二个参数,实现了对象所有权从主线程转移到Worker线程,
      // 而不是复制。这样可以避免大对象的复制开销,提高性能。转移后,主线程将无法再访问此对象。
      
      // 监听Worker消息
      drawingWorker.onmessage = handleWorkerMessage;
      
      console.log('Worker和OffscreenCanvas初始化成功');
    } catch (error) {
      console.error('Worker初始化失败:', error);
      fallbackToStandardCanvas();
    }
  }

3.3 处理触摸和鼠标事件

js
  // 触摸和鼠标事件变量
  let isDrawing = false;
  let points = [];
  
  // 统一处理鼠标和触摸事件的坐标
  function getCoordinates(event) {
    // getBoundingClientRect() 方法返回元素的大小及其相对于视口的位置
    // 返回的对象包含 left、top、right、bottom、width、height 等属性
    // - left/top:元素左上角相对于视口的水平/垂直距离
    // - right/bottom:元素右下角相对于视口的水平/垂直距离
    // - width/height:元素的宽度/高度(包括内边距和边框)
    // 这里用于将鼠标/触摸事件的客户端坐标转换为canvas内的相对坐标
    const rect = displayCanvas.getBoundingClientRect();
    let clientX, clientY;
    
    // 判断事件类型
    if (event.touches) {
      // 触摸事件处理
      // event.touches 是一个 TouchList 对象,包含了当前屏幕上所有的触摸点
      // touches[0] 表示第一个触摸点,因为我们只关心第一个手指的触摸
      // 在多点触控的情况下,touches 数组会包含多个触摸点的信息
      // 这里我们只取第一个触摸点的坐标,忽略其他触摸点
      // 这种处理方式适合单指签名的场景,确保只有一个笔画在绘制
      clientX = event.touches[0].clientX;
      clientY = event.touches[0].clientY;
    } else {
      // 鼠标事件处理
      // 鼠标事件直接包含 clientX 和 clientY 属性
      clientX = event.clientX;
      clientY = event.clientY;
    }
    
    return {
      x: clientX - rect.left,
      y: clientY - rect.top
    };
  }
  
  // 触摸开始/鼠标按下事件处理
  function handleStart(event) {
    event.preventDefault();
    
    isDrawing = true;
    const { x, y } = getCoordinates(event);
    points = [{ x, y }];
    
    // 通知 Worker开始新的绘制路径
    drawingWorker.postMessage({
      type: 'start',
      x: x,
      y: y
    });
  }
  
  // 触摸移动/鼠标移动事件处理
  function handleMove(event) {
    if (!isDrawing) return;
    event.preventDefault();
    
    const { x, y } = getCoordinates(event);
    points.push({ x, y });
    
    // 获取压力值(如果设备支持)
    let pressure = 0.5;
    if (event.touches && event.touches[0].force !== undefined) {
      pressure = event.touches[0].force;
    }
    
    // 发送点坐标给Worker
    drawingWorker.postMessage({
      type: 'move',
      x: x,
      y: y,
      pressure: pressure
    });
    
    // 如果积累了足够多的点,批量发送并清空
    if (points.length > 10) {
      drawingWorker.postMessage({
        type: 'batch', // 批量处理类型,用于一次性发送多个点的数据,提高性能
        points: points.slice()
      });
      points = [points[points.length - 1]];
    }
  }
  
  // 触摸结束/鼠标抬起事件处理
  function handleEnd(event) {
    if (!isDrawing) return;
    event.preventDefault();
    
    isDrawing = false;
    
    // 发送剩余点和结束消息
    if (points.length > 0) {
      drawingWorker.postMessage({
        type: 'batch',
        points: points.slice(),
        isLast: true
      });
    }
    
    drawingWorker.postMessage({
      type: 'end'
    });
    
    points = [];
  }

3.4 事件监听和UI交互

js
  // 添加事件监听器
  displayCanvas.addEventListener('mousedown', handleStart);
  displayCanvas.addEventListener('mousemove', handleMove);
  displayCanvas.addEventListener('mouseup', handleEnd);
  displayCanvas.addEventListener('mouseleave', handleEnd);
  
  displayCanvas.addEventListener('touchstart', handleStart, { passive: false });
  displayCanvas.addEventListener('touchmove', handleMove, { passive: false });
  displayCanvas.addEventListener('touchend', handleEnd, { passive: false });
  
  // 阻止默认的触摸行为
  // 阻止默认的触摸行为(如页面滚动、缩放等),确保在签名区域内的触摸事件只用于绘制签名
  // 设置passive: false允许我们调用preventDefault()来阻止默认行为
  document.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
  
  // 监听窗口大小变化
  window.addEventListener('resize', debounce(() => {
    // 重新计算Canvas尺寸
    canvasWidth = Math.min(window.innerWidth * 0.9, 800);
    canvasHeight = Math.min(window.innerHeight * 0.6, 600);
    
    // 更新显示Canvas尺寸
    displayCanvas.style.width = `${canvasWidth}px`;
    displayCanvas.style.height = `${canvasHeight}px`;
    
    // 通知Worker尺寸变化
    drawingWorker.postMessage({
      type: 'resize',
      width: canvasWidth,
      height: canvasHeight,
      dpr: dpr
    });
  }, 200));
  
  // 清除按钮点击事件
  clearBtn.addEventListener('click', () => {
    drawingWorker.postMessage({ type: 'clear' });
  });
  
  // 保存按钮点击事件
  saveBtn.addEventListener('click', async () => {
    // 请求 Worker发送图像数据
    drawingWorker.postMessage({ type: 'getImageData' });
  });

3.5 处理Worker消息和标准Canvas回退方案

js
  // 处理Worker发送的消息
  function handleWorkerMessage(event) {
    const { type, data } = event.data;
    
    switch (type) {
      case 'render':
        // 从Worker接收渲染结果
        // ImageData是Canvas API中的一个接口,用于存储像素数据
        // 它包含三个属性:width、height和data(像素数据数组)
        // Uint8ClampedArray是一个8位无符号整型数组,专门用于存储图像数据
        // 数组中的每四个元素表示一个像素点的RGBA值(红、绿、蓝、透明度),取值范围为0-255
        // 这里从Worker线程接收到的data.buffer是一个ArrayBuffer,需要转换为Uint8ClampedArray
        const imageData = new ImageData(
          new Uint8ClampedArray(data.buffer), // 将ArrayBuffer转换为Uint8ClampedArray
          canvasWidth * dpr,                  // 图像宽度(考虑设备像素比)
          canvasHeight * dpr                  // 图像高度(考虑设备像素比)
        );
        
        // 清除显示Canvas
        displayCtx.clearRect(0, 0, canvasWidth * dpr, canvasHeight * dpr);
        
        // 绘制Worker传来的图像数据
        displayCtx.putImageData(imageData, 0, 0);
        break;
        
      case 'imageData':
        // 将图像数据转换为Blob并下载
        saveSignatureAsImage(data);
        break;
        
      case 'error':
        console.error('Worker错误:', data.message);
        break;
    }
  }
  
  // 保存签名为图片
  function saveSignatureAsImage(imageDataBuffer) {
    // 创建临时Canvas用于转换图像数据
    const tempCanvas = document.createElement('canvas');
    tempCanvas.width = canvasWidth * dpr;
    tempCanvas.height = canvasHeight * dpr;
    const tempCtx = tempCanvas.getContext('2d');
    
    // 将ArrayBuffer转换为ImageData
    const imageData = new ImageData(
      new Uint8ClampedArray(imageDataBuffer),
      canvasWidth * dpr,
      canvasHeight * dpr
    );
    
    // 将ImageData绘制到临时Canvas
    // putImageData是Canvas 2D API的一个方法,用于将ImageData对象的数据
    // 绘制到Canvas上指定的位置。这个方法直接操作像素数据,不受Canvas变换矩阵的影响
    // 参数说明:
    // - 第一个参数:要绘制的ImageData对象
    // - 第二个参数:目标X坐标位置(左上角)
    // - 第三个参数:目标Y坐标位置(左上角)
    tempCtx.putImageData(imageData, 0, 0);
    
    // 转换为Blob并下载
    // toBlob方法将Canvas内容转换为二进制大对象(Blob)
    // Blob对象表示一个不可变的、原始数据的类文件对象
    // 参数说明:
    // - 第一个参数:回调函数,在转换完成后执行,接收生成的Blob对象
    // - 第二个参数:MIME类型,这里是'image/png'表示PNG图片格式
    // - 第三个参数(可选):图片质量,对PNG无效,对JPEG有效(0-1之间)
    tempCanvas.toBlob((blob) => {
      // URL.createObjectURL创建一个表示Blob对象的URL
      // 这个URL可以用于<img>的src或<a>的href等
      // 它的格式类似于:blob:https://example.com/550e8400-e29b-41d4-a716-446655440000
      // 这个URL只在当前文档打开期间有效,页面关闭后自动释放
      const url = URL.createObjectURL(blob);
      const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
      const a = document.createElement('a');
      a.href = url;
      a.download = `signature-${timestamp}.png`;
      a.click();
      // 释放URL对象
      setTimeout(() => URL.revokeObjectURL(url), 100);
    }, 'image/png');
  }
  
  // 回退到标准Canvas模式的实现
  function fallbackToStandardCanvas() {
    // 此处实现标准Canvas绘图逻辑,当OffscreenCanvas不可用时使用
    console.log('回退到标准Canvas模式');
    // ...标准Canvas实现代码
  }
  
  // 防抖函数实现
  function debounce(func, wait) {
    let timeout;
    return function(...args) {
      clearTimeout(timeout);
      timeout = setTimeout(() => func.apply(this, args), wait);
    };
  }
});

四、 Worker线程代码实现

4.1 Worker初始化和消息处理

worker.js文件中:

js
// worker.js

// 模块导入(Web Worker中可以使用importScripts或ES模块)
// importScripts('utils/bezier.js');

// 全局变量
let offscreenCanvas;
let ctx;
let width;
let height;
let dpr = 1;
let isDrawing = false;
let pathPoints = [];
let currentPath = [];

// 初始化函数
// 接收从主线程传递的离屏Canvas并设置绘图环境
function initialize(canvas, canvasWidth, canvasHeight, devicePixelRatio) {
  // 保存参数到全局变量
  offscreenCanvas = canvas;
  width = canvasWidth;
  height = canvasHeight;
  dpr = devicePixelRatio || 1;
  
  // 获取绘图上下文
  ctx = offscreenCanvas.getContext('2d');
  
  // 设置绘图样式
  setupDrawingStyle();
  
  // 清空画布并绘制白色背景
  clearCanvas();
}

// 设置绘图样式
function setupDrawingStyle(lineWidth = 2) {
  if (!ctx) return;
  
  ctx.lineWidth = lineWidth * dpr;
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
  ctx.strokeStyle = '#000000';
}

// 清空画布
function clearCanvas() {
  if (!ctx) return;
  
  // 清除整个画布
  ctx.clearRect(0, 0, width * dpr, height * dpr);
  
  // 填充白色背景
  ctx.fillStyle = '#FFFFFF';
  ctx.fillRect(0, 0, width * dpr, height * dpr);
  
  // 重置路径数据
  pathPoints = [];
  currentPath = [];
  
  // 发送渲染结果回主线程
  sendImageToMain();
}

// 监听主线程消息
self.onmessage = function(event) {
  const { type } = event.data;
  
  try {
    switch (type) {
      case 'init':
        initialize(
          event.data.canvas,
          event.data.width,
          event.data.height,
          event.data.dpr
        );
        break;
        
      case 'start':
        handleDrawStart(event.data.x, event.data.y);
        break;
        
      case 'move':
        handleDrawMove(event.data.x, event.data.y, event.data.pressure);
        break;
        
      case 'batch':
        handleBatchPoints(event.data.points, event.data.isLast);
        break;
        
      case 'end':
        handleDrawEnd();
        break;
        
      case 'clear':
        clearCanvas();
        break;
        
      case 'getImageData':
        sendImageToMain(true);
        break;
        
      case 'resize':
        handleResize(event.data.width, event.data.height, event.data.dpr);
        break;
        
      default:
        console.warn('Worker: 未知消息类型', type);
    }
  } catch (err) {
    self.postMessage({
      type: 'error',
      data: { message: err.message }
    });
  }
};

4.2 绘图处理和优化算法

js
// 处理绘制开始
function handleDrawStart(x, y) {
  isDrawing = true;
  
  // 保存起始点
  currentPath = [{ x: x * dpr, y: y * dpr }];
  // 将起始点添加到pathPoints数组中
  // pathPoints数组用于存储整个签名过程中的所有点
  // x和y是触摸/鼠标的坐标,乘以dpr(设备像素比)以适应高分辨率屏幕
  // 这行代码确保签名的起始点被记录下来,用于后续可能的签名回放或导出
  pathPoints.push({ x: x * dpr, y: y * dpr });
}

// 处理绘制移动
function handleDrawMove(x, y, pressure = 0.5) {
  if (!isDrawing) return;
  
  // 调整压力范围
  pressure = Math.max(0.2, Math.min(1, pressure || 0.5));
  
  // 保存坐标点和压力值
  const point = {
    x: x * dpr,
    y: y * dpr,
    pressure: pressure
  };
  
  currentPath.push(point);
  pathPoints.push(point);
  
  // 如果有足够的点,则平滑绘制
  if (currentPath.length >= 3) {
    drawSmoothLine(currentPath, pressure);
    
    // 保留最后两个点用于下次绘制的连续性
    currentPath = currentPath.slice(-2);
  }
}

// 处理批量点
function handleBatchPoints(points, isLast = false) {
  if (!isDrawing) return;
  
  // 转换并添加点坐标
  // 将接收到的点坐标转换为适应设备像素比(dpr)的坐标
  // 转换的原因:确保在高分辨率屏幕(如Retina显示屏)上绘制的签名清晰且比例正确
  // 转换内容:将逻辑像素坐标(CSS像素)转换为物理像素坐标,并保留压力值
  // x和y坐标乘以dpr进行缩放,pressure保持不变或使用默认值0.5
  const scaledPoints = points.map(p => ({
    x: p.x * dpr,
    y: p.y * dpr,
    pressure: p.pressure || 0.5
  }));
  
  // 合并到当前路径
  // concat方法用于合并数组,不会修改原数组,而是返回一个新数组
  // 这里将scaledPoints数组中的所有点添加到currentPath数组的末尾
  // 作用是将新接收到的批量点与当前正在处理的路径点集合合并,保持绘制的连续性
  currentPath = currentPath.concat(scaledPoints);
  
  // 同样,将所有新的点添加到pathPoints数组中
  // pathPoints保存了整个签名的所有点,用于后续可能的回放或保存
  pathPoints = pathPoints.concat(scaledPoints);
  
  // 使用贝塞尔曲线绘制平滑线条
  drawSmoothPath(currentPath);
  
  // 如果是最后一批点,则完成当前笔划
  if (isLast) {
    currentPath = [];
  } else {
    // 保留最后两个点用于下次绘制的连续性
    currentPath = currentPath.slice(-2);
  }
  
  // 发送渲染结果回主线程
  sendImageToMain();
}

// 处理绘制结束
function handleDrawEnd() {
  isDrawing = false;
  
  // 如果当前路径中有点,绘制最后的线段
  if (currentPath.length > 1) {
    drawSmoothPath(currentPath);
  }
  
  // 重置当前路径
  currentPath = [];
  
  // 发送最终渲染结果回主线程
  sendImageToMain();
}

// 绘制平滑曲线
function drawSmoothLine(points, pressure) {
  if (!ctx || points.length < 2) return;
  
  // 设置线宽(根据压力值调整)
  const minWidth = 1;
  const maxWidth = 5;
  const lineWidth = minWidth + pressure * (maxWidth - minWidth);
  ctx.lineWidth = lineWidth * dpr;
  
  // 开始新的路径
  ctx.beginPath();
  
  // 移动到起始点
  ctx.moveTo(points[0].x, points[0].y);
  
  // 绘制二次贝塞尔曲线
  for (let i = 1; i < points.length; i++) {
    const p1 = points[i - 1];
    const p2 = points[i];
    
    // 计算控制点(中点)
    const cpx = (p1.x + p2.x) / 2;
    const cpy = (p1.y + p2.y) / 2;
    
    // quadraticCurveTo(控制点x, 控制点y, 终点x, 终点y)
    // 绘制二次贝塞尔曲线,从当前点到终点(cpx,cpy),以(p1.x,p1.y)为控制点
    // 二次贝塞尔曲线需要三个点:起点(当前路径的最后一点)、控制点和终点
    // 控制点决定了曲线的弯曲程度和方向,但曲线不会通过控制点
    // 这里使用前一个点作为控制点,中点作为终点,可以创建平滑的曲线效果
    // 适合于手写签名等需要平滑过渡的场景
    ctx.quadraticCurveTo(p1.x, p1.y, cpx, cpy);
  }
  
  // 描边
  ctx.stroke();
}

// 绘制完整的平滑路径
function drawSmoothPath(points) {
  if (!ctx || points.length < 2) return;
  
  // 重新绘制整个路径以保证平滑度
  ctx.beginPath();
  ctx.moveTo(points[0].x, points[0].y);
  
  // 使用三次贝塞尔曲线绘制平滑路径
  for (let i = 1; i < points.length - 2; i++) {
    const p0 = points[i];
    const p1 = points[i + 1];
    
    // 计算控制点
    // 三次贝塞尔曲线需要两个控制点
    // 第一个控制点:从当前点p0向下一个点p1方向移动1/3距离
    const cp1x = p0.x + (p1.x - p0.x) / 3;
    const cp1y = p0.y + (p1.y - p0.y) / 3;
    // 第二个控制点:从当前点p0向下一个点p1方向移动2/3距离
    const cp2x = p0.x + 2 * (p1.x - p0.x) / 3;
    const cp2y = p0.y + 2 * (p1.y - p0.y) / 3;
    
    // 三次贝塞尔曲线
    // bezierCurveTo(控制点1x, 控制点1y, 控制点2x, 控制点2y, 终点x, 终点y)
    // 从当前点绘制到p1,使用两个控制点cp1和cp2来控制曲线形状
    // 三次贝塞尔曲线比二次贝塞尔曲线提供更精细的控制,能创造更平滑自然的曲线
    ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p1.x, p1.y);
    
    // 根据压力值动态调整线宽,实现压感效果
    if (p1.pressure) {
      const minWidth = 1;
      const maxWidth = 5;
      const lineWidth = minWidth + p1.pressure * (maxWidth - minWidth);
      ctx.lineWidth = lineWidth * dpr;
    }
  }
  
  // 处理最后几个点,确保路径完整闭合
  if (points.length > 2) {
    const lastIndex = points.length - 1;
    ctx.lineTo(points[lastIndex].x, points[lastIndex].y);
  }
  
  // 描边绘制整个路径
  ctx.stroke();
}

4.3 渲染结果传输和调整大小处理

js
// 将图像发送到主线程
function sendImageToMain(forSaving = false) {
  if (!ctx || !offscreenCanvas) return;
  
  // 获取整个画布的图像数据
  const imageData = ctx.getImageData(0, 0, width * dpr, height * dpr);
  
  // 将图像数据发送回主线程
  self.postMessage({
    type: forSaving ? 'imageData' : 'render',
    data: imageData.data.buffer
  }, [imageData.data.buffer]); // 使用可转移对象(Transferable Objects),将 buffer 的所有权从 Worker 转移到主线程
  // 这种转移是零拷贝的,避免了大量图像数据的复制开销,提高性能
  // 注意:转移后,Worker 中将无法再访问此 buffer,所有权完全转移给了主线程
}

// 处理画布大小调整
function handleResize(newWidth, newHeight, newDpr) {
  // 保存当前图像数据
  const imageData = ctx.getImageData(0, 0, width * dpr, height * dpr);
  
  // 更新尺寸
  width = newWidth;
  height = newHeight;
  dpr = newDpr || dpr;
  
  // 调整OffscreenCanvas大小
  offscreenCanvas.width = width * dpr;
  offscreenCanvas.height = height * dpr;
  
  // 重新设置绘图样式
  setupDrawingStyle();
  
  // 清空画布并绘制白色背景
  ctx.fillStyle = '#FFFFFF';
  ctx.fillRect(0, 0, width * dpr, height * dpr);
  
  // 创建临时canvas来调整大小
  const tempCanvas = new OffscreenCanvas(imageData.width, imageData.height);
  const tempCtx = tempCanvas.getContext('2d');
  tempCtx.putImageData(imageData, 0, 0);
  
  // 在新画布上绘制调整后的图像
  ctx.drawImage(
    tempCanvas,
    0, 0, imageData.width, imageData.height,
    0, 0, width * dpr, height * dpr
  );
  
  // 发送调整后的图像回主线程
  sendImageToMain();
}

4.4 贝塞尔曲线工具函数

js
// utils/bezier.js

/**
 * 计算贝塞尔曲线点
 * @param {Array} points 控制点数组
 * @param {Number} t 参数t,范围0-1
 * @returns {Object} 计算后的点坐标{x, y}
 */
function calculateBezierPoint(points, t) {
  if (points.length < 2) return points[0];
  
  // 递归计算贝塞尔曲线上的点
  // 这段代码实现了贝塞尔曲线的递归计算过程中的一步线性插值
  // 创建一个新数组来存储当前递归层级计算出的点
  const newPoints = [];
  
  // 遍历所有相邻的点对
  for (let i = 0; i < points.length - 1; i++) {
    const p0 = points[i];      // 当前点
    const p1 = points[i + 1];  // 下一个点
    
    // 根据参数t计算p0和p1之间的线性插值点
    // 公式: p = p0 + (p1 - p0) * t
    // 当t=0时,结果为p0;当t=1时,结果为p1
    // 当t在0到1之间时,结果为p0到p1之间的点
    newPoints.push({
      x: p0.x + (p1.x - p0.x) * t,  // x坐标的线性插值
      y: p0.y + (p1.y - p0.y) * t   // y坐标的线性插值
    });
  }
  // 这个过程会将n个点减少为n-1个点,递归调用后最终会得到贝塞尔曲线上的一个点
  
  // 虽然贝塞尔曲线计算本身使用递归会消耗一定性能,但它能够优化性能的原因在于:
  // 1. 数据压缩:只需存储少量控制点,而不是大量的实际绘制点
  // 2. 平滑处理:通过数学方法生成平滑曲线,减少了原始触摸点的抖动和噪声
  // 3. 减少绘制操作:使用贝塞尔曲线可以用较少的canvas绘制操作实现复杂的曲线
  // 4. 适应性缩放:贝塞尔曲线易于缩放而不失真,便于处理不同设备和分辨率
  
  // 递归计算
  return calculateBezierPoint(newPoints, t);
}

/**
 * 生成贝塞尔曲线上的插值点
 * @param {Array} points 控制点数组
 * @param {Number} numPoints 需要插值的点数量
 * @returns {Array} 插值后的点数组
 */
function generateBezierPoints(points, numPoints = 20) {
  const result = [];
  
  for (let i = 0; i <= numPoints; i++) {
    const t = i / numPoints;
    result.push(calculateBezierPoint(points, t));
  }
  
  return result;
}

/**
 * 生成三次贝塞尔曲线的控制点
 * @param {Array} points 路径点数组
 * @returns {Array} 带控制点的数组,每个元素为{point, cp1, cp2}
 */
function generateCubicBezierControlPoints(points) {
  if (points.length < 3) return [];
  
  const result = [];
  
  // 计算第一段曲线
  const firstPoint = points[0];
  const secondPoint = points[1];
  result.push({
    point: firstPoint,
    cp1: firstPoint,
    cp2: {
      x: firstPoint.x + (secondPoint.x - firstPoint.x) / 3,
      y: firstPoint.y + (secondPoint.y - firstPoint.y) / 3
    }
  });
  
  // 计算中间段曲线
  for (let i = 1; i < points.length - 1; i++) {
    const prev = points[i - 1];
    const curr = points[i];
    const next = points[i + 1];
    
    // 计算两个控制点
    const cp1 = {
      x: curr.x - (next.x - prev.x) / 6,
      y: curr.y - (next.y - prev.y) / 6
    };
    
    const cp2 = {
      x: curr.x + (next.x - prev.x) / 6,
      y: curr.y + (next.y - prev.y) / 6
    };
    
    result.push({
      point: curr,
      cp1: cp1,
      cp2: cp2
    });
  }
  
  // 计算最后一段曲线
  const lastPoint = points[points.length - 1];
  const secondLastPoint = points[points.length - 2];
  result.push({
    point: lastPoint,
    cp1: {
      x: lastPoint.x - (lastPoint.x - secondLastPoint.x) / 3,
      y: lastPoint.y - (lastPoint.y - secondLastPoint.y) / 3
    },
    cp2: lastPoint
  });
  
  return result;
}

// 导出工具函数
// 这是通用模块导出模式,兼容不同JavaScript环境
// 检查是否在Node.js环境中(存在module对象)
if (typeof module !== 'undefined') {
  // 在Node.js环境中,使用CommonJS模块系统导出
  module.exports = {
    calculateBezierPoint,
    generateBezierPoints,
    generateCubicBezierControlPoints
  };
} else {
  // 在浏览器或Web Worker环境中,将工具函数挂载到全局对象上
  // self指向当前执行环境的全局对象(在Worker中是WorkerGlobalScope)
  self.BezierTools = {
    calculateBezierPoint,
    generateBezierPoints,
    generateCubicBezierControlPoints
  };
}

五、性能优化与调优

5.1 内存管理优化

在Worker实现内存管理优化

js
// 在worker.js中添加

// 限制保存的点数量,防止内存占用过高
const MAX_POINTS = 10000;

// 在绘制后清理过多的点
function cleanupOldPoints() {
  if (pathPoints.length > MAX_POINTS) {
    // 保留最新的一半点
    pathPoints = pathPoints.slice(-Math.floor(MAX_POINTS / 2));
    
    // 重绘所有路径
    redrawAllPaths();
  }
}

// 重绘所有保存的路径
function redrawAllPaths() {
  // 清空画布
  ctx.clearRect(0, 0, width * dpr, height * dpr);
  
  // 重新填充白色背景
  ctx.fillStyle = '#FFFFFF';
  ctx.fillRect(0, 0, width * dpr, height * dpr);
  
  // 没有点时直接返回
  if (pathPoints.length < 2) return;
  
  // 重新构建并绘制路径
  // 这里可以实现更复杂的路径重建算法,根据点的时间戳或其他属性分组
  const pathSegments = splitIntoPathSegments(pathPoints);
  
  // 绘制每个路径段
  for (const segment of pathSegments) {
    if (segment.length > 1) {
      drawSmoothPath(segment);
    }
  }
}

// 将点分割为不同的路径段
function splitIntoPathSegments(points) {
  if (points.length < 2) return [points];
  
  const segments = [];
  let currentSegment = [points[0]];
  
  for (let i = 1; i < points.length; i++) {
    const prevPoint = points[i - 1];
    const currentPoint = points[i];
    
    // 检查两点之间的距离,如果太远则认为是新的笔画
    const distance = Math.sqrt(
      Math.pow(currentPoint.x - prevPoint.x, 2) +
      Math.pow(currentPoint.y - prevPoint.y, 2)
    );
    
    if (distance > 20 * dpr) {
      // 距离过大,开始新的路径段
      if (currentSegment.length > 1) {
        segments.push(currentSegment);
      }
      currentSegment = [currentPoint];
    } else {
      // 继续当前路径段
      currentSegment.push(currentPoint);
    }
  }
  
  // 添加最后一个路径段
  if (currentSegment.length > 1) {
    segments.push(currentSegment);
  }
  
  return segments;
}

5.2 渲染性能优化

js
// 在worker.js中添加

// 增加防抖渲染,避免频繁向主线程发送图像数据
let renderTimeout = null;
let pendingRender = false;

// 优化后的渲染函数
function scheduleRender(immediate = false) {
  // 标记有待处理的渲染请求
  pendingRender = true;
  
  // 取消已有的定时器
  if (renderTimeout) {
    clearTimeout(renderTimeout);
  }
  
  if (immediate) {
    // 立即渲染
    executeRender();
  } else {
    // 延迟渲染,合并短时间内的多次绘制
    renderTimeout = setTimeout(executeRender, 16); // 大约60fps
  }
}

// 执行实际的渲染
function executeRender() {
  if (!pendingRender) return;
  
  pendingRender = false;
  renderTimeout = null;
  
  // 执行实际的图像传输
  sendImageToMain();
}

// 修改handleDrawMove,加入渲染调度
function handleDrawMove(x, y, pressure = 0.5) {
  if (!isDrawing) return;
  
  // ... 现有代码 ...
  
  // 使用调度渲染替代直接渲染
  scheduleRender(false);
}

// 修改handleDrawEnd,确保立即进行最终渲染
function handleDrawEnd() {
  isDrawing = false;
  
  // ... 现有代码 ...
  
  // 确保立即进行最终渲染
  scheduleRender(true);
}

5.3 优化触摸事件与压力感应

js
// 在main.js中增强触摸事件处理

// 触摸事件节流
let lastMoveTime = 0;
const MOVE_THROTTLE = 10; // 毫秒

// 优化后的触摸移动处理函数
function handleMove(event) {
  if (!isDrawing) return;
  event.preventDefault();
  
  // 事件节流优化
  const now = Date.now();
  if (now - lastMoveTime < MOVE_THROTTLE) {
    return;
  }
  lastMoveTime = now;
  
  const { x, y } = getCoordinates(event);
  
  // 获取压力值(如果设备支持)
  let pressure = 0.5;
  if (event.touches && event.touches[0].force !== undefined) {
    // 某些设备上force值可能是0-1,某些是0-0.5
    pressure = Math.min(1, event.touches[0].force * 2);
    
    // Apple设备的特殊处理
    const isAppleDevice = /iPad|iPhone|iPod|Macintosh/.test(navigator.userAgent);
    if (isAppleDevice && pressure === 1) {
      // 在某些Apple设备上,未检测到压力时force=1
      pressure = 0.5;
    }
  }
  
  // 发送点坐标给Worker
  drawingWorker.postMessage({
    type: 'move',
    x: x,
    y: y,
    pressure: pressure
  });
  
  points.push({ x, y, pressure });
}

六、Worker通信优化

为了提高Worker与主线程之间的通信效率,我们可以进一步优化数据传输方式

6.1 使用TransferableObjects

js
// 在worker.js中优化图像数据传输

// 创建一个复用的缓冲区
let sharedBuffer = null;
let bufferWidth = 0;
let bufferHeight = 0;

// 初始化共享缓冲区
function initSharedBuffer() {
  const byteLength = width * height * 4 * dpr * dpr; // RGBA数据
  sharedBuffer = new ArrayBuffer(byteLength);
  bufferWidth = width * dpr;
  bufferHeight = height * dpr;
}

// 使用共享缓冲区发送图像
function sendImageUsingSharedBuffer() {
  if (!ctx || !offscreenCanvas) return;
  
  // 如果缓冲区不存在或尺寸变化,则重新创建
  if (!sharedBuffer || bufferWidth !== width * dpr || bufferHeight !== height * dpr) {
    initSharedBuffer();
  }
  
  // 获取图像数据
  const imageData = ctx.getImageData(0, 0, width * dpr, height * dpr);
  
  // 复制数据到共享缓冲区
  const uint8Array = new Uint8ClampedArray(sharedBuffer);
  uint8Array.set(imageData.data);
  
  // 发送元数据和共享缓冲区
  self.postMessage({
    type: 'render',
    meta: {
      width: width * dpr,
      height: height * dpr,
      timestamp: Date.now()
    },
    buffer: sharedBuffer
  }, [sharedBuffer]);
  
  // 缓冲区被转移,需要重新创建
  initSharedBuffer();
}

6.2 批量处理点数据

进一步优化点数据的传输:

js
// 在main.js中添加批量点处理

// 点缓冲区
let pointBuffer = [];
let pointBufferTimer = null;
const POINT_BUFFER_FLUSH_INTERVAL = 30; // 毫秒

// 添加点到缓冲区
function addPointToBuffer(point) {
  pointBuffer.push(point);
  
  // 安排发送缓冲区
  if (!pointBufferTimer) {
    pointBufferTimer = setTimeout(flushPointBuffer, POINT_BUFFER_FLUSH_INTERVAL);
  }
  
  // 如果缓冲区达到一定大小,立即发送
  if (pointBuffer.length >= 20) {
    flushPointBuffer();
  }
}

// 发送点缓冲区
function flushPointBuffer() {
  if (pointBufferTimer) {
    clearTimeout(pointBufferTimer);
    pointBufferTimer = null;
  }
  
  if (pointBuffer.length === 0) return;
  
  // 发送批量点数据
  drawingWorker.postMessage({
    type: 'batch',
    points: pointBuffer.slice(),
    timestamp: Date.now()
  });
  
  // 清空缓冲区
  pointBuffer = [];
}

// 修改handleMove使用点缓冲区
function handleMove(event) {
  if (!isDrawing) return;
  event.preventDefault();
  
  // 事件节流(保留)
  
  const { x, y } = getCoordinates(event);
  let pressure = getPressure(event);
  
  // 添加点到缓冲区而不是直接发送
  addPointToBuffer({ x, y, pressure });
}

// 在handleEnd中确保所有点都被发送
function handleEnd(event) {
  if (!isDrawing) return;
  event.preventDefault();
  
  // 确保缓冲区中的所有点都被发送
  flushPointBuffer();
  
  // 通知Worker绘制结束
  drawingWorker.postMessage({
    type: 'end'
  });
  
  isDrawing = false;
  points = [];
}

七、多设备兼容性处理

7.1 特性检测与回退处理

js

// 在main.js中添加特性检测

// 特性检测
// 检测浏览器是否支持OffscreenCanvas API
const supportsOffscreenCanvas = typeof OffscreenCanvas !== 'undefined';
// 检测浏览器是否支持Web Worker
const supportsWebWorker = typeof Worker !== 'undefined';
// 检测设备是否支持触摸事件(移动设备通常支持)
const supportsTouchEvents = 'ontouchstart' in window;
// 检测设备是否支持压力感应(用于实现笔压效果)
// 需要同时满足:1.支持触摸事件 2.支持force属性(通过两种可能的API检测)
const supportsPressure = supportsTouchEvents && 
                        (('ontouchforcechange' in window) || 
                         ('TouchEvent' in window && 'force' in TouchEvent.prototype));

console.log(`特性检测:
  - OffscreenCanvas: ${supportsOffscreenCanvas}
  - Web Worker: ${supportsWebWorker}
  - 触摸事件: ${supportsTouchEvents}
  - 压力感应: ${supportsPressure}`);

// 根据特性选择最佳实现
function chooseBestImplementation() {
  if (supportsOffscreenCanvas && supportsWebWorker) {
    return 'offscreen'; // 使用OffscreenCanvas + Worker
  } else if (supportsWebWorker) {
    return 'worker'; // 只用Worker但不用OffscreenCanvas
  } else {
    return 'standard'; // 标准Canvas实现
  }
}

const implementationType = chooseBestImplementation();
console.log(`选择实现类型: ${implementationType}`);

// 根据实现类型初始化
function initializeSignatureSystem() {
  switch (implementationType) {
    case 'offscreen':
      initializeWorker();
      break;
    case 'worker':
      initializeWorkerWithoutOffscreen();
      break;
    case 'standard':
    default:
      initializeStandardCanvas();
      break;
  }
}

// 标准Canvas实现
function initializeStandardCanvas() {
  console.log('使用标准Canvas实现');
  
  // 标准Canvas实现的代码
  // ...
}

7.2 移动端适配优化

js
// 移动端特定优化

// 阻止常见的移动端问题
function preventMobileIssues() {
  // 阻止页面缩放
  document.addEventListener('touchstart', (e) => {
    if (e.touches.length > 1) {
      e.preventDefault();
    }
  }, { passive: false });
  
  // 阻止长按菜单
  displayCanvas.addEventListener('contextmenu', (e) => {
    e.preventDefault();
  });
  
  // 阻止滚动
  displayCanvas.addEventListener('touchmove', (e) => {
    e.preventDefault();
  }, { passive: false });
  
  // iOS Safari特定修复
  if (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream) {
    // iOS Safari需要特别处理,使其正确响应触摸事件
    document.body.style.touchAction = 'none';
    
    // iOS的可视区域问题
    const fixIOSHeight = () => {
      const vh = window.innerHeight * 0.01;
      document.documentElement.style.setProperty('--vh', `${vh}px`);
      
      // 调整Canvas容器高度
      document.querySelector('.canvas-container').style.height = 
        `calc(var(--vh, 1vh) * 70)`;
    };
    
    window.addEventListener('resize', fixIOSHeight);
    fixIOSHeight();
  }
}

九、心得总结

通过本文,我们从0到1实现了一个基于OffscreenCanvas和Web Worker的高性能电子签名系统。主要技术要点包括:

  • 多线程绘图:使用Web Worker将绘图计算从主线程中分离
  • OffscreenCanvas:使用OffscreenCanvas在Worker中进行渲染
  • 性能优化:实现了事件节流、批量处理和内存管理
  • 贝塞尔曲线:使用贝塞尔曲线实现平滑绘制
  • 设备兼容性:适配不同设备的特性和限制
  • TransferableObjects:优化主线程与Worker之间的通信