Taro React 小程序实现签名功能 完整版

2,586 阅读10分钟

本篇文章我们完整的实现一个签名功能,包括设计架构、 canvas 绘制和管理、跨页面通讯,签名图展示及常见问题解决等,最终效果如下:

签名效果.gif

使用 Typescript 和 react hook 开发,UI 部分使用 taro-vant,含量不多请放心阅读。

页面设计

在开始写码之前先简单介绍一下我这里的设计,一共分为三层:

  • 签名 canvas 组件:包含最核心的 canvas 绘制功能,对外提供 ref,外部以 ref.api 方式进行调用。不包含 UI,方便复用到其他项目。

  • 签名 UI 页面:包含具体的 UI 组件,及上图里的“提交”、“重置”按钮,这些按钮会在点击回调里绑定签名 canvas 组件的对应 api,项目中的其他页面需要签名的页面通过 url 跳转过来。

  • 签名发起页面:即最开始的那个签名按钮,点击后会跳转到签名页。

签名 canvas 组件

咱们首先来实现最核心的 canvas 组件。

1、封装常用操作到 async

因为小程序的 api 很多还是回调形式的,Taro 也不例外,所以先来把两个主要操作 转换成图片获取尺寸 封装成 async 形式:

import Taro, { CanvasContext } from "@tarojs/taro";

/**
 * canvas 导入图片结果
 */
 export interface ToDataURLResult {
  tempFilePath: string
  errMsg: string
}

/**
 * 将 canvas 内容转换成 base64 字符串
 */
const toDataURL = async (canvasId: string, canvas?: CanvasContext): Promise<ToDataURLResult> => {
  if (!canvas) return { errMsg: 'canvas is null', tempFilePath: '' }

  return new Promise((resolve, reject) => {
    canvas.draw(true, () => {
      Taro.canvasToTempFilePath({
        canvasId: canvasId,
        fileType: 'png',
        success: res => resolve(res),
        fail: err => reject(err)
      });
    });
  })
}

/**
 * 获取 canvas 的尺寸
 */
const getCanvasSize = async (canvasId: string): Promise<{ height: number, width: number }> => {
  return new Promise((resolve) => {
    const query = Taro.createSelectorQuery()
    query.select('#' + canvasId)
      .boundingClientRect()
      .exec(([size]) => resolve(size))
  })
}

代码还是很简单的,都是调用 Taro 对应的 api,没什么技术含量。

2、实现 jsx

接下来看一下 jsx 部分:

import { FC } from "react";
import styles from "./index.module.less";

export const CanvasSign: FC = () => {
  // 逻辑部分...

  return (
    <View className={styles.container}>
      {/* 这个 canvas 用来签名 */}
      <Canvas
        className={styles.signCanvas}
        canvasId='myCanvas'
        id='myCanvas'
        disableScroll
        onTouchStart={canvasStart}
        onTouchMove={canvasMove}
      ></Canvas>

      {/* 这个 canvas 用于把签名内容旋转九十度 */}
      <Canvas
        className={styles.saveCanvas}
        // style={{ position: 'absolute', top: '100wv', left: '100vw', zIndex: -1 }}
        canvasId='saveCanvas'
        id='saveCanvas'
      ></Canvas>
    </View>
  )
}

注意,这里我们 创建了两个 canvas,因为我这里是需要 横屏 签名,而签名之后的内容对于代码来说是“竖着”的,如果我们直接把签名内容传出去的话会发现在外面显示的签名图片也是竖着的,所以这里需要再搞一个隐藏的 canvas 用于把签名内容旋转一下。对应的样式内容如下:

index.module.less

.container {
  position: relative;
  overflow: hidden;

  // 用于绘制签名的 canvas 占满全屏
  .signCanvas {
    width: 100vw;
    height: 100vh;
    overflow: hidden;
  }

  // 把旋转内容的 canvas 藏起来
  .saveCanvas {
    position: absolute;
    top: -600px;
    left: 0;
    z-index: -1;
  }
}

3、初始化 canvas

接下来完成初始化工作,简单来说就是把 canvas 的绘图上下文拿到手,然后配置一些线条颜色粗细等。如果你想添加一些默认的提示文字也可以在这里完成。

