uni-app手写签名组件开发实录:canvas跨端填坑与图片导出完整指南

2026-09-05 0 955

项目有个“服务确认”环节,需要用户用手指在屏幕上签个名字,保存成图片附件。原以为是调用个现成插件就行,结果公司审批流程不允许随便引入第三方原生SDK。索性自己用 uni-app 写了个纯前端签字组件。原本以为 canvas 画个线能有多难,实际在手机上跑通以后才发现,跨端问题比想象的多。这篇文章拆解我实现签字组件的全部经过,包括画布坐标修正、触摸事件兼容以及跨端导出图片的坑。

先看最终用法

我把签字功能封装成了一个 SvgSign 组件(其实是 Canvas),页面里只需要引入和调用:

<template>
    <view class="sign-page">
        <signature-board ref="signRef" @confirm="onConfirm"></signature-board>
        <button type="primary" @click="saveSign">保存签名</button>
    </view>
</template>

<script setup>
import SignatureBoard from '@/components/signature-board.vue';

const signRef = ref(null);
function saveSign() {
    signRef.value.exportImage()
        .then(res => console.log('生成图片', res.tempFilePath))
        .catch(err => console.error('签名失败或没画内容', err));
}
</script>

注意这个组件不能做成全屏遮罩那种,最好嵌入在页面里,否则容易和弹层滚动冲突。我这边就是普通 view 内放置 canvas,然后组件内部自适应宽度。

组件模板与基础 canvas

<template>
    <view class="sign-wrap">
        <canvas
            canvas-id="signCanvas"
            id="signCanvas"
            class="sign-canvas"
            disable-scroll
            @touchstart="onTouchStart"
            @touchmove="onTouchMove"
            @touchend="onTouchEnd"
        ></canvas>
        <view class="clear-btn" @click="clear">重签</view>
    </view>
</template>

有几点必须提前注意:

  • canvas-id 和 id 同时设置,小程序端给 canvas 取 id 用 canvas- 前缀,这里保持一致。
  • disable-scroll 属性可以避免手指绘图时页面跟着滚动。
  • H5 端的 canvas 用 canvas-id 后期通过 uni.createCanvasContext 操作,和小程序通用,所以不用兼容 createSelectorQuery 的方式。

初始化上下文和坐标系转换

组件 mounted 后需要获取 canvas 的尺寸。麻烦的是实际绘制区域是在 CSS 像素与真实设备像素之间存在缩放。

import { ref, onMounted, getCurrentInstance } from 'vue';

const ctx = ref(null);
const canvasW = ref(0);
const canvasH = ref(0);

onMounted(() => {
    // 获得渲染实例
    const instance = getCurrentInstance();
    // 使用 uni.createSelectorQuery 获取元素布局信息
    const query = uni.createSelectorQuery().in(instance.proxy);
    query.select('.sign-canvas').boundingClientRect(rect => {
        if (!rect) return;
        canvasW.value = rect.width;
        canvasH.value = rect.height;
        // 创建画布上下文
        ctx.value = uni.createCanvasContext('signCanvas', instance.proxy);
        // 关键一步:绘制区域高清适配
        const dpr = uni.getSystemInfoSync().pixelRatio || 1;
        // canvas 元素的实际渲染尺寸是 rect.width, height
        // 但物理像素是 dpr 倍,我们需要设置 canvas 的 width / height 属性
        // 小程序支持设置 canvas 的 width 与 height
        // 但在 H5 端,canvas 的 width/height 就是画布像素宽度
        // 所以我统一在 mounted 后调整尺寸
        // 这里使用 uni.createCanvasContext 后再设置
        // 经过多次试验,小程序端要直接操作 canvas 节点才行
        // 因此采用创建 CanvasContext 后绘制前动态设置
        // 但是 setCanvasWidth 不存在,直接修改 canvas 对象的属性?
        // 换个方式,直接给 canvas-id 所在节点设置样式宽高,并用 ctx.scale(dpr, dpr) 解决,
        // 但 canvas 自身宽度与样式的宽度比例关系在小程序里需要通过 select 拿到 node。
        // 索性拆开处理。
    });
});

