[轉載]原生js 插件

作爲一個前端er,如果不會寫一個小插件,都不好意思說自己是混前端界的。寫還不能依賴jquery之類的工具庫,否則裝得不夠高端。那麼,如何才能裝起來讓自己看起來逼格更高呢?當然是利用js純原生的寫法啦。以前一直說,掌握了js原生,就基本上可以解決前端的所有腳本交互工作了,這話大體上是有些浮誇了。不過,也從側面說明了原生js在前端中佔着多麼重要的一面。好了。廢話不多說。咱們就來看一下怎麼去做一個自己的js插件吧。

插件的需求

我們寫代碼,並不是所有的業務或者邏輯代碼都要抽出來複用。首先,我們得看一下是否需要將一部分經常重複的代碼抽象出來,寫到一個單獨的文件中爲以後再次使用。再看一下我們的業務邏輯是否可以爲團隊服務。
插件不是隨手就寫成的,而是根據自己業務邏輯進行抽象。沒有放之四海而皆準的插件,只有對插件,之所以叫做插件,那麼就是開箱即用,或者我們只要添加一些配置參數就可以達到我們需要的結果。如果都符合了這些情況,我們纔去考慮做一個插件。

插件封裝的條件

一個可複用的插件需要滿足以下條件:

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

關於插件封裝的條件,可以查看一篇文章:原生JavaScript插件編寫指南
而我想要說明的是,如何一步一步地實現我的插件封裝。所以,我會先從簡單的方法函數來做起。

插件的外包裝

用函數包裝

所謂插件,其實就是封裝在一個閉包中的一種函數集。我記得剛開始寫js的時候,我是這樣乾的,將我想要的邏輯,寫成一個函數,然後再根據不同需要傳入不同的參數就可以了。
比如,我想實現兩個數字相加的方法:

function add(n1,n2) {
    return n1 + n2;
}
// 調用
add(1,2)
// 輸出:3

這就是我們要的功能的簡單實現。如果僅僅只不過實現這麼簡單的邏輯,那已經可以了,沒必要弄一些花裏胡哨的東西。js函數本身就可以解決絕大多數的問題。不過我們在實際工作與應用中,一般情況的需求都是比較複雜得多。
如果這時,產品來跟你說,我不僅需要兩個數相加的,我還要相減,相乘,相除,求餘等等功能。這時候,我們怎麼辦呢?
當然,你會想,這有什麼難的。直接將這堆函數都寫出來不就完了。然後都放在一個js文件裏面。需要的時候,就調用它就好了。

// 加
function add(n1,n2) {
    return n1 + n2;
}
// 減
function sub(n1,n2) {
    return n1 - n2;
}
// 乘
function mul(n1,n2) {
    return n1 * n2;
}
// 除
function div(n1,n2) {
    return n1 / n2;
}
// 求餘
function sur(n1,n2) {
    return n1 % n2;
}

OK,現在已經實現我們所需要的所有功能。並且我們也把這些函數都寫到一個js裏面了。如果是一個人在用,那麼可以很清楚知道自己是否已經定義了什麼,並且知道自己寫了什麼內容,我在哪個頁面需要,那麼就直接引入這個js文件就可以搞定了。
不過,如果是兩個人以上的團隊,或者你與別人一起協作寫代碼,這時候,另一個人並不知道你是否寫了add方法,這時他也定義了同樣的add方法。那麼你們之間就會產生命名衝突,一般稱之爲變量的 全局污染

用全局對象包裝

爲了解決這種全局變量污染的問題。這時,我們可以定義一個js對象來接收我們這些工具函數。

var plugin = {
    add: function(n1,n2){...},//加
    sub: function(n1,n2){...},//減
    mul: function(n1,n2){...},//乘
    div: function(n1,n2){...},//除
    sur: function(n1,n2){...} //餘
}
// 調用
plugin.add(1,2)

上面的方式,約定好此插件名爲plugin,讓團隊成員都要遵守命名規則,在一定程度上已經解決了全局污染的問題。在團隊協作中只要約定好命名規則了,告知其它同學即可以。當然不排除有個別人,接手你的項目,並不知道此全局變量已經定義,則他又定義了一次並賦值,這時,就會把你的對象覆蓋掉。當然,可能你會這麼幹來解決掉命名衝突問題:

if(!plugin){ //這裏的if條件也可以用: (typeof plugin == 'undefined')
    var plugin = {
        // 以此寫你的函數邏輯
    }
}

或者也可以這樣寫:

var plugin;
if(!plugin){
    plugin = {
        // ...
    }
}

這樣子,就不會存在命名上的衝突了。

也許有同學會疑問,爲什麼可以在此聲明plugin變量?實際上js的解釋執行,會把所有聲明都提前。如果一個變量已經聲明過,後面如果不是在函數內聲明的,則是沒有影響的。所以,就算在別的地方聲明過var plugin,我同樣也以可以在這裏再次聲明一次。關於聲明的相關資料可以看阮一鋒的如何判斷Javascript對象是否存在

基本上,這就可以算是一個插件了。解決了全局污染問題,方法函數可以抽出來放到一單獨的文件裏面去。

利用閉包包裝