export const CanvasSign: FC = (props) => {
  // 绘图画布引用
  const context = useRef<Taro.CanvasContext>();

  useReady(() => {
    const query = Taro.createSelectorQuery()

    query.select('#myCanvas')
      .context(res => {
        if (!res.context) return

        const { windowWidth, windowHeight } = Taro.getSystemInfoSync()
        res.context.canvas.width = windowWidth;
        res.context.canvas.height = windowHeight;

        res.context.setStrokeStyle('#000000');
        res.context.setLineWidth(4);
        res.context.setLineCap('round');
        res.context.setLineJoin('round');
        context.current = res.context as CanvasContext;
      })
      .exec()
  })

  return (
    <View className={styles.container}>
      {/* ... */}
    </View>
  )
}

我这里是使用了 query.context 获取的 canvas 上下文,有的教程里使用的是 query.fields({ node: true, size: true }) 然后从 .exec() 的入参里取,都可以的。

由于我的签名组件是全屏显示的,所以这里直接把 SystemInfo 的屏幕长宽赋值给 canvas 了,这里有一个很难缠的轨迹绘制偏移问题,下面会细讲。

4、实现触摸轨迹绘制

ok,现在来搞一下绘制的核心逻辑,其实很简单,我们在上面的 jsx 里可以看到一共监听了两个方法:onTouchStartonTouchMove,思路很简单:

  • 在触摸开始时启动线条绘制,然后记录当前点坐标
  • 触摸移动时把当前点坐标和上一个点坐标连线,然后更新当前点坐标

说白了就是绘制一条折线,折线的圆润程度取决于 onTouchMove 的触发频率,对应代码如下:

// 绘制轨迹信息
const lineInfo = useRef({ startX: 0, startY: 0 });

const canvasStart = (e) => {
  e.preventDefault();

  lineInfo.current.startX = e.changedTouches[0].clientX
  lineInfo.current.startY = e.changedTouches[0].clientY
  context.current?.beginPath()
}

const canvasMove = (e) => {
  e.preventDefault();

  let x = e.changedTouches[0].clientX
  let y = e.changedTouches[0].clientY
  context.current?.moveTo(lineInfo.current.startX, lineInfo.current.startY)
  context.current?.lineTo(x, y)
  context.current?.stroke()
  context.current?.draw(true)
  lineInfo.current.startX = x
  lineInfo.current.startY = y
}

这里我们使用 useRef 保存上个坐标信息而不是 useState,原因在于我们是直接和 canvas api 交互的,没必要在 react 层面再添加一个高频的重渲染操作。

并且注意其中的 e.preventDefault(),如果不阻止默认行为的话每次触摸开始都会有一个拖拽屏幕的效果。

至此,我们已经完成了核心的签名功能:

笔迹绘制.gif

5、绘制偏移问题

下面来讲一下绘制的时候发现轨迹不跟手的问题,相信很多同学在自己开发的时候都会踩到这个坑里,我在这个问题上耗费的时间也是最长的:

绘制轨迹不跟手.gif

这个还不是固定偏移,而是越往右下角偏移的越大,所以很多人初见都会一脸懵逼。去网上搜了搜,按照教程里的做法设置了 canvas 长宽之后发现还是没解决,很难受。这里就来详细的说明一下。

导致这个问题的核心是:canvas 的 DPI 和屏幕的 DPI 不同!

为了理解这个问题,我们先来做个小实验,首先回到上面的样式文件里,把 canvas 组件的长宽从全屏设置为四分之一屏:

.container {
  // ...

  // 用于绘制签名的 canvas 占满全屏
  .signCanvas {
    // 把下面这俩从 100 改为 50
    width: 50vw;
    height: 50vh;
    overflow: hidden;
  }
  
  // ...
}

然后回到浏览器再试一下:

小实验.gif

发现了么?绘制出来的笔迹恰好是我们实际触摸位置的四分之一!打开 F12 看一下:

image.png

