手把手教你用原生JavaScript造輪子(1)——分頁器(最後更新:Vue插件版本,本篇Over!)

平常工做中常常會發現有大量業務邏輯是重複的,而用別人的插件也不能完美解決一些定製化的需求,因此我決定把一些經常使用的組件抽離、封裝出來,造成一套本身的插件庫。同時,我將用這個教程系列記錄下每個插件的開發過程,手把手教你如何一步一步去造出一套實用性、可複用性高的輪子。javascript

So, Let's begin!css

目前項目使用 ES5及UMD 規範封裝,因此在前端暫時只支持 <script>標籤的引入方式,將來會逐步用 ES6 進行重構

演示地址:pagination
Github:csdwheels
不要吝嗇你的Star哦~(〃'▽'〃)html

pagination

JavaScript模塊化

要開發一個JavaScript的插件,首先要從JavaScript的模塊化講起。
什麼是模塊化?簡單的說就是讓JavaScript可以以一個總體的方式去組織和維護代碼,當多人開發時能夠互相引用對方的代碼塊又不形成衝突。
ECMAScript6標準以前常見的模塊化規範有:CommonJSAMDUMD等,由於咱們的代碼暫時是採用ES5語法進行開發,因此咱們選用UMD的規範來組織代碼。
關於模塊化的發展過程能夠參考:前端

在這種模塊規範的標準之上,咱們還須要一種機制來加載不一樣的模塊,例如實現了AMD規範的require.js,其用法能夠參考阮一峯寫的這篇教程:vue

由於咱們開發的輪子暫時不涉及到多模塊加載,因此模塊的加載暫時不予過多討論,讀者可本身進行拓展學習。java

回到咱們的主題上,在正式開發以前,還須要補充一點其餘方面的知識。node

自執行函數

定義一個函數,ES5通常有三種方式:webpack

  • 函數聲明
function foo () {}

這樣聲明的函數和變量同樣,會被自動提高,因此咱們能夠把函數聲明放在調用它的語句後面:git

foo();
function foo () {}
  • 函數表達式
var foo = function () {}

右邊實際上是一個匿名函數,只不過賦值給了一個變量,咱們能夠經過這個變量名來調用它,可是和第一種方式不一樣的是,經過表達式聲明的函數不會被提高。es6

  • 使用Function構造函數
var foo = new Function ()

那麼有沒有一種辦法,能夠不寫函數名,直接聲明一個函數並自動調用它呢?
答案確定的,那就是使用自執行函數。(實際上個人另外一篇文章打磚塊——js面向對象初識中就曾提到過)

自執行函數Immediately-Invoked Function Expression,顧名思義,就是自動執行的函數,有的地方也稱爲當即調用的函數表達式。
它的基本形式以下:

(function () {
    console.log('hello')
}());

(function () {
    console.log('hello')
})();
兩種寫法是等效的,只不過前者讓代碼看起來更像是一個總體。

能夠看到,這兩種寫法的做用其實就是在()內定義函數,而後又使用()來執行該函數,所以它就是自執行的。

IIFE的一些好處以下:

  • 避免污染全局變量
  • 減小命名衝突
  • 惰性加載

最重要的一點,它能夠建立一個獨立的做用域,而在ES6以前JavaScript是沒有塊級做用域的。
利用這一點,咱們能夠很輕鬆的保證多個模塊之間的變量不被覆蓋了:

// libA.js
(function(){
  var num = 1;
})();

// libB.js
(function(){
    var num = 2;
})();

上面這兩個文件模塊中的做用域都是獨立的,互不影響。(若是模塊之間想要互相引用,就須要用到模塊的加載器了,例如上面提到的require.js等庫)、

在此基礎上,咱們就能夠看看一個實現了UMD規範的IIFE模板是什麼樣子了:

// if the module has no dependencies, the above pattern can be simplified to
(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD. Register as an anonymous module.
        define([], factory);
    } else if (typeof module === 'object' && module.exports) {
        // Node. Does not work with strict CommonJS, but
        // only CommonJS-like environments that support module.exports,
        // like Node.
        module.exports = factory();
    } else {
        // Browser globals (root is window)
        root.returnExports = factory();
  }
}(typeof self !== 'undefined' ? self : this, function () {
    // Just return a value to define the module export.
    // This example returns an object, but the module
    // can return a function as the exported value.
    return {};
}));

能夠看到,UMD規範同時兼容了瀏覽器、Node環境及AMD規範,這樣咱們的代碼使用UMD包裝後就能夠在不一樣的環境中運行了。

插件模板

開發插件最重要的一點,就是插件的兼容性,一個插件至少要能同時在幾種不一樣的環境中運行。其次,它還須要知足如下幾種功能及條件:

  1. 插件自身的做用域與用戶當前的做用域相互獨立,也就是插件內部的私有變量不能影響使用者的環境變量;
  2. 插件需具有默認設置參數;
  3. 插件除了具有已實現的基本功能外,需提供部分API,使用者能夠經過該API修改插件功能的默認參數,從而實現用戶自定義插件效果;
  4. 插件支持鏈式調用;
  5. 插件需提供監聽入口,及針對指定元素進行監聽,使得該元素與插件響應達到插件效果。

第一點咱們利用UMD包裝的方式已經實現了,如今來看看第二和第三點。

一般狀況下,一個插件內部會有默認參數,而且會提供一些參數讓用戶實現部分功能的自定義。那麼怎麼實現呢,這其實就是一個對象合併的問題,例如:

function extend(o, n, override) {
    for (var p in n) {
        if (n.hasOwnProperty(p) && (!o.hasOwnProperty(p) || override))
        o[p] = n[p];
    }
}

// 默認參數
var options = {
    pageNumber: 1,
    pageShow: 2
};

// 用戶設置
var userOptions = {
    pageShow: 3,
    pageCount: 10
}

extend(options, userOptions, true);

// 合併後
options = {
    pageNumber: 1,
    pageShow: 3,
    pageCount: 10
}

如上,採用一個相似的extend函數就能夠實現對象的合併了,這樣咱們插件也就實現了設置參數的功能。

這裏的extend函數爲淺拷貝,由於插件的用戶參數通常是不會修改的,若是想實現深拷貝可參考jQuery中extend的實現方法。

第四點咱們插件暫時不須要這樣的功能,能夠暫時不支持它。第五點在代碼中咱們會經過回調函數去逐步實現它。

綜上,咱們就能夠實現出一個基礎的插件模板了:

;// JavaScript弱語法的特色,若是前面恰好有個函數沒有以";"結尾,那麼可能會有語法錯誤
(function(root, factory) {
  if (typeof define === 'function' && define.amd) {
    define([], factory);
  } else if (typeof module === 'object' && module.exports) {
    module.exports = factory();
  } else {
    root.Plugin = factory();
  }
}(typeof self !== 'undefined' ? self : this, function() {
  'use strict';

  // tool
  function extend(o, n, override) {
    for (var p in n) {
      if (n.hasOwnProperty(p) && (!o.hasOwnProperty(p) || override))
        o[p] = n[p];
    }
  }

  // polyfill
  var EventUtil = {
    addEvent: function(element, type, handler) {
      // 添加綁定
      if (element.addEventListener) {
        // 使用DOM2級方法添加事件
        element.addEventListener(type, handler, false);
      } else if (element.attachEvent) {
        // 使用IE方法添加事件
        element.attachEvent("on" + type, handler);
      } else {
        // 使用DOM0級方法添加事件
        element["on" + type] = handler;
      }
    },
    // 移除事件
    removeEvent: function(element, type, handler) {
      if (element.removeEventListener) {
        element.removeEventListener(type, handler, false);
      } else if (element.datachEvent) {
        element.detachEvent("on" + type, handler);
      } else {
        element["on" + type] = null;
      }
    },
    getEvent: function(event) {
      // 返回事件對象引用
      return event ? event : window.event;
    },
    // 獲取mouseover和mouseout相關元素
    getRelatedTarget: function(event) {
      if (event.relatedTarget) {
        return event.relatedTarget;
      } else if (event.toElement) {
        // 兼容IE8-
        return event.toElement;
      } else if (event.formElement) {
        return event.formElement;
      } else {
        return null;
      }
    },
    getTarget: function(event) {
      //返回事件源目標
      return event.target || event.srcElement;
    },
    preventDefault: function(event) {
      //取消默認事件
      if (event.preventDefault) {
        event.preventDefault();
      } else {
        event.returnValue = false;
      }
    },
    stopPropagation: function(event) {
      if (event.stopPropagation) {
        event.stopPropagation();
      } else {
        event.cancelBubble = true;
      }
    },
    // 獲取mousedown或mouseup按下或釋放的按鈕是鼠標中的哪個
    getButton: function(event) {
      if (document.implementation.hasFeature("MouseEvents", "2.0")) {
        return event.button;
      } else {
        //將IE模型下的button屬性映射爲DOM模型下的button屬性
        switch (event.button) {
          case 0:
          case 1:
          case 3:
          case 5:
          case 7:
            //按下的是鼠標主按鈕(通常是左鍵)
            return 0;
          case 2:
          case 6:
            //按下的是中間的鼠標按鈕
            return 2;
          case 4:
            //鼠標次按鈕(通常是右鍵)
            return 1;
        }
      }
    },
    //獲取表示鼠標滾輪滾動方向的數值
    getWheelDelta: function(event) {
      if (event.wheelDelta) {
        return event.wheelDelta;
      } else {
        return -event.detail * 40;
      }
    },
    // 以跨瀏覽器取得相同的字符編碼,需在keypress事件中使用
    getCharCode: function(event) {
      if (typeof event.charCode == "number") {
        return event.charCode;
      } else {
        return event.keyCode;
      }
    }
  };

  // plugin construct function
  function Plugin(selector, userOptions) {
    // Plugin() or new Plugin()
    if (!(this instanceof Plugin)) return new Plugin(selector, userOptions);
    this.init(selector, userOptions)
  }
  Plugin.prototype = {
    constructor: Plugin,
    // default option
    options: {},
    init: function(selector, userOptions) {
      extend(this.options, userOptions, true);
    }
  };

  return Plugin;
}));

這裏還使用到了一個EventUtil對象,它主要是針對事件註冊的一些兼容性作了一些polyfill封裝,具體原理能夠參閱:

到此,一個插件的基本模板就大體成型了。下一節,咱們終於能夠正式開始分頁插件的開發了!

思路分析

有人說計算機的本質就是對現實世界的抽象,而編程則是對這個抽象世界規則的制定。

正如上面這句話所說,在實際編碼以前咱們通常須要對要實現的需求效果進行一個思路的分析,最後再進一步把這個思路過程抽象爲有邏輯的代碼。
咱們先看一下要實現的分頁效果是什麼樣的,我把它分紅兩種狀況,顯示和不顯示省略號的,首先來看第一種:

// 總共30頁
// 第一種狀況:不顯示省略號,當前頁碼先後最多顯示2個頁碼
當前頁碼爲 1,那麼顯示 1 2 3 4 5
當前頁碼爲 2,那麼顯示 1 2 3 4 5
當前頁碼爲 3,那麼顯示 1 2 3 4 5
當前頁碼爲 4,那麼顯示 2 3 4 5 6
...
當前頁碼爲 15,那麼顯示 13 14 15 16 17
...
當前頁碼爲 27,那麼顯示 25 26 27 28 29
當前頁碼爲 28,那麼顯示 26 27 28 29 30
當前頁碼爲 29,那麼顯示 26 27 28 29 30
當前頁碼爲 30,那麼顯示 26 27 28 29 30

雖然上面每個數字在實際應用中都是一個按鈕或超連接,但如今既然是分析,咱們不妨就把它簡化並忽略,因而這個問題就變成了一個簡單的字符串輸出題。
咱們先定義一個函數:

function showPages (page, total, show) {

}

函數傳入的參數分別爲:當前頁碼、總頁碼數、當前頁碼面先後最多顯示頁碼數,而後咱們須要循環調用這個函數打印分頁:

var total = 30;
for (var i = 1; i <= total; i++) {
    console.log(showPages(i, total));
}

這樣從頁碼爲1到最後一頁的結果就全輸出了,最後咱們須要完成showPages()函數:

function showPages (page, total, show) {
    var str = '';
    if (page < show + 1) {
        for (var i = 1; i <= show * 2 + 1; i++) {
            str = str + ' ' + i;
        }
    } else if (page > total - show) {
        for (var i = total - show * 2; i <= total; i++) {
            str = str + ' ' + i;
        }
    } else {
        for (var i = page - show; i <= page + show; i++) {
            str = str + ' ' + i;
        }
    }
    return str.trim();
}

思路是分段拼出頁碼,打印結果以下:
1

不顯示省略號的頁碼正常輸出了,而後咱們來看顯示省略號的狀況:

// 第二種狀況:顯示省略號,當前頁碼先後最多顯示2個頁碼
當前頁碼爲 1,那麼顯示 1 2 3 ... 30
當前頁碼爲 2,那麼顯示 1 2 3 4 ... 30
當前頁碼爲 3,那麼顯示 1 2 3 4 5 ... 30
當前頁碼爲 4,那麼顯示 1 2 3 4 5 6 ... 30
當前頁碼爲 5,那麼顯示 1 ... 3 4 5 6 7 ... 30
...
當前頁碼爲 15,那麼顯示 1 ... 13 14 15 16 17 ... 30
...
當前頁碼爲 26,那麼顯示 1 ... 24 25 26 27 28 ... 30
當前頁碼爲 27,那麼顯示 1 ... 25 26 27 28 29 30
當前頁碼爲 28,那麼顯示 1 ... 26 27 28 29 30
當前頁碼爲 29,那麼顯示 1 ... 27 28 29 30
當前頁碼爲 30,那麼顯示 1 ... 28 29 30

一樣須要完成showPages()函數:

function showPages(page, length, show) {
    var str = '';
    var preIndex = page - (show + 1);
    var aftIndex = page + (show + 1);
    if (page < show + 3) {
        for (var i = 1; i <= show * 2 + 3; i++) {
            if ((i !== preIndex && i !== aftIndex) || (i === 1 || i === total)) {
                str = str + ' ' + i;
            } else {
                str = str + ' ... ' + total;
                break;
            }
        }
    } else if (page > total - (show + 2)) {
        for (var i = total; i >= total - (show * 2 + 2); i--) {
            if ((i !== preIndex && i !== aftIndex) || (i === 1 || i === total)) {
                str = i + ' ' + str;
            } else {
                str = '1 ... ' + str;
                break;
            }
        }
    } else {
        for (var i = preIndex + 1; i <= aftIndex - 1; i++) {
            str = str + ' ' + i;
        }
        str = '1 ... ' + str + ' ... ' + total;
    }
    return str.trim();
}

一樣也是採用分段拼的思路,能成功打印出結果:
2

可是仔細看看上面的代碼會發現有大量重複冗餘的邏輯了,能不能優化呢?下面是一種更爲取巧的思路:

function showPages (page, total, show) {
    var str = page + '';
    for (var i = 1; i <= show; i++) {
        if (page - i > 1) {
            str = page - i + ' ' + str;
        }
        if (page + i < total) {
            str = str + ' ' + (page + i);
        }
    }
    if (page - (show + 1) > 1) {
        str = '... ' + str;
    }
    if (page > 1) {
        str = 1 + ' ' + str;
    }
    if (page + show + 1 < total) {
        str = str + ' ...';
    }
    if (page < total) {
        str = str + ' ' + total;
    }
    return str;
}

打印結果是同樣的,但代碼卻大爲精簡了。

基本架構

一個好的插件,代碼必定是高複用、低耦合、易拓展的,所以咱們須要採用面向對象的方法來搭建這個插件的基本架構:

// 模仿jQuery $()
function $(selector, context) {
    context = arguments.length > 1 ? context : document;
    return context ? context.querySelectorAll(selector) : null;
}

var Pagination = function(selector, pageOption) {
    // 默認配置
    this.options = {
        curr: 1,
        pageShow: 2,
        ellipsis: true,
        hash: false
    };
    // 合併配置
    extend(this.options, pageOption, true);
    // 分頁器元素
    this.pageElement = $(selector)[0];
    // 數據總數
    this.dataCount = this.options.count;
    // 當前頁碼
    this.pageNumber = this.options.curr;
    // 總頁數
    this.pageCount = Math.ceil(this.options.count / this.options.limit);
    // 渲染
    this.renderPages();
    // 執行回調函數
    this.options.callback && this.options.callback({
        curr: this.pageNumber,
        limit: this.options.limit,
        isFirst: true
    });
    // 改變頁數並觸發事件
    this.changePage();
};

Pagination.prototype = {
    constructor: Pagination,
    changePage: function() {}
};

return Pagination;

如上,一個採用原型模式的分頁器對象就搭建完成了,下面咱們對上面的代碼進行一一講解。

分頁配置

本分頁器提供以下基本參數:

// 分頁元素ID(必填)
var selector = '#pagelist';

// 分頁配置
var pageOption = {
  // 每頁顯示數據條數(必填)
  limit: 5,
  // 數據總數(通常經過後端獲取,必填)
  count: 162,
  // 當前頁碼(選填,默認爲1)
  curr: 1,
  // 是否顯示省略號(選填,默認顯示)
  ellipsis: true,
  // 當前頁先後兩邊可顯示的頁碼個數(選填,默認爲2)
  pageShow: 2,
  // 開啓location.hash,並自定義hash值 (默認關閉)
  // 若是開啓,在觸發分頁時,會自動對url追加:#!hash值={curr} 利用這個,能夠在頁面載入時就定位到指定頁
  hash: false,
  // 頁面加載後默認執行一次,而後當分頁被切換時再次觸發
  callback: function(obj) {
    // obj.curr:獲取當前頁碼
    // obj.limit:獲取每頁顯示數據條數
    // obj.isFirst:是否首次加載頁面,通常用於初始加載的判斷

    // 首次不執行
    if (!obj.isFirst) {
      // do something
    }
  }
};

在構造函數裏調用extend()完成了用戶參數與插件默認參數的合併。

回調事件

一般狀況下,在改變了插件狀態後(點擊事件等),插件須要做出必定的反應。所以咱們須要對用戶行爲進行必定的監聽,這種監聽習慣上就叫做回調函數。
在上面代碼中咱們能夠看到有這麼一段:

// 執行回調函數
this.options.callback && this.options.callback({
    curr: this.pageNumber,
    limit: this.options.limit,
    isFirst: true
});

這種寫法是否是有點奇怪呢,其實它至關於:

if(this.options.callback){
    this.options.callback({
        curr: this.pageNumber,
        limit: this.options.limit,
        isFirst: true
    });
}

想必聰明的你已經明白了吧,這裏的callback並非某個具體的東西,而是一個引用。無論callback指向誰,咱們只須要判斷它有沒有存在,若是存在就執行它。

事件綁定

接下來須要對分頁器進行點擊事件的綁定,也就是完成咱們的changePage()方法:

changePage: function() {
    var self = this;
    var pageElement = self.pageElement;
    EventUtil.addEvent(pageElement, "click", function(ev) {
        var e = ev || window.event;
        var target = e.target || e.srcElement;
        if (target.nodeName.toLocaleLowerCase() == "a") {
            if (target.id === "prev") {
                self.prevPage();
            } else if (target.id === "next") {
                self.nextPage();
            } else if (target.id === "first") {
                self.firstPage();
            } else if (target.id === "last") {
                self.lastPage();
            } else if (target.id === "page") {
                self.goPage(parseInt(target.innerHTML));
            } else {
                return;
            }
            self.renderPages();
            self.options.callback && self.options.callback({
                curr: self.pageNumber,
                limit: self.options.limit,
                isFirst: false
            });
            self.pageHash();
        }
    });
}

總體的邏輯你們應該都能輕鬆看懂,無非就是判斷當前點擊的是什麼,而後執行對應的邏輯操做,但具體的實現方式有的同窗可能會有一點陌生。

Q:這個target是啥?這個srcElement又是啥?
A:這實際上是JavaScript事件委託方面的知識,你們能夠參考以下文章進行學習,這裏再也不贅述。

js中的事件委託或是事件代理詳解

插件對象、配置完成了,事件也綁定了,那接下來就應該完成咱們頁碼上顯示的DOM節點的渲染了。

渲染DOM

渲染的過程其實就是對上面咱們封裝的那幾個字符串打印函數的改進,把字符串改成具體的DOM節點,而後添加進頁面便可。
首先咱們須要完成一個createHtml()函數:

createHtml: function(elemDatas) {
  var self = this;
  var fragment = document.createDocumentFragment();
  var liEle = document.createElement("li");
  var aEle = document.createElement("a");
  elemDatas.forEach(function(elementData, index) {
    liEle = liEle.cloneNode(false);
    aEle = aEle.cloneNode(false);
    liEle.setAttribute("class", CLASS_NAME.ITEM);
    aEle.setAttribute("href", "javascript:;");
    aEle.setAttribute("id", elementData.id);
    if (elementData.id !== 'page') {
      aEle.setAttribute("class", CLASS_NAME.LINK);
    } else {
      aEle.setAttribute("class", elementData.className);
    }
    aEle.innerHTML = elementData.content;
    liEle.appendChild(aEle);
    fragment.appendChild(liEle);
  });
  return fragment;
}

這個函數的做用很簡單,就是生成一個節點:

<li class="pagination-item"><a href="javascript:;" id="page" class="pagination-link current">1</a></li>

代碼中有涉及到兩個性能優化的API,第一個API是document.createDocumentFragment(),它的做用是建立一個臨時佔位符,而後存放那些須要插入的節點,能夠有效避免頁面進行DOM操做時的重繪和迴流,減少頁面的負擔,提高頁面性能。相關知識點,可參閱如下文章:

第二個API是cloneNode(),若是須要建立不少元素,就能夠利用這個API來減小屬性的設置次數,不過必須先提早準備一個樣板節點,例如:

var frag = document.createDocumentFragment();
for (var i = 0; i < 1000; i++) {
    var el = document.createElement('p');
    el.innerHTML = i;
    frag.appendChild(el);
}
document.body.appendChild(frag);
//替換爲:
var frag = document.createDocumentFragment();
var pEl = document.getElementsByTagName('p')[0];
for (var i = 0; i < 1000; i++) {
    var el = pEl.cloneNode(false);
    el.innerHTML = i;
    frag.appendChild(el);
}
document.body.appendChild(frag);

完成這個函數後,再進一步封裝成兩個插入節點的函數:(這一步可省略)

addFragmentBefore: function(fragment, datas) {
  fragment.insertBefore(this.createHtml(datas), fragment.firstChild);
}

addFragmentAfter: function(fragment, datas) {
  fragment.appendChild(this.createHtml(datas));
}

前者在最前插入節點,後者在最後插入節點。
一些常量和重複操做也能夠進一步抽取:

pageInfos: [{
    id: "first",
    content: "首頁"
  },
  {
    id: "prev",
    content: "前一頁"
  },
  {
    id: "next",
    content: "後一頁"
  },
  {
    id: "last",
    content: "尾頁"
  },
  {
    id: "",
    content: "..."
  }
]

getPageInfos: function(className, content) {
  return {
    id: "page",
    className: className,
    content: content
  };
}

利用上面封裝好的對象和方法,咱們就能夠對最開始那兩個字符串函數進行改造了:

renderNoEllipsis: function() {
  var fragment = document.createDocumentFragment();
  if (this.pageNumber < this.options.pageShow + 1) {
    fragment.appendChild(this.renderDom(1, this.options.pageShow * 2 + 1));
  } else if (this.pageNumber > this.pageCount - this.options.pageShow) {
    fragment.appendChild(this.renderDom(this.pageCount - this.options.pageShow * 2, this.pageCount));
  } else {
    fragment.appendChild(this.renderDom(this.pageNumber - this.options.pageShow, this.pageNumber + this.options.pageShow));
  }
  if (this.pageNumber > 1) {
    this.addFragmentBefore(fragment, [
      this.pageInfos[0],
      this.pageInfos[1]
    ]);
  }
  if (this.pageNumber < this.pageCount) {
    this.addFragmentAfter(fragment, [this.pageInfos[2], this.pageInfos[3]]);
  }
  return fragment;
}

