JavaScript 設計模式學習第二十八篇- 鏈模式

通常情況下,通過對構造函數使用 new 會返回一個綁定到 this上的新實例,所以我們可以在 new 出來的對象上直接用 . 訪問其屬性和方法。如果在普通函數中也返回當前實例,那麼我們就可以使用 . 在單行代碼中一次性連續調用多個方法,就好像它們被鏈接在一起一樣,這就是鏈式調用,又稱鏈模式。

之前建造者模式、組合模式等文章已經用到了鏈模式,日常使用的 jQuery、Promise 等也使用了鏈模式,我們對使用形式已經很熟悉了,下面一起來看看鏈模式的原理。

1. 什麼是鏈模式

1.1. 鏈模式的實現

在 jQuery 時代,下面這樣的用法我們很熟悉了:

// 使用鏈模式
$('div')
    .show()
    .addClass('active')
    .height('100px')
    .css('color', 'red')
    .on('click', function (e) {
        // ... 
    })

這就是很典型的鏈模式,對 jQuery 選擇器選擇的元素從上到下依次進行一系列操作,如果不使用鏈模式,則代碼如下:

// 不使用鏈模式
var divEls = $('div')
divEls.show()
divEls.addClass('active')
divEls.height('100px')
divEls.css('color', 'red')
divEls.on('click', function (e) {
    // ... 
})

可以看到不使用鏈模式,代碼量多了,代碼結構也複雜了不少。鏈模式是 jQuery 的一個重要特性,也是 jQuery 深受大家喜愛,並且經久不衰的原因之一。

鏈模式和一般的函數調用的區別在於:鏈模式一般會在調用完方法之後返回一個對象,有時則直接返回 this,因此又可以繼續調用這個對象上的其他方法,這樣可以對同一個對象連續執行多個方法。

比如這裏我們可以自己實現一個鏈模式:

// 四邊形 
var rectangle = {
    // 長
    length: null, 
    // 寬 
    width: null,  
    // 顏色 
    color: null,   

    getSize: function () {
        console.log(`length: ${this.length}, width: ${this.width}, color: ${this.color}`)
    },

    // 設置長度
    setLength: function (length) {
        this.length = length
        return this
    },

    // 設置寬度
    setWidth: function (width) {
        this.width = width
        return this
    },

    // 設置顏色
    setColor: function (color) {
        this.color = color
        return this
    }
}

var rect = rectangle.setLength('100px').setWidth('80px').setColor('blue').getSize()

// length: 100px, width: 80px, color: blue

由於所有對象都會繼承其原型對象的屬性和方法,所以我們可以讓原型方法都返回該原型的實例對象,這樣就可以對那些方法進行鏈式調用了:

// 四邊形
function Rectangle() {
    // 長
    this.length = null  
    // 寬 
    this.width = null  
    // 顏色  
    this.color = null    
}

// 設置長度 
Rectangle.prototype.setLength = function(length) {
    this.length = length
    return this
}

// 設置寬度
Rectangle.prototype.setWidth = function(width) {
    this.width = width
    return this
}

// 設置顏色 
Rectangle.prototype.setColor = function(color) {
    this.color = color
    return this
}

var rect = new Rectangle().setLength('100px').setWidth('80px').setColor('blue')
console.log(rect)

// {length: "100px", width: "80px", color: "blue"}

使用 Class 語法改造一下:

// 四邊形
class Rectangle {
    constructor() {
        // 長
        this.length = null   
        // 寬
        this.width = null  
        // 顏色 
        this.color = null   
    }

    // 設置長度 
    setLength(length) {
        this.length = length
        return this
    }

    // 設置寬度
    setWidth(width) {
        this.width = width
        return this
    }

    // 設置顏色
    setColor(color) {
        this.color = color
        return this
    }
}

const rect = new Rectangle().setLength('100px').setWidth('80px').setColor('blue')

console.log(rect)
// {length: "100px", width: "80px", color: "blue"}

1.2. 鏈模式不一定必須返回 this

在方法中不一定 return this,也可以返回其他對象,這樣後面的方法可以對這個新對象進行其他操作。比如在 Promise 的實現中,每次 then方法返回的就不是 this,而是一個新的 Promise,只不過其外觀一樣,所以我們可以不斷 then下去。後面的每一個 then都不是從最初的 Promise 實例點出來的,而是從前一個 then返回的新的 Promise 實例點出來的。

const promise1 = new Promise((resolve, reject) => {
    setTimeout(() => {
        console.log('Promise 1 resolved')
        resolve()
    }, 500)
})

const promise2 = promise1.then(() => {
    console.log('Then method')
})

console.log(promise1 === promise2)
// false

jQuery 中有一個有意思的方法 end(),是將匹配的元素還原爲之前一次的狀態,此時返回的也不是 this,然後可以在返回的之前一次匹配的狀態後繼續進行鏈模式:

// html: <p><span>Hello</span>,how are you?</p>

