匠心打造canvas签名组件

13,319 阅读12分钟

本文首发于CSDN网站,下面的版本又经过进一步的修订。

关于

全文共4k+字,阅读需5分钟。

导读

6月又是项目吃紧的时候,一大波需求袭来,猝不及防。

度过了漫长而煎熬的6月,是时候总结一波。最近移动端的一款产品原计划是引入第三方的签名插件,该插件依赖复杂,若干个js使用document.write顺序加载,插件源码是ES5的,甚至说是ES3都不为过。为了能够顺利嵌入我们的VUE项目,我阅读了两天插件的源码(demo及文档不全,囧),然后花了一天多点的时间使用ES6引用它。鉴于单页应用中,任何非全局资源都不该提前加载的指导性原则,为了做到动态加载,我甚至还专门写了一个simple的vue组件iload.js去顺序加载这些资源并执行回调。一切看似很完美,结果发现demo引用的一个压缩的js中居然写死了插件相关DOM节点的id和style,此刻我的内心几乎是崩溃的。这样的一个插件我怕是无力引入了吧。

虽然嘴上这么说,身体还是很诚实的,费尽千辛万苦我还是把这个插件用在了项目中。随着项目推进,业务上经过多次沟通,我们砍掉了该签名插件的数字证书验证部分。也就是说,这么大的一个插件,只剩下用户签名的功能,我完全可以自己做啊。于是我悄悄移除了这个插件,为这几天的调研和码字过程划上了一个完美的句号(深藏功与名)。

签名是若干操作的集合,起于用户手写姓名,终于签名图片上传,中间还包含图片的处理,比如说减少锯齿、旋转、缩小、预览等。canvas几乎是最适合的解决方案。

手写

从交互上看,用户签名的过程,只有开始的手写部分是有交互的,后面是自动处理。为了完成手写,需要监听画布的两个事件:touchstart、touchmove(移动端touchend在touchmove之后不触发)。前者定义起始点,后者不停地描线。

const canvas = document.getElementById('canvas');
const touchstart = (e) => {
  /* TODO 定义起点 */
};
const touchmove = (e) => {
  /* TODO 连点成线,并且填充颜色 */
};
canvas.addEventListener('touchstart', touchstart);
canvas.addEventListener('touchmove', touchmove);

注: 以下默认canvas和context对象已有。

可以先戳这里体验把后面将要提到的签名组件 canvas-draw

描线

既然要连点成线,自然需要一个变量来存储这些点。

const point = {};

接下来就是画线的部分。canvas画线只需4行代码:

  1. 开始路径(beginPath)
  2. 定位起点(moveTo)
  3. 移动画笔(lineTo)
  4. 绘制路径(stroke)

考虑到start和move两个动作,那么一个描线的方法就呼之欲出了,如下:

const paint = (signal) => {
  switch (signal) {
    case 1: // 开始路径
      context.beginPath();
      context.moveTo(point.x, point.y);
    case 2: // 前面之所以没有break语句,是为了点击时就能描画出一个点
      context.lineTo(point.x, point.y);
      context.stroke();
      break;
  }
};

绑定事件

为了兼容PC端的类似需求,我们有必要区分下平台。移动端,使用手指操作,需要绑定的是touchstart和touchmove;PC端,使用鼠标操作,需要绑定的是mousedown和mousemove。如下一行代码可用于判断是否移动端:

const isMobile = /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i.test(navigator.userAgent);

描线的方法准备妥当后,剩下的就是在适当的时候,记录当前划过的点,并且调用paint方法进行绘制。这里可以抽象出一个事件生成器:

let pressed = false; // 标示是否发生鼠标按下或者手指按下事件
const create = signal => (e) => {
  if (signal === 1) {
    pressed = true;
  }
  if (signal === 1 || pressed) {
    e = isMobile ? e.touches[0] : e;
    point.x = e.clientX - left + 0.5; // 不加0.5,整数坐标处绘制直线,直线宽度将会多1px(不理解的不妨谷歌下)
    point.y = e.clientY - top + 0.5;
    paint(signal);
  }
};

以上代码中的left和top并非内置变量,它们分别表示着画布距屏幕左边和顶部的像素距离,主要用于将屏幕坐标点转换为画布坐标点。以下是一种获取方法:

const { left, top } = canvas.getBoundingClientRect();

很明显,上述的事件生成器是一个高阶函数,用于固化signal参数并返回一个新的Function。基于此,start和move回调便呈现了。

const start = create(1);
const move = create(2);

为了避免UI过度绘制,让move操作执行得更加流畅,requestAnimationFrame优化自然是少不了的。

const requestAnimationFrame = window.requestAnimationFrame;
const optimizedMove = requestAnimationFrame ? (e) => {
  requestAnimationFrame(() => {
    move(e);
  });
} : move;

剩下的也是绑定事件中关键的一步。PC端中,mousedown和mousemove没有先后顺序,不是每一次画布之上的鼠标移动都是有效的操作,因此我们使用pressed变量来保证mousemove事件回调只在mousedown事件之后执行。实际上,设置后的pressed变量总需要还原,还原的契机就是mouseup和mouseleave回调,由于mouseup事件并不总能触发(比如说鼠标移动到别的节点上才弹起,此时触发的是其他节点的mouseup事件),mouseleave便是鼠标移出画布时的兜底逻辑。而移动端的touch事件,其天然的连续性,保证了touchmove只会在touchstart之后触发,因此无须设置pressed变量,也不需要还原它。代码如下:

if (isMobile) {
  canvas.addEventListener('touchstart', start);
  canvas.addEventListener('touchmove', optimizedMove);
} else {
  canvas.addEventListener('mousedown', start);
  canvas.addEventListener('mousemove', optimizedMove);
  ['mouseup', 'mouseleave'].forEach((event) => {
    canvas.addEventListener(event, () => {
      pressed = false;
    });
  });
}

旋转

想要在移动端签名,往往面临着屏幕宽度不够的尴尬。竖屏下写不了几个汉字,甚至三个都够呛。如果app webview或浏览器不支持横屏展示,此时并不是意味着没有了办法,起码我们可以将整个网页旋转90°。

方案一:起初我的想法是将画布也一同旋转90°,后来发现难以处理旋转后的坐标系和屏幕坐标系的对应关系,因此我采取了旋转90°绘制页面,但是正常布局画布的方案,从而保证坐标系的一致性(这样就不用重新纠正canvas画布的坐标系了,关于纠正坐标系后续还有方案二,请耐心阅读)。

由于用户是横屏操作画布的,完成签名后,图片需要逆时针旋转90°才能保上传到服务器。因此还差一个旋转的方法。实际上,rotate方法可以旋转画布,drawImage方法可以在新的画布中绘制一张图片或老的画布,这种绘制的定制化程度很高。

rotate

rotate用于旋转当前的画布。

语法: rotate(angle),angle表示旋转的弧度,这里需要将角度转换为弧度计算,比如顺时针旋转90°,angle的值就等于-90 * Math.PI / 180。ratate旋转时默认以画布左上角为中心,如果需要以画布中心位置为中心,需要在rotate方法执行前将画布的坐标原点移至中心位置,旋转完成后,再移动回来。如下:

const { width, height } = canvas;
context.translate(width / 2, height / 2); // 坐标原点移至画布中心
context.rotate(90 * Math.PI / 180); // 顺时针旋转90°
context.translate(-width / 2, -height / 2); // 坐标原点还原到起始位置

实际上,这种变换处理,使用transform(Math.cos(90 * Math.PI / 180), 1, -1, Math.cos(90 * Math.PI / 180), 0, 0)同样可以顺时针旋转90°。

drawImage

drawImage用于绘制图片、画布或者视频,可自定义宽高、位置、甚至局部裁剪。它有三种形态的api:

  • drawImage(img,x,y),x,y为画布中的坐标,img可以是图片、画布或视频资源,表示在画布的指定坐标处绘制。
  • drawImage(img,x,y,width,height),width,height表示指定图片绘制后的宽高(可以任意缩放或调整宽高比例)。
  • context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height),sx,sy表示从指定的坐标位置裁剪原始图片,并且裁剪swidth的宽度和sheight的高度。

通常情况下,我们可能需要旋转一张图片90°、180°或者-90°。代码如下:

const rotate = (degree, image) => {
  degree = ~~degree;
  if (degree !== 0) {
    const maxDegree = 180;
    const minDegree = -90;
    if (degree > maxDegree) {
      degree = maxDegree;
    } else if (degree < minDegree) {
      degree = minDegree;
    }

    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    const height = image.height;
    const width = image.width;
    const angle = (degree * Math.PI) / 180;

    switch (degree) {
      // 逆时针旋转90°
      case -90:
        canvas.width = height;
        canvas.height = width;
        context.rotate(angle);
        context.drawImage(image, -width, 0);
        break;
      // 顺时针旋转90°
      case 90:
        canvas.width = height;
        canvas.height = width;
        context.rotate(angle);
        context.drawImage(image, 0, -height);
        break;
      // 顺时针旋转180°
      case 180:
        canvas.width = width;
        canvas.height = height;
        context.rotate(angle);
        context.drawImage(image, -width, -height);
        break;
    }
    image = canvas;
  }
  return image;
};

缩放

旋转后的画布,通常需要进一步格式化其宽高才能上传。此处还是利用drawImage去改变画布宽高,以达到缩小和放大的目的。如下:

const scale = (width, height) => {
  const w = canvas.width;
  const h = canvas.height;
  width = width || w;
  height = height || h;
  if (width !== w || height !== h) {
    const tmpCanvas = document.createElement('canvas');
    const tmpContext = tmpCanvas.getContext('2d');
    tmpCanvas.width = width;
    tmpCanvas.height = height;
    tmpContext.drawImage(canvas, 0, 0, w, h, 0, 0, width, height);
    canvas = tmpCanvas;
  }
  return canvas;
};

上传

我们做了这么多的操作和转换,最终的目的还是上传图片。

首先,获取画布中的图片:

const getPNGImage = () => {
  return canvas.toDataURL('image/png');
};

getPNGImage方法返回的是dataURL,需要转换为Blob对象才能上传。如下:

const dataURLtoBlob = (dataURL) => {
  const arr = dataURL.split(',');
  const mime = arr[0].match(/:(.*?);/)[1];
  const bStr = atob(arr[1]);
  let n = bStr.length;
  const u8arr = new Uint8Array(n);
  while (n--) {
    u8arr[n] = bStr.charCodeAt(n);
  }
  return new Blob([u8arr], { type: mime });
};

完成了上面这些,才能一波ajax请求(xhr、fetch、axios都可)带走签名图片。

const upload = (blob, url, callback) => {
  const formData = new FormData();
  const xhr = new XMLHttpRequest();
  xhr.withCredentials = true;
  formData.append('image', blob, 'sign');

  xhr.open('POST', url, true);
  xhr.onload = () => {
    if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
      callback(xhr.responseText);
    }
  };
  xhr.onerror = (e) => {
    console.log(`upload img error: ${e}`);
  };
  xhr.send(formData);
};

设置

完成了上述功能,一个签名插件就已经成型了。除非你迫不及待想要发布,否则,这样的代码我是不建议拿出去的。一些必要的设置通常是不能忽略的。

通常画布中的直线是1px大小,这么细的线,是不能模拟笔触的,可如果你要放大至10px,便会发现,绘制的直线其实是矩形。这在签名过程中也是不合适的,我们期望的是圆滑的笔触,因此需要尽量模拟手写。实际上,lineCap就可指定直线首尾圆滑,lineJoin可以指定线条交汇时的边角圆滑。如下是一个simple的设置:

context.lineWidth = 10;         // 直线宽度
context.strokeStyle = 'black';     // 路径的颜色
context.lineCap = 'round';         // 直线首尾端圆滑
context.lineJoin = 'round';     // 当两条线条交汇时,创建圆形边角
context.shadowBlur = 1;         // 边缘模糊,防止直线边缘出现锯齿
context.shadowColor = 'black';  // 边缘颜色

优化

一切看似很完美,直到遇到了retina屏幕。retina屏是用4个物理像素绘制一个虚拟像素,屏幕宽度相同的画布,其每个像素点都会由4倍物理像素去绘制,画布中点与点之间的距离增加,会产生较为明显的锯齿,可通过放大画布然后压缩展示来解决这个问题。

