如何寫出好的 JavaScript —— 淺談 API 設計
很多同學覺得寫 JavaScript 很簡單,只要能寫出功能來,效果能實現就好。還有一些培訓機構,專門教人寫各種“炫酷特效”,以此讓許多人覺得這些培訓很“牛逼”。然而事實上,能寫 JavaScript 和寫好 JavaScript 這中間還有很遙遠的距離。成為專業前端,注定在 JavaScript 路途上需要一步步扎實的修煉,沒有捷徑。
看一個簡單的例子:
實現一個類似于“交通燈”的效果,讓三個不同顏色的圓點每隔 2 秒循環切換。
對應的 HTML 和 CSS 如下:
<ulid="traffic" class="wait">
<li><span></span></li>
<li><span></span></li>
<li><span></span></li>
</ul>
#traffic > li{
display: block;
}
#traffic span{
display: inline-block;
width: 50px;
height: 50px;
background-color: gray;
margin: 5px;
border-radius: 50%;
}
#traffic.stop li:nth-child(1) span{
background-color: #a00;
}
#traffic.wait li:nth-child(2) span{
background-color: #aa0;
}
#traffic.pass li:nth-child(3) span{
background-color: #0a0;
}
那么這一功能的 JavaScript 該如何實現呢?
版本一
有的同學說,這個實現還不簡單嘛?直接用幾個定時器一下切換不就好了:
const traffic = document.getElementById("traffic");
(function reset(){
traffic.className = "wait";
setTimeout(function(){
traffic.className = "stop";
setTimeout(function(){
traffic.className = "pass";
setTimeout(reset, 2000)
}, 2000)
}, 2000);
})();
沒錯,就這個功能本身,這樣實現就 OK 了。但是這樣實現有什么問題呢?
首先是 過程耦合 ,狀態切換是wait->stop->pass 循環,在上面的設計里,實際上操作順序是耦合在一起的,要先 ‘wait’,然后等待 2000 毫秒再 ‘stop’,然后再等待 2000 毫秒在 ‘pass’,這中間的順序一旦有調整,需求有變化,代碼都需要修改。
其次,這樣的異步嵌套是會產生 callback hell 的,如果需求不是三盞燈,而是五盞燈、十盞燈,代碼的嵌套結構就很深,看起來就很難看了。
所以我們說,版本一方法雖然直接,但因為抽象程度很低(幾乎沒有提供任何抽象 API),它的擴展性很不好,因為異步問題沒處理,代碼結構也很不好。如果只能寫這樣的代碼,是不能說就寫好了 JavaScript 的。
版本二
要解決版本一的 過程耦合 問題,最簡單的思路是將狀態 ['wait','stop','pass'] 抽象出來:
const traffic = document.getElementById("traffic");
var stateList = ["wait", "stop", "pass"];
var currentStateIndex = 0;
setInterval(function(){
var state = stateList[currentStateIndex];
traffic.className = state;
currentStateIndex = (currentStateIndex + 1) % stateList.length;
}, 2000);
這是一種數據抽象的思路,應用它我們得到了上面的這個版本。
這一版本比前一版本要好很多,但是它也有問題,最大的問題就是 封裝性很差 ,它把 stateList 和 currentStateIndex 都暴露出來了,而且以全局變量的形式,這么做很不好,需要優化。
版本三
const traffic = document.getElementById("traffic");
function start(traffic, stateList){
var currentStateIndex = 0;
setInterval(function(){
var state = stateList[currentStateIndex];
traffic.className = state;
currentStateIndex = (currentStateIndex + 1) % stateList.length;
}, 2000);
}
start(traffic, ["wait", "stop", "pass"]);
版本三是中規中矩的一版,也是一般我們在工作中比較常用的思路。應該將暴露出來的 API 暴露出來(本例中的 stateList)。將不應該暴露出來的數據或狀態隱藏(本例中的 currentStateIndex)。
有許多同學覺得說寫出這一版本來已經很不錯的。的確,應該也還不錯,但這一版的抽象程度其實也不是很高,或者說,如果考慮適用性,這版已經很好了,但是如果考慮可復用性的話,這版依然有改進空間。
我們再看一個思路上較有意思的版本。
版本四
const traffic = document.getElementById("traffic");
function poll(...fnList){
letstateIndex = 0;
return function(...args){
letfn = fnList[stateIndex++ % fnList.length];
return fn.apply(this, args);
}
}
function setState(state){
traffic.className = state;
}
lettrafficStatePoll = poll(setState.bind(null, "wait"),
setState.bind(null, "stop"),
setState.bind(null, "pass"));
setInterval(trafficStatePoll, 2000);
這一版用的是 過程抽象 的思路,而過程抽象,是 函數式編程 的基礎。在這里,我們抽象出了一個 poll(...fnList) 的高階組合函數,它將一個函數列表組合起來,每次調用時依次輪流執行列表里的函數。
我們說,程序設計的本質是抽象,而 過程抽象 是一種與 數據抽象 對應的思路,它們是兩種不同的抽象模型。數據抽象比較基礎,而過程抽象相對高級一些,也更靈活一些。數據抽象是研究函數如何操作數據,而過程抽象則在此基礎上研究函數如何操作函數。所以說如果把抽象比作數學,那么數據抽象是初等數學,過程抽象則是高等數學。同一個問題,既可以用初等數學來解決,又可以用高等數學來解決。用什么方法解決,取決于問題的模型和難度等等。
好了,上面我們有了四個版本,那么是否考慮了這些版本就足夠了呢?
并不是。因為需求是會變更的。假設現在需求變化了:
需求變更:讓 wait、stop、pass 狀態的持續時長不相等,分別改成 1秒、2秒、3秒。
那么,我們發現 ——
除了版本一之外,版本二、三、四全都跪了……
那是否意味著我們要 回歸到版本一 呢?
當然并不是。
版本五
const traffic = document.getElementById("traffic");
function wait(time){
return new Promise(resolve => setTimeout(resolve, time));
}
function setState(state){
traffic.className = state;
}
function reset(){
Promise.resolve()
.then(setState.bind(null, "wait"))
.then(wait.bind(null, 1000))
.then(setState.bind(null, "stop"))
.then(wait.bind(null, 2000))
.then(setState.bind(null, "pass"))
.then(wait.bind(null, 3000))
.then(reset);
}
reset();
版本五的思路是, 既然我們需要考慮不同的持續時間,那么我們需要將等待時間抽象出來 :
function wait(time){
return new Promise(resolve => setTimeout(resolve, time));
}
這一版本里我們用了 Promise 來處理回調問題,當然對 ES6 之前的版本,可以用 shim 或 polyfill、第三方庫,也可以選擇不用 Promise。
版本五抽象出的 wait 方法也還比較通用,可以用在其他地方。這是版本五好的一點。
版本六
我們還可以進一步抽象,設計出版本六,或者類似的 對象模型 :
const trafficEl = document.getElementById("traffic");
function TrafficProtocol(el, reset){
this.subject = el;
this.autoReset = reset;
this.stateList = [];
}
TrafficProtocol.prototype.putState = function(fn){
this.stateList.push(fn);
}
TrafficProtocol.prototype.reset = function(){
letsubject = this.subject;
this.statePromise = Promise.resolve();
this.stateList.forEach((stateFn) => {
this.statePromise = this.statePromise.then(()=>{
return new Promise(resolve => {
stateFn(subject, resolve);
});
});
});
if(this.autoReset){
this.statePromise.then(this.reset.bind(this));
}
}
TrafficProtocol.prototype.start = function(){
this.reset();
}
var traffic = new TrafficProtocol(trafficEl, true);
traffic.putState(function(subject, next){
subject.className = "wait";
setTimeout(next, 1000);
});
traffic.putState(function(subject, next){
subject.className = "stop";
setTimeout(next, 2000);
});
traffic.putState(function(subject, next){
subject.className = "pass";
setTimeout(next, 3000);
});
traffic.start();
這一版本里,我們設計了一個 TrafficProtocol 類,它有 putState、reset、start 三個方法:
- putState 接受一個函數作為參數,這個函數自身有兩個參數,一個是 subject,是由 TrafficProtocol 對象初始化時設定的 DOM 元素,一個是 next,是一個函數,表示結束當前 state,進入下一個 state。
- reset 結束當前狀態循環,開始新的循環。
- start 開始執行循環,這里的實現是直接調用 reset。
看一下 reset 的實現思路:
TrafficProtocol.prototype.reset = function(){
letsubject = this.subject;
this.statePromise = Promise.resolve();
this.stateList.forEach((stateFn) => {
this.statePromise = this.statePromise.then(()=>{
return new Promise(resolve => {
stateFn(subject, resolve);
});
});
});
if(this.autoReset){
this.statePromise.then(this.reset.bind(this));
}
}
在這里我們創建一個 statePromise,然后將 stateList 中的方法(通過 putState 添加的)依次綁定到 promise 上。如果設置了 autoReset,那么我們在 promise 的最后綁定 reset 自身,這樣就實現了循環切換。
有了這個模型,我們要添加新的狀態,只需要通過 putState 添加一個新的狀態就好了。這一模型不僅僅可以用在這個需求里,還可以用在任何需要順序執行異步請求的地方。
最后,我們看到,版本六用到了面向對象、過程抽象、Promise等模式,它的優點是 API 設計靈活,通用性和擴展性好。但是版本六也有缺點,它的實現復雜度比前面的幾個版本都高,我們在做這樣的設計時,也需要考慮是否有 過度設計 的嫌疑。
總結
- 設計是把雙刃劍,繁簡需要權衡,尺度需要把握。
- 寫代碼簡單,程序設計不易,需要走心。
來自:http://web.jobbole.com/89753/