首頁 > 軟體

JavaScript設計模式之職責鏈模式詳解

2022-08-09 22:00:12

職責鏈模式

職責鏈模式的定義是:使多個物件都有機會處理請求,從而避免請求的傳送者和接收者之間的耦合關係,將這些物件連成一條鏈,並沿著這條鏈傳遞該請求,直到有一個物件處理它為止。

職責鏈模式的名字非常形象,一系列可能會處理請求的物件被連線成一條鏈,請求在這些物件之間依次傳遞,直到遇到一個可以處理它的物件,我們把這些物件稱為鏈中的節點,如下圖所示。

1. 現實中的職責鏈模式

職責鏈模式的例子在現實中並不難找到,以下就是常見的跟職責鏈模式有關的場景。

如果早高峰能順利擠上公交車的話,那麼估計這一天都會過得很開心。因為公交車上人實在太多了,經常上車後卻找不到售票員在哪,所以只好把兩塊錢硬幣往前面遞。除非你運氣夠好,站在你前面的第一個人就是售票員,否則,你的硬幣通常要在 N 個人手上傳遞,才能最終到達售票員的手裡。

從這個例子中,我們很容易找到職責鏈模式的最大優點:請求傳送者只需要知道鏈中的第一個節點,從而弱化了傳送者和一組接收者之間的強聯絡。如果不使用職責鏈模式,那麼在公交車上,我就得先搞清楚誰是售票員,才能把硬幣遞給他。

2. 實際開發中的職責鏈模式

假設我們負責一個售賣手機的電商網站,經過分別交納 500 元定金和 200 元定金的兩輪預定後(訂單已在此時生成),現在已經到了正式購買的階段。

公司針對支付過定金的使用者有一定的優惠政策。在正式購買後,已經支付過 500 元定金的使用者會收到 100 元的商城優惠券,200 元定金的使用者可以收到 50 元的優惠券,而之前沒有支付定金的使用者只能進入普通購買模式,也就是沒有優惠券,且在庫存有限的情況下不一定保證能買到。

我們的訂單頁面是 Node 吐出的模板,在頁面載入之初,Node 會傳遞給頁面幾個欄位。

  • orderType:表示訂單型別(定金使用者或者普通購買使用者),code 的值為 1 的時候是 500 元定金使用者,為 2 的時候是 200 元定金使用者,為 3 的時候是普通購買使用者。
  • pay:表示使用者是否已經支付定金,值為 true 或者 false, 雖然使用者已經下過 500 元定金的訂單,但如果他一直沒有支付定金,現在只能降級進入普通購買模式。
  • stock:表示當前用於普通購買的手機庫存數量,已經支付過 500 元或者 200 元定金的使用者不受此限制。

下面我們把這個流程寫成程式碼:

const order = function (orderType, pay, stock) {
	if (orderType === 1) { // 500 元定金購買模式
		if (pay === true) { // 已支付定金
			console.log('500 元定金預購, 得到 100 優惠券');
		} else { // 未支付定金,降級到普通購買模式
			if (stock > 0) { // 用於普通購買的手機還有庫存
				console.log('普通購買, 無優惠券');
			} else {
				console.log('手機庫存不足');
			}
		}
	} else if (orderType === 2) { // 200 元定金購買模式
		if (pay === true) {
			console.log('200 元定金預購, 得到 50 優惠券');
		} else {
			if (stock > 0) {
				console.log('普通購買, 無優惠券');
			} else {
				console.log('手機庫存不足');
			}
		}
	} else if (orderType === 3) {
		if (stock > 0) {
			console.log('普通購買, 無優惠券');
		} else {
			console.log('手機庫存不足');
		}
	}
};
order(1, true, 500); // 輸出: 500 元定金預購, 得到 100 優惠券

雖然我們得到了意料中的執行結果,但這遠遠算不上一段值得誇獎的程式碼。order 函數不僅巨大到難以閱讀,而且需要經常進行修改。雖然目前專案能正常執行,但接下來的維護工作無疑是個夢魘。恐怕只有最“新手”的程式設計師才會寫出這樣的程式碼。

3. 用職責鏈模式重構程式碼