renderEllipsis: function() {
  var fragment = document.createDocumentFragment();
  this.addFragmentAfter(fragment, [
    this.getPageInfos(CLASS_NAME.LINK + " current", this.pageNumber)
  ]);
  for (var i = 1; i <= this.options.pageShow; i++) {
    if (this.pageNumber - i > 1) {
      this.addFragmentBefore(fragment, [
        this.getPageInfos(CLASS_NAME.LINK, this.pageNumber - i)
      ]);
    }
    if (this.pageNumber + i < this.pageCount) {
      this.addFragmentAfter(fragment, [
        this.getPageInfos(CLASS_NAME.LINK, this.pageNumber + i)
      ]);
    }
  }
  if (this.pageNumber - (this.options.pageShow + 1) > 1) {
    this.addFragmentBefore(fragment, [this.pageInfos[4]]);
  }
  if (this.pageNumber > 1) {
    this.addFragmentBefore(fragment, [
      this.pageInfos[0],
      this.pageInfos[1],
      this.getPageInfos(CLASS_NAME.LINK, 1)
    ]);
  }
  if (this.pageNumber + this.options.pageShow + 1 < this.pageCount) {
    this.addFragmentAfter(fragment, [this.pageInfos[4]]);
  }
  if (this.pageNumber < this.pageCount) {
    this.addFragmentAfter(fragment, [
      this.getPageInfos(CLASS_NAME.LINK, this.pageCount),
      this.pageInfos[2],
      this.pageInfos[3]
    ]);
  }
  return fragment;
}

renderDom: function(begin, end) {
  var fragment = document.createDocumentFragment();
  var str = "";
  for (var i = begin; i <= end; i++) {
    str = this.pageNumber === i ? CLASS_NAME.LINK + " current" : CLASS_NAME.LINK;
    this.addFragmentAfter(fragment, [this.getPageInfos(str, i)]);
  }
  return fragment;
}

邏輯和最開始的showPages()徹底同樣,只是變成了DOM的操做而已。

至此,渲染部分的函數基本也封裝完成,最後還剩一些操做頁碼的函數,比較簡單,這裏就不做講解了,可自行參考源碼

使用場景

相信你們也看出來了,此分頁器只負責分頁自己的邏輯,具體的數據請求與渲染須要另外去完成。
不過,此分頁器不只能應用在通常的異步分頁上,還可直接對一段已知數據進行分頁展示,使用場景以下:

前端分頁

在callback裏對總數據進行處理,而後取出當前頁須要展現的數據便可

後端分頁

利用url上的頁碼參數,能夠在頁面載入時就定位到指定頁碼,而且能夠同時請求後端指定頁碼下對應的數據 在callback回調函數裏取得當前頁碼,可使用window.location.href改變url,並將當前頁碼做爲url參數,而後進行頁面跳轉,例如"./test.html?page="

插件調用

插件的調用也很是方便,首先,咱們在頁面引入相關的CSS、JS文件:

<link rel="stylesheet" href="pagination.min.css">
<script type="text/javascript" src="pagination.min.js"></script>
樣式若是以爲不滿意可自行調整

而後將HTML結構插入文檔中:

<ol class="pagination" id="pagelist"></ol>

最後,將必填、選填的參數配置好便可完成本分頁插件的初始化:

// 分頁元素ID(必填)
var selector = '#pagelist';

// 分頁配置
var pageOption = {
  // 每頁顯示數據條數(必填)
  limit: 5,
  // 數據總數(通常經過後端獲取,必填)
  count: 162,
  // 當前頁碼(選填,默認爲1)
  curr: 1,
  // 是否顯示省略號(選填,默認顯示)
  ellipsis: true,
  // 當前頁先後兩邊可顯示的頁碼個數(選填,默認爲2)
  pageShow: 2,
  // 開啓location.hash,並自定義hash值 (默認關閉)
  // 若是開啓,在觸發分頁時,會自動對url追加:#!hash值={curr} 利用這個,能夠在頁面載入時就定位到指定頁
  hash: false,
  // 頁面加載後默認執行一次,而後當分頁被切換時再次觸發
  callback: function(obj) {
    // obj.curr:獲取當前頁碼
    // obj.limit:獲取每頁顯示數據條數
    // obj.isFirst:是否首次加載頁面,通常用於初始加載的判斷

    // 首次不執行
    if (!obj.isFirst) {
      // do something
    }
  }
};

// 初始化分頁器
new Pagination(selector, pageOption);
在兩種基礎模式之上,還能夠開啓Hash模式

那麼,整個分頁器插件的封裝到這裏就所有講解完畢了,怎麼樣,是否是以爲還挺簡單?偷偷告訴你,接下來咱們會逐漸嘗試點更有難度的插件哦!敬請期待~~

平心而論,總體的代碼質量雖然通常,可是邏輯和結構我以爲仍是寫得算比較清晰的吧。代碼的不足之處確定還有不少,也但願各位看官多多指教!

更新(2018-7-29)

ES6-環境配置

2015年,ECMAScript正式發佈了它的新版本——ECMAScript6,對JavaScript語言自己來講,這是一次不折不扣的升級。

通過此次更新,不只修復了許多ES5時代留下來的「坑」,更是在原有的語法和規則上增長了很多功能強大的新特性,儘管目前瀏覽器對新規範支持得並不完善,但通過一些神奇的工具處理後就能讓瀏覽器「認識」這些新東西,併兼容它們了。

so,咱們還有什麼理由不用強大的ES6呢?接下來就讓咱們先來看看這些神奇的工具是怎麼使用的吧。

Babel

首先,咱們須要一個工具來轉換ES6的代碼,它的芳名叫Babel。
Babel是一個編譯器,負責將源代碼轉換成指定語法的目標代碼,並使它們很好的執行在運行環境中,因此咱們能夠利用它來編譯咱們的ES6代碼。

要使用Babel相關的功能,必須先用npm安裝它們:(npm及node的使用方法請自行學習)

npm i babel-cli babel-preset-env babel-core babel-loader babel-plugin-transform-runtime babel-polyfill babel-runtime -D

安裝完成後,咱們就能夠手動使用命令編譯某個目錄下的js文件,並輸出它們了。

But,這就是完美方案了嗎?顯然不是。

在實際的開發環境中,咱們還須要考慮更多東西,好比模塊化開發、自動編譯和構建等等,因此咱們還須要一個更爲強大的工具來升級咱們的這套構建流程。

Webpack

圍觀羣衆:我知道了!你是想說Gulp對吧?!

喂,醒醒!大清亡了!

