原生JS写全屏滚动插件教程

第四节

  • 插件简介
  • 模块化
  • 原型继承
  • 构造函数
  • 事件处理
  • 公共方法
  • 使用插件

插件简介

右图是我对js插件的理解,如有描述不对请联系我邮箱lipten@foxmail.com指正。

一个插件需要同时满足四个点:模块化、可插拔、纯原生、易用性。

模块化

需要兼容常用的模块引入形式,一般采用兼容了amd和cmd的umd规范,参考UMD规范。模块化出来的插件最好是一个对象或者类,保证不会污染全局环境即可。

可插拔

可插拔指的是具有初始化、热更新、销毁等方法,这是我认为一个合格的插件必备的功能,在需要用的地方才会用它,在不需要的地方又能够完整移除,不会构成事件没有解绑导致的报错、或者html元素还存在着的问题。热更新则可有可无,适合用在无刷新更新局部区域的情况。

纯原生

最好是纯原生js编写的,不需要依赖某个框架库的插件会更通用,即使已经在用着一款框架也不需要考虑插件是否兼容框架的问题,如果框架引用起来比较麻烦的话,还可以用框架引入原生插件再封装出一份针对这个框架更易用的插件,就可以说是基于xx框架开发的xx插件啦。一般这种针对框架的插件都是以 'xx框架名-xx插件名' 命名的哈。

易用性

api文档是必须的,至于如何使用就要多考虑大众场景,不要让使用者过多的重复传值,要有预设的默认值。最好还能给出更多定制的方法,让开发者更自由的定制。我写插件的顺序是先设计好怎么调用比较好,再来针对这样的调用方式进行封装开发。

模块化

我们按照UMD规范将js代码模块化

                        
(function (root, factory) {
    'use strict';
    if (typeof define === 'function' && define.amd) {
        define([], function () {
            return factory(root, root.document);
        });
    } else if (typeof exports === 'object') {
        module.exports = factory(root, root.document);
    } else {
        root.slidePage = factory(root, root.document);
    }
}(typeof window !== 'undefined' ? window : this, function (window, document) {
    // 全屏滚动代码
    .....
}))
                        
                    

1.先判断是否支持Node.js模块格式(exports是否存在),存在则使用Node.js模块格式。
2.再判断是否支持AMD(define是否存在),存在则使用AMD方式加载模块。
3.前两个都不存在,则将模块公开到全局(window或global)。

原型继承

鄙人写插件的习惯顺序是先定好怎么用再决定怎么写的,假设我的需求是希望以实例化的方式带上配置参数初始化插件,实例化出来的对象具有有下一屏、上一屏、和唤醒的方法,最好还要有销毁的方法以便移除插件。按照样的需求,我们大概是以这样的方式调用插件的:

                        
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>全屏滚动</title>
        <meta http-equiv="X-UA-Compatible" content="IE=Edge">
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/animate.css@3.5.2/animate.min.css">
    </head> 
    <body>
        <!-- 全屏滚动容器 -->
        <div class="page-container">
            <!-- 每一屏滚动的容器-page1 -->
            <div class="page-item page1">
                <h2>Page1</h2>
                <div class="step step1 animated fadeIn" data-delay="1000"></div>
                <div class="lazy step2 animated fadeIn"></div>
                <button onclick="wakeup()" class="wakeup">唤醒动画</button>
            </div>
            ...
        </div>
        <script>
            var slide = new slidePage({
                pageItems: '.page-item',
                pageContainer: '.page-container',
            });
            
            // 唤醒第一屏的动画元素
            function wakeup() {
                slide.wakeup(1)
            }

            // 切换下一屏
            slide.next()

            // 切换上一屏
            slide.prev()
        </script>
    </body>
</html>                
                        
                    

根据上面的调用方式,我们再逐步实现这种调用方式,改写原来的代码会比较麻烦,需要一些耐心一步一步来。

为了更好的插件兼容性和更深刻的学习js语法,本教程使用ES5的语法,没有class关键字,所以用构造函数组合原型的方式将刚才写的全屏滚动封装成类似面向对象的类的形式,通过实例化,只暴露外部用到的方法,不需要考虑内部的形态。 具体解释写法可以参考一下这篇文章《JS中的构造函数,原型,原型链,继承》,而ES6的写法则清晰易懂很多《ES6的类》,如果有了解过babel和一些编译工具的话,可以自己在本地用ES6的写法来写,现阶段的在线编辑器还不支持ES6。