上面的例子,雖然可以實現了插件的基本上的功能。不過我們的plugin對象,是定義在全局域裏面的。我們知道,js變量的調用,從全局作用域上找查的速度會比在私有作用域裏面慢得多得多。所以,我們最好將插件邏輯寫在一個私有作用域中。
實現私有作用域,最好的辦法就是使用閉包。可以把插件當做一個函數,插件內部的變量及函數的私有變量,爲了在調用插件後依舊能使用其功能,閉包的作用就是延長函數(插件)內部變量的生命週期,使得插件函數可以重複調用,而不影響用戶自身作用域。
故需將插件的所有功能寫在一個立即執行函數中:

;(function(global,undefined) {
    var plugin = {
        add: function(n1,n2){...}
        ...
    }
    // 最後將插件對象暴露給全局對象
    'plugin' in global && global.plugin = plugin;
})(window);

對上面的代碼段傳參問題進行解釋一下:

  1. 在定義插件之前添加一個分號,可以解決js合併時可能會產生的錯誤問題;
  2. undefined在老一輩的瀏覽器是不被支持的,直接使用會報錯,js框架要考慮到兼容性,因此增加一個形參undefined,就算有人把外面的 undefined 定義了,裏面的 undefined 依然不受影響;
  3. 把window對象作爲參數傳入,是避免了函數執行的時候到外部去查找。

其實,我們覺得直接傳window對象進去,我覺得還是不太妥當。我們並不確定我們的插件就一定用於瀏覽器上,也有可能使用在一些非瀏覽端上。所以我們還可以這麼幹,我們不傳參數,直接取當前的全局this對象爲作頂級對象用。

;(function(global,undefined) {
    "use strict" //使用js嚴格模式檢查,使語法更規範
    var _global;
    var plugin = {
        add: function(n1,n2){...}
        ...
    }
    // 最後將插件對象暴露給全局對象
    _global = (function(){ return this || (0, eval)('this'); }());
    !('plugin' in _global) && (_global.plugin = plugin);
}());

如此,我們不需要傳入任何參數,並且解決了插件對環境的依事性。如此我們的插件可以在任何宿主環境上運行了。

上面的代碼段中有段奇怪的表達式:(0, eval)('this'),實際上(0,eval)是一個表達式,這個表達式執行之後的結果就是eval這一句相當於執行eval('this')的意思,詳細解釋看此篇:(0,eval)('this')釋義或者看一下這篇(0,eval)('this')

關於立即自執行函數,有兩種寫法:

// 寫法一
(function(){})()

//寫法二
(function(){}())

上面的兩種寫法是沒有區別的。都是正確的寫法。個人建議使用第二種寫法。這樣子更像一個整體。

附加一點知識:
js裏面()括號就是將代碼結構變成表達式,被包在()裏面的變成了表達式之後,則就會立即執行,js中將一段代碼變成表達式有很多種方式,比如:

void function(){...}();
// 或者
!function foo(){...}();
// 或者
+function foot(){...}();

當然,我們不推薦你這麼用。而且亂用可能會產生一些歧義。

到這一步,我們的插件的基礎結構就已經算是完整的了。

使用模塊化的規範包裝

雖然上面的包裝基本上已經算是ok了的。但是如果是多個人一起開發一個大型的插件,這時我們要該怎麼辦呢?多人合作,肯定會產生多個文件,每個人負責一個小功能,那麼如何才能將所有人開發的代碼集合起來呢?這是一個討厭的問題。要實現協作開發插件,必須具備如下條件:

  • 每功能互相之間的依賴必須要明確,則必須嚴格按照依賴的順序進行合併或者加載
  • 每個子功能分別都要是一個閉包,並且將公共的接口暴露到共享域也即是一個被主函數暴露的公共對象

關鍵如何實現,有很多種辦法。最笨的辦法就是按順序加載js

<script type="text/javascript" src="part1.js"></script>
<script type="text/javascript" src="part2.js"></script>
<script type="text/javascript" src="part3.js"></script>
...
<script type="text/javascript" src="main.js"></script>

但是不推薦這麼做,這樣做與我們所追求的插件的封裝性相背。
不過現在前端界有一堆流行的模塊加載器,比如requireseajs,或者也可以像類似於Node的方式進行加載,不過在瀏覽器端,我們還得利用打包器來實現模塊加載,比如browserify。不過在此不談如何進行模塊化打包或者加載的問題,如有問題的同學可以去上面的鏈接上看文檔學習。
爲了實現插件的模塊化並且讓我們的插件也是一個模塊,我們就得讓我們的插件也實現模塊化的機制。
我們實際上,只要判斷是否存在加載器,如果存在加載器,我們就使用加載器,如果不存在加載器。我們就使用頂級域對象。

if (typeof module !== "undefined" && module.exports) {
    module.exports = plugin;
} else if (typeof define === "function" && define.amd) {
    define(function(){return plugin;});
} else {
    _globals.plugin = plugin;
}

這樣子我們的完整的插件的樣子應該是這樣子的:

// plugin.js
;(function(undefined) {
    "use strict"
    var _global;
    var plugin = {
        add: function(n1,n2){ return n1 + n2; },//加
        sub: function(n1,n2){ return n1 - n2; },//減
        mul: function(n1,n2){ return n1 * n2; },//乘
        div: function(n1,n2){ return n1 / n2; },//除
        sur: function(n1,n2){ return n1 % n2; } //餘
    }
    // 最後將插件對象暴露給全局對象
    _global = (function(){ return this || (0, eval)('this'); }());
    if (typeof module !== "undefined" && module.exports) {
        module.exports = plugin;
    } else if (typeof define === "function" && define.amd) {
        define(function(){return plugin;});
    } else {
        !('plugin' in _global) && (_global.plugin = plugin);
    }
}());

我們引入了插件之後,則可以直接使用plugin對象。

with(plugin){
    console.log(add(2,1)) // 3
    console.log(sub(2,1)) // 1
    console.log(mul(2,1)) // 2
    console.log(div(2,1)) // 2
    console.log(sur(2,1)) // 0
}

插件的API

插件的默認參數

我們知道,函數是可以設置默認參數這種說法,而不管我們是否傳有參數,我們都應該返回一個值以告訴用戶我做了怎樣的處理,比如:

function add(param){
    var args = !!param ? Array.prototype.slice.call(arguments) : [];
    return args.reduce(function(pre,cur){
        return pre + cur;
    }, 0);
}

console.log(add()) //不傳參,結果輸出0,則這裏已經設置了默認了參數爲空數組
console.log(add(1,2,3,4,5)) //傳參,結果輸出15

則作爲一個健壯的js插件,我們應該把一些基本的狀態參數添加到我們需要的插件上去。
假設還是上面的加減乘除餘的需求,我們如何實現插件的默認參數呢?道理其實是一樣的。

// plugin.js
;(function(undefined) {
    "use strict"
    var _global;

    function result(args,fn){
        var argsArr = Array.prototype.slice.call(args);
        if(argsArr.length > 0){
            return argsArr.reduce(fn);
        } else {
            return 0;
        }
    }
    var plugin = {
        add: function(){
            return result(arguments,function(pre,cur){
                return pre + cur;
            });
        },//加
        sub: function(){
            return result(arguments,function(pre,cur){
                return pre - cur;
            });
        },//減
        mul: function(){
            return result(arguments,function(pre,cur){
                return pre * cur;
            });
        },//乘
        div: function(){
            return result(arguments,function(pre,cur){
                return pre / cur;
            });
        },//除
        sur: function(){
            return result(arguments,function(pre,cur){
                return pre % cur;
            });
        } //餘
    }

    // 最後將插件對象暴露給全局對象
    _global = (function(){ return this || (0, eval)('this'); }());
    if (typeof module !== "undefined" && module.exports) {
        module.exports = plugin;
    } else if (typeof define === "function" && define.amd) {
        define(function(){return plugin;});
    } else {
        !('plugin' in _global) && (_global.plugin = plugin);
    }
}());

// 輸出結果爲:
with(plugin){
    console.log(add()); // 0
    console.log(sub()); // 0
    console.log(mul()); // 0
    console.log(div()); // 0
    console.log(sur()); // 0

    console.log(add(2,1)); // 3
    console.log(sub(2,1)); // 1
    console.log(mul(2,1)); // 2
    console.log(div(2,1)); // 2
    console.log(sur(2,1)); // 0
}

實際上,插件都有自己的默認參數,就以我們最爲常見的表單驗證插件爲例:validate.js

(function(window, document, undefined) {
    // 插件的默認參數
    var defaults = {
        messages: {
            required: 'The %s field is required.',
            matches: 'The %s field does not match the %s field.',
            "default": 'The %s field is still set to default, please change.',
            valid_email: 'The %s field must contain a valid email address.',
            valid_emails: 'The %s field must contain all valid email addresses.',
            min_length: 'The %s field must be at least %s characters in length.',
            max_length: 'The %s field must not exceed %s characters in length.',
            exact_length: 'The %s field must be exactly %s characters in length.',
            greater_than: 'The %s field must contain a number greater than %s.',
            less_than: 'The %s field must contain a number less than %s.',
            alpha: 'The %s field must only contain alphabetical characters.',
            alpha_numeric: 'The %s field must only contain alpha-numeric characters.',
            alpha_dash: 'The %s field must only contain alpha-numeric characters, underscores, and dashes.',
            numeric: 'The %s field must contain only numbers.',
            integer: 'The %s field must contain an integer.',
            decimal: 'The %s field must contain a decimal number.',
            is_natural: 'The %s field must contain only positive numbers.',
            is_natural_no_zero: 'The %s field must contain a number greater than zero.',
            valid_ip: 'The %s field must contain a valid IP.',
            valid_base64: 'The %s field must contain a base64 string.',
            valid_credit_card: 'The %s field must contain a valid credit card number.',
            is_file_type: 'The %s field must contain only %s files.',
            valid_url: 'The %s field must contain a valid URL.',
            greater_than_date: 'The %s field must contain a more recent date than %s.',
            less_than_date: 'The %s field must contain an older date than %s.',
            greater_than_or_equal_date: 'The %s field must contain a date that\'s at least as recent as %s.',
            less_than_or_equal_date: 'The %s field must contain a date that\'s %s or older.'
        },
        callback: function(errors) {

        }
    };

    var ruleRegex = /^(.+?)\[(.+)\]$/,
        numericRegex = /^[0-9]+$/,
        integerRegex = /^\-?[0-9]+$/,
        decimalRegex = /^\-?[0-9]*\.?[0-9]+$/,
        emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,
        alphaRegex = /^[a-z]+$/i,
        alphaNumericRegex = /^[a-z0-9]+$/i,
        alphaDashRegex = /^[a-z0-9_\-]+$/i,
        naturalRegex = /^[0-9]+$/i,
        naturalNoZeroRegex = /^[1-9][0-9]*$/i,
        ipRegex = /^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[0-9]{1,2})\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[0-9]{1,2})$/i,
        base64Regex = /[^a-zA-Z0-9\/\+=]/i,
        numericDashRegex = /^[\d\-\s]+$/,
        urlRegex = /^((http|https):\/\/(\w+:{0,1}\w*@)?(\S+)|)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/,
        dateRegex = /\d{4}-\d{1,2}-\d{1,2}/;

    ... //省略後面的代碼
})(window,document);
/*
 * Export as a CommonJS module
 */
