移动端Canvas手写签名实现:横竖屏适配与底图旋转实践 写这个项目的时候是因为产品经理给我丢了一个需求要在一个App内嵌的H5页面里做电子签名而且用户可能在平板上横着签也可能在手机上竖着签签名完之后还要能叠加到一张模板图片上模板图还得允许用户自己旋转角度对准位置。说实话当时我脑子里第一个想法是“这不是随手就能搞定的活”但真正动手做的时候才发现移动端Canvas手写签名这一套东西坑比想象中多得多。这篇文章就把整个项目的实现过程和踩坑记录完整聊一遍涵盖横竖屏适配、Canvas分辨率处理、签名轨迹采集、图片旋转叠加这些核心环节代码可以直接拿去改适配你自己的业务场景。无论你是刚入行的前端新人还是被“移动端签字”这种需求砸中的老油条这篇文章应该都能帮你省下不少试错时间。1. 项目整体设计与需求拆解为什么选Canvas而不是原生组件1.1 核心需求解析先把这个需求拆开看表面上是“做个手写签名”但实际拆解开有四个独立的小需求支持移动端手写采集触摸跟手、笔迹顺滑不能有断线或者延迟感。同时适配横屏和竖屏设备方向变化时画布不能变形。用户能上传一张底图比如合同扫描件、ping单据照片签名需要叠加在底图上面。底图允许旋转角度用户手动调整签名的位置可以自由摆放。这四个需求叠加在一起技术选型其实没有太多悬念。移动端手写输入的方案里原生input或者div模拟跟手效果都很差Canvas是浏览器天生的绘图画布配合touch事件天然适合做轨迹采集。Web端也有些签名库比如signature_pad但那个库在移动端横竖屏切换和自定义底图旋转这块支持得比较弱自己封装反而更灵活。1.2 技术选型背后的考量为什么不用SVGSVG虽然也有路径绘制能力但在移动端高频触摸事件下DOM节点的频繁更新会带来明显的性能损耗。Canvas的优势在于它是一整块像素位图所有绘制操作都在底层位图上进行浏览器只需要在每一帧重绘指定区域不需要频繁操作DOM树性能开销小得多。为什么不用WebGLWebGL的性能确实更强但做二维手写签名完全是杀鸡用牛刀。Canvas 2D的渲染管线足够应付每秒上百次的触摸采样而且Canvas 2D导出图片的方式极其简单直接通过toDataURL就能拿到base64的图片数据跟后端对接或者转存都非常方便。竖屏签名和横屏签名的场景有很大不同。竖屏时用户通常签的是简短的姓名或者“同意”两个字签名区域窄长。横屏时用户可能会签比较完整的意见或者长段文字签名区域宽扁。同一个Canvas组件要在两种方向下都能正常工作不能写死宽高必须基于屏幕的实时尺寸动态计算。注意这个需求里最关键的一点是“底图旋转”。很多人第一反应是直接用CSS transform旋转底图但CSS旋转只改了显示效果最终导出的图片里底图还是原来的角度。必须用Canvas自身的坐标系变换去旋转绘制才能保证导出图片时旋转效果被真正“画”进去。1.3 整体方案架构我采用的方案是“双Canvas分层绘制”。底层Canvas负责绘制上传的底图和旋转效果上层Canvas负责采集手写笔迹两层Canvas在视觉上完全重叠。导出时新建一个临时Canvas按顺序先把底图含旋转效果绘制上去再把签名笔迹绘制上去最后导出整张图片。采用双Canvas而不是单Canvas核心原因是性能与交互的平衡。签名过程中的触摸事件触发频率极高上层Canvas在每次touchend之后才把当前笔画合并到底层中间过程只重绘当前正在绘制的那一笔避免整张底图每帧都重绘导致卡顿。2. 核心细节解析分辨率和坐标计算手写签名不糊的关键2.1 Canvas尺寸与移动端分辨率的适配原理直接设置Canvas的CSS宽高然后按CSS尺寸绘制在移动端几乎一定会出问题——画出来的字是糊的。原因在于移动端设备的物理分辨率设备像素和CSS逻辑分辨率CSS像素之间存在一个比例关系这个比例就是window.devicePixelRatio在iPhone上通常是2或者3在安卓机型上从1.5到3.5都很常见。Canvas元素本身有两套尺寸概念。一套是CSS尺寸决定它在页面上占多大显示区域另一套是绘图缓冲区尺寸也就是Canvas的width和height属性决定实际有多少个像素点可以绘制。如果两者一致那么在Retina屏幕上一个CSS像素对应多个物理像素但Canvas只能使用其中一个物理像素来绘制色彩信息其余像素只能用相邻像素的色彩信息插值填充结果就是文字和线条边缘发虚。解决办法也简单把Canvas的绘图缓冲区尺寸乘以devicePixelRatio然后把整个绘图上下文按同样比例缩放。代码写法是const dpr window.devicePixelRatio || 1; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; ctx.scale(dpr, dpr);这样一来绘图缓冲区就有足够的物理像素去对应屏幕上的显示区域了。后续所有绘制代码都按CSS像素坐标系来写不需要额外处理坐标换算。2.2 触摸坐标的正确换算移动端触摸事件的坐标信息有几种不同的来源。touch.clientX和touch.clientY是相对于浏览器可视区域左上角的坐标touch.pageX和touch.pageY是相对于整个页面的坐标touch.screenX和touch.screenY是相对于屏幕左上角的坐标。在常规移动端H5场景下页面没有滚动或者只有一个方向的滚动使用clientX和clientY最合适。但是Canvas元素在页面上的位置不一定从(0,0)开始还需要减去Canvas自身在页面中的偏移量。通过getBoundingClientRect()可以拿到Canvas相对于视口的精确位置。const rect canvas.getBoundingClientRect(); const x touch.clientX - rect.left; const y touch.clientY - rect.top;这里面有个隐藏的坑如果在CSS中设置了touch-action: none触摸事件不会触发浏览器的默认滚动行为如果没有设置手指在Canvas上滑动时会带动整个页面滚动导致触摸坐标和Canvas实际位置错位字迹就花了。2.3 笔画平滑处理的最优解手写签名最忌讳的就是笔迹生硬、棱角分明。连续触摸采样得到的一系列坐标点如果用直线段直接连接快速书写时会出现明显的折线感。解决思路是使用二次贝塞尔曲线替代直线段。具体做法是取三个相邻的点以前一个点和后一个点的中点作为贝塞尔曲线的起点和终点以中间点作为控制点。这样每两个采样点之间绘制的都是一段平滑的曲线整体轨迹非常圆润。ctx.beginPath(); ctx.moveTo(prevMidX, prevMidY); ctx.quadraticCurveTo(currentX, currentY, midX, midY); ctx.stroke();实测下来使用贝塞尔曲线之后笔迹的平滑度和书写舒适感提升非常明显。不过要注意如果采样点间隔太大说明手指移动速度很快可以适当对坐标做插值在两点之间生成中间点避免曲线变形。2.4 底图旋转的中心坐标计算底图旋转是整个项目里最容易出错的部分。Canvas的rotate()方法是围绕坐标系原点旋转的如果直接调用图片会绕着Canvas左上角转效果完全不对。正确做法是先通过translate()把坐标系原点移动到画布的中心然后旋转再把坐标系原点移回来。这样图片就是围绕画布中心旋转了。代码如下ctx.save(); ctx.translate(canvasWidth / 2, canvasHeight / 2); ctx.rotate(angle * Math.PI / 180); ctx.drawImage(img, -imgDrawWidth / 2, -imgDrawHeight / 2, imgDrawWidth, imgDrawHeight); ctx.restore();这里有一个经验要点在实际项目中底图往往需要等比缩放适配画布而旋转后的底图可能出现暴露出画布底色的情况比如底图是竖长的旋转90度后就横向超出了画布范围。所以旋转前要计算底图的最大边确保旋转后能完整覆盖整个Canvas区域。计算方式是底图相对于画布中心旋转θ角后其四个顶点可能超出画布边界安全尺寸应当取长 * cosθ 宽 * sinθ和长 * sinθ 宽 * cosθ中的较大值。为了简化代码里直接按45度角时的最大尺寸来缩放底图保证任何角度都不会露白。3. 核心代码实现签名板完整开发过程3.1 页面结构与基础样式移动端页面首先需要设置viewport禁止用户缩放同时保证页面占满整个屏幕。HTML结构很简单一个包裹容器加两个Canvas。div idsignWrap classsign-wrap canvas idbgCanvas/canvas canvas idsignCanvas/canvas div idsignTip请在此区域手写签名/div /div.sign-wrap { position: relative; width: 100%; height: 100vh; overflow: hidden; touch-action: none; background: #fff; } .sign-wrap canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }touch-action: none很关键。如果不加移动端浏览器会在触摸滑动时触发页面滚动或者双指缩放签名过程中页面会“跟着跑”。3.2 Canvas初始化与分辨率适配初始化时读取容器的宽高按devicePixelRatio设置Canvas的绘图缓冲区尺寸。function initCanvas() { const wrap document.getElementById(signWrap); const bgCanvas document.getElementById(bgCanvas); const signCanvas document.getElementById(signCanvas); const dpr window.devicePixelRatio || 1; const cssW wrap.clientWidth; const cssH wrap.clientHeight; [bgCanvas, signCanvas].forEach(canvas { canvas.width cssW * dpr; canvas.height cssH * dpr; canvas.style.width cssW px; canvas.style.height cssH px; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); }); // 保存全局变量供后续绘制使用 window.__signDpr dpr; window.__signCssW cssW; window.__signCssH cssH; }有一个细节值得说明两层的Canvas都需要执行ctx.scale(dpr, dpr)。底图Canvas如果不缩放代码里绘制图片时的尺寸单位会与预期不符导出时会出现图片只画出了一部分的问题。3.3 触摸事件采集与笔迹绘制签名Canvas绑定触摸事件维护一个isDrawing标记和一条当前路径的点集数组。const signCtx signCanvas.getContext(2d); let isDrawing false; let points []; signCanvas.addEventListener(touchstart, (e) { e.preventDefault(); const point getCanvasPos(e.touches[0]); points [point]; isDrawing true; signCtx.beginPath(); signCtx.moveTo(point.x, point.y); }); signCanvas.addEventListener(touchmove, (e) { e.preventDefault(); if (!isDrawing) return; const point getCanvasPos(e.touches[0]); points.push(point); if (points.length 3) { // 点数不够时直接画线段保证响应速度 signCtx.lineTo(point.x, point.y); signCtx.stroke(); return; } // 使用贝塞尔曲线平滑 const midPoint getMidPoint(points[points.length - 2], points[points.length - 1]); signCtx.quadraticCurveTo(points[points.length - 2].x, points[points.length - 2].y, midPoint.x, midPoint.y); signCtx.stroke(); }); signCanvas.addEventListener(touchend, (e) { e.preventDefault(); if (!isDrawing) return; isDrawing false; // 将当前画布内容固化 }); function getCanvasPos(touch) { const rect signCanvas.getBoundingClientRect(); return { x: touch.clientX - rect.left, y: touch.clientY - rect.top }; } function getMidPoint(p1, p2) { return { x: (p1.x p2.x) / 2, y: (p1.y p2.y) / 2 }; }绘制样式的设置里有两个常用的参数线条宽度lineWidth和线条样式lineCap、lineJoin。签名笔迹建议lineWidth控制在3到4像素lineCap和lineJoin都用round这样笔画起笔和转折处都是圆润的比较接近真实笔触。3.4 横竖屏切换的动态重绘移动端横竖屏切换时页面的宽高会互换。Canvas不能直接用原来的宽高必须重新初始化而且之前画在Canvas上的内容会丢失需要重绘。用orientationchange事件监听屏幕方向变化配合resize事件做兼容兜底。方向变化后需要延迟一段时间再重新初始化因为移动端浏览器在方向变化事件触发时窗口尺寸还没更新完毕直接读取会出现宽高不对的问题。window.addEventListener(orientationchange, () { setTimeout(() { // 保存当前签名内容为图片数据 saveSignSnapshot(); // 重新初始化画布 initCanvas(); // 重绘底图 drawBackgroundImage(); // 恢复签名内容 restoreSignSnapshot(); }, 300); });这里saveSignSnapshot用getImageData保存签名Canvas的所有像素数据重绘时再通过putImageData恢复。这样用户横屏写完转成竖屏之后签名内容不会丢失。getImageData保存的是Canvas坐标系下的像素数据Canvas尺寸变化后直接putImageData可能出现错位。解决方法是把保存的数据按照新的Canvas尺寸重新绘制到临时Canvas上进行自适应居中。实际操作中我们按“签名内容在原画布中的相对位置比例”进行恢复保证签名能完整显示。3.5 底图上传与旋转实现底图通过input[typefile]上传用FileReader读取并转换成Image对象。function loadBackgroundImage(file) { const reader new FileReader(); reader.onload (e) { const img new Image(); img.onload () { window.__bgImg img; drawBackgroundImage(); }; img.src e.target.result; }; reader.readAsDataURL(file); }底图绘制时按比例缩放到画布区域。不直接拉伸填满的原因是为了避免图片变形。function drawBackgroundImage() { const img window.__bgImg; if (!img) return; const ctx bgCanvas.getContext(2d); ctx.clearRect(0, 0, window.__signCssW, window.__signCssH); ctx.save(); const centerX window.__signCssW / 2; const centerY window.__signCssH / 2; ctx.translate(centerX, centerY); ctx.rotate((window.__bgAngle || 0) * Math.PI / 180); // 计算缩放比例保证图片旋转任意角度能完全覆盖画布 const rawRatio img.width / img.height; const canvasRatio window.__signCssW / window.__signCssH; let drawW, drawH; if (canvasRatio rawRatio) { drawW window.__signCssW * 1.5; drawH drawW / rawRatio; } else { drawH window.__signCssH * 1.5; drawW drawH * rawRatio; } ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH); ctx.restore(); }缩放系数取1.5而不是1.2的原因是旋转角度为45度时图片对角线长度会显著增加如果缩放系数太小图片旋转后四角会露出空白。旋转操作使用slider控件实时调整角度。用户拖动slider时监听input事件更新角度值并重新绘制底图。document.getElementById(angleSlider).addEventListener(input, (e) { window.__bgAngle parseFloat(e.target.value); drawBackgroundImage(); });3.6 签名图片导出导出是签名场景的收尾动作。新建一个临时Canvas将底图和签名按顺序绘制上去然后toDataURL转成base64文件流。function exportSignature() { const tempCanvas document.createElement(canvas); tempCanvas.width window.__signCssW; tempCanvas.height window.__signCssH; const tempCtx tempCanvas.getContext(2d); // 绘制白色背景 tempCtx.fillStyle #ffffff; tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); // 将底图Canvas内容绘制到临时画布 tempCtx.drawImage(bgCanvas, 0, 0, window.__signCssW, window.__signCssH); // 将签名Canvas内容绘制到临时画布 tempCtx.drawImage(signCanvas, 0, 0, window.__signCssW, window.__signCssH); return tempCanvas.toDataURL(image/png); }如果要导出非白色背景或者透明背景的签名图层只需要去掉填充白色背景那一步导出的图片就是透明底。这个在电商签收回执、电子合同盖章场景很常用。3.7 操作界面与用户交互完整的签名板需要提供这三个核心操作清除重写、撤销上一步和确认提交。清除就是clearRect清空签名Canvas撤销则需要维护一份历史快照栈。let historyStack []; function saveHistory() { historyStack.push(signCanvas.toDataURL(image/png)); if (historyStack.length 10) { historyStack.shift(); } } function undo() { if (historyStack.length 0) return; const img new Image(); img.onload () { signCtx.clearRect(0, 0, window.__signCssW, window.__signCssH); signCtx.drawImage(img, 0, 0, window.__signCssW, window.__signCssH); historyStack.pop(); }; img.src historyStack[historyStack.length - 1]; }在touchend时保存历史快照只在每次笔画结束时调用避免把同一笔中间过程存进历史。4. 常见问题与排查技巧实录4.1 签名识别率低的问题实际项目里如果要接OCR识别签名经常发现识别率很低。原因通常是签名笔迹线宽太小或者线宽不均边缘模糊。提高识别率有三个实用策略导出给OCR引擎时将图片做二值化处理把浅色像素全部转为白色深色像素转为黑色。在绘制时增加像素级描边处理保证笔迹的最细处不少于2个物理像素。导出前对图片做一次锐化处理提升边缘对比度。有个取巧的办法就是在签名结束后叠加一层整页的二值化滤镜。先用ctx.filter contrast(2) grayscale(1)把签名Canvas重新绘制到临时Canvas上再做导出识别率能提升一个档次。4.2 横竖屏切换丢内容这是签名组件最容易暴露的问题。大多数实现只是在orientationchange后重新初始化Canvas但是忽略了保存和恢复签名内容。推荐做法是在beforeunload和页面隐藏时都做一次快照保存防患于未然。操作上可以用visibilitychange事件当页面进入后台时保存签名快照和底图数据回前台后自动恢复。还要注意iOS的Safari在横竖屏切换时会出现约300ms左右的延迟才更新window.innerWidth和innerHeight所以定时器只用300ms还不够保险建议在初始化时用requestAnimationFrame轮询检测宽度变化等宽度值稳定后再执行重绘。4.3 安卓低端机掉帧断线安卓低端机上的Canvas签名经常出现笔画断线、跟手性差的问题。排查方向有几个检查触摸事件类型。部分安卓WebView对touchmove连续触发频率有限制如果有明显掉帧可以尝试PointerEventpointermove替代。降低Canvas的dpr系数。很多低端安卓机的devicePixelRatio是3但GPU性能跟不上此时可以设置const dpr Math.min(window.devicePixelRatio || 1, 2)牺牲一点清晰度换流畅度。绘制时减少不必要的状态切换。每次stroke()前不要重复设置strokeStyle、lineWidth可以只在初始化时设置一次。移除touchmove过程中的console.log、DOM操作等额外开销。实测下来把dpr钳制到2之后千元安卓机上的流畅度改善非常明显清晰度损失肉眼几乎不可见。4.4 Canvas内容被App内嵌WebView缩放App内嵌H5页面时如果原生App端开启了页面缩放或者WebView的viewport设置有误会导致Canvas绘制内容在视觉上被缩放签名大小与手写轨迹不一致。排查步骤检查index.html里meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno是否配置正确。在App的WebView设置中关闭手势缩放如Android的setSupportZoom(false)。页面CSS中禁用用户选择-webkit-user-select: none; user-select: none;。4.5 常见问题速查表问题现象可能原因解决方案签名模糊发虚Canvas分辨率未按dpr调整按devicePixelRatio设置宽度和高度并scale手写过程中页面滚动缺少touch-action禁止设置touch-action: none横竖屏切换后签名丢失未保存快照或恢复时机太早用orientationchange延时300ms快照恢复图片旋转后四角露白底图缩放比例不足扩大缩放到画布尺寸的1.5倍笔迹出现折线感使用lineTo直连改用quadraticCurveTo贝塞尔平滑导出图片被截断Canvas尺寸与CSS尺寸不一致统一乘以dpr再绘制并导出4.6 性能优化的几个实测技巧签名场景的性能优化原则是“减少每一帧的工作量”。我实际用下来最有效的手段按需重绘。签名移动过程中只绘制从上一采样点到当前位置的线段不清空画布、不触发整帧重绘。避免频繁保存和恢复上下文。ctx.save()和ctx.restore()是有开销的在touchmove这种高频回调里尽量少调用可以只在底图绘制时使用。减少Canvas尺寸突变。横竖屏切换时Canvas会整体重建这种场景无法完全避免但可以先把旧的Canvas位图保存为ImageBitmap快速恢复。合并多个笔画。签名过程中如果用户连续书写不要每一笔都单独走一次保存和绘制流程而是把所有笔画统一在touchend时绘制到同一个Canvas层。另外提一句如果签名板要嵌到比较复杂的H5页面里建议把签名区做成独立的全屏浮层。这样既能拿到最大的书写面积又能避免外层页面滚动、导航栏遮挡等问题。全屏浮层方案配合position: fixed触控坐标的计算也简单很多。5. 真实项目中的总结与补充建议这个签名组件开发完大致花了三个工作日第一天搭框架、第二天调样式和交互、第三天主要在处理各种机型适配和WebView兼容性问题。我个人经验里最重要的一点是**移动端Canvas签名花在“设备适配”上的时间一定超过“绘制逻辑”本身。**代码逻辑上的手写轨迹采集其实很成熟真正麻烦的是Retina屏的清晰度、横竖屏切换的尺寸变化、安卓机型性能差异还有各种WebView环境下的行为不一致。建议在开发初期就准备好一张常见机型的测试清单覆盖iOS、主流安卓、个别低端安卓机每改一轮都回归一遍。底图旋转这个功能加得比较划算。产品设计上只花了很小的成本但用户体验提升却非常明显。之前在别的项目里用户遇到图片方向不对只能重新上传现在旋转一下就能对准了。如果后续要迭代可以考虑再加一个双指缩放的功能让用户既能旋转又能缩放底图这样签名位置调整的自由度会更大。签名数据的落地方式也要提前想清楚。toDataURL输出的base64字符串体积比较大一张A4尺寸的签名图动辄几百KB。如果后端接口对数据大小有限制可以在前端先用Canvas把图片压一压一般质量系数设为0.7到0.8就足够清晰了。如果是高频签收场景建议走文件上传而不是base64字符串传输减少内存压力。最后再分享一个小技巧在Canvas下方叠一层慢速度的text-shadow提示文字比如“请在此处签名”能让用户的交互引导感强很多而且完全不影响导出图片用CSS加在Canvas容器上就行。.sign-wrap::after { content: 请在此区域手写签名; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #ccc; font-size: 18px; pointer-events: none; }等用户第一笔落下之后把这层提示文字隐藏体验细节上会显得比大部分签名组件都用心。