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

csdoker發表於2018-07-26

日常工作中經常會發現有大量業務邏輯是重複的,而用別人的外掛也不能完美解決一些定製化的需求,所以我決定把一些常用的元件抽離、封裝出來,形成一套自己的外掛庫。同時,我將用這個教程系列記錄下每一個外掛的開發過程,手把手教你如何一步一步去造出一套實用性、可複用性高的輪子。

So, Let's begin!

目前專案使用 ES5及UMD 規範封裝,所以在前端暫時只支援<script>標籤的引入方式,未來會逐步用 ES6 進行重構

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

pagination

JavaScript模組化

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

在這種模組規範的標準之上,我們還需要一種機制來載入不同的模組,例如實現了AMD規範的require.js,其用法可以參考阮一峰寫的這篇教程:

因為我們開發的輪子暫時不涉及到多模組載入,所以模組的載入暫時不予過多討論,讀者可自己進行擴充學習。

回到我們的主題上,在正式開發之前,還需要補充一點其他方面的知識。

自執行函式

定義一個函式,ES5一般有三種方式:

  1. 函式宣告
function foo () {}
複製程式碼

這樣宣告的函式和變數一樣,會被自動提升,所以我們可以把函式宣告放在呼叫它的語句後面:

foo();
function foo () {}
複製程式碼
  1. 函式表示式
var foo = function () {}
複製程式碼

右邊其實是一個匿名函式,只不過賦值給了一個變數,我們可以通過這個變數名來呼叫它,但是和第一種方式不同的是,通過表示式宣告的函式不會被提升。

  1. 使用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...

原文地址:部落格

本文原創,轉載請著名出處!

參考內容

相關文章