从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之间的通信