在前端框架以及工程化大行其道的今天,想必你們對Webpack、Gulp等工具並不會感到陌生,配合它們咱們能夠輕鬆實現一個大型前端應用的構建、打包、發佈的流程。
不過如今是2018年了,三大框架三足鼎立,而Gulp已經稍顯老態,做爲它的晚輩,一個名叫Webpack的少年正在逐漸崛起。
這位少年,相信你們在使用Vue、React的過程當中已經或多或少接觸過它了。簡而言之,它和Gulp在項目中的角色是同樣的,只不過配置更爲簡單,構建更爲高效,下面就讓咱們來看看Webpack是怎麼使用的吧。

若是你尚未接觸過Webpack,那能夠參考官方文檔,先對Webpack有一個大體的認識,咱們這裏不做過多介紹,只講解它的安裝與配置。

As usual,咱們須要安裝它:

npm i webpack webpack-cli webpack-dev-server -D

使用它也很是簡單,只須要創建一個名叫webpack.config.js的配置文件便可:

const path = require('path');

module.exports = {
  // 模式配置
  mode: 'development',
  // 入口文件
  entry: {},
  // 出口文件
  output: {},
  // 對應的插件
  plugins: [],
  // 處理對應模塊
  module: {}
}

這個配置文件的主要部分有:入口、出口、插件、模塊,在具體配置它們以前,咱們能夠先理一理咱們項目的打包構建流程:

  1. 尋找到./src/es6/目錄下面的index.js項目入口文件
  2. 使用Babel編譯它及它所引用的全部依賴(如Scss、css文件等)
  3. 壓縮編譯完成後的js文件,配置爲umd規範,重命名爲csdwheels.min.js
  4. 清空dist-es6目錄
  5. 輸出至dist-es6目錄下

要使用清空目錄、壓縮代碼、解析css等功能,咱們還須要安裝一下額外的包:

npm i clean-webpack-plugin uglifyjs-webpack-plugin css-loader style-loader node-sass sass-loader

要在配置中讓babel失效,還須要創建一個.babelrc文件,並在其中指定編碼規則:

{
  "presets": ["env"]
}

最後,咱們就能完成這個配置文件了:

const path = require('path');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin'); //每次構建清理dist目錄

module.exports = {
  // 模式配置
  mode: 'development',
  // 入口文件
  entry: {
    pagination: './src/es6/index.js'
  },
  // 出口文件
  output: {
    path: path.resolve(__dirname, 'dist-es6'),
    filename: "csdwheels.min.js",
    libraryTarget: 'umd',
    library: 'csdwheels'
  },
  // 對應的插件
  plugins: [
    new CleanWebpackPlugin(['dist-es6']),
    new UglifyJsPlugin({
      test: /\.js($|\?)/i
    })
  ],
  // 開發服務器配置
  devServer: {},
  // 處理對應模塊
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.join(__dirname , 'src/es6'),
        exclude: /node_modules/,
        use: ['babel-loader']
      },
      {
        test: /\.scss$/,
        use: [{
          loader: 'style-loader'
        }, {
          loader: 'css-loader'
        }, {
          loader: 'sass-loader'
        }]
      }
    ]
  }
}

光配置好還不夠,咱們總須要用命令來運行它吧,在package.json裏配置:

"scripts": {
  "test": "node test/test.js",
  "dev": "webpack-dev-server",
  "build": "webpack && gulp mini && npm run test"
}

這裏使用dev能夠啓動一個服務器來展現項目,不過這裏咱們暫時不須要,而運行npm run build命令就能夠同時將咱們的./src/es5./src/es6目錄下的源碼打包好輸出到指定目錄了。

不是說好不用Gulp的呢?嘛。。針對ES5的打包工做來講Gulp仍是挺好用的,真香警告!

ES6開發所須要的環境終於配置完成,接下來就讓咱們開始代碼的重構吧!

ES6-代碼重構

若是你想要入門ES6,強烈推薦阮一峯老師的 教程

相關的新語法和特性較多,不過要咱們的項目要重構爲ES6暫時還用不了多少比較高級的特性,你只須要着重看完Class部分便可。

ES6引入的新特性中,最重要的一個就是Class了。有了它,咱們不須要再像之前那樣用構造函數去模擬面向對象的寫法,由於它是JavaScript原生支持的一種面向對象的語法糖,雖然底層仍然是原型鏈,不過至少寫出來的代碼看上去像是那麼一回事了。

拿前面提到的插件模板來講,ES5的時候咱們是這樣寫的:

(function(root, factory) {
  if (typeof define === 'function' && define.amd) {
    define([], factory);
  } else if (typeof module === 'object' && module.exports) {
    module.exports = factory();
  } else {
    root.Plugin = factory();
  }
}(typeof self !== 'undefined' ? self : this, function() {
  'use strict';

  // tool
  function extend(o, n, override) {
    for (var p in n) {
      if (n.hasOwnProperty(p) && (!o.hasOwnProperty(p) || override))
        o[p] = n[p];
    }
  }

  // plugin construct function
  function Plugin(selector, userOptions) {
    // Plugin() or new Plugin()
    if (!(this instanceof Plugin)) return new Plugin(selector, userOptions);
    this.init(selector, userOptions)
  }
  Plugin.prototype = {
    constructor: Plugin,
    // default option
    options: {},
    init: function(selector, userOptions) {
      extend(this.options, userOptions, true);
    }
  };

  return Plugin;
}));

通過Class這種新語法糖的改造後,它變成了下面這樣:

// ES6 插件模板
class Plugin {
  constructor(selector, options = {}) {
    this.options = {};
    Object.assign(this.options, options);
    this.init(selector, options);
  }

  init(selector, options) {}
}
export default Plugin;

改造後的代碼,不只在語法層面直接支持了構造函數的寫法,更是去掉了IIFE這種臃腫的寫法,能夠說無論是看起來仍是寫起來都更爲清晰流暢了。

利用內置的 Object.assign()方法,能夠直接替換掉咱們實現的extend函數,功能能夠說徹底同樣,並且更爲強大

有了新的模板,咱們就能直接開始插件代碼的重構了,這裏只貼上變更比較大的幾個地方,其他部分可參考源碼

import '../../../style/pagination/pagination.scss'

class Pagination {
  static CLASS_NAME = {
    ITEM: 'pagination-item',
    LINK: 'pagination-link'
  }

