1. 现象描述
当我们封装一个通用的手势组件< GestureCheckIn/> 时,最直观的操作就是在父组件中直接引入。
代码看起来非常完美:
html
<GestureCheckIn @confirm="handleGestureSubmit" />
然而,当我们在 UniApp (Vue 3) 环境下运行这段代码时,经常遇到一种"代码没报错,但画面一片白"的Bug:
-
Canvas 画布全是空白,无论怎么在这个区域滑动手势,都没有线条出现。
-
**控制台没有报错,**draw() 方法似乎执行了, console.log 也能打印出坐标点。
而当我们逐步检查时,初始化 Canvas 的代码长这样:
javascript
ctx = uni.createCanvasContext('gestureCanvas')
如果我们把这段代码从组件 里搬出来,直接写在根页面里,它又是正常的。这背后的根本原因,在于 组件作用域 的不同。
2. 小程序端的陷阱
当你直接调用 时,uni.createCanvasContext('id') UniApp 默认是在 当前页面的范围内查找这个canvas-id。
但是,现在我们将 Canvas 封装在了一个 自定义组件内部。为了实现组件化隔离,Vue 3 对组件内部的 DOM 节点进行了封装。
限制一:为何渲染失败?
这是新手在 Vue 3 + UniApp 最容易踩的坑。
如果不传入第二个参数,UniApp 会去页面根节点找 gestureCanvas 。但因为你的 Canvas 藏在 < GestureCheckIn/> 组件的 Shadow DOM 或者组件作用域里,它根本找不到
错误的代码:
javascript
// 在 Vue 3 组件中,这样写会导致找不到 Canvas 上下文
ctx = uni.createCanvasContext('gestureCanvas')
解决方案:显式传入 instance
在 Vue 3 的 setup语法糖中,我们没有 this 。我们需要手动获取当前组件的实例,并把它作为第二个参数传给创建函数,告诉 UniApp:"请在这个组件实例的范围内找 Canvas"。
正确的代码(Vue 3 正解):
javascript
import { getCurrentInstance, onMounted } from 'vue'
// 获取当前组件实例
const instance = getCurrentInstance()
onMounted(() => {
// 必须将 instance 传进去!
ctx = uni.createCanvasContext('gestureCanvas', instance)
if (ctx) {
draw()
}
})
3. 如何实现连线效果?
解决了画不出来的问题,下一个挑战是:如何让线条既连接已选中的点,又能实时跟随手指移动?
很多新手实现的连线效果往往是"断裂"的,或者只能连接点与点,没有那条"正在寻找下一个点"的动态线。
核心逻辑拆解
要实现完美的连线,我们需要在 draw() 函数中分两步走:
-
连接"历史":画出已经确定的点之间的线段。
-
连接"当下":画出最后一个点到手指当前位置的线段。
关键代码解析
javascript
// 绘制连线逻辑
if (selectedIndices.length > 0) {
ctx.beginPath() // 必须开启新路径,否则会和圆点的绘制混在一起
// 1. 移动画笔到第一个选中的点
const startPoint = points[selectedIndices[0]]
ctx.moveTo(startPoint.x, startPoint.y)
// 2. 遍历后续所有已选中的点,将它们连起来
for (let i = 1; i < selectedIndices.length; i++) {
const p = points[selectedIndices[i]]
ctx.lineTo(p.x, p.y)
}
// 3. 【关键】如果是正在触摸状态,画一条线到当前手指的位置
// 这就是为什么手势看起来像在"拉橡皮筋"
if (isDrawing) {
ctx.lineTo(currentPos.x, currentPos.y)
}
// 4. 样式设置(改为蓝色主题 #007AFF)
ctx.setStrokeStyle('#007AFF')
ctx.setLineWidth(6)
// 5. 【细节】让线条拐角和端点变得圆润,防止出现锯齿或尖角
ctx.setLineCap('round')
ctx.setLineJoin('round')
ctx.stroke()
}
视觉优化Tips:
-
ctx.beginPath() 的重要性:在 Canvas 中,如果你不重新 beginPath,当你调用 stroke 时,它会把之前画过的所有圆圈再描一遍边,导致性能下降且样式混乱。
-
动态跟随: 当下的点是在 touchmove 事件中实时更新的。只有将它加入到 lineTo 序列的最后,用户才会感觉线条是"长"在手上的。
-
圆角处理:默认的线条连接处是尖的,在手势转折时非常难看。设置为 round 可以让折线连接处变得平滑圆润,提升质感。
4. 组件最终实现方案:
html
<template>
<view class="gesture-container">
<canvas canvas-id="gestureCanvas" id="gestureCanvas" class="gesture-canvas" @touchstart="start" @touchmove="move"
@touchend="end"></canvas>
<view class="action">
<button @click="reset">重设手势</button>
<text class="debug-info">{{ debugInfo }}</text>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, defineEmits } from 'vue'
import { getCurrentInstance } from 'vue'
// 定义颜色常量
const themeColor = '#007AFF'
const themeColorDark = '#005BBB'
const instance = getCurrentInstance()
const emit = defineEmits(['confirm'])
let ctx = null
const debugInfo = ref('等待初始化...')
// 基础样式配置
const canvasWidth = 300
const canvasHeight = 300
const r = 25
// 状态
let isDrawing = false
let points = []
let selectedIndices = []
let currentPos = { x: 0, y: 0 }
onMounted(() => {
setTimeout(() => {
ctx = uni.createCanvasContext('gestureCanvas', instance)
if (ctx) {
initPoints()
draw()
}
}, 200)
})
const initPoints = () => {
points = []
const padding = 50
const stepX = (canvasWidth - 2 * padding) / 2
const stepY = (canvasHeight - 2 * padding) / 2
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
points.push({
x: padding + j * stepX,
y: padding + i * stepY,
index: i * 3 + j
})
}
}
debugInfo.value = '请绘制手势密码(至少连接4个点)'
}
const draw = () => {
if (!ctx) {
return
}
ctx.clearRect(0, 0, canvasWidth, canvasHeight)
points.forEach((p, index) => {
// 绘制外圆路径
ctx.beginPath()
ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
ctx.setLineWidth(3)
ctx.setStrokeStyle(themeColor)
if (selectedIndices.includes(index)) {
// 选中状态:绘制外圈边框 + 内部实心圆点
ctx.stroke()
ctx.beginPath()
ctx.arc(p.x, p.y, r / 3.5, 0, Math.PI * 2)
// 选中时的内部实心圆点
ctx.setFillStyle(themeColor)
ctx.fill()
} else {
// 未选中状态:白色填充 + 边框
ctx.setFillStyle('#ffffff')
ctx.fill()
ctx.stroke()
}
})
// 绘制连线
if (selectedIndices.length > 0) {
ctx.beginPath()
const startPoint = points[selectedIndices[0]]
ctx.moveTo(startPoint.x, startPoint.y)
for (let i = 1; i < selectedIndices.length; i++) {
const p = points[selectedIndices[i]]
ctx.lineTo(p.x, p.y)
}
if (isDrawing) {
ctx.lineTo(currentPos.x, currentPos.y)
}
// 连线颜色改为蓝色
ctx.setStrokeStyle(themeColor)
ctx.setLineWidth(6)
ctx.setLineCap('round')
ctx.setLineJoin('round')
ctx.stroke()
}
ctx.draw()
if (selectedIndices.length > 0) {
debugInfo.value = `已连接 ${selectedIndices.length} 个点`
}
}
const getPosition = (e) => {
if (!e.touches || !e.touches[0]) {
return { x: 0, y: 0 }
}
// 增加兼容性处理,防止部分环境 e.touches[0] 不包含 x, y
const touch = e.touches[0]
return {
x: touch.x || touch.clientX,
y: touch.y || touch.clientY
}
}
const start = (e) => {
isDrawing = true
selectedIndices = []
const pos = getPosition(e)
currentPos = pos
checkCollision(pos)
draw()
}
const move = (e) => {
if (!isDrawing) return
const pos = getPosition(e)
currentPos = pos
checkCollision(pos)
draw()
}
const end = (e) => {
isDrawing = false
draw()
if (selectedIndices.length >= 4) {
const pattern = selectedIndices.join('')
emit('confirm', pattern)
debugInfo.value = `手势已确认`
} else if (selectedIndices.length > 0) {
debugInfo.value = `至少需要连接 4 个点(已连接 ${selectedIndices.length} 个)`
setTimeout(() => {
reset()
}, 1500)
}
}
const checkCollision = (pos) => {
points.forEach((p, i) => {
const dx = pos.x - p.x
const dy = pos.y - p.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance < r && !selectedIndices.includes(i)) {
selectedIndices.push(i)
}
})
}
const reset = () => {
selectedIndices = []
isDrawing = false
currentPos = { x: 0, y: 0 }
draw()
debugInfo.value = '请绘制手势密码(至少连接4个点)'
}
</script>
<style lang="scss" scoped>
// 定义 CSS 变量以便样式中使用
$theme-color: #007AFF;
$theme-color-dark: #005BBB;
$theme-shadow-light: rgba(0, 122, 255, 0.1);
$theme-shadow-medium: rgba(0, 122, 255, 0.2);
.gesture-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
background: #ffffff;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
border-radius: 24rpx;
}
.gesture-canvas {
width: 300px;
height: 300px;
background: #ffffff;
border: 2px solid $theme-color;
border-radius: 12px;
box-shadow: 0 2px 8px $theme-shadow-light;
}
.action {
margin-top: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
button {
padding: 12px 30px;
background: $theme-color;
color: #ffffff;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 500;
box-shadow: 0 2px 6px $theme-shadow-medium;
&:active {
background: $theme-color-dark;
}
}
.debug-info {
font-size: 13px;
color: $theme-color;
text-align: center;
}
}
</style>