
最近,开发了一个用于移动批阅的Web手写板工具。大致业务需求如下:
手写板工具读取指定图片并作为底板图片显示,用户在PC/手机/平板电脑上使用鼠标/手指/触控笔在底板图片上进行批注,最后将笔划和底板图片合成一张图片。
其中还包括一些功能需求,例如:
- 支持笔划的粗细、颜色设定
- 支持橡皮擦功能
- 支持画布的缩放、移动
- 支持撤销上一步操作
- 支持导出合成的图像
- 保存和读取笔划数据
一开始,因为任务时间紧,对开源工具进行了调研,最终选择了相对更接近需求的 react-canvas-draw 这套开源代码。这套代码在2021年11月就停更了,幸运的是,放在React18的项目中可以正常运行,但是它所依赖的其他npm包的版本不能轻易更新。在实际使用中,发现了它的一些不足:
- 没有橡皮擦功能
- 笔划过多(约200+)的时候会卡顿
- 横竖屏切换时,笔划与画面错位,这是因为笔划的粗细和坐标都是固定值,没有参考系进行适配调整
- 无法区分手指和触控笔,导致使用触控笔的时候,手掌等部位会蹭出多余笔划
- 在iPad上缩放和移动非常卡顿(Android设备没问题)
其中,问题1、2、3,在源码基础上经过一番折腾基本上解决了,但问题4、5实在是很难解决了。
于是,在空余时间,经过调研,基于目前主流的Konva.js开源框架进行了React19(兼容React18)和Vue3两个版本的重构,每个版本又分别做了Javascript版和Typescript版,合计4个版本。
这4个版本代码,一方面可以直接二次开发用于其他项目,另一方面也方便不同版本的对照学习(了解Javascript与Typescript的异同、了解React与Vue的异同)。
本文仅分享最为核心canvas前端手写板的开发内容,不涉及整体项目的其余开发部分(业务系统界面、后端开发等)。
注:本文为React版本,Vue版本请阅读《一个近乎完美的Konva手写板诞生记(Vue版)》
最终成果
先看下界面。
实现的功能如下:
- 加载远程图片URL或base64编码图片,作为底板图像
- 支持笔划的粗细和颜色设定
- 支持橡皮擦的粗细设定
- 支持对触控方式(鼠标/手指/触控笔)的开启和关闭
- 支持鼠标滚轮缩放及按住空格键的同时(光标变成小手)进行鼠标拖拽移动
- 支持双指缩放和移动(仅可手指,触控笔不能)
- 支持撤销上一步操作,可设置最多可撤销的次数
- 支持保存和读取笔划数据
- 支持导出合成图片或base64编码
- 支持清空所有笔划
- 支持根据底板图片及外层容器实时自动调整合适的画布尺寸
- 支持设置参考系,以参考系为标准,当画布尺寸改变时,能够自动调整笔划的粗细
- 支持实时显示DEBUG信息
在满足业务需求的同时,尽可能把配置项抽离出来,提高兼容性和可扩展性。
章节目录
1 开发及测试环境
2 核心点及解决方案
• 2.1 识别鼠标、手指、触控笔
• 2.2 高强度单点笔划操作时可能误识别为多点触控
• 2.3 双指缩放和移动
• 2.4 自适应外层容器尺寸
• 2.5 双指接触和抬起的瞬间导致误触笔划
• 2.6 笔划过多导致的卡顿
• 2.7 其他性能优化Tips
• 2.8 iPad与HUAWEI MatePad体验差异
• 2.9 Debug信息输出
3 项目源码
推荐阅读
ZIP项目源码解压密码
1 开发及测试环境
本教程的主要依赖包版本:
Node.js 22.14.0
vite 6.1.0
react 19.0.0
react-dom 19.0.0
konva 9.3.18
react-konva 19.0.2
antd 5.24.0
测试环境:
iPad Pro(11寸)第3代, iOS 18.3, Safari
HUAWEI MatePad Pro,Harmony OS 4.2.0, 自带浏览器
2 核心点及解决方案
Konva官网提供了一个React版的绘图板的Demo:
https://konvajs.org/docs/react/Free_Drawing.html
当时让我很兴奋,这个Demo才几十行代码,就实现了一个简易的绘图板,有笔和橡皮,能设置笔触粗细和颜色,再加上Konva内置好的缩放、位移导出图像等功能,看上去需求已经基本都满足。
但,实际开发起来发现,需要处理的细节问题非常多,并且有一些挑战性。下面就重点分享一下其中遇到的关键问题及解决方案。
2.1 识别鼠标、手指、触控笔
对于以上三种接触方式,使用了以下js原生事件:
onPointerDown:接触按下
onPointerMove:接触移动
onPointerUp:接触抬起
onPointerCancel:接触取消
onWheel:鼠标滚轮滑动
抛弃了onMouseDown、onTouchStart等事件。
Pointer Events API的pointerType中,可以直接识别当前的接触类型:
mouse=鼠标
pen=触控笔
touch=手指
此外,Pointer Events API的pointerId,是给输入设备(鼠标、手指、触控笔)自动分配的唯一标识。具有以下特点:
1. 唯一性
- 在每次交互中,浏览器会为每个活动的指针分配一个唯一的 pointerId。
- 这个 ID 在指针的整个生命周期中保持不变(从按下到释放)。
2. 多点触控支持
- 对于多点触控场景,每个触控点会有一个独立的 pointerId,使开发者能够区分不同的触控点。
3. 设备相关性
- 不同类型的输入设备(鼠标、触控笔、手指等)也会分配不同的 pointerId。
基于pointerId,可以有效解决误触、双指缩放移动等很多细节问题。
例如下面的代码,就是先判断允许的接触类型,再执行后续逻辑:
function DrawStage({
...(略)
// 允许的触控方式,mouse=鼠标,touch=手指,pen=触控笔
allowedPointerType = {
mouse: true,
touch: true,
pen: true,
},
}) {
...(略)
const handlePointerDown = (e) => {
...(略)
// 判断指针数量
if (activePointersRef.current.size === 1) {
// --- 单指 => 准备画线 ---
// 若不允许的指针类型就return
if (!allowedPointerType[e.evt.pointerType]) return
...(略)
}
}
}
2.2 高强度单点笔划操作时可能误识别为多点触控
在进行快速书写时,可能会因为Pointer Events还未处理(或者清理)完上一次的触点,新的触点又加入进来,导致误识别为多点触控。
解决方案就是:将当前触点的pointerId保存起来,当接触设备为触控笔或鼠标时,先清空pointerId,然后再将本次触点保存起来。这是因为,触控笔和鼠标都是单点触控设备,确保当前触点只有一个pointerId,从而避免多点触控误识别。
相关React源码如下:
const handlePointerDown = (e) => {
// 防止触屏页面滑动
e.evt.preventDefault()
...(略)
if (e.evt.pointerType === 'pen' || e.evt.pointerType === 'mouse') {
// 对于 pen 和 mouse,清空其他指针,确保单指操作,解决高强度笔划时可能导致的多指误识别问题
activePointersRef.current.clear()
activePointersRef.current.add(e.evt.pointerId)
} else if (e.evt.pointerType === 'touch') {
// 对于 touch,允许多指操作, 把当前 pointerId 加入集合
activePointersRef.current.add(e.evt.pointerId)
}
...(略)
}
2.3 双指缩放和移动
Konva虽然提供了画布缩放和位移的API,但是手势操作需要自己实现。
幸运的是,Konva官网给出了双指缩放的实现思路和Demo:
《How to enable pan and pinch zoom for canvas stage?》
缩放的大致思路就是记录两指的坐标,并计算出两指坐标连线的中心坐标,以这个中心坐标作为缩放的中心点,然后根据两个手指的初始距离和当前距离计算出缩放比例。
双指移动也是根据双指连线的中心坐标的位置进行画布移动。
当然,只看Konva官网给的思路还不够,还需要考虑移动画布时防止溢出的问题,也就是避免出现留白。
相关React源码如下:
...(略)
// 多指缩放相关状态
const [layerScale, setLayerScale] = useState(1)
const [layerPos, setLayerPos] = useState({ x: 0, y: 0 })
// 追踪所有 pointerId => { x, y } 的坐标
const pointerPositionsRef = useRef({})
// 捏合开始时记录:初始距离、初始中点、初始 scale、初始 pos
const pinchDataRef = useRef({
startDist: 0,
startCenter: { x: 0, y: 0 },
startScale: 1,
startPos: { x: 0, y: 0 },
})
// 记录 pointer 坐标
const updatePointerPosition = (pointerId, x, y) => {
pointerPositionsRef.current[pointerId] = { x, y }
}
const removePointerPosition = (pointerId) => {
delete pointerPositionsRef.current[pointerId]
}
// 双指缩放及移动功能:计算双指距离
const getDistance = (p1, p2) => {
const dx = p1.x - p2.x
const dy = p1.y - p2.y
return Math.sqrt(dx * dx + dy * dy)
}
// 双指缩放及移动功能:计算双指中心坐标
const getCenter = (p1, p2) => {
return {
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2,
}
}
// clamp 工具函数
const clamp = (value, min, max) => {
return Math.min(Math.max(value, min), max)
}
const handlePointerMove = (e) => {
// 防止触屏页面滑动
e.evt.preventDefault()
...(略)
// 按住空格时的鼠标拖拽
if (isDraggingPanRef.current) {
const newPos = stagePos
const lastPos = lastPanPosRef.current
if (newPos && lastPos) {
const dx = newPos.x - lastPos.x
const dy = newPos.y - lastPos.y
// 计算新的位置
let updatedPos = {
x: layerPos.x + dx,
y: layerPos.y + dy,
}
// ------- 防止溢出 Stage 边界 -------
const scaledWidth = parseFloat((stageSize.width * layerScale).toFixed(2))
const scaledHeight = parseFloat((stageSize.height * layerScale).toFixed(2))
// 计算可移动范围
const leftLimit = 0
const rightLimit = parseFloat((stageSize.width - scaledWidth).toFixed(2))
const topLimit = 0
const bottomLimit = parseFloat((stageSize.height - scaledHeight).toFixed(2))
// Clamp the updated position to prevent overflow
updatedPos.x = parseFloat(
clamp(updatedPos.x, rightLimit, leftLimit).toFixed(2)
)
updatedPos.y = parseFloat(
clamp(updatedPos.y, bottomLimit, topLimit).toFixed(2)
)
// Update layer position
setLayerPos(updatedPos)
// Update last position
lastPanPosRef.current = { x: newPos.x, y: newPos.y }
}
return
}
// 单指画线
if (activePointersRef.current.size === 1) {
...(略)
}
// 双指缩放移动
else if (activePointersRef.current.size === 2) {
// 取出两根指头的坐标
const ids = [...activePointersRef.current]
const p1 = pointerPositionsRef.current[ids[0]]
const p2 = pointerPositionsRef.current[ids[1]]
if (!p1 || !p2) return // 容错
// 计算当前两指距离 & 中心
const dist = getDistance(p1, p2)
const newCenter = getCenter(p1, p2)
// 取出开捏时记录的数据
const { startDist, startCenter, startScale, startPos } =
pinchDataRef.current
// 计算新的缩放比
const scaleRatio = dist / startDist
let newScale = startScale * scaleRatio
// 最小缩放限制
const MIN_SCALE = 1
newScale = Math.max(newScale, MIN_SCALE)
// 最大缩放限制
newScale = Math.min(newScale, maxScale)
newScale = parseFloat(newScale.toFixed(3))
// === 关键:让 Layer 围绕“捏合中心”缩放 ===
// 先把 startCenter (舞台坐标) 转到 Layer 本地坐标
const oldScale = startScale
const centerInLayer = {
x: (startCenter.x - startPos.x) / oldScale,
y: (startCenter.y - startPos.y) / oldScale,
}
// 让这个本地坐标点在 newScale 下映射到 newCenter
let newPos = {
x: newCenter.x - centerInLayer.x * newScale,
y: newCenter.y - centerInLayer.y * newScale,
}
// ------- 防止溢出 Stage 边界 -------
// 1) 假设 ImageLayer = (stageSize.width, stageSize.height)
// 缩放后实际大小:
const scaledWidth = parseFloat((stageSize.width * newScale).toFixed(2))
const scaledHeight = parseFloat((stageSize.height * newScale).toFixed(2))
// 2) 计算可移动范围
// 如果 scaledWidth > stageWidth,就可在 [rightLimit, leftLimit] 间移动
// leftLimit = 0 (不可拖出左侧)
// rightLimit = stageSize.width - scaledWidth (不可拖出右侧)
const leftLimit = 0
const rightLimit = parseFloat((stageSize.width - scaledWidth).toFixed(2))
const topLimit = 0
const bottomLimit = parseFloat((stageSize.height - scaledHeight).toFixed(2))
// 3) 根据实际需求 clamp
newPos.x = parseFloat(
clamp(newPos.x, rightLimit, leftLimit).toFixed(2)
)
newPos.y = parseFloat(
clamp(newPos.y, bottomLimit, topLimit).toFixed(2)
)
// 最终更新 Layer
setLayerScale(newScale)
setLayerPos(newPos)
return
}
}
2.4 自适应外层容器尺寸
一般来讲,目前手机、Pad上的绘图板软件,默认都是全屏运行,即使不是全屏,也会保持绘图板的比例或者大小不变(使用滚动条)。
但是在浏览器上,浏览器窗口或者绘图板组件的外层容器尺寸可能都是变化的,为了做到自适应,需要监听浏览器窗口发生变化的事件,对绘图板的尺寸进行重新计算。
这里涉及到三个方面的适配:
- Stage(画布)的尺寸适配。根据外层容器及底板图片尺寸进行适配,类似实现CSS的background-size: contain的填充效果。
- 底板图片的尺寸适配。图片尺寸与Stage尺寸是一样的。
- 笔划粗细和路径的适配。当画布尺寸发生变化时,笔划粗细和路径也要等比缩放,否则就会出现笔划与底板图片的错位。
相关React源码如下:
// 1. 动态测量 M-drawCanvas 容器大小
useEffect(() => {
const handleResize = () => {
...(略)
}
// 组件挂载后先进行一次测量
handleResize()
// 监听浏览器窗口 resize
window.addEventListener('resize', handleResize)
// 清理函数:组件卸载时,移除事件监听
return () => {
window.removeEventListener('resize', handleResize)
}
}, [containerRef])
// 2. 加载图片,获取图片的实际宽高
useEffect(() => {
...(略)
}, [imageSrc, onImageLoadSucceed, onImageLoadFailed])
// 3. 计算 Stage 的大小
useEffect(() => {
...(略)
// 如果还没测量到容器大小 或 图片大小,暂不计算
if (!containerW || !containerH || !imageW || !imageH) {
return
}
...(略)
// 等比缩放至容器大小
...(略)
// 自动设定参考系
...(略)
// 计算缩放比例,并同步调整笔划坐标和strokeWidth
if (prevStageSize.width && prevStageSize.height) {
...(略)
// 调整所有笔划的坐标
setLines((prevLines) =>
...(略)
)
setStaticLines((prevLines) =>
...(略)
)
// 调整 layerPos,当Stage尺寸变化时,图层的位移比例也要随之改变
setLayerPos((prevPos) => {
...(略)
})
// 更新前一个 Stage 尺寸
prevStageSizeRef.current = newStageSize
}
else {
// 初次设置前一个 Stage 尺寸
prevStageSizeRef.current = newStageSize
}
setStageSize({ width: finalStageWidth, height: finalStageHeight })
}, [containerSize, imageSize, borderWidth])
2.5 双指接触和抬起的瞬间导致误触笔划
在两个手指接触屏幕进行缩放动作的瞬间,很难保证每次都是两个手指同时接触屏幕。可能自我感觉是同时接触了,但对于设备来说,哪怕某一个手指抢先接触0.1秒,也会导致先接触屏幕的手指作为单指操作留下笔划。
同理,当双指抬起的瞬间,也很有可能有一个手指抢先抬起0.1秒,导致最后一个抬起的手指留下笔划。
要解决这个问题,代码实现不难,主要是把思路想清楚。
设置一个isDrawing用来表示当前是否正在书写。
当单点接触时,将isDrawing设置为true,
如果有多点接触时,isDrawing为true,说明发生了某个手指抢先接触屏幕了,这时只需要将上一个笔划删除,然后将isDrawing设置成false即可。
相关React源码如下:
const handlePointerDown = (e) => {
// 防止触屏页面滑动
e.evt.preventDefault()
...(略)
// 判断指针数量
if (activePointersRef.current.size === 1) {
...(略)
isDrawing.current = true
...(略)
}
else if (activePointersRef.current.size === 2) {
// --- 双指 => 准备缩放/移动 ---
// 如果本来 isDrawing = true,说明第一指已开始画线,立即删除
if (isDrawing.current) {
setLines((prev) => {
// 删除最后那条正在绘制的笔划
if (prev.length > 0) {
return prev.slice(0, -1)
}
return prev
})
isDrawing.current = false
currentLineIndexRef.current = null
}
...(略)
}
}
2.6 笔划过多导致的卡顿
在华为Pad和苹果iPad上测试时发现,当笔划达到300划以上时,可以感觉到明显的卡顿。
Konva.js是一款主流的优秀Canvas框架,很多非常高端复杂的Canvas项目都是用它来开发的,不可能区区200多个笔划就会卡顿。
问题很快就锁定在了React方面。
动态笔划层代码如下:
function DynamicsLines({ lines }) {
return (
<>
{lines &&
lines.map((line, i) => (
<Line
key={'dynamicsLine' + i}
points={line.points}
stroke={line.strokeColor}
strokeWidth={line.realStrokeWidth}
tension={tension}
lineCap={lineCap}
lineJoin={lineJoin}
globalCompositeOperation={
line.tool === 'eraser'
? 'destination-out'
: 'source-over'
}
perfectDrawEnabled={false}
shadowForStrokeEnabled={false}
/>
))}
</>
)
}
按照React组件的方式,将笔划<Line>循环输出。每次有新的笔划时,lines会发生改变,从而再次从第1个笔划开始渲染。也就是说,当写第301笔时,实际上是从头渲染了301个笔划,而不是只新增渲染第301笔。
解决方法就创建一个Layer专门存放旧笔划,Stage的分层如下:
<Stage>
{/* 底板图片层 */}
<Layer ...(略)>
<ImageLayer ...(略)/>
</Layer>
{/* 静态笔划层 */}
<Layer ...(略)>
<StaticLines lines={staticLines} />
</Layer>
{/* 动态笔划层 */}
<Layer ...(略)>
<DynamicsLines lines={lines} />
</Layer>
{/* 擦除提示笔划层 */}
<Layer ...(略)>
<EraserLines lines={eraserHintLines} />
</Layer>
{/* DEBUG信息层 */}
<DebugInfo ...(略) />
</Stage>
静态笔划层的代码如下:
import { memo } from 'react'
import { Line } from 'react-konva'
import PropTypes from 'prop-types'
function StaticLines({ lines, tension, lineCap, lineJoin }) {
console.log('lineCap', lineCap)
return (
<>
{lines &&
lines.map((line, i) => (
<Line
key={'staticLine' + i}
points={line.points}
stroke={line.strokeColor}
strokeWidth={line.realStrokeWidth}
tension={tension}
lineCap={lineCap}
lineJoin={lineJoin}
globalCompositeOperation={
line.tool === 'eraser'
? 'destination-out'
: 'source-over'
}
perfectDrawEnabled={false}
shadowForStrokeEnabled={false}
/>
))}
</>
)
}
StaticLines.propTypes = {
lines: PropTypes.array,
tension: PropTypes.number,
lineCap: PropTypes.string,
lineJoin: PropTypes.string,
}
export default memo(StaticLines)
以上,就是把旧笔划放到静态笔划层,把新笔划放到动态笔划层。使用React.memo将静态笔划层静态化,防止每次都重新渲染,这样就有效解决了笔划过多导致的卡顿问题。
那么,如何定义什么是旧笔划,什么是新笔划呢?
这里用到了两个关键的变量:
// 最大实时渲染的笔划数量
maxRealRenderLines = 20,
// 最大可撤销的笔划数量
maxUndoLines = 10,
当动态层笔划达到 maxRealRenderLines 的个数(例如:20个)时,就把动态笔划层的前 maxRealRenderLines - maxUndoLines 个笔划(例如:10个)搬迁至静态笔划层。这样的话,即使笔划数量过多,也只有在每次迁移笔划时才会略感到一丝卡顿,并不是每次笔划都卡顿,在很大程度上缓解了压力。
但是,这样又带来了新的问题:橡皮擦笔划只能擦除动态笔划层的笔划。
解决办法就是:
- 首先,给橡皮擦笔划添加一个唯一的自增eraserID,在添加至动态笔划层的同时,也同步添加至静态笔划层。
- 然后,当静态笔划层的笔划搬迁至动态笔划层后,根据eraserID,将前一个重复eraserID的橡皮擦笔划删除。
- 最后,删除静态笔划层的所有eraserID属性,保持数据的精简。
细心的你可能会问,当静态笔划层已经累计了很多笔划时(例如:1000个),同步向静态笔划层添加橡皮擦笔划的时候,会导致静态笔划层重新渲染,这样不会卡顿吗?
答案是:几乎察觉不到。
原因是,橡皮擦笔划的添加时机与普通笔划的时机不一样。
正在书写的普通笔划是实时更新路径并渲染在动态笔划层的;
而橡皮擦笔划的实时渲染是放在了橡皮擦笔划层,这个层是个临时层,最多只能保留一个橡皮擦笔划。当触点离开时,就会把这个橡皮擦笔划同时搬迁到静态笔划层和动态笔划层。因此,只有橡皮擦搬迁时才会重新渲染静态笔划层,这个重渲的过程可能在下一个笔划开始时就完成了,因此几乎感觉不到卡顿。
2.7 其他性能优化Tips
Konva官网的教程专门有一章内容是性能优化的:
《HTML5 Canvas All Konva performance tips list》
在解决笔划卡顿过多导致卡顿的问题时,借鉴过这里的优化Tips,虽然最终发现是React循环输出大量笔划导致的。但这里的优化Tips,还是能提供一丢丢的性能提升的。
- 优化点1:给静态笔划层Layer设置listening=false
本次需求只是渲染笔划,并不需要对笔划进行点击选中、拖动等操作,因此可以取消其事件监听。
在旧版本的Konva中,这个没有事件监听的Layer使用的是<FastLayer>,在目前的Konva版本中已经废弃了<FastLayer>,改为了Layer的listening属性(默认是true),可以将其改为false来提高渲染性能。Konva官网的说法是:取消了事件监听,Layer性能可提高一倍。
设置了listening=false后,该Layer不再监听本身的事件,因此会响应页面本身的事件(例如:页面滚动、下拉刷新等)。在本Demo中,如果对底板图片层Layer设置了listening=false,将导致在画布上的任何操作都被页面本身事件所取代,无法正常使用。
《Disable Listening Shapes Tip》
- 优化点2:关闭Line的perfectDraw和shadowForStroke
直接看代码就明白了:
<Line
...(略)
perfectDrawEnabled={false}
shadowForStrokeEnabled={false}
/>
详细解释请看官网说明:
《Disable Perfect Draw》
《Disable shadow for stroke》
- 优化点3:减少小数点后的位数
本项目涉及大量坐标、长度等数据计算问题,由于浮点数精度问题,JavaScript 使用 IEEE 754 双精度浮点数(64 位)表示 小数,某些小数无法用二进制精确表示,导致计算误差。
例如:计算 0.1 + 0.2 实际上得到的结果是0.30000000000000004。
虽然计算结果的细微误差不会对实际效果产生影响,但保留太多位数会增加不必要的计算量。
通过类似parseFloat(xxx.toFixed(2))的方法来修正精度问题。
2.8 iPad与HUAWEI MatePad体验差异
总体来说,差异不是非常明显。
当笔划数量过多时(600+),iPad依然非常丝滑,但HUAWEI MatePad在进行画布缩放时,丝滑程度略逊于iPad(但整体感觉还算丝滑)。
2.9 Debug信息输出
为了方便调试程序,增加了专用于Debug的Layer,用于实时显示当前的事件、触点数量、动态笔划数、静态笔划数、待删除的橡皮笔划数(在向静态层迁移前,动态笔划层的橡皮笔划也同时向静态笔划层复制一份,迁移后将删除重复的橡皮擦笔划)、可撤销次数,便于开发调试。
3 项目Git源码
本项目已上传至Gitee和GitHub,方便各位下载。
Gitee: gitee.com/betaq/draw-…
GitHub:
项目源码解压密码
🔑🔑💖💖 解压密码,见我的公众号【卧梅又闻花】原文。 💖💖🔑🔑