$("p")                   // 選擇所有 p 標籤         
    .find("span")        // 選擇了 p 標籤下的 span 標籤        
    .css('color', 'red')
    .end()               // 返回之前匹配的 p 標籤              
    .css('color', 'blue')

效果參見 CodePen - jQuery 中的 end 方法

事實上,某些原生的方法就可以使用鏈模式,以數組操作爲例,比如我們想查看一個數組中奇數的平方和:

[1, 2, 3, 4, 5, 6]
    .filter(num => num % 2)
    .map(num => num * num)
    .reduce((pre, curr) => pre + curr, 0)
    // 35

那麼這裏爲什麼可以使用鏈模式呢,是因爲 filter、map、reduce 這些數組方法返回的仍然是數組,因此可以繼續在後面調用數組的方法。

注意,並不是所有數組方法都返回數組,比如 push 的時候返回的是新數組的 length 屬性。

 

2. 實戰使用鏈模式

有時候 JavaScript 原生提供的方法不太好用,比如我們希望創建下面這樣一個 DOM 樹結構:

<ul id='data-list'>
    <li class='data-item'>li-item 1</li>
    <li class='data-item'>li-item 2</li>
    <li class='data-item'>li-item 3</li>
</ul>

如果使用原生方法,由於 setAttribute 等方法並沒有返回原對象,我們需要如下操作來實現。

const ul = document.createElement('ul')
ul.setAttribute('id', 'data-list')

const li1 = document.createElement('li')
const li2 = document.createElement('li')
const li3 = document.createElement('li')

li1.setAttribute('id', 'data-item')
li2.setAttribute('id', 'data-item')
li3.setAttribute('id', 'data-item')

const text1 = document.createTextNode('li-item 1')
const text2 = document.createTextNode('li-item 2')
const text3 = document.createTextNode('li-item 3')

li1.appendChild(text1)
li2.appendChild(text2)
li3.appendChild(text3)

ul.appendChild(li1)
ul.appendChild(li2)
ul.appendChild(li3)

太不直觀了,步驟零散且可維護性差。

這時我們可以改造一下,可以使用類似於組合模式一文第 4 小節<實戰中的組合模式>中那樣直接傳遞一個所需的對應 DOM 樹的對象樹,再根據這個對象樹來逐層生成 DOM。這裏我們可以徹底使用鏈模式來改造一下原生方法:

const createElement = function (tag) {
    return tag === 'text'
        ? document.createTextNode(tag)
        : document.createElement(tag)
}

HTMLElement.prototype._setAttribute = function (key, value) {
    this.setAttribute(key, value)
    return this
}

HTMLElement.prototype._appendChild = function (child) {
    this.appendChild(child)
    return this
}

createElement('ul')
    ._setAttribute('id', 'data-list')
    ._appendChild(
        createElement('li')
            ._setAttribute('class', 'data-item')
            ._appendChild('text', 'li-item 1'))
    ._appendChild(
        createElement('li')
            ._setAttribute('class', 'data-item')
            ._appendChild('text', 'li-item 2'))
    ._appendChild(
        createElement('li')
            ._setAttribute('class', 'data-item')
            ._appendChild('text', 'li-item 3'))

這樣就比較徹底地使用了鏈模式來生成 DOM 結構樹了,你可能感覺有點奇怪,但是如果你使用過 vue-cli3,那麼你可能對這個配置方式很熟悉。

 

3. 源碼中的鏈模式

3.1. jQuery 中的鏈模式

1. jQuery 構造函數

jQuery 方法看似複雜,可以簡寫如下:

var jQuery = function (selector, context) {
    // jQuery 方法返回的是 jQuery.fn.init 所 new 出來的對象
    return new jQuery.fn.init(selector, context, rootjQuery)
}

jQuery.fn = jQuery.prototype = {
    constructor: jQuery,
    // jQuery 對象的構造函數
    init: function (selector, context, rootjQuery) {
        // 一頓匹配操作,返回一個拼裝好的僞數組的自身實例
        // 是 jQuery.fn.init 的實例,也就是我們常用的 jQuery 對象
        return this
    },
    selector: '',
    eq: function () {},
    end: function() {},
    map: function() {},
    last: function() {},
    first: function() {},
    // 其他方法
}

// jQuery.fn.init 的實例都擁有 jQuery.fn 相應的方法
jQuery.fn.init.prototype = jQuery.fn

此處源碼位於 src/core.js

return new jQuery.fn.init() 這句看似複雜,其實也就是下面的這個 init 方法,這個方法最後返回的是我們常用的 jQuery對象,下面還有一句 jQuery.fn.init.prototype = jQuery.fn,因此最上面的 jQuery 方法返回的 new出來的 jQuery.fn.init 實例將繼承 jQuery.fn 上的方法:

const p = $("<p/>")
$.fn === p.__proto__   
// true

因此返回出來的實例也將繼承 eq、end、map、last等 jQuery.fn上的方法。

2. jQuery 實例方法

下面我們一起看看,show、hide、toggle 這些方法是如何實現鏈模式的呢 :