我们先建立一套基本的类结构,声明特权变量,私有方法,工具函数,并暴露一些公共方法。 slidePage

                        
(function (root, factory) {
    'use strict';
    if (typeof define === 'function' && define.amd) {
        define([], function () {
            return factory(root, root.document);
        });
    } else if (typeof exports === 'object') {
        module.exports = factory(root, root.document);
    } else {
        root.slidePage = factory(root, root.document);
    }
}(typeof window !== 'undefined' ? window : this, function (window, document) {
    // 全屏滚动代码,将之前写的js代码放入这里

    // 工具函数,提供一些无关业务的通用函数
    var utils = {}

    // 私有方法
    var methods = {
        slideCtrl: function(){},
        runAnimation: function(){},
        initAnimation: function(){},
        initEvent: function(){},
    }
    
    // 事件函数
    var eventHandler = {
        wheelFunc: function() {},
        transitionend: function() {},
        touchStart: function() {},
        touchMove: function() {},
        touchEnd: function() {},
    }

    var slidePage = function(option) {
        // 构造函数获取实例化传入的配置参数option
        // 实例化即初始化,将初始化的函数放在这执行
        this.canSlide = true;
		this.canNext = true;
        this.canPrev = true;
        ...
    }

    // 上一屏
    slidePage.prototype.prev = function () {
        ...
    };

    // 下一屏
    slidePage.prototype.next = function () {
        ...
    };

    // 唤醒某一屏的动画元素
    slidePage.prototype.wakeup = function() {
        ...
    }

    // 销毁插件
    slidePage.prototype.destroy = function() {
        ...
    }
    return slidePage;
}))
                        
                    

构造函数

构造函数是一个类实例化时执行的函数,我们可以把一些变量声明、初始化函数放在这里执行

                        
var slidePage = function (option) {
    // 构造函数获取实例化传入的配置参数option
    
    // 默认配置
    var default_opt = {
        page: 1,
        pageItems: '.slide-page',
        pageContainer: '.slide-container',
    };
    // 特权变量,供实例内使用
    this.canSlide = true;
    this.canNext = true;
    this.canPrev = true;

    // 将传入的配置覆盖默认配置
    this.opt = utils.extend(default_opt, option);
    this.page = this.opt.page;

    // 传入的slideContainer值可以是dom元素也可以是selector字符串
    this.container = utils.isDOM(this.opt.pageContainer) ? this.opt.pageContainer : document.querySelector(this.opt.pageContainer);
    this.items = utils.isDOM(this.opt.pageItems) ? this.opt.pageItems : document.querySelectorAll(this.opt.pageItems);
    this.count = this.items.length;

    // 记录触摸点
    this.touchPoint = {};

    // 存放事件函数
    this.eventHandler = {};

    // 重新赋值事件函数,改变this作用域,因为事件函数默认this是指向事件对象,我们要改变this指向当前实例
    for (var eventName in eventHandler) {
        this.eventHandler[eventName] = eventHandler[eventName].bind(this);
    }

    // 实例化即初始化,将初始化的函数放在这执行,由于是在函数外声明的私有变量,需要用call函数调整this指向当前实例
    methods.initEvent.call(this);
    methods.initAnimation.call(this, this.items, this.page - 1);
}
                        
                    

上面的代码提到了call和bind函数,是使用频率较少但很重要的函数,如不理解可以参考文档callbind

补充utils工具函数

                            
var utils = {
    // 传入两个对象,后者会覆盖前者
    extend: function (obj1, obj2) {
        for (var attr in obj2) {
            obj1[attr] = obj2[attr];
        }
        return obj1;
    },
    // 判断两个dom元素是否相同,不完全匹配,仅匹配几个属性
    isEqualNode: function (el1, el2) {
        var equalNodeName = el1.nodeName === el2.nodeName;
        var equalNodeType = el1.nodeType === el2.nodeType;
        var equalHTML = el1.innerHTML === el2.innerHTML;
        var equalClass = el1.className.replace(/ transition/g, '') === el2.className.replace(/ transition/g, '');
        return equalClass && equalNodeName && equalNodeType && equalHTML;
    },
    // 判断传入的参数是否是dom元素
    isDOM: function (obj) {
        if ((obj instanceof NodeList) || (obj instanceof HTMLCollection) && obj.length > 0) {
            var isTrue = 0;
            for (var i = 0, len = obj.length; i < len; i++) {
                (obj[i] instanceof Element) && (isTrue++);
            }
            return isTrue === len;
        } else {
            return (obj instanceof Element);
        }
    }
}
                        
                    

