电子签名,或者说自由画笔是一个很常用的功能,本文会给出几种实现方式,最后也会使用一个三方库来实现一个能模拟现实中书写效果的相对完善的电子签名,话不多说,开始吧。

最终效果抢先看:电子签名。

简单分析

首先创建一个画布:

<canvas id="canvas"></canvas>
  • 1.

获取一下绘图上下文:

const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')
  • 1.
  • 2.

接着监听三个事件,分别是mousedownmousemovemouseup,第一个事件绑定在画布元素上,另外两个绑定到window上,原因很简单,鼠标有可能会移出画布区域,如果都绑定到画布上,那么鼠标移出画布再松开就无法结束事件了。

let isMousedown = false
canvas.addEventListener('mousedown', () => {
    isMousedown = true
})
window.addEventListener('mousemove', () => {
    if (!isMousedown) return
})
window.addEventListener('mouseup', () => {
    isMousedown = false
})
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

接下来思考一下具体的绘制逻辑,大体上有两种方式:

一:鼠标在实时移动过程中,我们通过一个变量来记录上一个位置的坐标,然后每次都创建一条新路径,将上一个坐标点和当前坐标点连接并绘制出来。

二:通过一个数组收集鼠标实时移动过程中的所有点,每次都先清空画布,然后从第一个坐标点开始遍历进行绘制。

两种方式其实都可以,不过各有优劣。

性能上第一种会更好一点,因为第一种无论我们绘制多长,每次都只是新建和绘制当前最新的一段直线段,而第二种方式随着我们绘制的长度增加,点位数量也会快速膨胀,那么每次都要从头开始遍历并创建路径可能会变得卡顿。

效果上第二种会更好一点,因为第一种其实是无数没有关联的小路径组成的图形,连接处肯定没有第二种完整的路径好,甚至还会出现间隙。

方式一

接下来先看一下第一种方式的具体实现代码:

let lastPos = {
    x: 0,
    y: 0
}
canvas.addEventListener('mousedown', e => {
    lastPos = windowToCanvas(e)
})
window.addEventListener('mousemove', e => {
    ctx.beginPath()
    ctx.moveTo(lastPos.x, lastPos.y)
    lastPos = windowToCanvas(e)// 更新lastPos
    ctx.lineTo(lastPos.x, lastPos.y)
    ctx.lineWidth = 5
    ctx.stroke()
})