let { width, height } = window.getComputedStyle(canvas, null);
width = width.replace('px', '');
height = height.replace('px', '');

// 根据设备像素比优化canvas绘图
const devicePixelRatio = window.devicePixelRatio;
if (devicePixelRatio) {
  canvas.style.width = `${width}px`;
  canvas.style.height = `${height}px`;
  canvas.height = height * devicePixelRatio; // 画布宽高放大
  canvas.width = width * devicePixelRatio;
  context.scale(devicePixelRatio, devicePixelRatio); // 画布内容放大相同的倍数
} else {
  canvas.width = width;
  canvas.height = height;
}

重置坐标系

由于采取了方案一,签名的工作流变成了:『页面顺时针旋转90°绘制、画布正常竖屏绘制』—>『手写签名』—>『逆时针旋转画布90°』—> 『合理缩放画布至屏幕宽度』—> 『导出图片并上传』。由此可见方案一流程复杂,处理起来也比较麻烦。

换个角度想想,既然画布是可以旋转的,我刚好可以利用这种坐标系的反向旋转去抵消页面的正向旋转,这样页面上点的坐标就可以映射到画布本身的坐标上。于是有了方案二。

方案二:页面顺时针旋转90°,画布跟随着一起旋转(画布的坐标系也跟着旋转90°);然后再逆向旋转画布90°,重置画布的坐标系,使之与页面坐标系映射起来。

顺时针旋转90°的页面如下所示:

页面顺时针旋转90°
页面顺时针旋转90°

此时canvas画布也随着页面顺时针旋转90°,想要重置画布坐标系,可借由rotate逆向旋转90°,然后由translate平移坐标系。以下代码包含了顺逆时针旋转90°、180° 的处理(为了便于描述,假设画布充满屏幕):

context.rotate((degree * Math.PI) / 180);
switch (degree) {
  // 页面顺时针旋转90°后,画布左上角的原点位置落到了屏幕的右上角(此时宽高互换),围绕原点逆时针旋转90°后,画布与原位置垂直,居于屏幕右侧,需要向左平移画布当前高度相同的距离。
  case -90:
    context.translate(-height, 0);
    break;
  // 页面逆时针旋转90°后,画布左上角的原点位置落到了屏幕的左下角(此时宽高互换),围绕原点顺时针旋转90°后,画布与原位置垂直,居于屏幕下侧,需要向上平移画布当前宽度相同的距离。
  case 90:
    context.translate(0, -width);
    break;
  // 页面顺逆时针旋转180°回到了同一个位置(即页面倒立),画布左上角的原点位置落到了屏幕的右下角(此时宽高不变),围绕原点反方向旋转180°后,画布与原位置平行,居于屏幕右侧的下侧,需要向左平移画布宽度相同的距离,向右平移画布高度的距离。
  case -180:
  case 180:
    context.translate(-width, -height);
}

拥有了对画布坐标系重置的能力,我们能够将画布逆时针旋转90°、甚至180°,都是可行的。如下:

页面逆时针旋转90°
页面逆时针旋转90°

页面顺时针旋转180°
页面顺时针旋转180°

当然重置画布坐标系后,需要注意清屏时,清屏的范围也有可能发生变化,需要稍作如下处理。

const clear = () => {
  let width;
  let height;
  switch (this.degree) { // this.degree是画布坐标系旋转的度数
    case -90:
    case 90:
      width = this.height; // 画布旋转之前的高度
      height = this.width; // 画布选择之前的宽度
      break;
    default:
      width = this.width;
      height = this.height;
  }
  this.context.clearRect(0, 0, width, height);
};

方案一简单粗暴,布局上,canvas画布虽然不需要旋转,但需要单独绝对定位布局,给页面视觉展示带来不便,同时,上传图片之前需要对图片做旋转、缩放等处理,流程复杂。

方案二用纠正画布坐标系的方式,省去了布局和图片上的特殊处理,一步到位,因此方案二更佳。

以上,涉及的代码可以在这里找到:canvas-draw,这是一个借助vue cli 搭建起来的壳,主要是为了方便调试,核心代码见 canvas-draw/draw.js,喜欢的同学不妨轻点star。


本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.

本文作者:louis

本文链接: louiszhai.github.io/2017/07/07/…

参考文章: