通常情況下,通過對構造函數使用 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 則返回鏈式化實例,供給下一次方法調用,由此形成了鏈式化調用。