一个近乎完美的Konva手写板诞生记(React版)

806 阅读17分钟

封面_掘金_react.png

原创声明-掘金.png

最近,开发了一个用于移动批阅的Web手写板工具。大致业务需求如下:

手写板工具读取指定图片并作为底板图片显示,用户在PC/手机/平板电脑上使用鼠标/手指/触控笔在底板图片上进行批注,最后将笔划和底板图片合成一张图片。

其中还包括一些功能需求,例如:

  1. 支持笔划的粗细、颜色设定
  2. 支持橡皮擦功能
  3. 支持画布的缩放、移动
  4. 支持撤销上一步操作
  5. 支持导出合成的图像
  6. 保存和读取笔划数据

一开始,因为任务时间紧,对开源工具进行了调研,最终选择了相对更接近需求的 react-canvas-draw 这套开源代码。这套代码在2021年11月就停更了,幸运的是,放在React18的项目中可以正常运行,但是它所依赖的其他npm包的版本不能轻易更新。在实际使用中,发现了它的一些不足:

  1. 没有橡皮擦功能
  2. 笔划过多(约200+)的时候会卡顿
  3. 横竖屏切换时,笔划与画面错位,这是因为笔划的粗细和坐标都是固定值,没有参考系进行适配调整
  4. 无法区分手指和触控笔,导致使用触控笔的时候,手掌等部位会蹭出多余笔划
  5. 在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版)》

最终成果

先看下界面。

界面_react.png

实现的功能如下:

  1. 加载远程图片URL或base64编码图片,作为底板图像
  2. 支持笔划的粗细和颜色设定
  3. 支持橡皮擦的粗细设定
  4. 支持对触控方式(鼠标/手指/触控笔)的开启和关闭
  5. 支持鼠标滚轮缩放及按住空格键的同时(光标变成小手)进行鼠标拖拽移动
  6. 支持双指缩放和移动(仅可手指,触控笔不能)
  7. 支持撤销上一步操作,可设置最多可撤销的次数
  8. 支持保存和读取笔划数据
  9. 支持导出合成图片或base64编码
  10. 支持清空所有笔划
  11. 支持根据底板图片及外层容器实时自动调整合适的画布尺寸
  12. 支持设置参考系,以参考系为标准,当画布尺寸改变时,能够自动调整笔划的粗细
  13. 支持实时显示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:鼠标滚轮滑动

抛弃了onMouseDownonTouchStart等事件。

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?》

konvajs.org/docs/sandbo…

缩放的大致思路就是记录两指的坐标,并计算出两指坐标连线的中心坐标,以这个中心坐标作为缩放的中心点,然后根据两个手指的初始距离和当前距离计算出缩放比例。

双指移动也是根据双指连线的中心坐标的位置进行画布移动。

当然,只看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上的绘图板软件,默认都是全屏运行,即使不是全屏,也会保持绘图板的比例或者大小不变(使用滚动条)。

但是在浏览器上,浏览器窗口或者绘图板组件的外层容器尺寸可能都是变化的,为了做到自适应,需要监听浏览器窗口发生变化的事件,对绘图板的尺寸进行重新计算。

这里涉及到三个方面的适配:

  1. Stage(画布)的尺寸适配。根据外层容器及底板图片尺寸进行适配,类似实现CSS的background-size: contain的填充效果。
  2. 底板图片的尺寸适配。图片尺寸与Stage尺寸是一样的。
  3. 笔划粗细和路径的适配。当画布尺寸发生变化时,笔划粗细和路径也要等比缩放,否则就会出现笔划与底板图片的错位。

相关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个)搬迁至静态笔划层。这样的话,即使笔划数量过多,也只有在每次迁移笔划时才会略感到一丝卡顿,并不是每次笔划都卡顿,在很大程度上缓解了压力。

但是,这样又带来了新的问题:橡皮擦笔划只能擦除动态笔划层的笔划。

解决办法就是:

  1. 首先,给橡皮擦笔划添加一个唯一的自增eraserID,在添加至动态笔划层的同时,也同步添加至静态笔划层。
  2. 然后,当静态笔划层的笔划搬迁至动态笔划层后,根据eraserID,将前一个重复eraserID的橡皮擦笔划删除。
  3. 最后,删除静态笔划层的所有eraserID属性,保持数据的精简。

细心的你可能会问,当静态笔划层已经累计了很多笔划时(例如:1000个),同步向静态笔划层添加橡皮擦笔划的时候,会导致静态笔划层重新渲染,这样不会卡顿吗?

答案是:几乎察觉不到。

原因是,橡皮擦笔划的添加时机与普通笔划的时机不一样。

正在书写的普通笔划是实时更新路径并渲染在动态笔划层的;

而橡皮擦笔划的实时渲染是放在了橡皮擦笔划层,这个层是个临时层,最多只能保留一个橡皮擦笔划。当触点离开时,就会把这个橡皮擦笔划同时搬迁到静态笔划层和动态笔划层。因此,只有橡皮擦搬迁时才会重新渲染静态笔划层,这个重渲的过程可能在下一个笔划开始时就完成了,因此几乎感觉不到卡顿。

2.7 其他性能优化Tips

Konva官网的教程专门有一章内容是性能优化的:

《HTML5 Canvas All Konva performance tips list》

konvajs.org/docs/perfor…

在解决笔划卡顿过多导致卡顿的问题时,借鉴过这里的优化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》

konvajs.org/docs/perfor…

  • 优化点2:关闭Line的perfectDraw和shadowForStroke

直接看代码就明白了:

<Line
    ...(略)
    perfectDrawEnabled={false}
    shadowForStrokeEnabled={false}
/>

详细解释请看官网说明:

《Disable Perfect Draw》

konvajs.org/docs/perfor…

《Disable shadow for stroke》

konvajs.org/docs/perfor…

  • 优化点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,用于实时显示当前的事件、触点数量、动态笔划数、静态笔划数、待删除的橡皮笔划数(在向静态层迁移前,动态笔划层的橡皮笔划也同时向静态笔划层复制一份,迁移后将删除重复的橡皮擦笔划)、可撤销次数,便于开发调试。

DEBUG.png

3 项目Git源码

本项目已上传至Gitee和GitHub,方便各位下载。

Gitee: gitee.com/betaq/draw-…

GitHub:

github.com/Yuezi32/dra…

项目源码解压密码

🔑🔑💖💖 解压密码,见我的公众号【卧梅又闻花】原文。 💖💖🔑🔑

《一个近乎完美的Konva手写板诞生记(React版)》