現在我們採用職責鏈模式重構這段程式碼,先把 500 元訂單、200 元訂單以及普通購買分成 3 個函數。

接下來把 orderTypepaystock 這 3 個欄位當作引數傳遞給 500 元訂單函數,如果該函數不符合處理條件,則把這個請求傳遞給後面的 200 元訂單函數,如果 200 元訂單函數依然不能處理該請求,則繼續傳遞請求給普通購買函數,程式碼如下:

// 500 元訂單
const order500 = function (orderType, pay, stock) {
	if (orderType === 1 && pay === true) {
		console.log('500 元定金預購, 得到 100 優惠券');
	} else {
		order200(orderType, pay, stock); // 將請求傳遞給 200 元訂單
	}
};
// 200 元訂單
const order200 = function (orderType, pay, stock) {
	if (orderType === 2 && pay === true) {
		console.log('200 元定金預購, 得到 50 優惠券');
	} else {
		orderNormal(orderType, pay, stock); // 將請求傳遞給普通訂單
	}
};
// 普通購買訂單
const orderNormal = function (orderType, pay, stock) {
	if (stock > 0) {
		console.log('普通購買, 無優惠券');
	} else {
		console.log('手機庫存不足');
	}
};
// 測試結果:
order500(1, true, 500); // 輸出:500 元定金預購, 得到 100 優惠券
order500(1, false, 500); // 輸出:普通購買, 無優惠券
order500(2, true, 500); // 輸出:200 元定金預購, 得到 500 優惠券
order500(3, false, 500); // 輸出:普通購買, 無優惠券
order500(3, false, 0); // 輸出:手機庫存不足

可以看到,執行結果和前面那個巨大的 order 函數完全一樣,但是程式碼的結構已經清晰了很多,我們把一個大函數拆分了 3 個小函數,去掉了許多巢狀的條件分支語句。

目前已經有了不小的進步,但我們不會滿足於此,雖然已經把大函數拆分成了互不影響的 3 個小函數,但可以看到,請求在鏈條傳遞中的順序非常僵硬,傳遞請求的程式碼被耦合在了業務函數之中:

const order500 = function (orderType, pay, stock) {
	if (orderType === 1 && pay === true) {
		console.log('500 元定金預購, 得到 100 優惠券');
	} else {
		order200(orderType, pay, stock); // 將請求傳遞給 200 元訂單
		// order200 和 order500 耦合在一起
	}
};

這依然是違反開放—封閉原則的,如果有天我們要增加 300 元預訂或者去掉 200 元預訂,意味著就必須改動這些業務函數內部。就像一根環環相扣打了死結的鏈條,如果要增加、拆除或者移動一個節點,就必須得先砸爛這根鏈條。

4. 靈活可拆分的職責鏈節點

本節我們採用一種更靈活的方式,來改進上面的職責鏈模式,目標是讓鏈中的各個節點可以靈活拆分和重組。

首先需要改寫一下分別表示 3 種購買模式的節點函數,我們約定,如果某個節點不能處理請求,則返回一個特定的字串 'nextSuccessor'來表示該請求需要繼續往後面傳遞:

const order500 = function (orderType, pay, stock) {
	if (orderType === 1 && pay === true) {
		console.log('500 元定金預購,得到 100 優惠券');
	} else {
		return 'nextSuccessor'; // 我不知道下一個節點是誰,反正把請求往後面傳遞
	}
};
const order200 = function (orderType, pay, stock) {
	if (orderType === 2 && pay === true) {
		console.log('200 元定金預購,得到 50 優惠券');
	} else {
		return 'nextSuccessor'; // 我不知道下一個節點是誰,反正把請求往後面傳遞
	}
};
const orderNormal = function (orderType, pay, stock) {
	if (stock > 0) {
		console.log('普通購買,無優惠券');
	} else {
		console.log('手機庫存不足');
	}
};

接下來需要把函數包裝進職責鏈節點,我們定義一個建構函式 Chain,在 new Chain 的時候傳 遞的引數即為需要被包裝的函數,同時它還擁有一個範例屬性 this.successor,表示在鏈中的下 一個節點。

此外 Chainprototype 中還有兩個函數,它們的作用如下所示:

// Chain.prototype.setNextSuccessor 指定在鏈中的下一個節點
// Chain.prototype.passRequest 傳遞請求給某個節點
const Chain = function (fn) {
	this.fn = fn;
	this.successor = null;
};
Chain.prototype.setNextSuccessor = function (successor) {
	return this.successor = successor;
};
Chain.prototype.passRequest = function () {
	const ret = this.fn.apply(this, arguments);
	if (ret === 'nextSuccessor') {
		return this.successor && this.successor.passRequest.apply(this.successor, arguments);
	}
	return ret;
};

現在我們把 3 個訂單函數分別包裝成職責鏈的節點:

const chainOrder500 = new Chain(order500);
const chainOrder200 = new Chain(order200);
const chainOrderNormal = new Chain(orderNormal);

然後指定節點在職責鏈中的順序:

chainOrder500.setNextSuccessor(chainOrder200);
chainOrder200.setNextSuccessor(chainOrderNormal);

最後把請求傳遞給第一個節點:

chainOrder500.passRequest(1, true, 500); // 輸出:500 元定金預購,得到 100 優惠券
chainOrder500.passRequest(2, true, 500); // 輸出:200 元定金預購,得到 50 優惠券
chainOrder500.passRequest(3, true, 500); // 輸出:普通購買,無優惠券
chainOrder500.passRequest(1, false, 0); // 輸出:手機庫存不足

通過改進,我們可以自由靈活地增加、移除和修改鏈中的節點順序,假如某天網站運營人員又想出了支援 300 元定金購買,那我們就在該鏈中增加一個節點即可:

const order300 = function () {
	// 具體實現略 
};
const chainOrder300 = new Chain(order300);
chainOrder500.setNextSuccessor(chainOrder300);
chainOrder300.setNextSuccessor(chainOrder200);

對於程式設計師來說,我們總是喜歡去改動那些相對容易改動的地方,就像改動框架的組態檔遠比改動框架的原始碼簡單得多。在這裡完全不用理會原來的訂單函數程式碼,我們要做的只是增加一個節點,然後重新設定鏈中相關節點的順序。

5. 非同步的職責鏈

在上一節的職責鏈模式中,我們讓每個節點函數同步返回一個特定的值"nextSuccessor",來表示是否把請求傳遞給下一個節點。而在現實開發中,我們經常會遇到一些非同步的問題,比如我們要在節點函數中發起一個 ajax非同步請求,非同步請求返回的結果才能決定是否繼續在職責鏈中 passRequest

這時候讓節點函數同步返回"nextSuccessor"已經沒有意義了,所以要給 Chain 類再增加一個原型方法 Chain.prototype.next,表示手動傳遞請求給職責鏈中的下一個節點:

Chain.prototype.next = function () {
	return this.successor && this.successor.passRequest.apply(this.successor, arguments);
};

來看一個非同步職責鏈的例子:

const fn1 = new Chain(function () {
	console.log(1);
	return 'nextSuccessor';
});
const fn2 = new Chain(function () {
	console.log(2);
	setTimeout(() => {
		this.next();
	}, 1000);
});
const fn3 = new Chain(function () {
	console.log(3);
});
fn1.setNextSuccessor(fn2).setNextSuccessor(fn3);
fn1.passRequest();

現在我們得到了一個特殊的鏈條,請求在鏈中的節點裡傳遞,但節點有權利決定什麼時候把請求交給下一個節點。可以想象,非同步的職責鏈加上命令模式(把 ajax 請求封裝成命令物件),我們可以很方便地建立一個非同步 ajax 佇列庫。

6. 職責鏈模式的優缺點

前面已經說過,職責鏈模式的最大優點就是解耦了請求傳送者和 N 個接收者之間的複雜關係,由於不知道鏈中的哪個節點可以處理你發出的請求,所以你只需把請求傳遞給第一個節點即可,如下圖所示。

用職責鏈模式改進後:

在手機商城的例子中,本來我們要被迫維護一個充斥著條件分支語句的巨大的函數,在例子裡的購買過程中只列印了一條 log 語句。其實在現實開發中,這裡要做更多事情,比如根據訂單種類彈出不同的浮層提示、渲染不同的 UI 節點、組合不同的引數傳送給不同的 cgi 等。用了職責鏈模式之後,每種訂單都有各自的處理常式而互不影響。

