彦祖!!你不会用react写电子签名组件???

436 阅读8分钟

前言

在日常牛马工作中,产品给我提了一个需求,让我实现一个签名板子。

效果

ReactTsDemos - Google Chrome 2025-01-08 21-36-20.gif

实现

在实现之前,我们先确定功能点,然后按照我们确定的功能点一步步实现。

确定功能点

手写签名主要有四个功能:

  • 手写功能
  • 获取绘制数据
  • 根据数据绘制画板
  • 清除

步骤

一、搭建组件基础框架

先将组件需要的功能点确定下来,然后根据这些功能点声明需要的函数、确定外部组件调用方式、确定需要传入的参数;

我们将上面想要的四个功能点中,除了手写功能(这个不用函数去调用),其它三个均声明一个函数,方便后续填充具体的代码逻辑。

将内部的方法通过forwardRefuseImperativeHandle给暴露出去,外部组件通过ref的方式调用内部组件。

这里传入画布的宽度、高度用来确定画布的显示大小,以适配于不同的屏幕宽度;

是否禁用,用来满足只显示不绘制的场景;

配置项则是关于画布的一些配置。

import { useEffect, forwardRef, useImperativeHandle } from "react";

interface sketchpadProps {
  width?: number; // 画布宽度
  height?: number; // 画布高度
  disabled?: boolean; // 是否禁用
  config?: object; // 配置
}

const Sketchpad = (props: sketchpadProps, ref: any) => {
  const { width = 100, height = 100, disabled = false, config = {} } = props || {};

  // 初始化
  useEffect(() => {

  }, []);


  // 获取绘制数据
  const toDataURL = () => {

  };


  // 根据数据绘制画板
  const fromDataURL = (dataUrl: string) => {
    console.log('dataUrl: ', dataUrl);
  };


  // 清除签名
  const clear = () => {

  };


  // 向外暴露方法
  useImperativeHandle(ref, () => ({
    toDataURL,
    fromDataURL,
    clear
  }), []);


  return <div>
    我是画板
  </div>
}

export default forwardRef(Sketchpad);

二、创建画布

上面框架搭建完成后,然后我们进入到核心的功能,画布的搭建

我们这里使用的是canvas画布,然后还用了一个第三方的插件signature_pad

添加canvas画布,并声明一个idsketchpad-canvas

return <div className="sketchpad">
    <canvas id="sketchpad-canvas"/>
  </div>

将依赖下载下来

yarn add signature_pad

填充初始化的useEffect

import SignaturePad from "signature_pad";
......


// 初始化signaturePad配置
const initSignaturePadConfig = {
  backgroundColor: "rgb(255, 255, 255, 0)",
  penColor: "rgb(0, 0, 0)",
  velocityFilterWeight: 0.7,
  minWidth: 0.5,
  maxWidth: 2.5,
  throttle: 16,
  minDistance: 5,
};


......


 const sketchPadRef = useRef<SignaturePad | null>(null);


  // 初始化
  useEffect(() => {
    const canvas = document.getElementById(
      "sketchpad-canvas"
    ) as HTMLCanvasElement;
    if (canvas) {
      sketchPadRef.current = new SignaturePad(canvas, {
        ...initSignaturePadConfig,
      });
    }
  }, []);


......

现在就可以书写了:

image.png

三、将外部传入的参数进行填充

在第一步的时候,我们确定了四个传入的参数,我们将这四个参数依次补充到相应的位置上去。

我们这里并没有直接将宽高放在style上,而是借用CSS变量的方式,将这两个值传递到classNames上;

这里使用这种方法,是因为一些项目做了移动端适配,将className属性上的px单位转为rem单位,但是对style``不生效

1、传入的宽高

创建一个自定义属性的className:sketchpad-canvas--custom,之后会在这个类名下写css自定义属性,然后将传入的width以如下面代码的形式传入进去:

return <div className="sketchpad">
    <canvas 
      id="sketchpad-canvas"
      className={`sketchpad-canvas sketchpad-canvas--custom`}
      style={{ "--canvas-width": `${width}px`, "--canvas-height": `${height}px`} as React.CSSProperties}
    />
  </div>
    .....

之后建立一个less文件:index.less,引入进来,并在里面输入内容:

.sketchpad {
  border: 1px solid pink;
  .sketchpad-canvas--custom {
    width: var(--canvas-width);
    height: var(--canvas-height);
  }
}

