美文网首页让前端飞Web前端之路
一张刮刮卡竟包含这么多前端知识点

一张刮刮卡竟包含这么多前端知识点

作者: Mr_兔子先生 | 来源:发表于2019-09-25 09:11 被阅读0次
    一张刮刮卡竟包含这么多前端知识点.jpg

    刮刮卡是大家非常熟悉的一种网页交互元素了。实现刮涂层的效果,需要借助canvas来实现,想必每个前端工程师都清楚。实现刮刮卡并不难,但其中却涉及很多知识点,掌握这些知识点,有助于我们更深刻理解原理,对于提升举一反三的能力很有帮助。本期以实现刮刮卡为例,分享下如何科学合理地封装函数,并对涉及的相关知识点进行讲解。

    先看下最终效果:

    最终效果预览.gif

    实现刮刮卡都涉及到哪些知识点呢?

    知识点1:canvas元素尺寸与画布尺寸

    知识点2:prototype、__proto__、constructor

    知识点3:canvas的globalCompositeOperation

    知识点4:addEventListener第三个参数的passive属性

    知识点5:canvas的ImageData

    下面进入本期分享的正式内容。

    1 需求分析

    为了满足更多的场景需要,我们尽可能地提供更多的参数,方便使用者。先从产品和UI的角度来思考下,一个刮刮卡可能需要哪些配置选项。

    • 涂层样式(图片 or 纯色)
    • 涂抹画笔半径
    • 涂抹到百分之多少时,直接刮开全部涂层
    • 刮开全部涂层的效果(淡出 or 直接消除)

    接下来再补充下技术配置选项:

    • canvas元素
    • 屏幕像素显示倍数(适应Retina等高倍屏)
    • 淡出效果的过渡动画时间

    OK,确认好以上配置参数后,就可以正式开工了。

    2 页面构建

    项目目录结构如下:

    |- 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画布尺寸的知识点。

    现在页面显示效果如下,结果图像已显示出来:

    award.jpg

    知识点1:canvas元素尺寸与画布尺寸

    HTML中canvas的width、height是画布大小,通俗来讲就是canvas画布的“绘制区域大小”,一定要跟元素的显示大小区别开来。

    我们的结果图素材是750x280,所以要让canvas完全绘制这张图片,画布大小也需要是750x280。

    那么元素大小,就是canvas在页面的“显示大小”。通过CSS对canvas元素进行宽高设置,使其正确的显示。

    3 构建类的雏形

    新建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);
    }
    

    使用对象的方式向函数传参有很多优点:

    1. 参数语义化,方便理解
    2. 不用在意参数顺序
    3. 传参的增删和顺序调整不会影响业务代码的使用

    使用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')
        }
    });
    

    刮刮卡的类使用起来非常方便,仅传递不使用默认配置的值即可。

    4 实现ScratchCard

    4.1 构建ScratchCard原型

    继续编写scratchcard.js:

        function ScratchCard(config) {
            this.config = {
                ...(略)
            }
            Object.assign(this.config, config)
    +       this._init();
        }
    
    +   ScratchCard.prototype = {
    +       constructor: ScratchCard,
    +       // 初始化
    +       _init: function() {}
    +   }
    

    这里设置了constructor: ScratchCard,仅仅是为了显得更加严谨,省略这一行也是没有问题的。

    由代码中prototypeconstructor引出第2个知识点。

    知识点2:prototype、__proto__、constructor

    先记住两点:

    1. __proto__constructor属性是对象所独有的(函数也是对象)。
    2. 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。

    4.2 实现canvas涂层

    先添加以下代码:

        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个知识点。

    知识点3:canvas的globalCompositeOperation

    在w3school上可以查阅到该属性的详细说明:

    描述
    source-over 默认。在目标图像上显示源图像。
    source-atop 在目标图像顶部显示源图像。源图像位于目标图像之外的部分是不可见的。
    source-in 在目标图像中显示源图像。只有目标图像内的源图像部分会显示,目标图像是透明的。
    source-out 在目标图像之外显示源图像。只会显示目标图像之外源图像部分,目标图像是透明的。
    destination-over 在源图像上方显示目标图像。
    destination-atop 在源图像顶部显示目标图像。源图像之外的目标图像部分不会被显示。
    destination-in 在源图像中显示目标图像。只有源图像内的目标图像部分会被显示,源图像是透明的。
    destination-out 在源图像外显示目标图像。只有源图像外的目标图像部分会被显示,源图像是透明的。
    lighter 显示源图像 + 目标图像。
    copy 显示源图像。忽略目标图像。
    xor 使用异或操作对源图像与目标图像进行组合。

    看上去好像有点懵逼难理解,其实就是类似于指定photoshop里两个图层怎么融合,比如谁遮罩谁、交叉部分消除、交叉部分颜色融合等等。

    可以参看下w3school的图示,蓝色为目标图像,红色为源图像。

    canvas图层融合示例.png

    回到刮刮卡,图片涂层是目标图像,目前源图像还未设置,所以源图像为全透明(源图像的不透明的部分用来抠除目标图像并呈现透明),所以目标图像(图片涂层)全部显示。

    现在效果如下图所示,涂层已经覆盖上了。

    scratch-2x.jpg

    4.3 添加涂抹事件

    涂抹事件,其实就是用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个知识点。

    知识点4:addEventListener第三个参数的passive属性

    最开始,addEventListener() 的参数约定是这样的:

    el.addEventListener(type, listener, useCapture)
    
    • el:事件对象
    • type:事件类型,click、mouseover 等
    • listener:事件处理函数,也就是事件触发后的回调
    • useCapture:布尔值,是否是捕获型,默认 false(冒泡)

    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。

    滑动演示.gif

    OK,那么问题来了?既然默认是passive: false,为什么代码里还要再多此一举写一遍呢?

    答案在这里,来看chrome的官方说明:
    https://www.chromestatus.com/feature/5093566007214080

    原文如下:

    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.4 阻止页面滚动

    看完了4.3小节,那么阻止页面滚动就很简单了。在index.html的script里加入以下代码:

    +   window.addEventListener('touchmove', function(e) {
    +       e.preventDefault();
    +   }, {passive: false});
    
        new ScratchCard({
            ...(略)
        });
    

    4.5 实现擦除效果

    这里完善下_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();
            }
        }
    }
    

    逻辑大致如下:

    1. 判断刮刮卡还没刮完(this.done为false),并且处于按下状态(this.isDown为true)。
    2. 如果存在多个触点,则使用最后一个触点。通过e.changedTouches获取最后一个触点。
    3. 计算触点在canvas里的相对坐标。
    4. 在canvas中的触点位置绘制圆形。

    需要说明的是,乘以pixelRatio是为了适应多倍屏幕。在本示例中,画布尺寸是2倍尺寸,而坐标是按照网页元素的尺寸计算出来的,正好相差一倍,所以要乘以pixelRatio(pixelRatio = 2)。

    还记得4.2小节讲的globalCompositeOperation么?当设置为destination-out的时候,源图像的非透明部分会抠去目标图像,因此实现了刮刮卡的刮涂层效果。

    4.6 检测涂层的透明部分占比

    虽然刮涂层的效果实现了,但是还要实时检测刮开了多少,来判断是否完成刮刮卡。

    继续修改代码:

        _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() {
    +       let imgData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
    +       let pixels = imgData.data;
    +       let transPixels = [];
    +       for (let i = 0; i < pixels.length; i += 4) {
    +           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个知识点。

    知识点5:canvas的ImageData

    利用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即为透明。

    4.7 注意事项

    由于浏览器安全限制,Image不能读取本地图片,因此需要部署在服务端,以http协议浏览本项目。

    以上就是本期分享的全部内容了。完整代码请前往GitHub:https://github.com/Yuezi32/scratchcard

    看似简单的刮刮卡却隐藏了这么多的知识点,你都掌握了么?

    欢迎关注我的个人微信公众号,随时获取最新文章_

    微信关注.png

    相关文章

      网友评论

        本文标题:一张刮刮卡竟包含这么多前端知识点

        本文链接:https://www.haomeiwen.com/subject/cajiuctx.html