为了少绕弯,我用了常规但稳妥的途径:在 mounted 后取出 canvas 的底层 node,然后设置它的宽高。对于微信小程序端支持 <canvas type=”2d”>,但 uni-app 如果也要支持 App 和 H5,最好还是定义两种模式。 我这边项目主要编译到微信小程序和 H5,所以采用了两套逻辑处理。

canvas 尺寸在不同端的处理

经过整理,我写了以下初始化方法:

function initCanvas() {
    return new Promise((resolve) => {
        const query = uni.createSelectorQuery().in(getCurrentInstance().proxy);
        query.select('.sign-canvas').fields({ node: true, size: true }, (res) => {
            if (res && res.node) {
                // 小程序或App-vue支持 node 方式
                const canvas = res.node;
                const dpr = uni.getSystemInfoSync().pixelRatio || 1;
                canvas.width = res.width * dpr;
                canvas.height = res.height * dpr;
                canvasContext = canvas.getContext('2d');
                canvasContext.scale(dpr, dpr);
                canvasW.value = res.width;
                canvasH.value = res.height;
                isCanvas2D = true;
                resolve();
            } else {
                // H5 和其它渠道,使用旧版 CanvasContext
                canvasContext = uni.createCanvasContext('signCanvas', getCurrentInstance().proxy);
                canvasW.value = res.width;
                canvasH.value = res.height;
                isCanvas2D = false;
                resolve();
            }
        }).exec();
    });
}

为什么这么麻烦?因为小程序里 canvas 成功绘制需要 node 方式,如果直接用 uni.createCanvasContext,会发现线条清晰度差或者画布显示空白。而在 H5 端不太容易出现 node 方式获取,只好退回到旧版 API。

绘图时的坐标修正

触摸事件中的 e.touches 里 x 和 y 是屏幕坐标,但在画布中要减去 canvas 左上角的偏移量,否则签名位置会偏移。这里注意页面滚动和组件位置的影响。

function getDrawPosition(e) {
    const touch = e.touches ? e.touches[0] : e.changedTouches[0];
    // 普通事件拿不到,直接使用触摸点相对于 canvas 的x/y(uni-app已处理过)
    // 在小程序中,touch.x 会自动基于 canvas 坐标吗?不会!
    // 所以需要手工偏移,用CanvasBoundingRect记录初始位置。
    return {
        x: touch.x - canvasLeft.value,
        y: touch.y - canvasTop.value
    };
}

我在触摸开始的时候,使用 SelectorQuery 查询当前 canvas 的 left/top,并存在两个变量里:

let canvasLeft = 0;
let canvasTop = 0;

function refreshRect() {
    const query = uni.createSelectorQuery().in(getCurrentInstance().proxy);
    query.select('.sign-canvas').boundingClientRect(rect => {
        if (rect) {
            canvasLeft = rect.left;
            canvasTop = rect.top;
        }
    }).exec();
}

绘制逻辑很简单:记录上一坐标点,并画一条线。难点在于如果直接修改 touch 坐标,会发现画出的线和手指轨迹有偏差。这时候需要对比 canvas 的 CSS 尺寸和物理尺寸。如果用 isCanvas2D 方式,则上下文已经设置了 scale(dpr, dpr),那么传入的坐标应该按 CSS 像素来。这样就对了。对于旧版 CanvasContext,uni-app 内部也会处理好像素比,只需要用相对坐标即可。

完整绘图代码

let drawing = false;
let lastPoint = null;

function onTouchStart(e) {
    drawing = true;
    refreshRect();
    const pos = getDrawPosition(e);
    lastPoint = pos;
}