此时自定义属性的值就完成了,我们可以在浏览器里看到: image.png

但是此时,我们在画板上写字的时候会发现一个问题,鼠标位置和绘制位置有了一定的偏差,这个在下面会解决。

2、传入的disabled、config

SignaturePad提供了一个api:off(),我们只需要在初始化的时候判断,disabled是否为true,去执行该函数就可以了,然后config可以在初始化的时候传入进去:

    ...... 
// 初始化signaturePad
   useEffect(() => {
    const canvas = document.getElementById(
      "sketchpad-canvas"
    ) as HTMLCanvasElement;
    if (canvas) {
      sketchPadRef.current = new SignaturePad(canvas, {
        ...initSignaturePadConfig,
        ...config,
      });
    }
    if(disabled) {
      sketchPadRef.current?.off();
    }
  }, []);
......

四、补充完善函数逻辑

完善toDataURL

这里借用两个api:toDataURL()isEmpty()

toDataURL是将画板的绘制数据转成base64的数据,这个函数内部有一些参数,可以转成jpegsvgpng格式的base64

我这里选择了png格式,因为png的图像可以保存透明度的信息,这里生成的base64数据就不会显示画板的背景色了,只会显示画笔

然后在绘制数据的时候一定要先判断当前画板上有数据了,才转成base64的数据

 // 获取绘制数据
  const toDataURL = () => {
    if (!sketchPadRef.current?.isEmpty()) {
      const base64Data = sketchPadRef.current?.toDataURL() || "";
      return base64Data;
    }
    return "";
  };
完善fromDataURL

这里直接用API就行

 // 根据数据绘制画板
  const fromDataURL = (dataUrl: string) => {
    sketchPadRef.current?.fromDataURL(dataUrl);
  };
完善clear

这里也是直接用API就行

// 清除签名
  const clear = () => {
    sketchPadRef.current?.clear();
  };

五、解决画笔和鼠标位置不一致

在第三步的时候,我们发现给了画板一个固定的宽高之后,发现画笔和鼠标位置对不上了,我们这里专门做一个处理,确保在不同的屏幕或窗口大小发生变化时,调整 canvas 元素的尺寸和比例,以适应新的窗口大小,并保持绘图内容的清晰度

且在每次调整canvas的尺寸的时候,清空画板,这样就能保证画板数据的准确:

......  
// 处理高 DPI 屏幕
    const resizeCanvas = () => {
      const canvas = document.getElementById(
        "sketchpad-canvas"
      ) as HTMLCanvasElement;
      if (!canvas) return;
      const ratio =  Math.max(window.devicePixelRatio || 1, 1);
      canvas.width = canvas.offsetWidth * ratio;
      canvas.height = canvas.offsetHeight * ratio;
      canvas.getContext("2d").scale(ratio, ratio);
      sketchPadRef.current?.clear(); 
  }
    useEffect(() => {
      window.addEventListener("resize", resizeCanvas);
      resizeCanvas();
      return () => {
        window.removeEventListener("resize", resizeCanvas);
      };
    }, []);
......

这样子我们的组件就写好了;

应用

那我们怎么去使用该组件呢,我们想要实现的功能是绘制的数据能够保存,并且能获取到绘制的数据,以及画板上数据的清除功能:

/** 手写签名组件 */
import { useEffect, useRef, useState } from 'react';
import Sketchpad from './sketchpad';


const HandSIgn = () => {


  const sketchpadRef = useRef<any>(null);


  const [data, setData] = useState<string>("");


    /** 拿数据绘制画板数据 */
    const drawSignaturePadData = () => {
      sketchpadRef?.current?.clear(); // 绘制数据前先清空画板
      sketchpadRef?.current?.fromDataURL(data);
    }

    /** 获取当前画板数据并更新 */
    const getSignaturePadData =  () => {
      const data = sketchpadRef?.current?.toDataURL();
      setData(data);
      console.log("🚀 ~ getSignaturePadData ~ data:", data)
    }

    // 清空逻辑
    const handleClear = () => {
      sketchpadRef?.current?.clear();
    }


  // 初始化
  useEffect(() => {


  }, []);


  return (
    <div>
      <Sketchpad ref={sketchpadRef} width={500} height={500}/>
      <button onClick={drawSignaturePadData}>绘制画板数据</button>
      <button onClick={getSignaturePadData}>获取画板数据</button>
      <button onClick={handleClear}>清空画板</button>
    </div>
  );
};