这里可以看到 Taro 的 Canvas 组件由一个外层的 html 元素和内部的原生 canvas 构成,外层的 taro-canvas-core 容器负责监听 touch 事件,而内部的原生 canvas 负责绘制内容。还记得之前我们代码里初始化 canvas 时赋值的长宽么:

useReady(() => {
  const query = Taro.createSelectorQuery()

  query.select('#myCanvas')
    .context(res => {
      if (!res.context) return

      // ———————————————— 注意这里 ————————————————
      const { windowWidth, windowHeight } = Taro.getSystemInfoSync()
      res.context.canvas.width = windowWidth;
      res.context.canvas.height = windowHeight;
      
      // ...
    })
    .exec()
})

在这里我们把整个 window 的宽高赋值给了 canvas,也就是说。在只有四分之一面积的情况下,canvas 里塞下了和整个屏幕一样多的像素点

image.png

这下就清晰了,touch 事件传给我们基于屏幕的坐标,而 canvas 的分辨率和屏幕一样,但是尺寸只有屏幕的四分之一(即刚才提到的 DPI 不一致),所以绘制出来的线条就是我们实际触摸轨迹的四分之一等比映射。

明白了原因想解决就简单了,既然实际尺寸是四分之一,那我们把 canvas 的分辨率也设置为屏幕的四分之一,或者把 touch 事件的坐标放大为原来的两倍,保证 canvas 和屏幕的 DPI 是一致的就可以:

方法一:缩小 canvas 分辨率

useReady(() => {
  const query = Taro.createSelectorQuery()

  query.select('#myCanvas')
    .context(res => {
      if (!res.context) return

      const { windowWidth, windowHeight } = Taro.getSystemInfoSync()
      // 把 canvas 分辨率也改为四分之一
      res.context.canvas.width = windowWidth / 2;
      res.context.canvas.height = windowHeight / 2;
      
      // ...
    })
    .exec()
})

效果如下:

效果1.gif

方法二:放大 touch 事件的坐标:

const canvasStart = (e) => {
  e.preventDefault();
  // —————————— 看后面的乘以 2 ——————————
  lineInfo.current.startX = e.changedTouches[0].clientX * 2
  lineInfo.current.startY = e.changedTouches[0].clientY * 2
  context.current?.beginPath()
}

const canvasMove = (e) => {
  e.preventDefault();

  // —————————— 这里也乘以 2 了 ——————————
  let x = e.changedTouches[0].clientX * 2
  let y = e.changedTouches[0].clientY * 2
  // ...
}

效果如下:

效果2.gif

可以看到,笔迹已经跟手了,并且我们还可以注意到,由于方法二的分辨率更高,所以他的笔迹更细腻一点。

笔迹偏移问题总结

这个笔迹偏移问题的核心在于 canvas 和屏幕的 DPI 不匹配,所以说我们单纯抄网上教程的配置是没什么用的,你需要根据自己的 canvas 宽高来配置,例如 UI 让你把签名区域设置为 100% 宽度,80% 高度。你在 css 里写 width: 100vw; height: 80vh;,那么在初始化 canvas Context 的时候你就需要 canvas.height = windowHeight * 0.8;

简单说就是 style 里的宽高比例要和 canvas 的实际像素比例相同

6、封装图片导出和情况

现在基本的逻辑都完成了,再把一些交互的 api 封装好,通过 forwardRef 转发出去就可以了,组件完成代码如下,导出图片的逻辑在 saveAsImage,有兴趣的同学可以读一下:

import { Canvas, View } from "@tarojs/components"
import Taro, { CanvasContext, useReady } from "@tarojs/taro";
import { FC, forwardRef, Ref, useImperativeHandle, useRef } from "react";

import styles from "./index.module.less";

/**
 * 签名组件 ref context
 */
export interface CanvasSignContext {
  clear: () => void
  saveAsImage: () => Promise<ToDataURLResult>
}

/**
* CanvasSign.props 参数类型
*/
export interface CanvasSignProps {
  ref?: Ref<CanvasSignContext>
}

/**
 * canvas 导入图片结果
 */
 export interface ToDataURLResult {
  tempFilePath: string
  errMsg: string
}

/**
 * 将 canvas 内容转换成 base64 字符串
 */