补充初始化函数,注意原来的变量名都要改成this作用域下的特权变量

                            
// 私有方法
var methods = {
    runAnimation: function(index, is_lazy){
        var steps = this.items[index].querySelectorAll(is_lazy ? '.lazy' : '.step');
        for (var element of Array.from(steps)) {
            triggerAnim(element)
        }
        function triggerAnim(element) {
            var delay = element.getAttribute('data-delay') || 100; //默认100毫秒的延迟
            var timer = setTimeout(function () {
                element.style.display = '';
                clearTimeout(timer);
            }, delay);
        }
    },
    initAnimation: function(){
        var lazys = this.container.querySelectorAll('.lazy');
        if (lazys.length > 0) {
            for (var element of Array.from(lazys)) {
                // 将所有lazy元素隐藏起来
                element.style.display = 'none';
            }
        }
        var steps = this.container.querySelectorAll('.step');
        if (steps.length > 0) {
            for (var element of Array.from(steps)) {
                // 将所有step元素隐藏起来
                element.style.display = 'none';
            }
        }
        // 然后播放第一屏动画
        methods.runAnimation.call(this, this.page - 1);
        for (var i = 0, len=this.count; i < len; i++) {
            var item = this.items[i];
            // 使第一屏置于屏幕中
            if (i === this.page-1) {
                    item.style.transform = 'translate3d(0, 0, 0)';
            } else {
                if (i < this.page-1) {
                    item.style.transform = 'translate3d(0, -100%, 0)';
                } else if (i > this.page-1) {
                    item.style.transform = 'translate3d(0, 100%, 0)';
                }
            }
            // 让所有屏都加上过渡动画class
            item.classList.add('transition');
        }
        this.canSlide = true;
        if (this.page <= 1){
            this.canNext = true;
        } else if (this.page >= count){
            this.canPrev = true;
        }
    },
    initEvent: function(){
        document.addEventListener('DOMMouseScroll', this.eventHandler.wheelFunc, false);
        document.addEventListener('mousewheel', this.eventHandler.wheelFunc, false);
        
        // 当每次滑动结束后的触发的事件
        this.container.addEventListener('transitionend', this.eventHandler.transitionend);

        touchPoint = {
            startpoint: 0,
            endpoint: 0
        }
        this.container.addEventListener('touchstart', this.eventHandler.touchStart);
        this.container.addEventListener('touchmove', this.eventHandler.touchMove);
        this.container.addEventListener('touchend', this.eventHandler.touchEnd);
    },
    slideCtrl: function() {
        this.canSlide = false;
        if (this.page == count) {
            this.canNext = false;
            this.canPrev = true;
        } else if (this.page == 1) {
            this.canNext = true;
            this.canPrev = false;
        } else {
            this.canNext = true;
            this.canPrev = true;
        }
    }
}
                        
                    

事件处理

上一页补充了初始化事件函数,现在可以把事件函数也补充上来

                        
// 事件函数
var eventHandler = {
    wheelFunc: function(e) {
        var e = e || window.event;
        if (e.wheelDeltaY < 0 || e.wheelDelta < 0 || e.detail > 0) {
            this.canSlide && this.canNext && this.next();
        } else if (e.wheelDeltaY > 0 || e.wheelDelta > 0 || e.detail < 0) {
            this.canSlide && this.canPrev && this.prev();
        }
    },
    transitionend: function() {
       this.canSlide = true;
    },
    touchStart: function(e) {
        // 当手指开始触摸屏幕时记录起始位置
        this.touchPoint.startpoint = e.targetTouches[0].clientY;
    },
    touchMove: function(e) {
        // 手指滑动时禁止默认的行为(比如微信从顶部往下滑会整个网页拉下,或者内容超过容器长度时会有滚动条正常滚动,这都属于默认行为)
        e.preventDefault();
        // 每像素滑动都会记录最新的结束点。
        this.touchPoint.endpoint = e.targetTouches[0].clientY;
    },
    touchEnd: function(e) {
        if (!this.touchPoint.endpoint) {
            return false;
        }
        // 取纵向Y轴的结束点与起始点对比,结束点比起始点小60,视为往上滑,结束点比起始点大60视为往下滑。
        // 60这个值视情况而定,有时候用户只是点按操作,起始点与结束点还是有细微的差距的,所以这个值不能太小。
        if ((this.touchPoint.endpoint - this.touchPoint.startpoint) < -60) {
            this.canSlide && this.canNext && this.next();
        } else if ((this.touchPoint.endpoint - this.touchPoint.startpoint) > 60) {
            this.canSlide && this.canPrev && this.prev();
        }
        // 完成一次滑屏后重置
        this.touchPoint = {};
    }
}
                        
                    