if (typeof module !== 'undefined' && module.exports) {
    module.exports = FormValidator;
}

當然,參數既然是默認的,那就意味着我們可以隨意修改參數以達到我們的需求。插件本身的意義就在於具有複用性。
如表單驗證插件,則就可以new一個對象的時候,修改我們的默認參數:

var validator = new FormValidator('example_form', [{
    name: 'req',
    display: 'required',
    rules: 'required'
}, {
    name: 'alphanumeric',
    rules: 'alpha_numeric'
}, {
    name: 'password',
    rules: 'required'
}, {
    name: 'password_confirm',
    display: 'password confirmation',
    rules: 'required|matches[password]'
}, {
    name: 'email',
    rules: 'valid_email'
}, {
    name: 'minlength',
    display: 'min length',
    rules: 'min_length[8]'
}, {
    names: ['fname', 'lname'],
    rules: 'required|alpha'
}], function(errors) {
    if (errors.length > 0) {
        // Show the errors
    }
});

插件的鉤子

我們知道,設計一下插件,參數或者其邏輯肯定不是寫死的,我們得像函數一樣,得讓用戶提供自己的參數去實現用戶的需求。則我們的插件需要提供一個修改默認參數的入口。
如上面我們說的修改默認參數,實際上也是插件給我們提供的一個API。讓我們的插件更加的靈活。如果大家對API不瞭解,可以百度一下API
通常我們用的js插件,實現的方式會有多種多樣的。最簡單的實現邏輯就是一個方法,或者一個js對象,又或者是一個構造函數等等。
** 然我們插件所謂的API,實際就是我們插件暴露出來的所有方法及屬性。 **
我們需求中,加減乘除餘插件中,我們的API就是如下幾個方法:

...
var plugin = {
    add: function(n1,n2){ return n1 + n2; },
    sub: function(n1,n2){ return n1 - n2; },
    mul: function(n1,n2){ return n1 * n2; },
    div: function(n1,n2){ return n1 / n2; },
    sur: function(n1,n2){ return n1 % n2; } 
}
...

可以看到plubin暴露出來的方法則是如下幾個API:

  • add
  • sub
  • mul
  • div
  • sur

在插件的API中,我們常常將容易被修改和變動的方法或屬性統稱爲鉤子(Hook),方法則直接叫鉤子函數。這是一種形象生動的說法,就好像我們在一條繩子上放很多掛鉤,我們可以按需要在上面掛東西。
實際上,我們即知道插件可以像一條繩子上掛東西,也可以拿掉掛的東西。那麼一個插件,實際上就是個形象上的。不過我們上面的所有鉤子都是掛在對象上的,用於實現鏈並不是很理想。

插件的鏈式調用(利用當前對象)

插件並非都是能鏈式調用的,有些時候,我們只是用鉤子來實現一個計算並返回結果,取得運算結果就可以了。但是有些時候,我們用鉤子並不需要其返回結果。我們只利用其實現我們的業務邏輯,爲了代碼簡潔與方便,我們常常將插件的調用按鏈式的方式進行調用。
最常見的jquery的鏈式調用如下:

$(<id>).show().css('color','red').width(100).height(100)....

那,如何才能將鏈式調用運用到我們的插件中去呢?假設我們上面的例子,如果是要按照plugin這個對象的鏈式進行調用,則可以將其業務結構改爲:

...
var plugin = {
    add: function(n1,n2){ return this; },
    sub: function(n1,n2){ return this; },
    mul: function(n1,n2){ return this; },
    div: function(n1,n2){ return this; },
    sur: function(n1,n2){ return this; } 
}
...

顯示,我們只要將插件的當前對象this直接返回,則在下一下方法中,同樣可以引用插件對象plugin的其它勾子方法。然後調用的時候就可以使用鏈式了。

plugin.add().sub().mul().div().sur()  //如此調用顯然沒有任何實際意義

顯然這樣做並沒有什麼意義。我們這裏的每一個鉤子函數都只是用來計算並且獲取返回值而已。而鏈式調用本身的意義是用來處理業務邏輯的。

插件的鏈式調用(利用原型鏈)

JavaScript中,萬物皆對象,所有對象都是繼承自原型。JS在創建對象(不論是普通對象還是函數對象)的時候,都有一個叫做__proto__的內置屬性,用於指向創建它的函數對象的原型對象prototype。關於原型問題,感興趣的同學可以看這篇:js原型鏈
在上面的需求中,我們可以將plugin對象改爲原型的方式,則需要將plugin寫成一個構造方法,我們將插件名換爲Calculate避免因爲Plugin大寫的時候與Window對象中的API衝突。