  static PAGE_INFOS = [{
      id: "first",
      content: "首頁"
    },
    {
      id: "prev",
      content: "前一頁"
    },
    {
      id: "next",
      content: "後一頁"
    },
    {
      id: "last",
      content: "尾頁"
    },
    {
      id: "",
      content: "..."
    }
  ]

  constructor(selector, options = {}) {
    // 默認配置
    this.options = {
      curr: 1,
      pageShow: 2,
      ellipsis: true,
      hash: false
    };
    Object.assign(this.options, options);
    this.init(selector);
  }

  changePage () {
    let pageElement = this.pageElement;
    this.addEvent(pageElement, "click", (ev) => {
      let e = ev || window.event;
      let target = e.target || e.srcElement;
      if (target.nodeName.toLocaleLowerCase() == "a") {
        if (target.id === "prev") {
          this.prevPage();
        } else if (target.id === "next") {
          this.nextPage();
        } else if (target.id === "first") {
          this.firstPage();
        } else if (target.id === "last") {
          this.lastPage();
        } else if (target.id === "page") {
          this.goPage(parseInt(target.innerHTML));
        } else {
          return;
        }
        this.renderPages();
        this.options.callback && this.options.callback({
          curr: this.pageNumber,
          limit: this.options.limit,
          isFirst: false
        });
        this.pageHash();
      }
    });
  }

  init(selector) {
    // 分頁器元素
    this.pageElement = this.$(selector)[0];
    // 數據總數
    this.dataCount = this.options.count;
    // 當前頁碼
    this.pageNumber = this.options.curr;
    // 總頁數
    this.pageCount = Math.ceil(this.options.count / this.options.limit);
    // 渲染
    this.renderPages();
    // 執行回調函數
    this.options.callback && this.options.callback({
      curr: this.pageNumber,
      limit: this.options.limit,
      isFirst: true
    });
    // 改變頁數並觸發事件
    this.changePage();
  }
}
export default Pagination;

總結起來,此次改造用到的語法就這麼幾點:

  1. const、let替換var
  2. 用constructor實現構造函數
  3. 箭頭函數替換function

除此以外,在安裝了Sass的編譯插件後,咱們還能直接在這個js文件中把樣式import進來,這樣打包壓縮後的js中也會包含進咱們的樣式代碼,使用的時候就不須要額外再引入樣式文件了。
最後,因爲ES6並不支持類的靜態屬性,因此還須要用到ES7新提案的static語法。咱們能夠安裝對應的babel包:

npm i babel-preset-stage-0 -D

安裝後,在.babelrc文件中添加它便可:

{
  "presets": ["env", "stage-0"]
}

如今萬事俱備,你只須要運行npm run build,而後就能夠看到咱們打包完成後的csdwheels.min.js文件了。

打包後,咱們還能夠發佈這個npm包,運行以下命令便可:(有關npm的發佈流程,這裏就不囉嗦了)

npm login

npm publish

要使用發佈後的插件,只須要安裝這個npm包,並import對應的插件:

npm i csdwheels -D
import { Pagination } from 'csdwheels';

更新(2018-08-01)

Vue插件版本

按照原定開發計劃,實際上是不想立刻更新Vue版本的,畢竟這個系列的「賣點」是原生開發,不過最近用Vue作的項目和本身的博客都剛好用到了分頁這個組件,因此我決定一氣呵成把這個插件的Vue版本寫出來,正好也利用這個機會學學Vue插件的開發。

開發規範

既然是框架,那確定有它本身的開發規範了,相似於咱們本身寫的插件同樣,它也會給咱們提供各式各樣的API接口,讓咱們能定製本身的插件模塊。
簡單來講,咱們的插件在Vue中須要掛載到全局上,這樣才能直接在任何地方引入插件:

import Pagination from './components/vue-wheels-pagination'

const VueWheelsPagination = {
  install (Vue, options) {
    Vue.component(Pagination.name, Pagination)
  }
}

if (typeof window !== 'undefined' && window.Vue) {
  window.Vue.use(VueWheelsPagination)
}

export { VueWheelsPagination }

vue-wheels-pagination是咱們即將要開發的單文件組件,引入後經過install方法把它掛載上去,而後在外部就能夠use這個插件了,最後導出這個掛載了咱們插件的對象。(若是檢測到瀏覽器環境後,能夠直接掛載它)
這差很少就是一個最簡單的插件模板了,更詳細的配置可參考官方文檔

將這個入口用Webpack打包後,就能夠在你Vue項目中的main.js中全局加載這個插件了:

import { VueWheelsPagination } from 'vue-wheels'
Vue.use(VueWheelsPagination)

接下來,就讓咱們來看看用Vue的方式是怎麼完成這個分頁插件的吧!

DOM渲染

利用現代MVVM框架雙向綁定的特性,咱們已經沒必要再用原生JS的API去直接操做DOM了,取而代之的,能夠在DOM結構上利用框架提供的API間接進行DOM的渲染及交互:

<template lang="html">
  <nav class="pagination">
    <a href="javascript:;" class="pagination-item first" @click="goFirst()" v-if="pageNumber > 1">{{info.firstInfo}}</a>
    <a href="javascript:;" class="pagination-item prev" @click="goPrev()" v-if="pageNumber > 1">{{info.prevInfo}}</a>
    <ul class="pagination-list" v-if="ellipsis">
      <li class="pagination-item" @click="goFirst()" v-if="pageNumber > 1">1</li>
      <li class="pagination-item ellipsis" v-if="pageNumber - (max + 1) > 1">...</li>
      <li class="pagination-item"
          @click="goPage(pageNumber - pageIndex)"
          v-if="pageNumber - pageIndex > 1"
          v-for="pageIndex in rPageData"
          :key="pageNumber - pageIndex">
        {{pageNumber - pageIndex}}
      </li>
      <li class="pagination-item current" @click="goPage(pageNumber)">{{pageNumber}}</li>
      <li class="pagination-item"
          @click="goPage(pageNumber + pageIndex)"
          v-if="pageNumber + pageIndex < pageCount"
          v-for="pageIndex in pageData"
          :key="pageNumber + pageIndex">
        {{pageNumber + pageIndex}}
      </li>
      <li class="pagination-item ellipsis" v-if="pageNumber + max + 1 < pageCount">...</li>
      <li class="pagination-item" @click="goLast()" v-if="pageNumber < pageCount">{{pageCount}}</li>
    </ul>
    <ul class="pagination-list" v-if="!ellipsis">
      <li :class="pageIndex === pageNumber ? 'pagination-item current' : 'pagination-item'"
          @click="goPage(pageIndex)"
          v-for="pageIndex in pageDataFront"
          v-if="pageNumber < max + 1"
          :key="pageIndex">
        {{pageIndex}}
      </li>
      <li :class="pageIndex === pageNumber ? 'pagination-item current' : 'pagination-item'"
          @click="goPage(pageIndex)"
          v-for="pageIndex in pageDataCenter"
          v-if="pageNumber > pageCount - max"
          :key="pageIndex">
        {{pageIndex}}
      </li>
      <li :class="pageIndex === pageNumber ? 'pagination-item current' : 'pagination-item'"
          @click="goPage(pageIndex)"
          v-for="pageIndex in pageDataBehind"
          v-if="max + 1 <= pageNumber && pageNumber <= pageCount - max"
          :key="pageIndex">
        {{pageIndex}}
      </li>
    </ul>
    <a href="javascript:;" class="pagination-item next" @click="goNext()" v-if="pageNumber < pageCount">{{info.nextInfo}}</a>
    <a href="javascript:;" class="pagination-item last" @click="goLast()" v-if="pageNumber < pageCount">{{info.lastInfo}}</a>
  </nav>
