iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 19
0
Modern Web

JS Design Pattern 系列 第 19

JS Design Pattern Day19-狀態模式 State(下)

第19天,今天帶筆電出門一整天,一個字都沒寫,果然不要太高估自己在玩樂的時候會想工作

狀態模式 繼續來練習

這裡再舉一個較複雜的例子,本書作者有提供一段檔案上傳的範例程式。當一個檔案上傳時通常畫面上會有兩顆按鈕來控制這個上傳的檔案。按鈕1的功能是暫停以及繼續上傳,按鈕2則是刪除此上傳檔案。就這簡單兩個按鈕配上檔案上傳來說就會有幾個狀態,當檔案在掃描狀態(sign)時是不能進行任何操作的,不能暫停也不能刪除。之後檔案上傳則會是檔案上傳中的狀態(uploading)。上傳之後會進行檢查,檢查完成之後若檔案沒有錯誤則會顯示檔案上傳完成(done),若是檔案有問題就會顯示檔案錯誤(error)。另外,系統會額外提供plugin工具,並且會利用window.external.upload來通知我們程式的狀態。
那們我們故意寫個反例來看看(其實太細節不必在意,只要注意反例與修改之後差異的部分):
先建立剛剛所說的plugin的部分

window.external.upload = function (state) {
	console.log(state); //可能為sign、uploading、done、error
};
var plugin = (function () {
	var plugin = document.createElement('embed');
	plugin.style.display = 'none';
	plugin.type = 'application/txftn-webkit';

	plugin.sign = function () {
		console.log('開始檔案掃描');
	};
	plugin.pause = function () {
		console.log('暫停檔案上傳');
	};
	plugin.uploading = function () {
		console.log('開始檔案上傳');
	};
	plugin.del = function () {
		console.log('刪除檔案上傳');
	};
	plugin.done = function () {
		console.log('檔案上傳完成');
	};
	document.body.appendChild(plugin);
	return plugin;
})();

我們首先來建立Upload物件,狀態我們就用字串來表示

var Upload = function (fileName) {
	this.plugin = plugin;
	this.fileName = fileName;
	this.$button1 = null;
	this.$button2 = null;
	this.state = 'sign';
};

製作檔案上傳的畫面,包含兩個控制按鈕

Upload.prototype.init = function () {
	var self = this;
	self.$dom = createUploadView();
	self.bindEvent();

	function createUploadView() {
		var $text = $('<span>').text('檔案名稱:' + self.fileName);
		self.$button1 = $('<button>').attr('data-action', 'button1').text('掃描中');
		self.$button2 = $('<button>').attr('data-action', 'button2').text('刪除');
		return $('<div>').append($text).append(self.$button1).append(self.$button2).appendTo('body');
	}
};

針對兩個按鈕綁定點擊事件

Upload.prototype.bindEvent = function () {
	var self = this;
	self.$button1.click(function () {
		if (self.state === 'sign') {
			console.log('掃描中,點擊無效');
		} else if (self.state === 'uploading') {
			self.changeState('pause');
		} else if (self.state === 'pause') {
			self.changeState('uploading');
		} else if (self.state === 'done') {
			console.log('檔案已上傳,點擊無效');
		} else if (self.state === 'error') {
			console.log('檔案上傳失敗,點擊無效');
		}
	});

	self.$button2.click(function () {
		if (self.state === 'done' || self.state === 'error' ||
			self.state === '') {
			self.changeState('del');
		} else if (self.state === 'sign') {
			console.log('檔案正在掃描中,無法刪除');
		} else if (self.state === 'uploading') {
			console.log('檔案正在上傳中,無法刪除');
		}
	});
};

改變狀態的方法,針對每個狀態的行為來實作

Upload.prototype.changeState = function (state) {
	switch (state) {
		case 'sign':
			this.plugin.sign();
			this.$button1.text('掃描中,任何操作無效');
			break;
		case 'uploading':
			this.plugin.uploading();
			this.$button1.text('正在上傳,點擊暫停');
			break;
		case 'pause':
			this.plugin.pause();
			this.$button1.text('已暫停,點擊繼續上傳');
			break;
		case 'done':
			this.plugin.done();
			this.$button1.text('上傳完成');
			break;
		case 'error':
			this.$button1.text('上傳失敗');
			break;
		case 'del':
			this.plugin.del();
			this.$dom.remove();
			console.log('刪除完成');
			break;
	}
	this.state = state;
};

我們實際來模擬上傳一個檔案(123),過稱中我們利用setTimeout來模擬非同步的感覺。

var uploadObj = new Upload('123');
uploadObj.init();
window.external.upload = function (state) {
	uploadObj.changeState(state);
};
window.external.upload('sign');
setTimeout(function () {
	window.external.upload('uploading');
}, 1000);
setTimeout(function () {
	window.external.upload('done');
}, 5000);

那現在我們就用狀態模式來重構一下:
首先建立外掛程式這段沒有改變

window.external.upload = function (state) {
	console.log(state); //可能為sign、uploading、done、error
};
var plugin = (function () {
	var plugin = document.createElement('embed');
	plugin.style.display = 'none';
	plugin.type = 'application/txftn-webkit';

	plugin.sign = function () {
		console.log('開始檔案掃描');
	};
	plugin.pause = function () {
		console.log('暫停檔案上傳');
	};
	plugin.uploading = function () {
		console.log('開始檔案上傳');
	};
	plugin.del = function () {
		console.log('刪除檔案上傳');
	};
	plugin.done = function () {
		console.log('檔案上傳完成');
	};
	document.body.appendChild(plugin);
	return plugin;
})();