// 将鼠标位置坐标转换为画布坐标
const rect = canvas.getBoundingClientRect()
const windowToCanvas = e => {
    const x = e.clientX - rect.left
    const y = e.clientY - rect.top
    return {
        x,
        y
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.

效果如下:

用Canvas实现一个相对完善的电子签名_前端

实现非常简单,不过正如前面所说,绘制出来的图形比较粗糙,还存在间隙,快速书写时问题不大,但当速度慢下来就很明显了。

方式二

接下来看一下第二种实现:

let pointList = []
canvas.addEventListener('mousedown', e => {
    pointList.push(windowToCanvas(e))
})
window.addEventListener('mousemove', e => {
    pointList.push(windowToCanvas(e))
    ctx.clearRect(0, 0, canvasWidth, canvasHeight)
    ctx.beginPath()
    pointList.forEach((point, index) => {
        if (index === 0) {
            ctx.moveTo(point.x, point.y)
        } else {
            ctx.lineTo(point.x, point.y)
        }
    })
    ctx.lineWidth = 5
    ctx.stroke()
})
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.

用Canvas实现一个相对完善的电子签名_Canvas_02

比前面好了很多,至少不会再出现间隙,但是当速度慢下来时同样比较粗糙。那么出现这个问题的原因是什么呢,其实很简单,当鼠标滑过同样的距离时,速度越快,mousemove事件触发的次数越少,反之越多,那么同一段距离我们所收集到的坐标点数量也就更多,最终的结果就是连接点变多了,当然就粗糙了。

知道了原因我们就可以减少坐标点的数量,具体的减少策略,你可以根据点的距离来判断,也可以根据当前的速度来判断,甚至可以重新取样,这里我们选择根据点的距离来判断,如果本次的坐标点和上次的坐标点距离小于指定数值,我们就跳过本次的坐标点:

let lastPos = null
canvas.addEventListener('mousedown', e => {
    isMousedown = true
    lastPos = windowToCanvas(e)
    pointList.push(lastPos)
})
window.addEventListener('mousemove', e => {
    const curPos = windowToCanvas(e)
    // getTwoPointDistance是一个计算两个点的直线距离的方法
    const distance = getTwoPointDistance(curPos.x, curPos.y, lastPos.x, lastPos.y)
    // 当两点之间的距离小于4时跳过当前点
    if (distance <= 4) {
        return
    }
    lastPos = curPos
    pointList.push(curPos)
    // 省略原有逻辑
})
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.

如上所示,当本次的坐标点距上次的坐标点小于4时就跳过,具体的数值你可以自己调整,现在的效果如下:

用Canvas实现一个相对完善的电子签名_前端_03

是不是比之前要好了一点?

使用曲线连接

其实除了用直线段连接每个点外,还可以通过曲线的方式来连接,我们可以使用二次贝塞尔曲线,但是问题来了,起点、控制点、终点分别怎么计算呢,这里介绍一种简单的算法,来源于后面会介绍的一个库:

1.前三个坐标点,使用moveTo方法移动到第一个坐标点,然后第二个点作为控制点,第二个和第三个点取平均值作为终点,使用quadraticCurveTo创建二次贝塞尔曲线路径,并且记录下控制点和终点坐标。

2.从第三个坐标点开始不断使用quadraticCurveTo创建路径,控制点取上一个控制点相对于上一个终点的对称点,终点取和下一个坐标的平均值,同样记录下控制点和终点。

3.不断重复第2步。

同样可以把前面根据距离减少点数量的逻辑加上,下面是具体的代码实现:

let isMousedown = false
let points = []
let lastPos = null
canvas.addEventListener('mousedown', e => {
    isMousedown = true
    lastPos = windowToCanvas(e)
    pointList.push(lastPos)
})
window.addEventListener('mousemove', e => {
    if (!isMousedown) return
    const curPos = windowToCanvas(e)
    const distance = getTwoPointDistance(curPos.x, curPos.y, lastPos.x, lastPos.y)
    if (distance <= 4) {
        return
    }
    lastPos = curPos

    points.push(windowToCanvas(e))
    const len = points.length
    // 点位数量小于4个不处理
    if (len < 4) {
        return
    }
    // 上一个控制点
    const lastControlPoint = {
        x: 0,
        y: 0
    }
    // 上一个终点
    const lastEndPoint = {
        x: 0,
        y: 0
    }
    let a = points[0]
    let b = points[1]
    const c = points[2]

    ctx.clearRect(0, 0, canvasWidth, canvasHeight)
    ctx.beginPath()
    ctx.moveTo(a.x, a.y)

    // 记录控制点和终点
    lastControlPoint.x = b.x
    lastControlPoint.y = b.y
    lastEndPoint.x = average(b.x, c.x)// average是一个计算平均值的方法
    lastEndPoint.y = average(b.y, c.y)

    ctx.quadraticCurveTo(
        lastControlPoint.x,
        lastControlPoint.y,
        lastEndPoint.x,
        lastEndPoint.y
    )

    for (let i = 2, max = len - 1; i < max; i++) {
        a = points[i]
        b = points[i + 1]
        // 更新控制点和终点
        lastControlPoint.x =
            lastEndPoint.x + (lastEndPoint.x - lastControlPoint.x)
        lastControlPoint.y =
            lastEndPoint.y + (lastEndPoint.y - lastControlPoint.y)
        lastEndPoint.x = average(a.x, b.x)
        lastEndPoint.y = average(a.y, b.y)
        ctx.quadraticCurveTo(
            lastControlPoint.x,
            lastControlPoint.y,
            lastEndPoint.x,
            lastEndPoint.y
        )
    }

    ctx.lineWidth = 5
    ctx.stroke()
})
window.addEventListener('mouseup', () => {
    isMousedown = false
    points = []
})
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.
  • 71.
  • 72.
  • 73.
  • 74.
  • 75.
  • 76.
  • 77.
  • 78.
  • 79.

代码比较长,但是基本就是对前面算法描述的实现,所以应该很容易理解,看一下现在的效果:

用Canvas实现一个相对完善的电子签名_Canvas_04

有没有感觉用曲线连接比用直接连接效果更好一点?

更好的效果

那么到这里就结束了吗,显然没有,目前我们绘制出来的线宽都是一样的,这不太符合现实,实际生活中我们写字时,用力写的地方线条会更粗一点,轻轻写的则会更细,要实现这个效果我们可以使用这个库perfect-freehand,它会把我们输入的点数组转换为线条对应的轮廓点数组,通过其仓库的这张动图应该很容易理解:

用Canvas实现一个相对完善的电子签名_JavaScript_05

这个库会根据每个点的压力来计算对应位置的线条的粗细,如果你有支持压力输入的硬件,那么可以使用 Pointer events来代替我们前面使用的mousemove等鼠标事件来获取坐标点及坐标点对应的压力值,然后传递给perfect-freehand即可,但是我们的鼠标显然不支持输入压力,不过没关系,这个库会根据前后两个点的距离来模拟压力,进而改变线条的粗细。

这个库只提供了一个方法,就是将我们传入的点数组转换为对应的轮廓图形的点数组,所以我们只要收集鼠标滑过的坐标点,然后调用这个方法即可:

import { getStroke } from 'perfect-freehand'

let isMousedown = false
let pointList = []
canvas.addEventListener('mousedown', e => {
    isMousedown = true
    pointList = []
    pointList.push(windowToCanvas(e))
})
window.addEventListener('mousemove', e => {
    if (!isMousedown) return
    pointList.push(windowToCanvas(e))
    const points = getStroke(pointList, {
        size: 16,
        thinning: 0.5,
        smoothing: 0.5,
        streamline: 0.5,
        start: {
            cap: true,
            taper: 0,
            easing: t => t
        },
        end: {
            cap: true,
            taper: 0,
            easing: t => t
        }
    })
    // ...
})
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.

perfect-freehand支持一些配置选项,上面我们用了它示例的配置,配置的具体含义可以参考它的文档。

它返回的points也是一个点数组,那么我们要如何使用呢,其实就是我们前面通过二次贝塞尔曲线来连接的逻辑,只是最后我们是描边,而这里是填充,所以你可以直接复用前面的代码,也可以使用perfect-freehand提供示例代码,通过拼接SVG路径,然后通过Path2D来使用:

window.addEventListener('mousemove', e => {
    // ...
    const points = getStroke(pointList, {
        size: 5
    })
    const pathData = getSvgPathFromStroke(points)
    const path = new Path2D(pathData)
    ctx.fill(path)
})

// 将点数组转换为svg路径字符串
const getSvgPathFromStroke = (points) => {
    const len = points.length
    if (len < 4) {
        return ''
    }
    let a = points[0]
    let b = points[1]
    const c = points[2]
    let result = `M${a[0]},${a[1]} Q${b[0]},${b[1]} ${average(b[0], c[0])},${average(b[1], c[1])} T`
    for (let i = 2, max = len - 1; i < max; i++) {
        a = points[i]
        b = points[i + 1]
        result += `${average(a[0], b[0])},${average(a[1], b[1])} `
    }
    result += 'Z'
    return result
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.

SVG提供了非常多的路径命令,同样是创建二次贝塞尔曲线,canvas只提供了一个方法,而SVG提供了4个命令,实际上我们前面计算控制点的方法就参考自它的 T命令。

用Canvas实现一个相对完善的电子签名_JavaScript_06

可以看到粗细已经有了,但是图形却存在明显的锯齿,要解决这个问题,我们需要每次绘制前都清空画布然后重新绘制:

window.addEventListener('mousemove', e => {
    if (!isMousedown) return
    pointList.push(windowToCanvas(e))
    ctx.clearRect(0, 0, canvasWidth, canvasHeight)// ++
    const points = getStroke(pointList, {})
    // ...
})
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

用Canvas实现一个相对完善的电子签名_JavaScript_07

现在好了很多,但是又有新问题,就是之前绘制的图形都没了,这显然不行,解决方法有两个。

一是保存之前所绘制的路径,每次都进行重绘:

let lineList = []// 保存所有绘制的线条路径
let line = null// 当前绘制中的线条路径

window.addEventListener('mousemove', e => {
    // ...
    ctx.clearRect(0, 0, canvasWidth, canvasHeight)
    // 重新绘制之前所有线条
    lineList.forEach(item => {
        ctx.fill(item)
    })
    // ...
    const path = new Path2D(pathData)
    line = path//++
    ctx.fill(path)
})
window.addEventListener('mouseup', () => {
    isMousedown = false
    lineList.push(line)// ++
})
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.

用Canvas实现一个相对完善的电子签名_前端_08

这种方式的优点是实现很简单,缺点也很明显,就是性能不好,理论上之前绘制的线条不会再变化,不需要每次重新绘制。所以最好使用第二种方法,也就是通过创建两个重叠的canvas标签,之前的所有线条都绘制到 下层canvas上,当前正在绘制的线条绘制到第二个canvas上。

首先在之前的基础上再加一个canvas标签:

<div class="canvasBox">
    <div class="canvasWrap">
        <canvas id="canvas2"></canvas>
        <canvas id="canvas"></canvas>
    </div>
</div>
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
.canvasWrap {
    position: relative;
    width: 500px;
    height: 500px;
}
canvas {
    position: absolute;
    left: 0;
    top: 0;
    border: 1px solid;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

idcanvas2的标签是我们新加的,用来绘制不变的线条,两个canvas标签都设为绝对定位,位置也一样,那么idcanvas的标签会在idcanvas2标签的上面。

然后要做的很简单,把之前在mousemove回调里重新绘制之前所有线条的逻辑移到mouseup回调里就可以了:

window.addEventListener('mousemove', e => {
    // ...
    ctx.clearRect(0, 0, canvasWidth, canvasHeight)
    // 重新绘制之前所有线条 ----删除
    lineList.forEach(item => {
        ctx.fill(item)
    })
    // ----删除
    // ...
    const path = new Path2D(pathData)
})
window.addEventListener('mouseup', () => {
    isMousedown = false
    lineList.push(line)
    // 绘制完成后就将图形移到canvas2
    ctx.clearRect(0, 0, canvasWidth, canvasHeight)
    ctx2.clearRect(0, 0, canvasWidth, canvasHeight)
    lineList.forEach(item => {
        ctx2.fill(item)
    })
})
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.

这样当前实时绘制的图形在上层canvas上进行绘制,当绘制结束后就清除两个canvas,然后把之前及本次绘制的图形再全部在下层canvas上绘制一遍,因为绘制速度很快,所以人眼基本感觉不到变化。

用Canvas实现一个相对完善的电子签名_sed_09

总结

本文介绍了几种实现电子签名的方法,实际项目中我们都可以选择最后一种方式。

完整源码请移步:Signature。