基于 JS 画线技术实现网页手写签名板
基于JavaScript与HTML5 Canvas画线技术实现网页手写签名板的完整指南。深入讲解高分屏Retina高清适配、跨端Pointer事件统一、中点二次贝塞尔平滑插值算法以及签名图像导出与空白校验的实用落地步骤。
<canvas> 画布上,通过统一捕获鼠标与触控指针轨迹(Pointer Events),利用几何中点与二次贝塞尔曲线对离散采样点进行平滑插值,并在视网膜屏幕上依据物理像素比(devicePixelRatio)完成缩放校准,最终将笔迹可靠地导出为 Base64 或二进制图片。
一、手写签名板的核心问题与技术选型
在网页中实现手写签名看似只需要“按住鼠标划线”,但在实际工程落地中,开发者往往会遇到以下三个典型瓶颈:
- 高分屏显示模糊:在手机、平板或高分辨率笔记本屏幕上,直接设置的 Canvas 画布会出现明显的边缘锯齿与毛边虚化。
- 跨端触控冲突与延迟:移动端浏览器默认带有页面滚动、双击缩放及橡皮筋回弹效果,在手写划线时极易误触页面滚动导致笔画中断。
- 快速绘制折角突兀:浏览器分发鼠标或触摸移动事件的频率受渲染帧率限制(约每秒 60 次)。用户快速签名时,相邻采样点距离较大,单纯使用两点直线连接(
lineTo)会导致字迹呈生硬的多边形折线,失去真实手写笔锋的平滑感。
针对以上问题,现代 Web 标准提供了完整的解决方案:使用 touch-action: none 隔离默认手势;借助 window.devicePixelRatio 进行物理像素升采样;利用 Pointer Events 统一多端输入,并引入中点二次贝塞尔插值算法消除折角。
二、DOM 搭建与视网膜高分屏清晰度适配
Canvas 具有两套尺寸体系:元素绘图缓冲区的物理像素尺寸(通过 HTML 属性 width / height 控制)和 CSS 样式呈现尺寸(通过 CSS width / height 控制)。
在 Retina 等高清屏幕上,window.devicePixelRatio(简称 DPR)通常为 2 或 3。若未作针对性缩放,浏览器会将低分辨率缓冲区拉伸至逻辑像素大小,导致文字和线条发虚。
1. 容器 HTML 与样式隔离
<div class="signature-box">
<canvas id="signatureCanvas"></canvas>
<div class="signature-actions">
<button type="button" id="btnClear">清空重签</button>
<button type="button" id="btnSave">确认保存</button>
</div>
</div>
<style>
.signature-box {
width: 100%;
max-width: 640px;
border: 1px solid #CBDCE8;
border-radius: 8px;
background: #FFFFFF;
box-sizing: border-box;
}
#signatureCanvas {
display: block;
width: 100%;
height: 240px;
/* 关键属性:彻底阻止浏览器默认滑动、缩放与橡皮筋回弹 */
touch-action: none;
cursor: crosshair;
}
.signature-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 10px 16px;
border-top: 1px solid #E2EBF2;
background: #F8FAFC;
}
</style>
2. DPR 物理像素与缩放矩阵绑定
适配的做法是:首先使用 getBoundingClientRect() 获取元素在当前视口下的 CSS 像素宽高,然后将 Canvas 缓冲区的宽高乘以 dpr,最后通过 ctx.scale(dpr, dpr) 将绘图上下文的坐标系缩放回 CSS 像素尺度。
const canvas = document.getElementById('signatureCanvas');
const ctx = canvas.getContext('2d');
function initCanvasSize() {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
// 1. 设置底层位图物理像素
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
// 2. 保持 CSS 逻辑像素大小
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
// 3. 坐标映射归一化
ctx.scale(dpr, dpr);
// 4. 初始化画笔样式
ctx.strokeStyle = '#1E293B';
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
initCanvasSize();
canvas.width 或 canvas.height 会导致画布内容被重置且清空上下文状态。因此在窗口尺寸变化重置尺寸前,如果已经绘制了签名,需要先将图层保存为临时位图或重绘历史路径。
三、跨端统一事件监听与坐标精准映射
早期网页签名常需分别编写 mousedown/mousemove/mouseup 以及 touchstart/touchmove/touchend 两套事件逻辑。现代浏览器均已支持 Pointer Events(指针事件),它将鼠标、触控屏、触控笔(Stylus)抽象为统一模型。
1. 获取相对画布的绝对精准坐标
事件对象中的 e.clientX 与 e.clientY 是相对于浏览器视口左上角的坐标。为了确保在页面发生滚动或签名板嵌套在复杂浮层中时坐标不产生偏移,每次事件触发时都应减去画布的实时视口边界偏移量:
function getCanvasPoint(event) {
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
}
2. 防止指针快速滑脱画布:setPointerCapture
当用户签名速度很快、笔画带出画布边缘时,常规监听会导致 pointermove 丢失或者 pointerup 无法捕获,导致下一次移入时依然处于绘制状态。
调用 canvas.setPointerCapture(event.pointerId) 可以在按下时锁定当前指针,即便手指或鼠标划出画布外,后续移动和松开事件仍会精准派发给该 Canvas,抬起后再调用 releasePointerCapture 释放。
四、笔迹平滑优化:中点二次贝塞尔插值算法
如果在 pointermove 中直接调用 ctx.lineTo(x, y),由于输入设备的采样率在快速书写时每帧间隔较大,笔画拐角处会呈现尖锐的硬折线(如“V”字形或多边形棱角)。
1. 算法推导与数学原理
为了在不依赖复杂样条曲线计算开销的前提下获得顺滑的手写手感,业界广泛采用中点二次贝塞尔曲线拟合:
- 当指针按下(
pointerdown)时,记录当前点为起点 $P_0$。 - 当指针移动(
pointermove)产生新点 $P_1$ 时,不直接将曲线画到 $P_1$,而是计算当前起点与 $P_1$ 的几何中点 $M_1 = (P_0 + P_1) / 2$。 - 调用
ctx.quadraticCurveTo(P_0.x, P_0.y, M_1.x, M_1.y),即以 $P_0$ 作为贝塞尔曲线的控制点,以中点 $M_1$ 作为曲线的终点绘制弧线。 - 将上一个中点作为下一段曲线的起点,持续更新前序采样点。
该算法保证了相邻两段贝塞尔曲线在中点处的切线连续($C^1$ 连续),从根本上消除了离散采样带来的尖角锯齿。
五、签名板完整可用代码实现
以下代码将 DPR 视网膜适配、Pointer Events 跨端绑定、二次贝塞尔平滑插值、单点盖章兼容以及清空与 Base64/Blob 导出功能完整封装为一个开箱即用的类:
class SmoothSignature {
constructor(canvasElement, options = {}) {
this.canvas = canvasElement;
this.ctx = canvasElement.getContext('2d');
this.strokeColor = options.color || '#1E293B';
this.strokeWidth = options.width || 3;
this.isDrawing = false;
this.hasDrawn = false; // 用于判断是否为空白签名
this.points = []; // 采样点缓存队列
this.init();
}
init() {
this.handleResize();
this.bindEvents();
}
handleResize() {
const rect = this.canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
// 适配物理像素
this.canvas.width = rect.width * dpr;
this.canvas.height = rect.height * dpr;
this.canvas.style.width = rect.width + 'px';
this.canvas.style.height = rect.height + 'px';
this.ctx.scale(dpr, dpr);
this.setupBrush();
}
setupBrush() {
this.ctx.strokeStyle = this.strokeColor;
this.ctx.lineWidth = this.strokeWidth;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
}
getPoint(e) {
const rect = this.canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
bindEvents() {
this.canvas.addEventListener('pointerdown', (e) => {
this.isDrawing = true;
this.hasDrawn = true;
this.canvas.setPointerCapture(e.pointerId);
const pt = this.getPoint(e);
this.points = [pt];
// 针对单击或单点触碰,绘制一个圆点(模拟盖章式签名)
this.ctx.beginPath();
this.ctx.arc(pt.x, pt.y, this.strokeWidth / 2, 0, Math.PI * 2);
this.ctx.fillStyle = this.strokeColor;
this.ctx.fill();
});
this.canvas.addEventListener('pointermove', (e) => {
if (!this.isDrawing) return;
const currentPt = this.getPoint(e);
this.points.push(currentPt);
// 当采集到至少 3 个点时进行二次贝塞尔插值
if (this.points.length >= 3) {
const p0 = this.points[0];
const p1 = this.points[1];
const p2 = this.points[2];
// 计算 p1 与 p2 的几何中点作为曲线终点
const midPoint = {
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2
};
this.ctx.beginPath();
this.ctx.moveTo(p0.x, p0.y);
this.ctx.quadraticCurveTo(p1.x, p1.y, midPoint.x, midPoint.y);
this.ctx.stroke();
// 移除最老点,更新滑动窗口
this.points.shift();
this.points[0] = midPoint;
}
});
const finishDrawing = (e) => {
if (!this.isDrawing) return;
this.isDrawing = false;
try {
this.canvas.releasePointerCapture(e.pointerId);
} catch (err) {}
// 处理最后剩余的尾部线段
if (this.points.length === 2) {
const p0 = this.points[0];
const p1 = this.points[1];
this.ctx.beginPath();
this.ctx.moveTo(p0.x, p0.y);
this.ctx.lineTo(p1.x, p1.y);
this.ctx.stroke();
}
this.points = [];
};
this.canvas.addEventListener('pointerup', finishDrawing);
this.canvas.addEventListener('pointercancel', finishDrawing);
}
// 清空画布
clear() {
const rect = this.canvas.getBoundingClientRect();
this.ctx.clearRect(0, 0, rect.width, rect.height);
this.points = [];
this.hasDrawn = false;
}
// 检查是否为空白签名
isEmpty() {
return !this.hasDrawn;
}
// 导出为 Base64 格式
toDataURL(type = 'image/png') {
if (this.isEmpty()) return null;
return this.canvas.toDataURL(type);
}
// 导出为 Blob 二进制对象(适用于 FormData 文件上传)
toBlob(callback, type = 'image/png') {
if (this.isEmpty()) {
callback(null);
return;
}
this.canvas.toBlob(callback, type);
}
}
六、运行验证与签名资产导出方案
完成手写板编码后,在工程实践中需对以下三个输出场景进行验证:
| 导出目标 | 调用方式 | 特点与推荐场景 |
|---|---|---|
| Base64 DataURL | signature.toDataURL('image/png') |
即时字符串,便于直接绑定到 <img src="..."> 进行回显确认,或通过轻量 JSON 协议传输。 |
| 二进制 Blob | signature.toBlob((blob) => ..., 'image/png') |
相比 Base64 体积节省约 33%,推荐通过 FormData.append('file', blob) 直接上传至对象存储。 |
| 透明通道保留 | 默认透明底色 PNG | 便于将签名图层无缝覆盖合成到电子合同(PDF)或打印表单上,无白底遮挡。 |
1. 空白签名双重校验机制
仅依靠 hasDrawn 状态位可能出现用户无意触碰后立即清空或微小误触。若需严谨校验,可读取 Canvas 局部像素数据(getImageData):
function isCanvasTrulyBlank(canvas) {
const ctx = canvas.getContext('2d');
const pixelBuffer = new Uint32Array(
ctx.getImageData(0, 0, canvas.width, canvas.height).data.buffer
);
// 遍历所有像素的 32 位值,只要有一个像素不全为 0(非透明),即视为有笔迹
return !pixelBuffer.some(color => color !== 0);
}
七、常见问题排查与工程边界
- iOS Safari 橡皮筋下拉拦截失效:如果在 iOS 移动端手写时仍能下拉页面,需确认 Canvas 所在容器或父级元素是否显式设置了
touch-action: none。此外,部分老旧 WebView 中需在pointerdown/touchmove中显式调用event.preventDefault()。 - 页面布局缩放(Zoom / Transform)导致光标偏移:当外部父容器使用了 CSS
transform: scale(...)时,getBoundingClientRect()返回的是缩放后的视口盒模型,而event.clientX处于未缩放视口。计算点坐标时需额外除以父级 scale 缩放系数。 - 背景底色要求为白色而非透明:若服务端合同渲染器要求必须是白底 JPEG,在导出前需先通过
ctx.globalCompositeOperation = 'destination-over'填充一层白色矩形,再调用导出方法。
JavaScript零基础入门:从环境搭建到运行第一段代码
2026-11-05 15:29:58
HTML链接中javascript:void(0)的作用及现代替代方案
2026-11-05 10:07:47
在线调试 JS 时怎么引入 jQuery 或 Vue 等第三方库
2026-11-05 08:53:56
原生JavaScript实现简易颜色选择器原理与代码
2026-11-03 23:22:19
JS原生表单序列化:FormData与URLSearchParams用法详解
2026-11-03 23:13:21