const toDataURL = async (canvasId: string, canvas?: CanvasContext): Promise<ToDataURLResult> => {
  if (!canvas) return { errMsg: 'canvas is null', tempFilePath: '' }

  return new Promise((resolve, reject) => {
    canvas.draw(true, () => {
      Taro.canvasToTempFilePath({
        canvasId: canvasId,
        fileType: 'png',
        success: res => resolve(res),
        fail: err => reject(err)
      });
    });
  })
}

/**
 * 获取 canvas 的尺寸
 */
const getCanvasSize = async (canvasId: string): Promise<{ height: number, width: number }> => {
  return new Promise((resolve) => {
    const query = Taro.createSelectorQuery()
    query.select('#' + canvasId)
      .boundingClientRect()
      .exec(([size]) => resolve(size))
  })
}

/**
 * 签名绘图 canvas 组件
 * 
 * @see https://juejin.cn/post/6978721559397531678
 */
export const CanvasSign: FC<CanvasSignProps> = forwardRef((props, ref) => {
  // 绘图画布引用
  const context = useRef<Taro.CanvasContext>();
  // 绘制轨迹信息
  const lineInfo = useRef({ startX: 0, startY: 0 });

  useReady(() => {
    const query = Taro.createSelectorQuery()

    query.select('#myCanvas')
      .context(res => {
        if (!res.context) return

        const { windowWidth, windowHeight } = Taro.getSystemInfoSync()

        res.context.canvas.width = windowWidth;
        res.context.canvas.height = windowHeight;

        res.context.setStrokeStyle('#000000');
        res.context.setLineWidth(4);
        res.context.setLineCap('round');
        res.context.setLineJoin('round');
        context.current = res.context as CanvasContext;
      })
      .exec()
  })

  const canvasStart = (e) => {
    e.preventDefault();
    lineInfo.current.startX = e.changedTouches[0].clientX
    lineInfo.current.startY = e.changedTouches[0].clientY
    context.current?.beginPath()
  }

  const canvasMove = (e) => {
    e.preventDefault();

    let x = e.changedTouches[0].clientX
    let y = e.changedTouches[0].clientY
    context.current?.moveTo(lineInfo.current.startX, lineInfo.current.startY)
    context.current?.lineTo(x, y)
    context.current?.stroke()
    context.current?.draw(true)
    lineInfo.current.startX = x
    lineInfo.current.startY = y
  }

  const clear = () => {
    context.current?.draw();
  }

  const saveAsImage = async () => {
    const { tempFilePath } = await toDataURL('myCanvas', context.current)
    const { width, height } = await getCanvasSize('saveCanvas')

    // 这里完成了签名图片的旋转操作
    const saveCanvas = Taro.createCanvasContext('saveCanvas')
    saveCanvas.translate(0, height)
    saveCanvas.rotate(-90 * Math.PI / 180)
    saveCanvas.drawImage(tempFilePath, 0, 0, height, width)

    return await toDataURL('saveCanvas', saveCanvas)
  }

  useImperativeHandle(ref, () => ({ clear, saveAsImage }))

  return (
    <View className={styles.container}>
      {/* 这个 canvas 用来签名 */}
      <Canvas
        className={styles.signCanvas}
        canvasId='myCanvas'
        id='myCanvas'
        disableScroll
        onTouchStart={canvasStart}
        onTouchMove={canvasMove}
      ></Canvas>

      {/* 这个 canvas 用于把签名内容旋转九十度 */}
      <Canvas
        className={styles.saveCanvas}
        canvasId='saveCanvas'
        id='saveCanvas'
      ></Canvas>
    </View>
  )
})

我这里需求比较简单,所以通过 ref 暴露了两个操作,分别是清空画布 clear 和导出为图片 saveAsImage。接下来,我们就来实现签名页,把这些 api 绑定到具体的按钮上。

签名页面

上面的签名 canvas 组件可以拿到其他小程序项目里用,而现在要做的这个组件就是用来实现本项目的 UI 需求的,这部分没什么难点,直接贴代码了:

src\pages\sign\index.tsx

