背景
看了一段时间 canvas, 重拾当初学前端的那种热情, 👱 就是喜欢整点花里胡哨的。正好在掘金上看到不少 canvas 好文章,结合理论进行实战, 记录一下过程中的知识点。
1 需求分析
设计可配置项, 这里分为产品同学看得懂的配置项和技术上的配置项
所有配置项包括:
1 2 3 4 5 6 7 8 9 10
| interface ScratchCardConfig { canvas: HTMLCanvasElement; showAllPercent?: number; coverImg?: string; coverColor?: string; doneCallback?: () => void; radius?: number; pixelRatio: number; fadeOut?: number; }
|
2 项目结构
因为 canvas 有很多方法和属性,js 无法正确进行代码提示, 所以使用 ts 进行开发
1 2 3 4 5
| |- index.html |- award.jpg // 底部结果图片 |- index.js |- index.ts // 实际编写的逻辑 |- scratch-2x.png // 上层遮罩的图片
|
3 具体实现
页面结构
前置知识: canva 设置 width/height 改变的是绘图区域的宽高, 设置 style.width/height 改变的是元素的宽高, 绘图区域没有发生改变, 绘图区域会根据元素宽高等比例缩放.
而对于 retina 屏幕(这里指定 2 倍物理像素, 实际项目可以使用 window.devicePixelRatio 判断), 一个逻辑像素 = 2 物理像素, 相当于图片放大了一倍, 所以这里指定 canvas 属性 width/height 为 750/280, style.width/height 为 350/140. 相当于图片缩小一倍. 这样图片就变清晰了.
不要问为什么不直接把 canvas style width/height 直接设为 750/280, 这样图片就无法绘制完全
award.jpg 也是一样, 缩小一倍进行显示
HTML 代码:
1 2 3
| <div class="card"> <canvas id="canvas" width="750" height="280"></canvas> </div>
|
CSS 代码:
1 2 3 4 5 6 7 8 9 10
| .card { width: 375px; height: 140px; background: url('./award.jpg'); background-size: 375px 140px; } .card canvas { width: 375px; height: 140px; }
|
初始化
构造函数
设置选填的配置默认值, 直接全部刮开的百分比, 刮开时绘制的圆半径, 纯色遮罩图层的颜色, 全部刮开的淡出时间
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| class ScratchCard { config: ScratchCardConfig; ctx: CanvasRenderingContext2D; canvas: HTMLCanvasElement; offsetX: number; offsetY: number; done: boolean; isDown: boolean; constructor(config: ScratchCardConfig) { this.config = { showAllPercent: 45, radius: 20, coverColor: '#999', fadeOut: 2000, ...config }; } }
|
遮罩图层
这里逻辑很简单, 没有图片图层时设置纯色图层, 需要介绍的是globalCompositeOperation
属性, 用于设置两个绘图路径交叉时的渲染方式, destination-out
指在源图像外显示目标图像, 源图像透明, 这里 coverImg 是目标图像, 刮开时绘制的圆是源图像, 这样源图像区域就会展示最底部的结果图片
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| class ScratchCard { ... constructor(config: ScratchCardConfig) { ... this._init(); } private _init() { this.canvas = this.config.canvas; this.ctx = this.canvas.getContext('2d'); this.offsetX = this.canvas.offsetLeft; this.offsetY = this.canvas.offsetTop; this._addEvent(); if (this.config.coverImg) { const coverImg = new Image(); coverImg.src = this.config.coverImg; coverImg.onload = () => { this.ctx.drawImage(coverImg, 0, 0); this.ctx.globalCompositeOperation = 'destination-out'; }; } else { this.ctx.fillStyle = this.config.coverColor; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.globalCompositeOperation = 'destination-out'; } } }
|
绑定事件
刮奖效果其实就是通过绑定touchstart
, touchmove
, touchend
事件来绘制源图像, 这里把 mouse 事件也加上
这里通过监听touchmove
来绘制图像, touchstart
和touchend
来控制开始停止
虽然默认 addEventListener 第三个参数的属性 passive 用于控制是否禁用 preventDefault, 默认是 false, 但是还是要显式指定{passive: false}, 因为touchstart
和touchend
passive 默认值还是 true
isDown 表示是否触摸屏幕
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| class ScratchCard { ... private _addEvent() { this.canvas.addEventListener('touchstart', this._eventDown.bind(this), { passive: false }); this.canvas.addEventListener('touchend', this._eventUp.bind(this), { passive: false }); this.canvas.addEventListener('touchmove', this._scratch.bind(this), { passive: false }); this.canvas.addEventListener('mousedown', this._eventDown.bind(this), { passive: false }); this.canvas.addEventListener('mouseup', this._eventUp.bind(this), { passive: false }); this.canvas.addEventListener('mousemove', this._scratch.bind(this), { passive: false }); } private _eventDown(e: MouseEvent | TouchEvent) { e.preventDefault(); this.isDown = true; } private _eventUp(e: MouseEvent | TouchEvent) { e.preventDefault(); this.isDown = false; } }
|
擦除效果
逻辑大致如下:
- 判断刮刮卡还没挂完 this.done 为 false, 且处于按下状态 this.isDown 为 true
- 如果存在多个触点, 则使用最后一个触点, 使用 e.changedTouches 获取最后一个触点
- 获取当前点击的坐标 x, y, 这里 ev.clientX + document.body.scrollLeft 相当于 ev.pageX
- 绘图
需要注意的是这里不能用解构赋值const { beginPath, arc, fill } = this.ctx;
,会使绘图方法的上下文失效, 可以用 with(this.ctx)
, 但不推荐
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| _scratch(e: MouseEvent | TouchEvent) { e.preventDefault(); let ev: MouseEvent | Touch = e as MouseEvent; if (!this.done && this.isDown) { if (e instanceof TouchEvent && e.changedTouches) { ev = e.changedTouches[e.changedTouches.length - 1]; } const x = (ev.clientX + document.body.scrollLeft || ev.pageX) - this.offsetX || 0; const y = (ev.clientY + document.body.scrollTop || ev.pageY) - this.offsetY || 0; this.ctx.beginPath(); this.ctx.arc( x * this.config.pixelRatio, y * this.config.pixelRatio, this.config.radius * this.config.pixelRatio, 0, Math.PI * 2 ); this.ctx.fill(); } }
|
全部刮开
这里就是判断刮开的区域百分比是否超过初始化时设置的阈值, 如果有淡出效果则设置 canvas 的 style.transition, 没有就直接清除画布.
这里判断刮开区域所占百分比的方法_getFilledPercentage()
具体逻辑如下:
- 首先要知道 imgData.data 获得的是一个 Uint8Array 点阵数组, 其中 4 个字节表示一个像素, 每个字节分别代表 rgba
- 所以这里需要从 i=3 开始累加 4 计算 alpha=0(也可以不是 0,通过设置能够表示透明的阈值)的个数, 最后除以像素数就能够得到刮开区域的百分比
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| _scratch(e: MouseEvent | TouchEvent) { ... if (this._getFilledPercentage() > this.config.showAllPercent) { this._scratchAll(); } } } _scratchAll() { this.done = true; if (this.config.fadeOut > 0) { this.canvas.style.transition = `all ${this.config.fadeOut}ms linear`; this.canvas.style.opacity = '0'; setTimeout(() => { this._clear(); }, this.config.fadeOut); } else { this._clear(); } this.config?.doneCallback(); } _clear() { this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); } _getFilledPercentage() { const imgData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height); const pixels = imgData.data; let threshold = 0; let transparentPixelCount = 0; for (let i = 3; i < pixels.length; i += 4) { if (pixels[i] <= threshold) { transparentPixelCount++; } } return Number(((transparentPixelCount / (pixels.length / 4)) * 100).toFixed(2)); }
|
由于 ImageData 的跨域问题, 不要在本地直接打开, 可以启动一个静态服务器以 http 形式打开
完整代码已上传到 github查看源码
参考资料