function onTouchMove(e) {
    if (!drawing) return;
    const curPoint = getDrawPosition(e);
    // 防止短时间内移动过快线条断开,画线走直线连接
    if (isCanvas2D) {
        canvasContext.beginPath();
        canvasContext.moveTo(lastPoint.x, lastPoint.y);
        canvasContext.lineTo(curPoint.x, curPoint.y);
        canvasContext.strokeStyle = '#333';
        canvasContext.lineWidth = 2;
        canvasContext.lineCap = 'round';
        canvasContext.lineJoin = 'round';
        canvasContext.stroke();
    } else {
        // 旧版
        canvasContext.beginPath();
        canvasContext.moveTo(lastPoint.x, lastPoint.y);
        canvasContext.lineTo(curPoint.x, curPoint.y);
        canvasContext.setStrokeStyle('#333');
        canvasContext.setLineWidth(2);
        canvasContext.setLineCap('round');
        canvasContext.setLineJoin('round');
        canvasContext.stroke();
    }
    canvasContext.draw && canvasContext.draw();
    lastPoint = curPoint;
}

function onTouchEnd() {
    drawing = false;
}

在这里犯过一个错误:在旧版 CanvasContext 中,每次画线后必须调用 draw(),否则不会渲染出来。而在新版 node 方式中不能调用 draw(),它需要显式调用 canvas.requestAnimationFrame? 不需要,因为直接基于 context2d 绘制,绘图是同步的。所以需要根据 isCanvas2D 判断。

导出图片与保存相册

签名完成后,最重要的一步是导出为图片。在不同端导出方式有差异。

对于旧版 CanvasContext(H5或非 node 的小程序基础库),uni.canvasToTempFilePath() 可以直接把 canvas-id 内容转成临时图片:

function exportImageFromLegacy() {
    return new Promise((resolve, reject) => {
        uni.canvasToTempFilePath({
            canvasId: 'signCanvas',
            success: res => resolve(res),
            fail: err => reject(err)
        });
    });
}

但新版 canvas 2D 结构不同,你需要传入 canvas 节点,参数有点古怪:

function exportImageFrom2D() {
    return new Promise((resolve) => {
        const query = uni.createSelectorQuery().in(getCurrentInstance().proxy);
        query.select('.sign-canvas').fields({ node: true, size: true }, (res) => {
            if (res && res.node) {
                uni.canvasToTempFilePath({
                    canvas: res.node,
                    success: res2 => resolve(res2),
                    fail: (e) => reject(e)
                });
            } else {
                // fallback
                resolve(exportImageFromLegacy());
            }
        }).exec();
    });
}

为了两个端都能用,我在组件里暴露出 exportImage 方法:

function exportImage() {
    return new Promise((resolve, reject) => {
        // 如果画布没有任何像素点,但不主动判断空
        // 简单判断是否画过
        if (!hasContent) {
            reject(new Error('没有签名内容'));
            return;
        }
        const query = uni.createSelectorQuery().in(getCurrentInstance().proxy);
        query.select('.sign-canvas').fields({ node: true, size: true }, (res) => {
            let param = {
                success: res => resolve(res),
                fail: err => reject(err)
            };
            if (res && res.node) {
                param.canvas = res.node;
            } else {
                param.canvasId = 'signCanvas';
            }
            uni.canvasToTempFilePath(param);
        }).exec();
    });
}

但是还有个大坑:App端或者小程序端 canvas 如果css里设了宽高是百分比,比如 width: 100%,然后再用 node 方式获取的尺寸可能为0。所以我在组件内用了 CSS 固定高度,宽度使用百分比并顺带用 rect.width 获取。

“hasContent” 判断

用户如果没有签字就点保存,最好给个提示。我通过 canvas 上下文的相关方法判断像素不行,因为不同端支持不同,于是采用在 touchmove 时设置 hasContent = true。

function onTouchMove(e) {
    // ...
    hasContent = true;
}
function clear() {
    hasContent = false;
    if (isCanvas2D) {
        canvasContext.clearRect(0, 0, canvasW.value, canvasH.value);
    } else {
        canvasContext.clearRect(0, 0, canvasW.value, canvasH.value);
        canvasContext.draw();
    }
}

保存到系统相册又是另一个坑

导出得到临时文件路径后,下一步是保存到相册或者上传服务器。保存相册需要先获得授权。我在组件外处理,组件只负责导出临时路径,另外走下载上传。