jQuery.fn.extend({
    show: function () {
        var elem;
        for (i = 0; i < this.length; i++) {
            elem = this[i]
            if (elem.style.display === 'none') {
                elem.style.display = 'block'
            }
        }
        return this
    },
    hide: function () {},
    toggle: function () {}
})

此處源碼位於 src/effects.js,代碼示例見 CodePen - jQuery中的show

這裏首先使用了一個方法 jQuery.fn.extend(),簡單看一下這個方法做啥的:

jQuery.extend = jQuery.fn.extend = function(options) {
    // 一系列囉囉嗦嗦的判斷
    for (name in options) {
        // 此處 this === jQuery.fn
        this[name] = options[ name ]  
    }
}

此處源碼位於 src/core.js

這個方法就是把傳參的對象的值賦值給 jQuery.fn,因爲這時候這個方法是通過上下文對象 jQuery.fn.extend()方式來調用,屬於隱式綁定。

以 show 方法爲例,此時這個方法被賦到 jQuery.fn 對象上,而通過上文我們知道,jQuery.fn.init.prototype = jQuery.fn,而 jQuery.fn.init這個方法是作爲構造函數被 jQuery 函數 new 出來並返回,因此 show方法此時可以被 jQuery.fn.init實例訪問到,也就可以被 $('selector')訪問到,因此此時我們已經可以: $('p').show()了。

那麼我們再回頭來看看 show 方法的實現,show 方法將匹配的元素的 display 置爲 block 之後返回了 this。注意了,此時的 this 也是隱式綁定,而且是通過 $('p') 點出來的,因此返回的值就是 $('p')  的引用。

經過以上步驟,我們知道 show 方法返回的仍然是 $('p')的引用,我們可以繼續在之後點出來其他 jQuery.fn 對象上的方法,css、hide、toggle、addClass、on 等等方法同理,至此,jQuery 的鏈模式就形成了。

3.2. Underscore 中的鏈模式

如果你用過 Underscore,那麼你可能知道 Underscore 提供的一個鏈模式實現 _.chain。通過這個方法,可以方便地使用 Underscore 提供的一些方法鏈模式地對數據進行處理。另外,Lodash 的 chain 實現和 Underscore 的基本一樣,可以自行去 Lodash 的 GitHub 倉庫 閱讀。

比如這裏我們需要對一個用戶對象數組進行一系列操作,首先按年齡排序,去掉年齡爲奇數的人,再將這些用戶的名字列成數組:

var users = [
    { 'name': 'barney', 'age': 26 },
    { 'name': 'fred', 'age': 21 },
    { 'name': 'pebbles', 'age': 28 },
    { 'name': 'negolas', 'age': 23 }
]

_.chain(users)
    .sortBy('age')
    .reject(user => user.age % 2)
    .map(user => user.name)
    .value()

// ["barney", "pebbles"]

經過 _.chain 方法處理後,就可以使用 Underscore 提供的其他方法對這個數據進行操作,下面一起來看看源碼是如何實現鏈模式。

首先是 _.chain 方法:

_.chain = function(obj) {
    // 獲得一個經 underscore 包裹後的實例
    var instance = _(obj)  
    // 標記是否使用鏈式操作
    instance._chain = true 
    return instance
}

此處源碼位於 underscore.js#L1615-L1619

這裏通過 _(obj) 的方式把數據進行了包裝,並返回了一個對象,結構如下:

{
    _chain: true,
    _wrapped: [...],
    __proto__:  ...
}

返回的對象的隱式原型可以訪問到 Undersocre 提供的很多方法,如下圖:

圖片描述

這個 chain 方法的作用就是創建一個包裹了 obj 的 Underscore 實例對象,並標記該實例是使用鏈模式,最後返回這個包裝好的鏈式化實例(叫鏈式化是因爲可以繼續調用 underscore 上的方法)。

我們一起看看 sort 方法是如何實現的:

var chainResult = function (instance, obj) {
    // 這裏 _chain 爲 true
    return instance._chain ? _(obj).chain() : obj;  
};

_.each(['pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'], function(name) {
    var method = Array.prototype[name];
    _.prototype[name] = function() {
        var obj = this._wrapped;
        // 執行方法
        method.apply(obj, arguments);   
      
        return chainResult(this, obj);
        
    };
});

此處源碼位於 underscore.js#L1649-L1657

sort 方法執行之後,把結果重新放在 _wrapped 裏,並執行 chainResult 方法,這個方法裏由於 _chain  之前已經置爲true,因此會繼續對結果調用 chain() 方法,包裝成鏈式化實例並返回。

最後的這個 _.value 方法比較簡單,就是返回鏈式化實例的 _wrapped值:

_.prototype.value = function() {
   return this._wrapped;
};

此處源碼位於 underscore.js#L1668-L1670

總結一下,只要一開始調用了 chain方法, _chain這個標誌位就會被置爲 true,在類似的方法中,返回的值都用 chainResult 包裹一遍,並判斷這個 _chain 這個標誌位,爲 true  則返回鏈式化實例,供給下一次方法調用,由此形成了鏈式化調用。

 

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