其次,使用了職責鏈模式之後,鏈中的節點物件可以靈活地拆分重組。增加或者刪除一個節點,或者改變節點在鏈中的位置都是輕而易舉的事情。這一點我們也已經看到,在上面的例子中,增加一種訂單完全不需要改動其他訂單函數中的程式碼。

職責鏈模式還有一個優點,那就是可以手動指定起始節點,請求並不是非得從鏈中的第一個節點開始傳遞。比如在公交車的例子中,如果我明確在我前面的第一個人不是售票員,那我當然可以越過他把公交卡遞給他前面的人,這樣可以減少請求在鏈中的傳遞次數,更快地找到合適的請求接受者。這在普通的條件分支語句下是做不到的,我們沒有辦法讓請求越過某一個 if 判斷。

拿程式碼來證明這一點,假設某一天網站中支付過定金的訂單已經全部結束購買流程,我們在接下來的時間裡只需要處理普通購買訂單,所以我們可以直接把請求交給普通購買訂單節點:

orderNormal.passRequest(1, false, 500); // 普通購買, 無優惠券

如果運用得當,職責鏈模式可以很好地幫助我們組織程式碼,但這種模式也並非沒有弊端,首先我們不能保證某個請求一定會被鏈中的節點處理。此時的請求就得不到答覆,而是徑直從鏈尾離開,或者丟擲一個錯誤異常。在這種情況下,我們可以在鏈尾增加一個保底的接受者節點來處理這種即將離開鏈尾的請求。

另外,職責鏈模式使得程式中多了一些節點物件,可能在某一次的請求傳遞過程中,大部分節點並沒有起到實質性的作用,它們的作用僅僅是讓請求傳遞下去,從效能方面考慮,我們要避免過長的職責鏈帶來的效能損耗。

7. 用 AOP 實現職責鏈

在之前的職責鏈實現中,我們利用了一個 Chain 類來把普通函數包裝成職責鏈的節點。其實利用 JavaScript 的函數式特性,有一種更加方便的方法來建立職責鏈。

下面我們改寫一下之前的 Function.prototype.after 函數,使得第一個函數返回'nextSuccessor' 時,將請求繼續傳遞給下一個函數,無論是返回字串'nextSuccessor'或者 false 都只是一個約定,當然在這裡我們也可以讓函數返回 false 表示傳遞請求,選擇'nextSuccessor'字串是因為它看起來更能表達我們的目的,程式碼如下:

Function.prototype.after = function (fn) {
	const self = this;
	return function () {
		const ret = self.apply(this, arguments);
		if (ret === 'nextSuccessor') {
			return fn.apply(this, arguments);
		}
		return ret;
	}
};
const order = order500yuan.after(order200yuan).after(orderNormal);
order(1, true, 500); // 輸出:500 元定金預購,得到 100 優惠券
order(2, true, 500); // 輸出:200 元定金預購,得到 50 優惠券
order(1, false, 500); // 輸出:普通購買,無優惠券

用 AOP 來實現職責鏈既簡單又巧妙,但這種把函數疊在一起的方式,同時也疊加了函數的作用域,如果鏈條太長的話,也會對效能有較大的影響。

8. 小結

在 JavaScript 開發中,職責鏈模式是最容易被忽視的模式之一。實際上只要運用得當,職責鏈模式可以很好地幫助我們管理程式碼,降低發起請求的物件和處理請求的物件之間的耦合性。職責鏈中的節點數量和順序是可以自由變化的,我們可以在執行時決定鏈中包含哪些節點。

無論是作用域鏈、原型鏈,還是 DOM 節點中的事件冒泡,我們都能從中找到職責鏈模式的影子。職責鏈模式還可以和組合模式結合在一起,用來連線部件和父部件,或是提高組合物件的效率。學會使用職責鏈模式,相信在以後的程式碼編寫中,將會對你大有裨益。

到此這篇關於JavaScript設計模式之職責鏈模式詳解的文章就介紹到這了,更多相關JS職責鏈模式內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com