前言
在日常牛马工作中,产品给我提了一个需求,让我实现一个签名板子。
效果
实现
在实现之前,我们先确定功能点,然后按照我们确定的功能点一步步实现。
确定功能点
手写签名主要有四个功能:
- 手写功能
- 获取绘制数据
- 根据数据绘制画板
- 清除
步骤
一、搭建组件基础框架
先将组件需要的功能点确定下来,然后根据这些功能点声明需要的函数、确定外部组件调用方式、确定需要传入的参数;
我们将上面想要的四个功能点中,除了手写功能(这个不用函数去调用),其它三个均声明一个函数,方便后续填充具体的代码逻辑。
将内部的方法通过forwardRef、useImperativeHandle给暴露出去,外部组件通过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画布,并声明一个id:sketchpad-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,
});
}
}, []);
......
现在就可以书写了:
三、将外部传入的参数进行填充
在第一步的时候,我们确定了四个传入的参数,我们将这四个参数依次补充到相应的位置上去。
我们这里并没有直接将宽高放在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);
}
}
此时自定义属性的值就完成了,我们可以在浏览器里看到:
但是此时,我们在画板上写字的时候会发现一个问题,鼠标位置和绘制位置有了一定的偏差,这个在下面会解决。
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的数据,这个函数内部有一些参数,可以转成jpeg、svg、png格式的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;
优化
经过上面,我们已经基本实现了一个手写签名的组件,但是我们还有一个必须的点去优化,那就是压缩画板数据,在画板上写的字越复杂资源越大,这里我举个例子,比如说我写一个复杂的字:
他有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了,就是有点糊,这里可以根据自身的需求去设置不同的压缩
还有一点小小减少资源大小的优化,那就是由于我们每次生成的base64码头部是固定的,那么我们可以将固定的头部去掉,只保留主体,然后后续我们从接口拿值显示的时候,再把头部给拼上去:
// 去掉base64的头部信息,只保留base64编码
export const base64RemoveHeader = (base64: string): string => {
return base64.replace('data:image/png;base64,', '');
};
踩坑记录
画板背景设置
当我们使用
signaturePad.toDataURL("image/jpeg")
转成jpeg的格式时,signaturePad的backgroundColor一定得有颜色,不能透明,否则会得到一个纯黑的背景:
// 初始化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了,各位大佬学会了嘛?如有问题,欢迎评论区互动,询问哈,我一定第一时间回复!!!!!!!!!!!!!!!