function confirmSign() {
    signRef.value.exportImage().then(res => {
        // 保存相册前需要权限提示
        uni.saveImageToPhotosAlbum({
            filePath: res.tempFilePath,
            success: () => console.log('保存成功'),
            fail: (e) => console.log('保存失败可能是没授权', e)
        });
    }).catch(err => {
        uni.showToast({
            title: err.message || '请先签名',
            icon: 'none'
        });
    });
}

注意在小程序端如果用户拒绝过授权,再次调用保存不会弹窗,直接 fail。需要在 fail 里引导去设置页。这个被拒的逻辑大家都很熟,不浪费篇幅。

还要处理多端差异:H5 的导出路径

在 H5 端,uni.canvasToTempFilePath 返回的 tempFilePath 是 base64 图片地址,可以直接用于预览。但如果要上传到服务器,得先把 base64 转成 blob 再上传。我的项目里后端只接受 multipart 文件,所以我写了个上传函数检测 H5 和 APP:

function uploadSignImage(imagePath, failCallback) {
    // #ifdef H5
    // 将base64转成File对象比较麻烦,简单用uni.uploadFile虽然也行?
    // 实测uni.uploadFile在H5端会接受tempFilePath作为文件
    uni.uploadFile({
        url: 'https://api.yourserver.com/upload',
        filePath: imagePath,
        name: 'file',
        success: (res) => console.log(res.data)
    });
    // #endif
    // #ifndef H5
    uni.uploadFile({
        url: 'https://api.yourserver.com/upload',
        filePath: imagePath,
        name: 'file',
        success: function (res) {
            console.log(res.data);
        }
    });
    // #endif
}

参数:清场与按钮样式

如果你把组件的 clear 按钮设计在画布内部,点击的时候有可能会意外触发触摸移动。我给 clear 按钮绑定 @touchstart.stop 与 @click.stop,防止事件冒泡到画布。

<view class="clear-btn" @touchstart.stop @click.stop="clear">重签</view>

完整组件代码不足100行?

下面提供组件源码压缩概括(已去掉注释)。

<template>
    <view class="sig-wrapper">
        <canvas canvas-id="signCanvas" id="signCanvas" disable-scroll @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd" class="sig-canvas"></canvas>
        <view class="sig-toolbar">
            <text class="sig-placeholder" v-if="!hasContent">// 原生文字,不用样式搞复杂</text>
        </view>
    </view>
</template>

主要难点都在脚本里,模板就这些。算下来实现一个基础可用的手写签名组件,所需代码不到300行。有定制需求可以在 Canvas 上增加背景色、笔锋粗细,核心思路一致。

如果还要考虑 App 端

App-vue 默认使用的是 canvas 组件 type=2d? 官方推荐使用 plus html5webview ?但 uni-app 提供的 canvas 在 App 端表现历来一般。我的项目主要运行在微信小程序和 H5,所以没有对 App 进行真机深挖。如果你打算在 App 上同样使用,建议使用 vue 页面里的 canvas 并且通过 renderjs 或 wxs 处理触摸轨迹来提升性能。方案会复杂很多,等下次有空我再写写那一版。

签名组件做完后,用户反馈“挺好用”、“没觉得有延迟”。其实手写签名本身就没有非常高的帧率要求,只要线条轨迹正常即可。

最后罗列我踩过的坑排行榜

  • 用 canvas 2D 的 node 方式绘图后,必须检查坐标系放大倍率,否则导出的图片清晰但显示变形。
  • 不要用 CSS transform scale 对 canvas 做缩放,否则触摸坐标错位到怀疑人生。
  • 单次 draw() 在部分小程序里会闪屏,使用 requestAnimationFrame 批量绘制更丝滑,但代码复杂度大幅上升。
  • canvas 的 type 属性在小程序里有时默认旧版,要手动加上 type=”2d” 但 uni-app 的编译又有自己的规矩,最终使用 fields({ node: true }) 后 iOS 挺正常。

手写签名,其实是个很老的功能,但放到跨端框架里就变得细节缠身。希望这篇经验记录能帮你少走一点弯路。

uni-app手写签名组件开发实录:canvas跨端填坑与图片导出完整指南
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uni-app手写签名组件开发实录:canvas跨端填坑与图片导出完整指南 https://www.taomawang.com/web/uniapp/2701.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务