import Taro, { useRouter } from "@tarojs/taro";
import { useRef } from "react";
import { View } from "@tarojs/components";
import { CanvasSign, CanvasSignContext } from '@/components/CanvasSign'
import { Button } from "@antmjs/vantui";

import styles from "./index.module.less";

const QuestionList: React.FC = () => {
  const signRef = useRef<CanvasSignContext>(null);
  const router = useRouter();

  // 确认签名完成
  const onSubmit = async () => {
    const result = await signRef.current?.saveAsImage()
    if (!result) return console.error('签名失败')

    // 用事件总线把导出的签名图发射出去
    Taro.eventCenter.trigger(router.params.type || '', { url: result.tempFilePath });
    Taro.navigateBack({ delta: 1 });
  }

  const onClear = () => {
    signRef.current?.clear();
  }

  const onCancel = () => {
    Taro.navigateBack({ delta: 1 });
  }

  return (
    <View className={styles.container}>
      <CanvasSign ref={signRef} />
      <View className={styles.btns}>
        <Button className={styles.btn} type='primary' plain color='#00D3A3' onClick={onClear}>
          重置
        </Button>
        <Button className={styles.btn} type='primary' plain color='#00D3A3' onClick={onCancel}>
          取消
        </Button>
        <Button className={styles.btn} type='primary' color='#00D3A3' onClick={onSubmit}>
          提交
        </Button>
      </View>
    </View>
  );
};

export default QuestionList;

src\pages\sign\index.module.less

.container {
  position: relative;

  .btns {
    position: absolute;
    top: 0;
    left: 0;
    width: 100vh;
    transform-origin: 0 0;
    // 注意这里,手动把按钮组横了过来
    transform: rotateZ(90deg) translateY(-100%);
    display: flex;
    justify-content: center;
    padding: 32px 0px;

    .btn {
      margin: 0px 16px;
      width: 20vh;
    }
  }
}

因为小程序内置的横屏会把标题也横过来,导致签名区域变窄,所以这里选择手写横屏而不是直接用小程序内置的横屏配置。

并且注意 onSubmit 中,从 router params 里读了一个 type,然后把导出的签名图 base64 发射到名字为 type 的事件上。所以说,想要发起签名的页面需要在导航到本页前先通过 Taro.eventCenter 绑定一个事件,然后把事件名字通过 router params(即 type 变量)告诉签名页,这样让双方可以进行签名图的传递。

签名调用页

最后,我们来实现如何发起签名和如何接受并显示得到的签名图:

import Taro from "@tarojs/taro";
import { FC, useState } from "react";
import { Text, View, Image } from "@tarojs/components";

const CallSignPage: FC = () => {
  // 签名图片的 base64
  const [ownerSignUrl, setOwnerSignUrl] = useState('');

  // 拉起签名页
  const jumpToSign = () => {
    const eventKey = `${new Date().getTime()}`

    Taro.eventCenter.once(eventKey, data => {
      setOwnerSignUrl(data.url)
    })

    Taro.navigateTo({ url: `/pages/sign/index?type=${eventKey}` });
  }

  return (
    <View>
      {ownerSignUrl
        ? <Image src={ownerSignUrl} onClick={() => jumpToSign()} />
        : <Text onClick={() => jumpToSign()}>点击签名</Text>
      }
    </View>
  );
};

export default CallSignPage;

这里我精简掉了样式代码,核心逻辑就是创建一个 state 来保存签名图的 base64,然后在请求签名前先监听对应的事件(监听一次即可,所以是 once),随后把时间名字发送给签名页。接下来等待签名发回来即可。

这里需要注意的是,我用的是 Taro 内置的 Image 组件,而不是 taro-vant 的 Image,因为这里有个小坑,taro-vant 的 Image 没法直接显示 base64 图片,没找到原因,Taro 的 Image 就可以。

总结

本文内容到这里就结束了。写的比较全,因为在网上很多找到的教程都是只有只言片语,好不容易加到项目里却发现不行,实在浪费时间。

最后再推荐一个翻阅资料时找到的开源签名组件,也是 react hook,写的不错,有兴趣的可以看一下: yz1311/taro-signature-pad: taro3中支持手写签名的库 (github.com)