</template>

如上,咱們直接在單文件組件的template標籤中就完成了這個插件大部分的渲染邏輯。相對原生JS實現的版本,不只輕鬆省去了事件監聽、DOM操做等步驟,並且讓咱們能只關注插件自己具體的交互邏輯,能夠說大大減輕了開發難度,並提高了頁面性能。剩下的數據部分的邏輯及交互處理,在JS中完成便可。

交互邏輯

export default {
  name: 'VueWheelsPagination',
  props: {
    count: {
      type: Number,
      required: true
    },
    limit: {
      type: Number,
      required: true
    },
    curr: {
      type: Number,
      required: false,
      default: 1
    },
    max: {
      type: Number,
      required: false,
      default: 2
    },
    ellipsis: {
      type: Boolean,
      required: false,
      default: true
    },
    info: {
      type: Object,
      required: false,
      default: {
        firstInfo: '首頁',
        prevInfo: '前一頁',
        nextInfo: '後一頁',
        lastInfo: '尾頁'
      }
    }
  },
  data () {
    return {
      pageNumber: this.curr
    }
  },
  watch: {
    curr (newVal) {
      this.pageNumber = newVal
    }
  },
  computed: {
    pageData () {
      let pageData = []
      for (let index = 1; index <= this.max; index++) {
        pageData.push(index)
      }
      return pageData
    },
    rPageData () {
      return this.pageData.slice(0).reverse()
    },
    pageDataFront () {
      let pageDataFront = []
      for (let index = 1; index <= this.max * 2 + 1; index++) {
        pageDataFront.push(index)
      }
      return pageDataFront
    },
    pageDataCenter () {
      let pageDataCenter = []
      for (let index = this.pageCount - this.max * 2; index <= this.pageCount; index++) {
        pageDataCenter.push(index)
      }
      return pageDataCenter
    },
    pageDataBehind () {
      let pageDataBehind = []
      for (let index = this.pageNumber - this.max; index <= this.pageNumber + this.max; index++) {
        pageDataBehind.push(index)
      }
      return pageDataBehind
    },
    pageCount () {
      return Math.ceil(this.count / this.limit)
    }
  },
  methods: {
    goFirst () {
      this.pageNumber = 1
      this.$emit('pageChange', 1)
    },
    goPrev () {
      this.pageNumber--
      this.$emit('pageChange', this.pageNumber)
    },
    goPage (pageNumber) {
      this.pageNumber = pageNumber
      this.$emit('pageChange', this.pageNumber)
    },
    goNext () {
      this.pageNumber++
      this.$emit('pageChange', this.pageNumber)
    },
    goLast () {
      this.pageNumber = this.pageCount
      this.$emit('pageChange', this.pageNumber)
    }
  }
}

整體分紅幾個部分:

  1. props屬性中對父組件傳遞的參數進行類型、默認值、是否必填等配置的定義
  2. 計算屬性中對分頁器自己所需數據進行初始化
  3. 定義操做頁碼的方法,並向父組件傳遞當前頁碼
  4. 在watch屬性中監聽頁碼的變化(主要應用於不經過分頁而在其餘地方改變頁碼的狀況)

這樣,整個分頁插件的開發就已經完成了。相信你們能夠感受獲得,關於分頁邏輯部分的代碼量是明顯減小了很多的,而且插件自己的邏輯也更清晰,和咱們前面一步一步從底層實現起來的版本比較起來,更易拓展和維護了。

在外層的組件上調用起來大概就像這樣:

<template>
  <div id="app">
    <div class="main">
      <vue-wheels-pagination @pageChange="change" :count="count" :limit="limit" :info="info"></vue-wheels-pagination>
    </div>
  </div>
</template>
export default {
  name: 'app',
  data () {
    return {
      count: 162,
      limit: 5,
      info: {
        firstInfo: '<<',
        prevInfo: '<',
        nextInfo: '>',
        lastInfo: '>>'
      }
    }
  },
  methods: {
    change (pageNumber) {
      console.log(pageNumber)
    }
  }
}

傳入必填和選填的參數,再監聽到子組件冒泡回來的頁碼值,最後在你本身定義的change()方法裏進行跳轉等對應的邏輯處理就好了。

項目的打包流程和上一節提到的差很少,只不過在配置上額外增長了一個本地開發環境服務器的啓動,能夠參考個人源碼。打包完成後,一樣能夠發佈一個npm包,而後就能夠在任何Vue項目中引入並使用了。

後面開發的輪子不必定都會發布Vue版本,由於已經給你們提供了一種重構和包裝插件的思路,若是你有本身的需求,可自行利用框架的規範進行插件開發。

到止爲止,咱們第一個輪子的開發就算真正結束了,全部源碼已同步更新到github,若是你們發現有bug或其餘問題,能夠回覆在項目的issue中,我們後會有期!(挖坑不填,逃。。

To be continued...

參考內容

相關文章
相關標籤/搜索