刮刮卡是你们很是熟悉的一种网页交互元素了。实现刮涂层的效果,须要借助canvas来实现,想必每一个前端工程师都清楚。实现刮刮卡并不难,但其中却涉及不少知识点,掌握这些知识点,有助于咱们更深入理解原理,对于提高触类旁通的能力颇有帮助。本期以实现刮刮卡为例,分享下如何科学合理地封装函数,并对涉及的相关知识点进行讲解。html
先看下最终效果:前端
实现刮刮卡都涉及到哪些知识点呢?git
知识点1:canvas元素尺寸与画布尺寸github
知识点2:prototype、__proto__、constructorchrome
知识点3:canvas的globalCompositeOperationcanvas
知识点4:addEventListener第三个参数的passive属性数组
知识点5:canvas的ImageData浏览器
下面进入本期分享的正式内容。安全
为了知足更多的场景须要,咱们尽量地提供更多的参数,方便使用者。先从产品和UI的角度来思考下,一个刮刮卡可能须要哪些配置选项。bash
接下来再补充下技术配置选项:
OK,确认好以上配置参数后,就能够正式开工了。
项目目录结构以下:
|- award.jpg <--刮刮卡底层结果页图片
|- index.html
|- scratch-2x.jpg <--刮刮卡涂层图片
|- scratchcard.js
复制代码
页面结构很简单,div的background显示结果,div里的canvas用来作涂层。
新建index.html,加入如下代码(HTML模板代码略过):
HTML代码:
<div class="card">
<canvas id="canvas" width="750" height="280"></canvas>
</div>
复制代码
CSS代码:
.card {
width: 375px;
height: 140px;
background: url('award.jpg');
background-size: 375px 140px;
}
.card canvas {
width: 375px;
height: 140px;
}
复制代码
award.jpg用的是2倍图,所以使用 background-size缩放回1倍显示大小。
这里能够发现,HTML中canvas的width、height与CSS中的width、height不一致。缘由就是要适应Retina 2倍屏幕。这里就涉及到了canvas画布尺寸的知识点。
如今页面显示效果以下,结果图像已显示出来:
HTML中canvas的width、height是画布大小,通俗来说就是canvas画布的“绘制区域大小”,必定要跟元素的显示大小区别开来。
咱们的结果图素材是750x280,因此要让canvas彻底绘制这张图片,画布大小也须要是750x280。
那么元素大小,就是canvas在页面的“显示大小”。经过CSS对canvas元素进行宽高设置,使其正确的显示。
新建scratchcard.js。
结合第1章节的需求分析,类的雏形以下:
function ScratchCard(config) {
// 默认配置
this.config = {
// canvas元素
canvas: null,
// 直接所有刮开的百分比
showAllPercent: 65,
// 图片图层
coverImg: null,
// 纯色图层,若是图片图层值不为null,则纯色图层无效
coverColor: null,
// 所有刮开回调
doneCallback: null,
// 擦除半径
radius: 20,
// 屏幕倍数
pixelRatio: 1,
// 展示所有的淡出效果时间(ms)
fadeOut: 2000
}
Object.assign(this.config, config);
}
复制代码
使用对象的方式向函数传参有不少优势:
使用Object.assign方法,可将传递进来的config参数覆盖默认参数。传递的config中没有的属性,则使用默认配置。
在index.html中引入scratchcard.js,在body最下边插入script代码:
new ScratchCard({
canvas: document.getElementById('canvas'),
coverImg: 'scratch-2x.jpg',
pixelRatio: 2,
doneCallback: function() {
console.log('done')
}
});
复制代码
刮刮卡的类使用起来很是方便,仅传递不使用默认配置的值便可。
继续编写scratchcard.js:
function ScratchCard(config) {
this.config = {
...(略)
}
Object.assign(this.config, config)
+ this._init();
}
+ ScratchCard.prototype = {
+ constructor: ScratchCard,
+ // 初始化
+ _init: function() {}
+ }
复制代码
这里设置了constructor: ScratchCard,仅仅是为了显得更加严谨,省略这一行也是没有问题的。
由代码中prototype
和constructor
引出第2个知识点。
先记住两点:
__proto__
和constructor
属性是对象所独有的(函数也是对象)。prototype
属性是函数所独有的。※因为JS中函数也是一种对象,因此函数也拥有
__proto__
和constructor
属性。
【__proto__】
__proto__
属性都是由一个对象指向一个对象,即指向它们的原型对象(也能够理解为父对象)。
它的做用就是当访问一个对象的属性时,若是该对象内部不存在这个属性,那么就会去它的__proto__
属性所指向的那个对象(父对象)里找,若是父对象也不存在这个属性,则继续在父对象的__proto__
属性所指向的对象(爷爷对象)里找,若是还没找到,则继续往上找,直到原型链顶端null。null为原型链的终点。
由以上这种经过__proto__
属性来链接对象直到null的一条链即为所谓的原型链。
【prototype】
prototype
对象是函数所独有的,它是从一个函数指向一个对象。它的含义是函数的原型对象,也就是由这个函数所建立的实例的原型对象。
// 示例代码
var demo = new Demo()
function Demo(config) { ... }
复制代码
所以,以上代码中,demo.__proto__ === Demo.prototype。
prototype
属性的做用就是:prototype
包含的属性和方法可被其建立的所有实例所共用。
【constructor】
constructor
属性也是对象独有的,它是从一个对象指向一个函数。其含义就是指向该对象的构造函数。全部函数最终的构造函数都指向Function。
当建立一个函数的时候,会同时自动建立它的prototype
对象,这个对象也会自动得到constructor
属性,并指向本身。
那么,为何咱们这里还要手动设置constructor: ScratchCard呢?
缘由就是咱们用这样的语法:
ScratchCard.prototype = {}
复制代码
会致使自动设置的constructor属性值被覆盖。在这种状况下,若是咱们不特地设置constructor: ScratchCard的话,constructor则会指向Object。
先添加如下代码:
function ScratchCard(config) {
this.config = {
...(略)
}
Object.assign(this.config, config);
+ this.canvas = this.config.canvas;
+ this.ctx = null;
+ this.offsetX = null;
+ this.offsetY = null;
this._init();
}
ScratchCard.prototype = {
constructor: ScratchCard,
// 初始化
_init: function() {
+ var that = this;
+ this.ctx = this.canvas.getContext('2d');
+ this.offsetX = this.canvas.offsetLeft;
+ this.offsetY = this.canvas.offsetTop;
+ if (this.config.coverImg) {
+ // 若是设置的图片涂层
+ var coverImg = new Image();
+ coverImg.src = this.config.coverImg;
+ // 读取图像
+ coverImg.onload = function() {
+ // 绘制图像
+ that.ctx.drawImage(coverImg, 0, 0);
+ that.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';
+ }
}
}
复制代码
初始化代码就是实现涂层的覆盖。这里的关键逻辑是:若是设置了图像涂层,则忽略纯色涂层。
涉及到了canvas两个API:
drawImage
用于绘制图像。
fillRect
用于绘制矩形,在绘制以前要先设置笔刷,即经过fillStyle
属性设置颜色。
这段代码是什么意思呢?
this.ctx.globalCompositeOperation = 'destination-out';
复制代码
globalCompositeOperation就是第3个知识点。
在w3school上能够查阅到该属性的详细说明:
值 | 描述 |
---|---|
source-over | 默认。在目标图像上显示源图像。 |
source-atop | 在目标图像顶部显示源图像。源图像位于目标图像以外的部分是不可见的。 |
source-in | 在目标图像中显示源图像。只有目标图像内的源图像部分会显示,目标图像是透明的。 |
source-out | 在目标图像以外显示源图像。只会显示目标图像以外源图像部分,目标图像是透明的。 |
destination-over | 在源图像上方显示目标图像。 |
destination-atop | 在源图像顶部显示目标图像。源图像以外的目标图像部分不会被显示。 |
destination-in | 在源图像中显示目标图像。只有源图像内的目标图像部分会被显示,源图像是透明的。 |
destination-out | 在源图像外显示目标图像。只有源图像外的目标图像部分会被显示,源图像是透明的。 |
lighter | 显示源图像 + 目标图像。 |
copy | 显示源图像。忽略目标图像。 |
xor | 使用异或操做对源图像与目标图像进行组合。 |
看上去好像有点懵逼难理解,其实就是相似于指定photoshop里两个图层怎么融合,好比谁遮罩谁、交叉部分消除、交叉部分颜色融合等等。
能够参看下w3school的图示,蓝色为目标图像,红色为源图像。
回到刮刮卡,图片涂层是目标图像,目前源图像还未设置,因此源图像为全透明(源图像的不透明的部分用来抠除目标图像并呈现透明),因此目标图像(图片涂层)所有显示。
如今效果以下图所示,涂层已经覆盖上了。
涂抹事件,其实就是用touchstart、touchmove、touchend事件,为了顺便兼容鼠标操做,也把mousedown、mousemove、mouseup带上。
修改代码:
function ScratchCard(config) {
this.config = {
...(略)
}
Object.assign(this.config, config);
this.canvas = this.config.canvas;
this.ctx = null;
this.offsetX = null;
this.offsetY = null;
+ // 是否在画布上处于按下状态
+ this.isDown = false;
+ // 是否已完成刮刮卡
+ this.done = false;
this._init();
}
ScratchCard.prototype = {
constructor: ScratchCard,
// 初始化
_init: function() {
...(略)
this.offsetY = this.canvas.offsetTop;
+ this._addEvent();
if (this.config.coverImg) { ...(略) }
},
+ // 添加事件
+ _addEvent: function() {
+ 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 });
+ },
+ _eventDown: function(e) {
+ e.preventDefault();
+ this.isDown = true;
+ },
+ _eventUp: function(e) {
+ e.preventDefault();
+ this.isDown = false;
+ },
+ // 刮涂层
+ _scratch: function(e) {
+ }
}
复制代码
代码很好理解,就是添加事件监听。当按下的时候,把isDown设置为true,当抬起的时候,把isDown设置为false。
能够看到addEventListener的第3个参数{ passive: false },这是个什么鬼?这就是第4个知识点。
最开始,addEventListener() 的参数约定是这样的:
el.addEventListener(type, listener, useCapture)
复制代码
2015年末,为了扩展新的选项,DOM 规范作了修订:
el.addEventListener(type, listener, {
capture: false, // useCapture
once: false, // 是否设置单次监听
passive: false // 是否让阻止默认行为preventDefault()失效
})
复制代码
三个属性的默认值都为 false。
为何会多出个passive属性呢?
为了防止页面滚动,不少移动端页面都会监听 touchmove 等 touch 事件,像这样:
document.addEventListener("touchmove", function(e){
e.preventDefault()
})
复制代码
因为 touchmove 事件对象的 cancelable 属性为 true,也就是说它的默认行为能够被监听器经过 preventDefault() 方法阻止。那它的默认行为是什么呢,一般来讲就是滚动当前页面(还多是缩放页面),若是它的默认行为被阻止了,页面就必须静止不动。但浏览器没法预先知道一个监听器会不会调用 preventDefault(),它能作的只有等监听器执行完后再去执行默认行为,而监听器执行是要耗时的,有些甚至耗时很明显,这样就会致使页面卡顿。即使监听器是个空函数,也会产生必定的卡顿,毕竟空函数的执行也会耗时。
当设置了passtive为true,则会忽略代码中的preventDefault(), 所以页面会变得更流畅。以下演示,右侧手机的页面设置了passtive为true。
OK,那么问题来了?既然默认是passive: false,为何代码里还要再画蛇添足写一遍呢?
答案在这里,来看chrome的官方说明: www.chromestatus.com/feature/509…
原文以下:
AddEventListenerOptions defaults passive to false. With this change touchstart and touchmove listeners added to the document will default to passive:true (so that calls to preventDefault will be ignored)..
意思是:addEventListener的option里,默认passive是false。可是若是事件是 touchstart 或 touchmove的话,passive的默认值则会变成true(因此preventDefault就会被忽略了)。
OK,原理讲完了,咱们尚未把页面的默认滑动行为阻止掉。不阻止的话,在滑动刮刮卡的时候,页面也会跟着滚动。
看完了4.3小节,那么阻止页面滚动就很简单了。在index.html的script里加入如下代码:
+ window.addEventListener('touchmove', function(e) {
+ e.preventDefault();
+ }, {passive: false});
new ScratchCard({
...(略)
});
复制代码
这里完善下_scratch方法,代码以下:
_scratch: function(e) {
e.preventDefault();
var that = this;
if (!this.done && this.isDown) {
if (e.changedTouches) {
e = e.changedTouches[e.changedTouches.length - 1];
}
var x = (e.clientX + document.body.scrollLeft || e.pageX) - this.offsetX || 0,
y = (e.clientY + document.body.scrollTop || e.pageY) - this.offsetY || 0;
with(this.ctx) {
beginPath()
arc(x * that.config.pixelRatio, y * that.config.pixelRatio, that.config.radius * that.config.pixelRatio, 0, Math.PI * 2);
fill();
}
}
}
复制代码
逻辑大体以下:
须要说明的是,乘以pixelRatio是为了适应多倍屏幕。在本示例中,画布尺寸是2倍尺寸,而坐标是按照网页元素的尺寸计算出来的,正好相差一倍,因此要乘以pixelRatio(pixelRatio = 2)。
还记得4.2小节讲的globalCompositeOperation么?当设置为destination-out
的时候,源图像的非透明部分会抠去目标图像,所以实现了刮刮卡的刮涂层效果。
虽然刮涂层的效果实现了,可是还要实时检测刮开了多少,来判断是否完成刮刮卡。
继续修改代码:
_scratch: function(e) {
...(略)
if (!this.done && this.isDown) {
...(略)
with(this.ctx) {
...(略)
}
+ if (this._getFilledPercentage() > this.config.showAllPercent) {
+ this._scratchAll()
+ }
}
}
+ // 刮开所有涂层
+ _scratchAll() {
+ var that = this;
+ this.done = true;
+ if (this.config.fadeOut > 0) {
+ // 先使用CSS opacity清除,再使用canvas清除
+ this.canvas.style.transition = 'all ' + this.config.fadeOut / 1000 + 's linear';
+ this.canvas.style.opacity = '0';
+ setTimeout(function() {
+ that._clear();
+ }, this.config.fadeOut)
+ } else {
+ // 直接使用canvas清除
+ that._clear();
+ }
+ // 执行回调函数
+ this.config.doneCallback && this.config.doneCallback();
+ },
+ // 清除所有涂层
+ _clear() {
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+ },
+ // 获取刮开区域百分比
+ _getFilledPercentage: function() {
+ var imgData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
+ // 存储当前cavnas画布的所有像素点信息
+ var pixels = imgData.data;
+ // 存储当前canvas画布的透明像素信息
+ var transPixels = [];
+ // 遍历所有像素点信息
+ for (var i = 0; i < pixels.length; i += 4) {
+ // 把透明的像素点添加到transPixels里
+ if (pixels[i + 3] < 128) {
+ transPixels.push(pixels[i + 3]);
+ }
+ }
+ // 计算透明像素点的占比
+ return (transPixels.length / (pixels.length / 4) * 100).toFixed(2)
+ }
}
复制代码
新增了3个方法:
_scratchAll
: 清空涂层(所有刮开)。若是设置的fadeOut(淡出时间),则经过CSS动画,将canvas作淡出效果,而后再清除涂层。若是fadeOut为0,则直接清除涂层。
_clear
:清除涂层。很简单,直接画一个铺满画布的矩形便可。
_getFilledPercentage
:计算刮开区域的百分比。经过遍历canvas每一个像素点,计算全透明像素的占比。
这里就涉及到了第5个知识点。
利用canvas的getImageData()方法能够获取到所有的像素点信息,返回数组格式。数组中,并非每一个元素表明一个像素的信息,而是每4个元素为一个像素的信息。例如:
data[0] = 像素1的R值,红色(0-255)
data[1] = 像素1的G值,绿色(0-255)
data[2] = 像素1的B值,蓝色(0-255)
data[3] = 像素1的A值,alpha 通道(0-255; 0 透明,255彻底可见)
data[4] = 像素2的R值,红色(0-255)
...
本例的透明度不存在中间值,因此就能够认为alpha小于128即为透明。
因为浏览器安全限制,Image不能读取本地图片,所以须要部署在服务端,以http协议浏览本项目。
以上就是本期分享的所有内容了。完整代码请前往GitHub:github.com/Yuezi32/scr…
看似简单的刮刮卡却隐藏了这么多的知识点,你都掌握了么?
欢迎关注个人我的微信公众号,随时获取最新文章^_^