export default HandSIgn;

优化

经过上面,我们已经基本实现了一个手写签名的组件,但是我们还有一个必须的点去优化,那就是压缩画板数据,在画板上写的字越复杂资源越大,这里我举个例子,比如说我写一个复杂的字:

image.png

他有53.7kb,这个有点大,如果再复杂点,那数据会更大

我这里利用利用canvas和指定的宽度、高度来压缩图片,并返回压缩后的Base64编码字符串,代码如下:


// 利用canvas和宽度来压缩图片
export const base64Compress = async (base64: string, targetWidth: number, targetHeight: number): Promise<string> => {
  return new Promise((resolve, reject) => {
      const img = new Image();
      img.src = base64;
      img.onload = async () => {
          try {
              const canvas = document.createElement('canvas');
              const ctx = canvas.getContext('2d');
              if (!ctx) {
                  throw new Error('Canvas context is not available');
              }
              canvas.width = targetWidth;
              canvas.height = targetHeight;
              ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
              const pngBase64 = canvas.toDataURL("image/png");
              // 释放canvas资源,先移除canvas元素(如果添加到DOM了)
              canvas.parentNode && canvas.parentNode.removeChild(canvas);
              resolve(base64RemoveHeader(pngBase64));
          } catch (error) {
              console.error('Error during image conversion:', error);
              reject(error);
          }
      };
      img.onerror = (err) => {
          console.error('Error loading the image:', err);
          reject(err);
      };
  });
};

使用的方式如下:

...... 
/** 获取当前画板数据并更新 */
const getSignaturePadData = async () => {
  const data = sketchpadRef?.current?.toDataURL();
  setData(data);
  console.log("🚀 ~ getSignaturePadData ~ data:", await base64Compress(data, 100, 100))
}
......

此时我们用同样的数据发现它大小已经变为1.8kb了,就是有点糊,这里可以根据自身的需求去设置不同的压缩

image.png

还有一点小小减少资源大小的优化,那就是由于我们每次生成的base64码头部是固定的,那么我们可以将固定的头部去掉,只保留主体,然后后续我们从接口拿值显示的时候,再把头部给拼上去:

// 去掉base64的头部信息,只保留base64编码
export const base64RemoveHeader = (base64: string): string => {
  return base64.replace('data:image/png;base64,', '');
};

踩坑记录

画板背景设置

当我们使用

signaturePad.toDataURL("image/jpeg")

转成jpeg的格式时,signaturePadbackgroundColor一定得有颜色,不能透明,否则会得到一个纯黑的背景:

// 初始化signaturePad配置
const initSignaturePadConfig = {
  backgroundColor: "rgb(255, 255, 255, 1)", // 这里的透明度不能为0
  penColor: "rgb(0, 0, 0)",
  velocityFilterWeight: 0.7,
  minWidth: 0.5,
  maxWidth: 2.5,
  throttle: 16,
  minDistance: 5,
};

浏览器屏幕变化导致画板大小变化

当屏幕大小发生变化导致画布的宽度或高度变化时,浏览器会自动清除它。SignaturePad 本身并不知道这一点,因此需要调用signaturePad.fromData(signaturePad.toData())来重置绘图,或者signaturePad.clear()确保signaturePad.isEmpty()在这种情况下返回正确的值。

这也是我们在上面demo中,resizeCanvas函数的最后一步写上sketchPadRef.current?.clear()的原因。

但是如果有场景要求当屏幕发生变化,还有保存原有的数据,那就不能这么写了。

我们得在屏幕发生变化之前拿到画板的数据,在屏幕变化之后再画出来,我们的resizeCanvas函数就得变成:

// 处理高 DPI 屏幕
const resizeCanvas = () => {
const canvas = document.getElementById(
  "signature-pad"
) as HTMLCanvasElement;
if (!canvas) return;
  const __dataURL = toDataURL();
  const ratio =  Math.max(window.devicePixelRatio || 1, 1);
  canvas.width = canvas.offsetWidth * ratio;
  canvas.height = canvas.offsetHeight * ratio;
  canvas.getContext("2d").scale(ratio, ratio);
  signaturePadRef.current?.clear();
  fromDataURL(__dataURL);
  return;
} 

总结

以上我们就实现了一个完整的可以直接使用的demo了,各位大佬学会了嘛?如有问题,欢迎评论区互动,询问哈,我一定第一时间回复!!!!!!!!!!!!!!!