公共方法

我们要暴露几个方法让外部的实例化对象调用。分别是next、prev、wakeup、和销毁插件的destroy

                        
// 上一屏
slidePage.prototype.prev = function () {
    this.items[this.page - 2].style.transform = 'translate3d(0, 0, 0)';
    this.items[this.page - 1].style.transform = 'translate3d(0, 100%, 0)';
    this.page--;
    methods.runAnimation.call(this, this.page - 1);
    methods.slideCtrl.call(this);
};

// 下一屏
slidePage.prototype.next = function () {
    this.items[this.page - 1].style.transform = 'translate3d(0, -100%, 0)';
    this.items[this.page].style.transform = 'translate3d(0, 0, 0)';
    this.page++;
    methods.runAnimation.call(this, this.page - 1);
    methods.slideCtrl.call(this);
};

// 唤醒某一屏的动画元素
slidePage.prototype.wakeup = function(page) {
    methods.runAnimation.call(this, page-1, true)
}
                        
                    

鄙人认为,destroy方法重点在于还原到无插件状态,比如绑定过的事件、由插件插入的dom元素、插件影响到的样式。也就是一切由插件发生的事情都要还原到没有插件的样子。

由于我们初始化时绑定了很多事件,所以销毁就要解绑事件,还有插件里会控制page的display、transform属性,所以也要还原。但是不要直接把一开始的容器元素一并移除,因为容器元素不是插件初始化时插入的,要保留一些不是插件插入的元素,即使跟插件有关。

                        
// 销毁插件
slidePage.prototype.destroy = function() {
    var steps = this.container.querySelectorAll('.step');
    var lazys = this.container.querySelectorAll('.lazy');
    if (steps.length > 0) {
        for (var element of steps) {
            element.style.display = '';
        }
    }
    if (lazys.length > 0) {
        for (var element of lazys) {
            element.style.display = '';
        }
    }
    // 清除所有transform属性
    for (var i = 0, len=count; i < len; i++) {
		var item = this.items[i];
		item.style.transform = '';
    }
    // 解绑鼠标滚轮事件
    document.removeEventListener('DOMMouseScroll', this.eventHandler.wheelFunc);
    document.removeEventListener('mousewheel', this.eventHandler.wheelFunc);
    // 解绑touch事件
    this.container.removeEventListener('touchstart', this.eventHandler.touchStart);
    this.container.removeEventListener('touchmove', this.eventHandler.touchMove);
    this.container.removeEventListener('touchend', this.eventHandler.touchEnd);
    // 解绑transitionend事件
    this.container.removeEventListener('transitionend', this.eventHandler.transitionEnd);
}
                        
                    

使用插件

前面有提到过先想好如何使用插件再开始写,并有一段使用的示例代码,现在插件已经根据使用要求写出来了,所以我们可以直接按照示例代码那样去使用插件

                        
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>全屏滚动</title>
        <meta http-equiv="X-UA-Compatible" content="IE=Edge">
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/animate.css@3.5.2/animate.min.css">
    </head> 
    <body>
        <!-- 全屏滚动容器 -->
        <div class="page-container">
            <!-- 每一屏滚动的容器-page1 -->
            <div class="page-item page1">
                <h2>Page1</h2>
                <div class="step step1 animated fadeIn" data-delay="1000"></div>
                <div class="lazy step2 animated fadeIn"></div>
                <button onclick="wakeup()" class="wakeup">唤醒动画</button>
            </div>
            ...
        </div>
        <!-- <script src="javascript面板里的代码文件"></script> -->
        <script>
            var slide = new slidePage({
                pageItems: '.page-item',
                pageContainer: '.page-container',
            });
            
            // 唤醒第一屏的动画元素
            function wakeup() {
                slide.wakeup(1)
            }
        </script>
    </body>
</html> 
                        
                    

由于本站的编辑器开发环境限制,没有目录结构,只能把javascript面板里的代码在运行时插入在Html里的script标签之前执行,所以html代码里不需要引用javascript面板里的js文件。

除了上面的用法,还可以按照amd规范或cmd规范的require函数引入,根据不同的开发环境自由选择。这里就不多赘述了

运行之后如果没有问题,那么恭喜你完成了本次课程,点击 生成可访问的页面分享给你的亲朋好友吧

下节将带大家如何把自己写的教程发布到开源平台,大家也可以把自己写过的觉得有意思对别人有帮助的代码封装成插件贡献出来。

本节到此结束

下一节:发布开源社区