简介:面向需要在PC端和移动端实现刮刮卡交互效果的Vue开发者,这份组件资源提供了一套可直接运行的刮刮卡方案,覆盖从涂抹刮层到结果回调的完整流程,无需从零搭建画布逻辑。压缩包共16个文件,以5个JavaScript文件、2个Vue组件文件、2个JSON配置文件为核心,另含打包后的产物、源码映射map、示例页面HTML及基础工程配置,整体仅86KB,便于快速集成与二次修改。目前已有1321人学习使用,适合具备一定Vue基础、希望为活动页或营销场景增加趣味交互的开发者参考。通过源码可重点理解vue-scratch-card的组件封装方式、canvas刮层与移动端触摸事件的处理思路,同时借助dist目录下的编译文件直接部署到现有项目中。
1. 刮刮卡 Vue 组件:为什么 PC 和移动端都要自己做而不是找插件?
在营销活动里,刮刮卡是转化率一直很稳定的交互,但要做成一个兼容 PC 和移动端的 Vue 组件,远比放一张 gif 复杂。真正动手时才会发现:PC 上鼠标刮得顺滑,换到手机就变成“点一下才动一下”,或者刮完底色花掉、画布模糊。与其在现成插件和业务之间反复改样式,不如自己封装一个刮刮卡 Vue 组件,同时兼顾两端。下面会从 Canvas 原理讲起,给出一份能跑通的最小实现、事件归一化方案,以及我在活动落地里踩过的坑。适合正在做营销活动、想要把刮刮卡变成可复用业务的开发者。
2. 刮开效果的核心原理:Canvas 混合模式与刮开判定如何选型
要封装一个能在 PC 和移动端同时用的刮刮卡 Vue 组件,第一步不是写工具函数,而是先把刮开这个动作在浏览器里变成像素级的操作。我把常见实现梳理了一下:最终能稳定工作的路线是 Canvas 2D 图层 + 擦除式合成模式 + 事件坐标映射。底层图片放在 canvas 下面,canvas 覆盖一层涂层,当用户滑动时把涂层的对应区域清成透明,露出的就是奖品区。听起来简单,但里面的混合模式、触摸事件和像素采样,每个点都能出问题。
2.1 为什么用 Canvas 而不是 CSS 遮罩或双层图片
先对比一下另两种常见做法。有人会用 CSS 的 background-clip: text 或者 mask 做擦除,但 CSS 遮罩只能按照固定图形或路径来裁剪,没法跟随用户的鼠标轨迹实时生成不规则的擦除区域。也有人用上下两层 div,上层做涂层,手指滑过时给上层添加“已刮掉”的矩形块,这种做法的颗粒感太强,边缘也很生硬。
Canvas 的优势在于它的每个像素都可以被独立控制。涂层是 canvas 上的一层半透明像素,刮开就是在这些像素上做 alpha 通道的“减法”。无论是圆头线条、曲线还是不规则轨迹,都能以像素为单位平滑呈现。对于刮刮卡这种交互,2D Canvas 已经能完全覆盖需求;WebGL 虽然也能做,但还要管理着色器、纹理和上下文丢失,业务侧用它属于杀鸡用牛刀。我一般建议组件基于原生 Canvas 2D 实现,不引第三方图形库,这样包体积最小,也最容易排查问题。
2.2 globalCompositeOperation 与刮开动作的像素级模拟
Canvas 里负责“擦除”的核心属性是 globalCompositeOperation,它决定新绘制的图形如何与已有内容合成。做刮刮卡时,先把涂层画上去,再把合成模式设置为 destination-out,然后在涂层上画一条线或一个圆,线条经过的地方会变成透明。destination-out 的含义是“保留目标图像中不需要绘制新图形覆盖的区域”,翻译成大白话就是:用画笔把原涂层挖掉。
下面这段代码是刮开一笔的最小逻辑,不经过 Vue,直接在页面里验证:
const canvas = document.querySelector('#scratchCard'); const ctx = canvas.getContext('2d', { alpha: true }); // 第一层:涂层 ctx.fillStyle = '#c8c8c8'; ctx.fillRect(0, 0, canvas.width, canvas.height); // 刮开一笔 ctx.save(); ctx.globalCompositeOperation = 'destination-out'; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.lineWidth = 24; ctx.beginPath(); ctx.moveTo(80, 60); ctx.lineTo(140, 100); ctx.stroke(); ctx.restore();这段代码先把整块 canvas 用灰色填充,然后画一条结束点与起始点不同的线段,线段覆盖区域的像素 alpha 会被清成 0。需要注意两点:一是 save/restore 要成对出现,保证后续的 fillStyle 不会和混合模式串掉;二是在设置合成模式时最好显式创建带 alpha 的 context,避免某些浏览器把背景默认成不透明黑色。实际拖动刮擦时,应该用 stroke 一笔接一笔地画,而不是每次画一个独立圆点,否则圆与圆之间会留下未刮到的缝隙,而且大量 fill 调用会对性能不友好。这里的 lineWidth=24 是笔刷宽度,建议根据画布尺寸动态设置。
2.3 为什么不引第三方库:类型选型与兼容矩阵
我在封装前也考虑过直接找现成库,但最终决定自己写。原因有几个:市面上刮刮卡轮子大多只针对移动端或只针对桌面端,两端都维护好的很少;第三方绘制库会把 Canvas 封装成自己的渲染模型,遇到业务要自定义涂层、兑奖码露出动画时,又要再套一层;另外,刮刮卡的核心逻辑只有 100 多行,引一个几十 KB 的库反而让依赖变重。
组件内使用原生 Canvas 2D 是兼容性最好的选择,从 Vue 2 到 Vue 3、从 PC 到移动端都能跑。如果项目必须支持老版本浏览器,需要确认 PointerEvent 是否可用,不可用的情况可以用 mouse + touch 做降级。下面这张表是选型时我习惯对照的:
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| CSS mask | 实现简单 | 无法按轨迹擦除 | 不选 |
| 双层 div + 矩形 | 不需要 Canvas | 边缘生硬、颗粒感强 | 不选 |
| Canvas 2D | 像素级控制、兼容性全面 | 需要自己处理事件和缩放 | 选它 |
| WebGL | 性能高 | 复杂度高、上下文管理繁琐 | 不选 |
2.4 刮开率判定:算透明像素还是算轨迹面积
组件最终要告诉业务方“刮开了多少”,常见误区是统计刮擦轨迹的总长度或圆点面积。但轨迹会有大量重叠,重复计算会让百分比虚高;而且用户刮得细碎时,整体覆盖率并不等于轨迹面积。我一般用离屏 Canvas 来算:维护一个与主画布尺寸相同的离屏画布,每次刮擦时同步在离屏画布上画同样的轨迹,然后定时调用 getImageData 统计 alpha 通道接近 0 的像素比例。
function getScratchPercent(offCanvas) { const offCtx = offCanvas.getContext('2d'); const imageData = offCtx.getImageData(0, 0, offCanvas.width, offCanvas.height); const pixels = imageData.data; let clearCount = 0; const total = pixels.length / 4; for (let i = 3; i < pixels.length; i += 4) { if (pixels[i] < 30) clearCount++; } return Math.round((clearCount / total) * 100); }这段代码遍历每个像素的 alpha 通道,把 alpha 小于 30 的像素视为“已刮开”。统计时要注意阈值的选取:阈值设成 0,涂层边缘的抗锯齿像素容易被漏掉;设成 30~50 比较稳。getImageData 返回的是整个画布的快照,反复大量调用会有性能开销,所以我会加上节流,比如每 100ms 最多统计一次,而不是在每次 move 事件里都执行。
这一章把原理和选型立住了,下一章可以直接进入组件实现。
3. 封装组件的第一版:Props、事件与 30 行可用代码
原理验证通过后,剩下就是把刮开逻辑封装成 Vue 组件。我用 Vue 3 的<script setup>写了一份最小可用版本,放在业务里能直接跑;如果你是 Vue 2 项目,把响应式部分换成 data 和 methods,Canvas 逻辑完全不用改。
3.1 最小可用版本:先跑通再谈优雅
先把能跑的组件贴出来。这个版本不依赖任何第三方库,核心逻辑集中在 onMounted、事件回调和暴露方法上。
<template> <div class="scratch-card" :style="{ width: width + 'px', height: height + 'px' }"> <div class="scratch-card__prize"> <slot /> </div> <canvas ref="canvasRef" class="scratch-card__canvas"></canvas> </div> </template> <script setup> import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'; const props = defineProps({ width: { type: Number, default: 300 }, height: { type: Number, default: 160 }, brushSize: { type: Number, default: 24 }, coverColor: { type: String, default: '#c0c0c0' }, percent: { type: Number, default: 60 } }); const emit = defineEmits(['progress', 'complete']); const canvasRef = ref(null); const state = reactive({ isScratching: false, isDone: false, lastX: 0, lastY: 0 }); let ctx = null; let offCanvas = null; let offCtx = null; let timer = null; onMounted(() => { const canvas = canvasRef.value; canvas.width = props.width; canvas.height = props.height; ctx = canvas.getContext('2d', { alpha: true }); offCanvas = document.createElement('canvas'); offCanvas.width = props.width; offCanvas.height = props.height; offCtx = offCanvas.getContext('2d', { alpha: true }); drawCover(); canvas.addEventListener('pointerdown', onPointerDown); canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerup', onPointerUp); canvas.addEventListener('pointercancel', onPointerUp); }); function drawCover() { ctx.fillStyle = props.coverColor; ctx.fillRect(0, 0, props.width, props.height); offCtx.fillStyle = props.coverColor; offCtx.fillRect(0, 0, props.width, props.height); } function getPos(e) { const rect = canvasRef.value.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; } function onPointerDown(e) { if (state.isDone) return; state.isScratching = true; const pos = getPos(e); state.lastX = pos.x; state.lastY = pos.y; canvasRef.value.setPointerCapture(e.pointerId); } function onPointerMove(e) { if (!state.isScratching || state.isDone) return; const pos = getPos(e); drawLine(state.lastX, state.lastY, pos.x, pos.y); state.lastX = pos.x; state.lastY = pos.y; } function onPointerUp() { if (!state.isScratching) return; state.isScratching = false; } function drawLine(x1, y1, x2, y2) { ctx.save(); ctx.globalCompositeOperation = 'destination-out'; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.lineWidth = props.brushSize; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); ctx.restore(); offCtx.save(); offCtx.globalCompositeOperation = 'destination-out'; offCtx.lineCap = 'round'; offCtx.lineJoin = 'round'; offCtx.lineWidth = props.brushSize; offCtx.beginPath(); offCtx.moveTo(x1, y1); offCtx.lineTo(x2, y2); offCtx.stroke(); offCtx.restore(); checkProgress(); } function checkProgress() { if (timer) return; timer = setTimeout(() => { timer = null; const percent = getScratchPercent(offCanvas); emit('progress', percent); if (percent >= props.percent && !state.isDone) { state.isDone = true; emit('complete'); } }, 100); } function getScratchPercent(offCanvas) { const pCtx = offCanvas.getContext('2d'); const imageData = pCtx.getImageData(0, 0, offCanvas.width, offCanvas.height); const data = imageData.data; let clearCount = 0; const total = data.length / 4; for (let i = 3; i < data.length; i += 4) { if (data[i] < 30) clearCount++; } return Math.round((clearCount / total) * 100); } onBeforeUnmount(() => { if (canvasRef.value) { canvasRef.value.removeEventListener('pointerdown', onPointerDown); canvasRef.value.removeEventListener('pointermove', onPointerMove); canvasRef.value.removeEventListener('pointerup', onPointerUp); canvasRef.value.removeEventListener('pointercancel', onPointerUp); } if (timer) clearTimeout(timer); }); defineExpose({ reset() { state.isDone = false; state.isScratching = false; ctx.clearRect(0, 0, props.width, props.height); offCtx.clearRect(0, 0, props.width, props.height); drawCover(); } }); </script> <style scoped> .scratch-card { position: relative; overflow: hidden; -webkit-user-select: none; user-select: none; touch-action: none; } .scratch-card__prize { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; } .scratch-card__canvas { position: absolute; inset: 0; width: 100%; height: 100%; } </style>这里有一个关键点:同步维护了一个离屏 canvas,保证主 canvas 只负责展示,离屏 canvas 只负责计算刮开率。pointerdown/move/up用 PointerEvent 统一处理,避免同时写 mouse 和 touch 两套事件;样式里的touch-action: none是为了禁止浏览器在刮擦时把 canvas 当成滚动区域。setPointerCapture让手指划过 canvas 边缘后事件依然会回到当前画布,不会中途丢失。
onPointerDown把当前坐标记为上一笔的起点,onPointerMove每次画一条从上一笔到当前位置的线段,并在离屏画布同步画一遍。checkProgress用 100ms 节流防止 getImageData 频繁执行。这里先提供最基础版本,坐标换算的细节会在下一章展开。
3.2 Props 参数设计:涂层、笔刷与刮开阈值
组件的可复用性主要靠 Props 区分。我习惯给业务侧暴露 width、height、brushSize、coverColor、percent 这几个参数。percent 就是刮开率达到多少后判定完成,默认 60;brushSize 在 PC 上默认 24,在触摸屏上建议按画布宽度比例调大,例如width * 0.08。下表是参数的具体建议:
| props | 类型 | 默认值 | 说明 |
|---|---|---|---|
| width | Number | 300 | 画布 CSS 宽度,单位 px |
| height | Number | 160 | 画布 CSS 高度,单位 px |
| brushSize | Number | 24 | 笔刷大小,决定刮痕粗细 |
| coverColor | String | '#c0c0c0' | 涂层颜色,也可以是图片 |
| percent | Number | 60 | 刮开多少百分比判定完成 |
有人会把 width 和 height 直接设为 props.width 赋值给 canvas 的 width 属性,这在没有缩放的页面上没问题。但一旦页面有 CSS 缩放、DPR 适配,就要把物理像素和逻辑像素分开处理。第 4 章会专门说这个坑。
3.3 事件设计:progress 和 complete 各在什么时机触发
组件对外只暴露两个业务事件:progress 返回当前刮开百分比,complete 表示达到阈值后的完成通知。注意 complete 必须只在“未完成转完成”的状态边界触发一次,否则事件会重复触发。上面的代码用state.isDone做了拦截。
业务侧使用方式如下:
<ScratchCard :width="320" :height="180" :brush-size="28" :percent="65" @progress="onProgress" @complete="onComplete" > <div class="prize-code">AXJD2025</div> </ScratchCard>在 onComplete 里你可以去做发奖、埋点、弹窗。只要提供 reset 接口,就能继续下一轮。这里要提醒的是:progress 事件的频率受 getImageData 节流影响,不要期望每次 move 都拿到实时值,业务埋点按 100ms 一个点收到就能接受。写完事件,组件已经可以在业务里跑,但事件层和坐标换算还需要再仔细处理。
4. PC 与移动端事件统一:PointerEvent 状态机和坐标换算
刮刮卡组件能不能在 PC 和移动端同时使用,关键不在 Canvas 绘制,而在事件层。我见过不少把 mouse 和 touch 分开监听的项目,PC 上用 mouseDown/mouseMove/mouseUp,手机上用 touchStart/touchMove/touchEnd,结果代码分支又多又容易漏掉 touchend 的清理。用 PointerEvent 可以一次性覆盖鼠标、触摸和触控笔。
4.1 mouse + touch 分写的三个痛点,PointerEvent 如何解决
第一,mouse 事件在移动端并不会全部生效,某些浏览器会模拟 click,但 move 的连续性和坐标精度都不可靠。第二,touch 事件里拿坐标要用e.touches[0],同一个事件对象的结构和 mouse 不同,代码里到处是条件分支。第三,触摸离开 canvas 之后,touch 事件流不一定能正确结束,容易出现“一直认为在刮”的状态残留。
PointerEvent 把这几种输入统一成 pointerdown、pointermove、pointerup,三种输入源都有 clientX/clientY 和 pointerId。组件里只需要关注 pointerId 对应的那个活动指针,多个手指时也不会互相干扰。对于老项目,我会先判断window.PointerEvent是否存在,不存在再退回到 mouse + touch 的兼容层,但这个组件默认按 PointerEvent 写。
4.2 用 PointerEvent 重写刮擦回调:状态机与边界处理
一组稳定的指针生命周期是:pointerdown 进入“正在刮”的状态;pointermove 在这个状态下持续绘制;pointerup 退出状态;pointercancel 也要退出,否则来电、系统手势等场景会让状态卡死。在 Vue 组件里维护一个 state 对象,isScratching 就是这个状态机的核心标志。
function onPointerDown(e) { if (state.isDone) return; state.isScratching = true; const pos = canvasToPaint(e); state.lastX = pos.x; state.lastY = pos.y; canvasRef.value.setPointerCapture(e.pointerId); } function onPointerMove(e) { if (!state.isScratching || state.isDone) return; const pos = canvasToPaint(e); drawLine(state.lastX, state.lastY, pos.x, pos.y); state.lastX = pos.x; state.lastY = pos.y; } function onPointerUp(e) { state.isScratching = false; }这里有两个容易被忽略的点。一是setPointerCapture必须在 pointerdown 里调用,否则手指移动到 canvas 外部时,后续 pointermove 会不再派发到 canvas;二是 pointermove 的触发频率远高于屏幕刷新率,如果不加节流,Canvas 的 stroke 会积压。我一般在刮擦回调里直接画线,因为 Canvas 的 stroke 本身是增量绘制,不需要清空画布,高频执行的压力远低于 getImageData。
4.3 canvas 坐标换算与 devicePixelRatio 的尺寸陷阱
在 PC 上我们直接用clientX - rect.left得到画布坐标,但在移动端和高分屏上,这个值是错的。浏览器中 canvas 元素有两个尺寸:一个是 CSS 尺寸(由样式决定),一个是画布坐标尺寸(由 width/height 属性决定)。当我们为了清晰度把 canvas.width 设为 CSS 宽度的 2 倍时,用clientX - rect.left拿到的只是逻辑坐标,必须乘以缩放系数才能对应到物理像素。
处理方式是统一封装一个canvasToPaint(e)函数:
function canvasToPaint(e) { const canvas = canvasRef.value; const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY }; }如果 canvas.width 和 CSS width 一致,scaleX 就是 1;如果按 devicePixelRatio 放大,scaleX 也会自动放大。注意在绘制涂层时,也要按放大后的画布尺寸绘制,否则涂层只覆盖了左上角一部分。建议组件初始化时这样处理:
const dpr = window.devicePixelRatio || 1; canvas.width = props.width * dpr; canvas.height = props.height * dpr; canvasRef.value.style.width = props.width + 'px'; canvasRef.value.style.height = props.height + 'px'; ctx.scale(dpr, dpr);调用 canvasToPaint 时,canvas.width 已经是物理宽,rect.width 是逻辑宽,scaleX 正好是 dpr,拿到的坐标也是物理坐标,而此时 ctx 已经 scale 了 dpr,所以可以直接在 drawLine 里用这个坐标绘制。这套逻辑比“每个事件都除以 dpr”更省心,也避免后期在多个入口改参数。到这里事件层的核心问题都已经处理完,接下来集中写我踩过的坑。
5. 刮刮卡组件避坑指南:从刮不开到卡成 PPT 的五个案例
这一章的内容大多来自真实调试记录。每个问题都按照“现象 → 原因 → 解决”的顺序整理,方便直接对照。
5.1 现象:手机上刮完没有任何效果,涂层纹丝不动
在 iOS 上 canvas 绑定了 pointermove,但刮的时候页面会在原地滚动,涂层没有任何变化。原因通常是浏览器的触摸动作默认由系统处理,canvas 上的 touchmove 被当成了滚动,PointerEvent 的默认行为没有及时阻止,而且 canvas 样式没有设置touch-action: none。解决方法是给刮刮卡容器显式加touch-action: none,同时组件初始化时确认没有在全局样式中覆盖掉它。另外,监听 touch 事件的老写法一定记得给addEventListener('touchmove', handler, { passive: false }),否则 preventDefault 根本无效,这一点最容易踩。
5.2 现象:刮开后涂层变成黑色或灰色,底层内容显示不完整
如果刮开区域露出来的是深色而不是底层兑奖码,先检查 Canvas 上下文是否带了 alpha。getContext('2d', { alpha: false })会把画布背景当成不透明黑色,destination-out 擦掉涂层之后,露出的黑色其实是画布底色。解决方法是显式使用{ alpha: true },并且不要在 CSS 里给 canvas 设置背景色。另一种情况是底层内容z-index比 canvas 高,canvas 虽然透明,但依然盖住了底层,检查.scratch-card__prize的位置是否在 canvas 下方。样式上我固定让 canvas 绝对定位、prize 容器放在它前面,这样 DOM 层级靠后的 canvas 遮住 prize,透明区域就能露出底层,而不是反过来。
5.3 现象:刮到百分之七十了,complete 事件迟迟不触发
complete 不触发通常是百分比判定口径不对。有人统计的是刮擦轨迹圆形面积之和,但轨迹重叠导致面积虚高,后面刮新区域时累计涨幅却很小;有人用 getImageData 但没有充分考虑涂层边缘的抗锯齿像素,alpha 不为 0 但接近 0,阈值设成 0 后这些像素从来没算作刮开。解决方法是把阈值设成 30,并且在刮开判定前先做一次离屏 canvas 的整体采样。另一个坑是节流和事件顺序:如果用 setTimeout 做节流,但 setTimeout 执行时刮擦已经停止,百分比更新会比实际晚一拍,需要再 onPointerUp 时立刻计算一次,或者把节流函数设计成时间戳。
5.4 现象:PC 上正常,手机上一刮就位置偏移且画面模糊
高分屏的 devicePixelRatio 通常大于 1,如果 canvas 的 width/height 只按 CSS 尺寸设置,物理像素不足,画面一放大就糊;如果只放大物理尺寸,而没有同步做坐标换算,刮擦位置就会对不上。解决方式就是第 4 章的canvas.width = cssWidth * dpr和 canvasToPaint 缩放系数。注意 dpr 不是固定的,不同设备可能是 2、2.75 或 3,不能写死某个值。组件里我习惯在 onMounted 读取window.devicePixelRatio || 1,并且在窗口 resize 后重新初始化画布。
5.5 现象:页面上放了三张刮刮卡,刮第一张时第二张也被刮开
事件监听和组件实例管理问题。如果事件监听被挂到 window 上,且在回调中使用“获取页面上特定 class 的 canvas”,多个实例就会互相干扰;还有一种情况是组件没有在 onBeforeUnmount 移除监听,组件被 keep-alive 缓存后,旧的监听还在,点上新的卡会同时触发旧实例的回调。解决原则是:事件监听只挂在当前组件自己的 canvas 元素上,所有状态只存在当前实例的闭包中,离开页面时统一 removeEventListener。给出的最小版本里已经写了移除逻辑,如果业务里需要批量渲染,最好给每个实例加唯一标识,在事件对象中通过 dataset 标记。
6. 进阶加分项:离屏 Canvas 算刮开率与组件接口扩展
组件能用了,但还有三个点会直接影响活动页体验:进度计算不能卡 UI、业务需要手动重置或自动刮开、多端真机自检。
6.1 用离屏 Canvas 采样,进度计算不占用渲染线程
主 canvas 每帧都在被浏览器绘制,如果每次刮擦都在主 canvas 上调 getImageData,会把整张画布的像素读回到 CPU,移动端低端机很容易掉帧。我的习惯是额外创建一个不在 DOM 中的离屏 canvas,绘制刮痕时同步画到离屏画布,进度统计只读离屏画布的像素。这样主 canvas 和统计逻辑彻底分离,即使采样频率再高,也不会让画面卡顿。
function checkProgress() { const percent = getScratchPercent(offCanvas); emit('progress', percent); if (percent >= props.percent && !state.isDone) { state.isDone = true; ctx.clearRect(0, 0, props.width, props.height); emit('complete'); } }注意这里达到阈值后清空主 canvas,避免涂层残留和反复刮擦。有些业务希望刮开后保留刮痕,这个行为可以做成 prop 控制。
6.2 给组件增加 reset 和 scratchAll 两个对外方法
业务场景经常是:用户刮完一次后刷新奖品,或者运营想直接展示结果。通过 defineExpose 暴露 reset 和 scratchAll 是低成本方案。reset 清空涂层并重置状态,scratchAll 则把涂层直接清掉并触发完成事件。
defineExpose({ reset() { state.isDone = false; state.isScratching = false; ctx.clearRect(0, 0, props.width, props.height); offCtx.clearRect(0, 0, props.width, props.height); drawCover(); }, scratchAll() { state.isDone = true; state.isScratching = false; ctx.clearRect(0, 0, props.width, props.height); offCtx.clearRect(0, 0, props.width, props.height); emit('complete'); } });调用方式是在父组件拿到模板 ref,然后scratchCardRef.value.reset()。注意 reset 后要主动处理上一轮的 complete 事件,避免旧事件对新的状态造成干扰。
6.3 上线前我习惯做三个真机验证
组件最终要在 PC 和移动端同时使用,我的自检顺序是:先用桌面浏览器鼠标拖拽,确认刮痕圆滑连续;再用桌面浏览器的开发者工具触摸模拟,确认 pointer 事件能正常触发;最后拿一台 iOS 真机和一台中端安卓真机,重点看刮开后是否透明、是否出现滚动,以及安卓真机上连续刮 10 秒会不会掉帧。这类交互问题在真机上远比在模拟器上明显。
我自己在这个组件上翻车最多的一次,是只测了浏览器触摸模拟就上线,结果 iOS 真机上涂层刮不掉。原因是桌面触摸模拟不会完全复现触摸滚动行为,必须真机确认touch-action: none真正生效。另一个习惯是给组件预留debug模式,显示当前刮开百分比和 pointerId,排查时比肉眼猜快很多。把这三个加分项补齐之后,这个刮刮卡组件就不只是“能刮出来”,而是可以放心投到活动页并且能自己掌控细节的完整业务组件了。希望这些从原理到踩坑的笔记能帮到你。
本文还有配套的精品资源,点击获取