需要改的是Upload構造函數,我們等等會為每個狀態建立類別物件,現在在這邊我們要為每一個狀態類別都建立一個實例物件

var Upload = function (fileName) {
	this.plugin = plugin;
	this.fileName = fileName;
	this.$button1 = null;
	this.$button2 = null;
	this.signState = new SignState(this);
	this.uploadingState = new UploadingState(this);
	this.pauseState = new PauseState(this);
	this.doneState = new DoneState(this);
	this.errorState = new ErrorState(this);
	this.currentState = this.state;
};

建立畫面物件的部分無變動

Upload.prototype.init = function () {
	var self = this;
	self.$dom = createUploadView();
	self.bindEvent();

	function createUploadView() {
		var $text = $('<span>').text('檔案名稱:' + self.fileName);
		self.$button1 = $('<button>').attr('data-action', 'button1').text('掃描中');
		self.$button2 = $('<button>').attr('data-action', 'button2').text('刪除');
		return $('<div>').append($text).append(self.$button1).append(self.$button2).appendTo('body');
	}
};

這裡要將每次事件都委託給當前的狀態類別來執行

Upload.prototype.bindEvent = function () {
	var self = this;
	self.$button1.click(function () {
		self.currentState.clickHandler1();
	});

	self.$button2.click(function () {
		self.currentState.clickHandler2();
	});
};

再來我們移除changeState,把相對應的狀態要做的事情放到Upload類別中

Upload.prototype.sign = function () {
	this.plugin.sign();
	this.currentState = this.signState;
};

Upload.prototype.uploading = function () {
	this.plugin.uploading();
	this.$button1.text('正在上傳,點擊暫停');
	this.currentState = this.uploadingState;
};

Upload.prototype.pause = function () {
	this.plugin.pause();
	this.$button1.text('已暫停,點擊繼續上傳');
	this.currentState = this.pauseState;
};

Upload.prototype.done = function () {
	this.plugin.done();
	this.$button1.text('上傳完成');
	this.currentState = this.doneState;
};

Upload.prototype.error = function () {
	this.$button1.text('上傳失敗');
	this.currentState = this.errorState;
};

Upload.prototype.del = function () {
	this.plugin.del();
	this.$dom.remove();
	console.log('刪除完成');
};

接下來做一個工廠,用來避免JS語法中沒有抽象類別帶來的困擾

var StateFactory = (function () {
	var state = function () { };
	state.prototype.clickHandler1 = function () {
		throw new Error('子類別要覆蓋此方法');
	};
	state.prototype.clickHandler2 = function () {
		throw new Error('子類別要覆蓋此方法');
	};
	return function (parameter) {
		var F = function (uploadObj) {
			this.uploadObj = uploadObj;
		};
		F.prototype = new state();
		for (var i in parameter) {
			F.prototype[i] = parameter[i];
		}
		return F;
	};
})();

接下來製作這些狀態類別

var SignState = StateFactory({
	clickHandler1: function () {
		console.log('掃描中,點擊無效');
	},
	clickHandler2: function () {
		console.log('檔案正在掃描中,無法刪除');
	}
});

var UploadingState = StateFactory({
	clickHandler1: function () {
		this.uploadObj.pause();
	},
	clickHandler2: function () {
		console.log('檔案正在掃描中,無法刪除');
	}
});

var PauseState = StateFactory({
	clickHandler1: function () {
		this.uploadObj.uploading();
	},
	clickHandler2: function () {
		this.uploadObj.del();
	}
});

var DoneState = StateFactory({
	clickHandler1: function () {
		console.log('檔案已上傳完成,點擊無效');
	},
	clickHandler2: function () {
		this.uploadObj.del();
	}
});

var ErrorState = StateFactory({
	clickHandler1: function () {
		console.log('檔案上傳失敗,點擊無效');
	},
	clickHandler2: function () {
		this.uploadObj.del();
	}
});

那我們最後一樣來實際模擬一下,結果應該也是一樣的

var uploadObj = new Upload('123');
uploadObj.init();
window.external.upload = function (state) {
	uploadObj[state]();
};
window.external.upload('sign');
setTimeout(function () {
	window.external.upload('uploading');
}, 1000);
setTimeout(function () {
	window.external.upload('done');
}, 5000);

經過上篇與這篇的範例,我們可以知道狀態模式的優點:由於抽離了狀態,我們可以容易增加狀態和轉換。狀態轉換的邏輯更被放在狀態類別中,在程式中簡化了分支。當然除了優點也會有缺點:如果狀態很多可能幾十種,就會發現系統物件相當多,且邏輯都被分散在個物件之中,不容易在一個地方看出邏輯。物件太多的的話就效能來說是會受到影響的,那可以考慮加入輕量模式共用這些狀態模組。


上一篇
JS Design Pattern Day18-狀態模式 State(上)
下一篇
JS Design Pattern Day20-配接器模式 Adapter
系列文
JS Design Pattern 30

尚未有邦友留言

立即登入留言