...
function Calculate(){}
Calculate.prototype.add = function(){return this;}
Calculate.prototype.sub = function(){return this;}
Calculate.prototype.mul = function(){return this;}
Calculate.prototype.div = function(){return this;}
Calculate.prototype.sur = function(){return this;}
...

當然,假設我們的插件是對初始化參數進行運算並只輸出結果,我們可以稍微改一下:

// plugin.js
// plugin.js
;(function(undefined) {
    "use strict"
    var _global;

    function result(args,type){
        var argsArr = Array.prototype.slice.call(args);
        if(argsArr.length == 0) return 0;
        switch(type) {
            case 1: return argsArr.reduce(function(p,c){return p + c;});
            case 2: return argsArr.reduce(function(p,c){return p - c;});
            case 3: return argsArr.reduce(function(p,c){return p * c;});
            case 4: return argsArr.reduce(function(p,c){return p / c;});
            case 5: return argsArr.reduce(function(p,c){return p % c;});
            default: return 0;
        }
    }

    function Calculate(){}
    Calculate.prototype.add = function(){console.log(result(arguments,1));return this;}
    Calculate.prototype.sub = function(){console.log(result(arguments,2));return this;}
    Calculate.prototype.mul = function(){console.log(result(arguments,3));return this;}
    Calculate.prototype.div = function(){console.log(result(arguments,4));return this;}
    Calculate.prototype.sur = function(){console.log(result(arguments,5));return this;}


    // 最後將插件對象暴露給全局對象
    _global = (function(){ return this || (0, eval)('this'); }());
    if (typeof module !== "undefined" && module.exports) {
        module.exports = Calculate;
    } else if (typeof define === "function" && define.amd) {
        define(function(){return Calculate;});
    } else {
        !('Calculate' in _global) && (_global.Calculate = Calculate);
    }
}());

這時調用我們寫好的插件,則輸出爲如下:

var plugin = new Calculate();
plugin
    .add(2,1)
    .sub(2,1)
    .mul(2,1)
    .div(2,1)
    .sur(2,1);
// 結果:
// 3
// 1
// 2
// 2
// 0

上面的例子,可以並沒有太多的現實意義。不過在網頁設計中,我們的插件基本上都是服務於UI層面,利用js腳本實現一些可交互的效果。這時我們編寫一個UI插件,實現過程也是可以使用鏈式進行調用。

編寫UI組件

一般情況,如果一個js僅僅是處理一個邏輯,我們稱之爲插件,但如果與dom和css有關係並且具備一定的交互性,一般叫做組件。當然這沒有什麼明顯的區分,只是一種習慣性叫法。
利用原型鏈,可以將一些UI層面的業務代碼封裝在一個小組件中,並利用js實現組件的交互性。
現有一個這樣的需求:

  1. 實現一個彈層,此彈層可以顯示一些文字提示性的信息;
  2. 彈層右上角必須有一個關閉按扭,點擊之後彈層消失;
  3. 彈層底部必有一個“確定”按扭,然後根據需求,可以配置多一個“取消”按扭;
  4. 點擊“確定”按扭之後,可以觸發一個事件;
  5. 點擊關閉/“取消”按扭後,可以觸發一個事件。

根據需求,我們先寫出dom結構:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>index</title>
    <link rel="stylesheet" type="text/css" href="index.css">
</head>
<body>
    <div class="mydialog">
        <span class="close">×</span>
        <div class="mydialog-cont">
            <div class="cont">hello world!</div>
        </div>
        <div class="footer">
            <span class="btn">確定</span>
            <span class="btn">取消</span>
        </div>
    </div>
    <script src="index.js"></script>
</body>
</html>

寫出css結構:

* { padding: 0; margin: 0; }
.mydialog { background: #fff; box-shadow: 0 1px 10px 0 rgba(0, 0, 0, 0.3); overflow: hidden; width: 300px; height: 180px; border: 1px solid #dcdcdc; position: absolute; top: 0; right: 0; bottom: 0; left: 0; margin: auto; }
.close { position: absolute; right: 5px; top: 5px; width: 16px; height: 16px; line-height: 16px; text-align: center; font-size: 18px; cursor: pointer; }
.mydialog-cont { padding: 0 0 50px; display: table; width: 100%; height: 100%; }
.mydialog-cont .cont { display: table-cell; text-align: center; vertical-align: middle; width: 100%; height: 100%; }
.footer { display: table; table-layout: fixed; width: 100%; position: absolute; bottom: 0; left: 0; border-top: 1px solid #dcdcdc; }
.footer .btn { display: table-cell; width: 50%; height: 50px; line-height: 50px; text-align: center; cursor: pointer; }
.footer .btn:last-child { display: table-cell; width: 50%; height: 50px; line-height: 50px; text-align: center; cursor: pointer; border-left: 1px solid #dcdcdc; }

接下來,我們開始編寫我們的交互插件。
我們假設組件的彈出層就是一個對象。則這個對象是包含了我們的交互、樣式、結構及渲染的過程。於是我們定義了一個構造方法:

function MyDialog(){} // MyDialog就是我們的組件對象了

對象MyDialog就相當於一個繩子,我們只要往這個繩子上不斷地掛上鉤子就是一個組件了。於是我們的組件就可以表示爲:

function MyDialog(){}
MyDialog.prototype = {
    constructor: this,
    _initial: function(){},
    _parseTpl: function(){},
    _parseToDom: function(){},
    show: function(){},
    hide: function(){},
    css: function(){},
    ...
}

然後就可以將插件的功能都寫上。不過中間的業務邏輯,需要自己去一步一步研究。無論如何寫,我們最終要做到通過實例化一個MyDialog對象就可以使用我們的插件了。
在編寫的過程中,我們得先做一些工具函數:

1.對象合併函數

// 對象合併
function extend(o,n,override) {
    for(var key in n){
        if(n.hasOwnProperty(key) && (!o.hasOwnProperty(key) || override)){
            o[key]=n[key];
        }
    }
    return o;
}

2.自定義模板引擎解釋函數

// 自定義模板引擎
function templateEngine(html, data) {
    var re = /<%([^%>]+)?%>/g,
        reExp = /(^( )?(if|for|else|switch|case|break|{|}))(.*)?/g,
        code = 'var r=[];\n',
        cursor = 0;
    var match;
    var add = function(line, js) {
        js ? (code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n') :
            (code += line != '' ? 'r.push("' + line.replace(/"/g, '\\"') + '");\n' : '');
        return add;
    }
    while (match = re.exec(html)) {
        add(html.slice(cursor, match.index))(match[1], true);
        cursor = match.index + match[0].length;
    }
    add(html.substr(cursor, html.length - cursor));
    code += 'return r.join("");';
    return new Function(code.replace(/[\r\t\n]/g, '')).apply(data);
}

3.查找class獲取dom函數

// 通過class查找dom
if(!('getElementsByClass' in HTMLElement)){
    HTMLElement.prototype.getElementsByClass = function(n, tar){
        var el = [],
            _el = (!!tar ? tar : this).getElementsByTagName('*');
        for (var i=0; i<_el.length; i++ ) {
            if (!!_el[i].className && (typeof _el[i].className == 'string') && _el[i].className.indexOf(n) > -1 ) {
                el[el.length] = _el[i];
            }
        }
        return el;
    };
    ((typeof HTMLDocument !== 'undefined') ? HTMLDocument : Document).prototype.getElementsByClass = HTMLElement.prototype.getElementsByClass;
}

結合工具函數,再去實現每一個鉤子函數具體邏輯結構:

// plugin.js
;(function(undefined) {
    "use strict"
    var _global;

    ...

    // 插件構造函數 - 返回數組結構
    function MyDialog(opt){
        this._initial(opt);
    }
    MyDialog.prototype = {
        constructor: this,
        _initial: function(opt) {
            // 默認參數
            var def = {
                ok: true,
                ok_txt: '確定',
                cancel: false,
                cancel_txt: '取消',
                confirm: function(){},
                close: function(){},
                content: '',
                tmpId: null
            };
            this.def = extend(def,opt,true);
            this.tpl = this._parseTpl(this.def.tmpId);
            this.dom = this._parseToDom(this.tpl)[0];
            this.hasDom = false;
        },
        _parseTpl: function(tmpId) { // 將模板轉爲字符串
            var data = this.def;
            var tplStr = document.getElementById(tmpId).innerHTML.trim();
            return templateEngine(tplStr,data);
        },
        _parseToDom: function(str) { // 將字符串轉爲dom
            var div = document.createElement('div');
            if(typeof str == 'string') {
                div.innerHTML = str;
            }
            return div.childNodes;
        },
        show: function(callback){
            var _this = this;
            if(this.hasDom) return ;
            document.body.appendChild(this.dom);
            this.hasDom = true;
            document.getElementsByClass('close',this.dom)[0].onclick = function(){
                _this.hide();
            };
            document.getElementsByClass('btn-ok',this.dom)[0].onclick = function(){
                _this.hide();
            };
            if(this.def.cancel){
                document.getElementsByClass('btn-cancel',this.dom)[0].onclick = function(){
                    _this.hide();
                };
            }
            callback && callback();
            return this;
        },
        hide: function(callback){
            document.body.removeChild(this.dom);
            this.hasDom = false;
            callback && callback();
            return this;
        },
        modifyTpl: function(template){
            if(!!template) {
                if(typeof template == 'string'){
                    this.tpl = template;
                } else if(typeof template == 'function'){
                    this.tpl = template();
                } else {
                    return this;
                }
            }
            // this.tpl = this._parseTpl(this.def.tmpId);
            this.dom = this._parseToDom(this.tpl)[0];
            return this;
        },
        css: function(styleObj){
            for(var prop in styleObj){
                var attr = prop.replace(/[A-Z]/g,function(word){
                    return '-' + word.toLowerCase();
                });
                this.dom.style[attr] = styleObj[prop];
            }
            return this;
        },
        width: function(val){
            this.dom.style.width = val + 'px';
            return this;
        },
        height: function(val){
            this.dom.style.height = val + 'px';
            return this;
        }
    }

    _global = (function(){ return this || (0, eval)('this'); }());
    if (typeof module !== "undefined" && module.exports) {
        module.exports = MyDialog;
    } else if (typeof define === "function" && define.amd) {
        define(function(){return MyDialog;});
    } else {
        !('MyDialog' in _global) && (_global.MyDialog = MyDialog);
    }
}());

到這一步,我們的插件已經達到了基礎需求了。我們可以在頁面這樣調用:

<script type="text/template" id="dialogTpl">
    <div class="mydialog">
        <span class="close">×</span>
        <div class="mydialog-cont">
            <div class="cont"><% this.content %></div>
        </div>
        <div class="footer">
            <% if(this.cancel){ %>
            <span class="btn btn-ok"><% this.ok_txt %></span>
            <span class="btn btn-cancel"><% this.cancel_txt %></span>
            <% } else{ %>
            <span class="btn btn-ok" style="width: 100%"><% this.ok_txt %></span>
            <% } %>
        </div>
    </div>
</script>
<script src="index.js"></script>
<script>
    var mydialog = new MyDialog({
        tmpId: 'dialogTpl',
        cancel: true,
        content: 'hello world!'
    });
    mydialog.show();
</script>

插件的監聽

彈出框插件我們已經實現了基本的顯示與隱藏的功能。不過我們在怎麼時候彈出,彈出之後可能進行一些操作,實際上還是需要進行一些可控的操作。就好像我們進行事件綁定一樣,只有用戶點擊了按扭,才響應具體的事件。那麼,我們的插件,應該也要像事件綁定一樣,只有執行了某些操作的時候,調用相應的事件響應。
這種js的設計模式,被稱爲 訂閱/發佈模式,也被叫做 觀察者模式。我們插件中的也需要用到觀察者模式,比如,在打開彈窗之前,我們需要先進行彈窗的內容更新,執行一些判斷邏輯等,然後執行完成之後才顯示出彈窗。在關閉彈窗之後,我們需要執行關閉之後的一些邏輯,處理業務等。這時候我們需要像平時綁定事件一樣,給插件做一些“事件”綁定回調方法。
我們jquery對dom的事件響應是這樣的:

$(<dom>).on("click",function(){})

我們照着上面的方式設計了對應的插件響應是這樣的:

mydialog.on('show',function(){})

則,我們需要實現一個事件機制,以達到監聽插件的事件效果。關於自定義事件監聽,可以參考一篇博文:漫談js自定義事件、DOM/僞DOM自定義事件。在此不進行大篇幅講自定義事件的問題。
最終我們實現的插件代碼爲:

// plugin.js
;(function(undefined) {
    "use strict"
    var _global;

    // 工具函數
    // 對象合併
    function extend(o,n,override) {
        for(var key in n){
            if(n.hasOwnProperty(key) && (!o.hasOwnProperty(key) || override)){
                o[key]=n[key];
            }
        }
        return o;
    }
    // 自定義模板引擎
    function templateEngine(html, data) {
        var re = /<%([^%>]+)?%>/g,
            reExp = /(^( )?(if|for|else|switch|case|break|{|}))(.*)?/g,
            code = 'var r=[];\n',
            cursor = 0;
        var match;
        var add = function(line, js) {
            js ? (code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n') :
                (code += line != '' ? 'r.push("' + line.replace(/"/g, '\\"') + '");\n' : '');
            return add;
        }
        while (match = re.exec(html)) {
            add(html.slice(cursor, match.index))(match[1], true);
            cursor = match.index + match[0].length;
        }
        add(html.substr(cursor, html.length - cursor));
        code += 'return r.join("");';
        return new Function(code.replace(/[\r\t\n]/g, '')).apply(data);
    }
    // 通過class查找dom
    if(!('getElementsByClass' in HTMLElement)){
        HTMLElement.prototype.getElementsByClass = function(n){
            var el = [],
                _el = this.getElementsByTagName('*');
            for (var i=0; i<_el.length; i++ ) {
                if (!!_el[i].className && (typeof _el[i].className == 'string') && _el[i].className.indexOf(n) > -1 ) {
                    el[el.length] = _el[i];
                }
            }
            return el;
        };
        ((typeof HTMLDocument !== 'undefined') ? HTMLDocument : Document).prototype.getElementsByClass = HTMLElement.prototype.getElementsByClass;
    }

    // 插件構造函數 - 返回數組結構
    function MyDialog(opt){
        this._initial(opt);
    }
    MyDialog.prototype = {
        constructor: this,
        _initial: function(opt) {
            // 默認參數
            var def = {
                ok: true,
                ok_txt: '確定',
                cancel: false,
                cancel_txt: '取消',
                confirm: function(){},
                close: function(){},
                content: '',
                tmpId: null
            };
            this.def = extend(def,opt,true); //配置參數
            this.tpl = this._parseTpl(this.def.tmpId); //模板字符串
            this.dom = this._parseToDom(this.tpl)[0]; //存放在實例中的節點
            this.hasDom = false; //檢查dom樹中dialog的節點是否存在
            this.listeners = []; //自定義事件,用於監聽插件的用戶交互
            this.handlers = {};
        },
        _parseTpl: function(tmpId) { // 將模板轉爲字符串
            var data = this.def;
            var tplStr = document.getElementById(tmpId).innerHTML.trim();
            return templateEngine(tplStr,data);
        },
        _parseToDom: function(str) { // 將字符串轉爲dom
            var div = document.createElement('div');
            if(typeof str == 'string') {
                div.innerHTML = str;
            }
            return div.childNodes;
        },
        show: function(callback){
            var _this = this;
            if(this.hasDom) return ;
            if(this.listeners.indexOf('show') > -1) {
                if(!this.emit({type:'show',target: this.dom})) return ;
            }
            document.body.appendChild(this.dom);
            this.hasDom = true;
            this.dom.getElementsByClass('close')[0].onclick = function(){
                _this.hide();
                if(_this.listeners.indexOf('close') > -1) {
                    _this.emit({type:'close',target: _this.dom})
                }
                !!_this.def.close && _this.def.close.call(this,_this.dom);
            };
            this.dom.getElementsByClass('btn-ok')[0].onclick = function(){
                _this.hide();
                if(_this.listeners.indexOf('confirm') > -1) {
                    _this.emit({type:'confirm',target: _this.dom})
                }
                !!_this.def.confirm && _this.def.confirm.call(this,_this.dom);
            };
            if(this.def.cancel){
                this.dom.getElementsByClass('btn-cancel')[0].onclick = function(){
                    _this.hide();
                    if(_this.listeners.indexOf('cancel') > -1) {
                        _this.emit({type:'cancel',target: _this.dom})
                    }
                };
            }
            callback && callback();
            if(this.listeners.indexOf('shown') > -1) {
                this.emit({type:'shown',target: this.dom})
            }
            return this;
        },
        hide: function(callback){
            if(this.listeners.indexOf('hide') > -1) {
                if(!this.emit({type:'hide',target: this.dom})) return ;
            }
            document.body.removeChild(this.dom);
            this.hasDom = false;
            callback && callback();
            if(this.listeners.indexOf('hidden') > -1) {
                this.emit({type:'hidden',target: this.dom})
            }
            return this;
        },
        modifyTpl: function(template){
            if(!!template) {
                if(typeof template == 'string'){
                    this.tpl = template;
                } else if(typeof template == 'function'){
                    this.tpl = template();
                } else {
                    return this;
                }
            }
            this.dom = this._parseToDom(this.tpl)[0];
            return this;
        },
        css: function(styleObj){
            for(var prop in styleObj){
                var attr = prop.replace(/[A-Z]/g,function(word){
                    return '-' + word.toLowerCase();
                });
                this.dom.style[attr] = styleObj[prop];
            }
            return this;
        },
        width: function(val){
            this.dom.style.width = val + 'px';
            return this;
        },
        height: function(val){
            this.dom.style.height = val + 'px';
            return this;
        },
        on: function(type, handler){
            // type: show, shown, hide, hidden, close, confirm
            if(typeof this.handlers[type] === 'undefined') {
                this.handlers[type] = [];
            }
            this.listeners.push(type);
            this.handlers[type].push(handler);
            return this;
        },
        off: function(type, handler){
            if(this.handlers[type] instanceof Array) {
                var handlers = this.handlers[type];
                for(var i = 0, len = handlers.length; i < len; i++) {
                    if(handlers[i] === handler) {
                        break;
                    }
                }
                this.listeners.splice(i, 1);
                handlers.splice(i, 1);
                return this;
            }
        },
        emit: function(event){
            if(!event.target) {
                event.target = this;
            }
            if(this.handlers[event.type] instanceof Array) {
                var handlers = this.handlers[event.type];
                for(var i = 0, len = handlers.length; i < len; i++) {
                    handlers[i](event);
                    return true;
                }
            }
            return false;
        }
    }

    // 最後將插件對象暴露給全局對象
    _global = (function(){ return this || (0, eval)('this'); }());
    if (typeof module !== "undefined" && module.exports) {
        module.exports = MyDialog;
    } else if (typeof define === "function" && define.amd) {
        define(function(){return MyDialog;});
    } else {
        !('MyDialog' in _global) && (_global.MyDialog = MyDialog);
    }
}());

然後調用的時候就可以直接使用插件的事件綁定了。

var mydialog = new MyDialog({
    tmpId: 'dialogTpl',
    cancel: true,
    content: 'hello world!'
});
mydialog.on('confirm',function(ev){
    console.log('you click confirm!');
    // 寫你的確定之後的邏輯代碼...
});
document.getElementById('test').onclick = function(){
    mydialog.show();
}

給出此例子的demo,有需要具體實現的同學可以去查閱。

插件發佈

我們寫好了插件,實際上還可以將我們的插件發佈到開源組織去分享給更多人去使用(代碼必須是私人擁有所有支配權限)。我們將插件打包之後,就可以發佈到開源組織上去供別人下載使用了。
我們熟知的npm社區就是一個非常良好的發佈插件的平臺。具體可以如下操作:
寫初始化包的描述文件:

$ npm init

註冊包倉庫帳號

$ npm adduser
Username: <帳號>
Password: <密碼>
Email:(this IS public) <郵箱>
Logged in as <帳號> on https://registry.npmjs.org/.

上傳包

$ npm publish

安裝包

$ npm install mydialog

到此,我們的插件就可以直接被更多人去使用了。

結論

寫了這麼多,比較囉嗦,我在此做一下總結:
關於如何編寫出一個好的js原生插件,需要平時在使用別人的插件的同時,多查看一下api文檔,瞭解插件的調用方式,然後再看一下插件的源碼的設計方式。基本上我們可以確定大部分插件都是按照原型的方式進行設計的。而我從上面的例子中,就使用了好多js原生的知識點,函數的命名衝突、閉包、作用域,自定義工具函數擴展對象的鉤子函數,以及對象的初始化、原型鏈繼承,構造函數的定義及設計模式,還有事件的自定義,js設計模式的觀察者模式等知識。這些內容還是需要初學者多多瞭解才能進行一些高層次一些的插件開發。

原文地址:https://www.cnblogs.com/Longhua-0/p/9271625.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章