为什么在 Vue 3 中 uni.createCanvasContext 画不出图?

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() 函数中分两步走:

  1. 连接"历史":画出已经确定的点之间的线段。

  2. 连接"当下":画出最后一个点到手指当前位置的线段。

关键代码解析
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>
相关推荐
咸鱼加辣2 小时前
【vue面试】ref和reactive
前端·javascript·vue.js
LYFlied2 小时前
【每日算法】LeetCode 104. 二叉树的最大深度
前端·算法·leetcode·面试·职场和发展
宁雨桥2 小时前
前端并发控制的多种实现方案与最佳实践
前端
KLW752 小时前
vue2 与vue3 中v-model的区别
前端·javascript·vue.js
李广山Samuel2 小时前
Node-OPCUA 入门(1)-创建一个简单的OPC UA服务器
javascript
zhongjiahao2 小时前
一文带你了解前端全局状态管理
前端
柳安2 小时前
对keep-alive的理解,它是如何实现的,具体缓存的是什么?
前端
keyV2 小时前
告别满屏 v-if:用一个自定义指令搞定 Vue 前端权限控制
前端·vue.js·promise
X_Eartha_8152 小时前
前端学习—HTML基础语法(1)
前端·学习·html