iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 17
0
Modern Web

JS Design Pattern 系列 第 17

JS Design Pattern Day17-裝飾者模式 Decorator(下)

第17天,聚餐吃太晚,差點來不及發文,沒想到這變成我每天最重要的小事了。

繼續 裝飾者模式

在JS中我們可以很方便的替物件增加屬性和方法,但卻難在不改動某個函數原始碼的狀態下,給該函數添加額外功能。
很多時候我們不會想要去改動某個函數內部,有可能這個函數相當龐大,內容可能是過去好幾任工程是增修過的,在不改變原始碼的情況下,我們其實可以通過存放原引用的方式(像裝飾者模式(上)最後範例一樣)就可以改寫某個函數:

var a = function() {
	console.log(1);
};
var _a = a;
a = function() {
	_a();
	console.log(2);
};

a();

實際上的應用也是有這種狀況的,例如想給window綁定onload事件的話,因為不確定這個事件是否已經有人先綁定過了,所以為了避免覆蓋掉之前的函數,我們可以這麼做:

window.onload = function() {
	alert(1);
};

var _onload = window.onload || function() {};
window.onload = function() {
	_onload();
	alert(2);
};

這樣的寫法仍然是符合開放-封閉原則,過程中我們並沒有去更動到原始的function。但這樣仍會有些問題:必須維護_onload、_a這些中間變數,如果裝飾函數越來越多,這樣的變數數量可能會過多。再來是this被更動的問題,第二次呼叫的this可能不是該方法應該指定用的物件,例如以下這狀況:

var _getElementById = document.getElementById;
document.getElementById = function(id) {
	alert(1);
	return _getElementById(id);
};

var btn = document.getElementById('test');

這段會拋出錯誤訊息,原因是因為getElementById這個方法要求this指定的物件要是document,現在this指的是window。或許可以這麼改:

var _getElementById = document.getElementById;
document.getElementById = function(id) {
	alert(1);
	return _getElementById.apply(document, id);
};

但這樣顯然不是很方便,我們接下來用AOP來給函數動態增加功能。
AOP(面向導向程式設計)簡單來說就是把跟核心業務邏輯模組無關的功能抽離,再通過動態加入的方式參入整個業務邏輯中。在JS實作中會在Function的原型物件裡增加before跟after函數,讓before、after函數當作裝飾者:

Function.prototype.before = function(fn) {
	var _self = this;
	return function() {
		fn.apply(this, arguments);
		return _self.apply(this, arguments);
	}
};

Function.prototype.after = function(fn) {
	var _self = this;
	return function() {
		var ret = _self.apply(this, arguments);
		fn.apply(this, arguments);
		return ret;
	}
};

實際使用看看

document.getElementById = document.getElementById.before(function() {
	alert(1);
});

$('<button>').attr('id', 'test').appendTo('body');
var btn = document.getElementById('test');
console.log(btn);

再回到onload例子上,就會發現方便很多

window.onload = function() {
	console.log(1);
};

window.onload = (window.onload || function() {}).after(function() {
	console.log(2);
}).after(function() {
	console.log(3);
}).after(function() {
	console.log(4);
});

我們舉個例子來實際展示分離業務邏輯程式碼與資料統計程式碼。我們要做一個登入按鈕,點擊後會跳出登入畫面,同時要進行資料回傳至server,來統計多少使用者點擊這個登入按鈕:

(function() {
	$('<body>').text('Login').attr('tag', 'login')
		.appendTo('body')
		.click(showLogin);
})();

function showLogin() {
	console.log('打開登入畫面');
	log($(this).attr('tag'));
}

function log(tag) {
	console.log('回傳tag:' + tag);
	//省略傳server部分
}

上述例子中showLogin裡面不僅要顯示畫面,還要負責回傳log,這樣兩種層面的功能現在耦合在一起。現在我們用AOP分離的方式修改:

Function.prototype.after = function(fn) {
	var _self = this;
	return function() {
		var ret = _self.apply(this, arguments);
		fn.apply(this, arguments);
		return ret;
	}
};

var $btn;
(function() {
	$btn = $('<body>').text('Login').attr('tag', 'login')
		.appendTo('body');

})();

//把原本的log那段拿掉
function showLogin() {
	console.log('打開登入畫面');
}
//想像成元素本身直接綁定此事件一樣
function log() {
	console.log('回傳tag:' + $(this).attr('tag'));
	//省略傳server部分
}

showLogin = showLogin.after(log);
//如果一開始就先綁定了showLogin的話,showLogin會是舊的,因此要在最後再綁定上去
$btn.click(showLogin);

我們除了一般的增加職責以外還可以在裡面做些判斷,一樣舉例來解釋,還記得我們在策略模式中所寫的檢查表單並送出的範例嗎,我們現在要onSubmit中除了檢查表單,還要ajax送出表單(如果你看過策略模式的話,整段範例只要注意onSubmit函數就好):

var $form = getForm();
$form.submit(onSubmit);

function onSubmit() {
	var form = this;
	var errorMsg = myFormValidator(form);
	if (errorMsg) {
		alert(errorMsg);
		return false;
	}
	ajax( /*省略*/ );
}
var strategies = {
	inNotEmpty: function(val, errorMsg) {
		if (val === '') {
			return errorMsg;
		}
	},
	minLength: function(val, length, errorMsg) {
		if (val.length < length) {
			return errorMsg;
		}
	},
	isMobileNumber: function(val, errorMsg) {
		if (!/^[09]{2}[0-9]{8}$/.test(val)) {
			return errorMsg;
		}
	}
};

function myFormValidator(form) {
	var validator = new Validator();
	validator.add(form.userName.value, [{
		strategy: 'inNotEmpty',
		errorMsg: '使用者名稱不為空'
	}, {
		strategy: 'minLength:6',
		errorMsg: '使用者名稱位數不得少於6'
	}]);
	validator.add(form.password.value, [{
		strategy: 'minLength:6',
		errorMsg: '密碼位數不得少於6'
	}]);
	validator.add(form.phoneNumber.value, [{
		strategy: 'isMobileNumber',
		errorMsg: '手機號碼錯誤'
	}]);
	var errorMsg = validator.start();
	return errorMsg;
}

var Validator = function() {
	this.cache = [];
};

Validator.prototype.add = function(item, rules) {
	var self = this;
	rules.forEach(function(rule) {
		var strategySet = rule.strategy.split(':');
		self.cache.push(function() {
			var strategyName = strategySet.shift();
			strategySet.unshift(item);
			strategySet.push(rule.errorMsg);
			return strategies[strategyName].apply(self, strategySet);
		});
	});
};

Validator.prototype.start = function() {
	var errorMsg;
	this.cache.some(function(validatorFunc) {
		errorMsg = validatorFunc();
		if (errorMsg) {
			return true;
		}
	});
	return errorMsg;
};

這個範例只修改了onSubmit裡面增加ajax那段,其它都跟策略模式那篇一樣。

在範例裡你就會發現在onSubmit中除了檢查規則還要ajax送出,那我們可以怎麼改呢?我們可以在before裡面增加判斷,如果回傳為false就不要繼續執行之後的動作:

Function.prototype.before = function(fn) {
	var _self = this;
	return function() {
		if (fn.apply(this, arguments) === false) {
			return;
		}
		return _self.apply(this, arguments);
	}
};

之後就可以修改onSubmit函數

var $form = getForm();
onSubmit = onSubmit.before(check);
$form.submit(onSubmit);

function onSubmit() {
	ajax( /*省略*/ );
}

function check() {
	var form = this;
	var errorMsg = myFormValidator(form);
	if (errorMsg) {
		alert(errorMsg);
		return false;
	}
}

//以下內容都跟上一個範例一樣

var strategies = {
	inNotEmpty: function(val, errorMsg) {
		if (val === '') {
			return errorMsg;
		}
	},
	minLength: function(val, length, errorMsg) {
		if (val.length < length) {
			return errorMsg;
		}
	},
	isMobileNumber: function(val, errorMsg) {
		if (!/^[09]{2}[0-9]{8}$/.test(val)) {
			return errorMsg;
		}
	}
};

function myFormValidator(form) {
	var validator = new Validator();
	validator.add(form.userName.value, [{
		strategy: 'inNotEmpty',
		errorMsg: '使用者名稱不為空'
	}, {
		strategy: 'minLength:6',
		errorMsg: '使用者名稱位數不得少於6'
	}]);
	validator.add(form.password.value, [{
		strategy: 'minLength:6',
		errorMsg: '密碼位數不得少於6'
	}]);
	validator.add(form.phoneNumber.value, [{
		strategy: 'isMobileNumber',
		errorMsg: '手機號碼錯誤'
	}]);
	var errorMsg = validator.start();
	return errorMsg;
}

var Validator = function() {
	this.cache = [];
};

Validator.prototype.add = function(item, rules) {
	var self = this;
	rules.forEach(function(rule) {
		var strategySet = rule.strategy.split(':');
		self.cache.push(function() {
			var strategyName = strategySet.shift();
			strategySet.unshift(item);
			strategySet.push(rule.errorMsg);
			return strategies[strategyName].apply(self, strategySet);
		});
	});
};

Validator.prototype.start = function() {
	var errorMsg;
	this.cache.some(function(validatorFunc) {
		errorMsg = validatorFunc();
		if (errorMsg) {
			return true;
		}
	});
	return errorMsg;
};

裝飾者模式跟代理模式結構上看起來頗像,都替物件提供一定程度的間接引用。
代理模式有點像你多了一個經紀人的感覺,你本身的功能不會改變,但經紀人可以替你處理很多事。
而裝飾者模式就像你本身身上多加裝許多裝備,當然本身功能不會變,但是你最後就是可以多做一些事情。
以上就是裝飾者模式。


上一篇
JS Design Pattern Day16-裝飾者模式 Decorator(上)
下一篇
JS Design Pattern Day18-狀態模式 State(上)
系列文
JS Design Pattern 30

1 則留言

0
SimbaFs
iT邦新手 5 級 ‧ 2021-09-21 11:10:41

before 和 after 這兩個功能真應該放進 JS 原生功能裡面

我要留言

立即登入留言