重構 JavaScript 影片商店
計算並格式化影片商店帳單的簡單範例開啟了我在 1999 年出版的重構書籍。如果使用現代 JavaScript 撰寫,重構可以採取多種方式。我在這裡探討四種方式:重構為頂層函式、重構為具有調度器的巢狀函式、使用類別,以及使用中間資料結構進行轉換。
2016 年 5 月 18 日
多年前,我在撰寫重構書籍時,以一個(非常)簡單的範例開啟這本書,重構一些計算顧客租借影片帳單的程式碼(在那個年代,我們必須到店裡才能租借影片)。我最近思考這個重構範例,特別是如果用現代 JavaScript 撰寫,它會是什麼樣子。
任何重構都是為了朝特定方向改善程式碼,以符合開發團隊的程式設計風格。書中的範例是用 Java 撰寫的,而 Java(特別是在當時)建議採用特定的程式設計風格,也就是物件導向風格。然而,JavaScript 有更多選擇可以決定要採用哪種風格。雖然你可以採用類似 Java 的物件導向風格,特別是使用 ES6(Ecmascript 2015),但並非所有 JavaScript 專家都支持這種風格,許多人確實認為使用類別是一件壞事。
這段最初的影片商店程式碼
為了進一步探討,我需要提供一些程式碼。在這個範例中,使用 JavaScript 版本的原始範例,我寫於世紀之交。
function statement(customer, movies) { let totalAmount = 0; let frequentRenterPoints = 0; let result = `Rental Record for ${customer.name}\n`; for (let r of customer.rentals) { let movie = movies[r.movieID]; let thisAmount = 0; // determine amount for each movie switch (movie.code) { case "regular": thisAmount = 2; if (r.days > 2) { thisAmount += (r.days - 2) * 1.5; } break; case "new": thisAmount = r.days * 3; break; case "childrens": thisAmount = 1.5; if (r.days > 3) { thisAmount += (r.days - 3) * 1.5; } break; } //add frequent renter points frequentRenterPoints++; // add bonus for a two day new release rental if(movie.code === "new" && r.days > 2) frequentRenterPoints++; //print figures for this rental result += `\t${movie.title}\t${thisAmount}\n` ; totalAmount += thisAmount; } // add footer lines result += `Amount owed is ${totalAmount}\n`; result += `You earned ${frequentRenterPoints} frequent renter points\n`; return result; }
我在這裡使用 ES6。此程式碼在兩個資料結構上執行,兩個都是 JSON 記錄清單。客戶記錄如下所示
{ "name": "martin", "rentals": [ {"movieID": "F001", "days": 3}, {"movieID": "F002", "days": 1}, ] }
電影結構如下所示
{ "F001": {"title": "Ran", "code": "regular"}, "F002": {"title": "Trois Couleurs: Bleu", "code": "regular"}, // etc }
在原始書籍中,電影僅作為 Java 物件結構中的物件呈現。對於此範文,我比較喜歡將 JSON 結構作為參數傳入。我將假設使用某種全域查詢,例如 儲存庫,並不適合此應用程式。
此陳述方法會列印租賃報表的簡單文字輸出
Rental Record for martin Ran 3.5 Trois Couleurs: Bleu 2 Amount owed is 5.5 You earned 2 frequent renter points
此輸出很粗糙,即使依範例程式碼的標準來看也是如此。我甚至無法格式化數字嗎?不過,請記住,此書是使用 Java 1.1 編寫的,當時 String.format 尚未加入語言中。這或許可以部分原諒我的懶惰。
此陳述函式是 長方法 這個氣味的範例。光是它的規模就足以讓我懷疑。但程式碼有問題並不構成重構它的充分理由。編寫不良的程式碼是一個問題,因為它難以理解。難以理解的程式碼難以修改,無論是要新增新功能或除錯。因此,如果您不需要閱讀和理解某些程式碼,那麼它不良的結構不會傷害您,您可以暫時放著不管。因此,為了引發我們對此程式碼片段的興趣,我們需要一個讓它變更的理由。我用於書中的理由是撰寫陳述方法的 HTML 版本,這會列印出類似這樣的內容。
<h1>Rental Record for <em>martin</em></h1> <table> <tr><td>Ran</td><td>3.5</td></tr> <tr><td>Trois Couleurs: Bleu</td><td>2</td></tr> </table> <p>Amount owed is <em>5.5</em></p> <p>You earned <em>2</em> frequent renter points</p>
正如我先前所指示的,在此範文中,我正在探索許多方法,其中我可以重構此程式碼,使其更容易新增其他輸出呈現。所有這些方法都有相同的起點:將單一方法分解為一組函式,以擷取邏輯的不同部分。一旦完成此分解,我將探索四種不同的方法,這些方法可以排列這些函式以支援替代呈現。

分解成多個函式
每當我使用像這樣的過長函式時,我的第一個想法就是使用 Extract Method 尋找邏輯程式碼區塊,並將它們轉換為自己的函式。[1] 第一個吸引我注意的區塊是 switch 陳述式。

function statement(customer, movies) {
let totalAmount = 0;
let frequentRenterPoints = 0;
let result = `Rental Record for ${customer.name}\n`;
for (let r of customer.rentals) {
let movie = movies[r.movieID];
let thisAmount = 0;
// determine amount for each movie
switch (movie.code) {
case "regular":
thisAmount = 2;
if (r.days > 2) {
thisAmount += (r.days - 2) * 1.5;
}
break;
case "new":
thisAmount = r.days * 3;
break;
case "childrens":
thisAmount = 1.5;
if (r.days > 3) {
thisAmount += (r.days - 3) * 1.5;
}
break;
}
//add frequent renter points
frequentRenterPoints++;
// add bonus for a two day new release rental
if(movie.code === "new" && r.days > 2) frequentRenterPoints++;
//print figures for this rental
result += `\t${movie.title}\t${thisAmount}\n` ;
totalAmount += thisAmount;
}
// add footer lines
result += `Amount owed is ${totalAmount}\n`;
result += `You earned ${frequentRenterPoints} frequent renter points\n`;
return result;
}
我的 IDE (IntelliJ) 提供自動執行此重構的選項,但它無法正確執行 - 它的 JavaScript 能力不如 Java 重構那樣穩固或成熟。因此我採用手動方式,這包括檢視候選萃取所使用的資料。其中有三個資料片段
thisAmount
是由萃取程式碼計算的值。我可以在函式中初始化它並在最後傳回r
是迴圈中正在檢查的租賃,我可以將它傳入作為參數。movie
是租賃的電影,它是一個稍早建立的暫時變數。在重構程序碼時,像這樣的暫時變數通常會造成阻礙,因此我比較喜歡先使用 以查詢取代暫時變數 將它們轉換成函式,以便在任何萃取的程式碼中呼叫。
完成 以查詢取代暫時變數 之後,程式碼如下所示。
function statement(customer, movies) { let totalAmount = 0; let frequentRenterPoints = 0; let result = `Rental Record for ${customer.name}\n`; for (let r of customer.rentals) { let thisAmount = 0; // determine amount for each movie switch (movieFor(r).code) { case "regular": thisAmount = 2; if (r.days > 2) { thisAmount += (r.days - 2) * 1.5; } break; case "new": thisAmount = r.days * 3; break; case "childrens": thisAmount = 1.5; if (r.days > 3) { thisAmount += (r.days - 3) * 1.5; } break; } //add frequent renter points frequentRenterPoints++; // add bonus for a two day new release rental if(movieFor(r).code === "new" && r.days > 2) frequentRenterPoints++; //print figures for this rental result += `\t${movieFor(r).title}\t${thisAmount}\n` ; totalAmount += thisAmount; } // add footer lines result += `Amount owed is ${totalAmount}\n`; result += `You earned ${frequentRenterPoints} frequent renter points\n`; return result; function movieFor(rental) {return movies[rental.movieID];} }
現在我萃取 switch 陳述式。
function statement(customer, movies) { let totalAmount = 0; let frequentRenterPoints = 0; let result = `Rental Record for ${customer.name}\n`; for (let r of customer.rentals) { const thisAmount = amountFor(r); //add frequent renter points frequentRenterPoints++; // add bonus for a two day new release rental if(movieFor(r).code === "new" && r.days > 2) frequentRenterPoints++; //print figures for this rental result += `\t${movieFor(r).title}\t${thisAmount}\n` ; totalAmount += thisAmount; } // add footer lines result += `Amount owed is ${totalAmount}\n`; result += `You earned ${frequentRenterPoints} frequent renter points\n`; return result; function movieFor(rental) {return movies[rental.movieID];} function amountFor(r) { let thisAmount = 0; // determine amount for each movie switch (movieFor(r).code) { case "regular": thisAmount = 2; if (r.days > 2) { thisAmount += (r.days - 2) * 1.5; } break; case "new": thisAmount = r.days * 3; break; case "childrens": thisAmount = 1.5; if (r.days > 3) { thisAmount += (r.days - 3) * 1.5; } break; } return thisAmount; } }
現在我將注意力轉移到計算常客點數。我可以對其程式碼執行類似的萃取
function statement(customer, movies) {
let totalAmount = 0;
let frequentRenterPoints = 0;
let result = `Rental Record for ${customer.name}\n`;
for (let r of customer.rentals) {
const thisAmount = amountFor(r);
frequentRenterPointsFor(r);
//print figures for this rental
result += `\t${movieFor(r).title}\t${thisAmount}\n` ;
totalAmount += thisAmount;
}
// add footer lines
result += `Amount owed is ${totalAmount}\n`;
result += `You earned ${frequentRenterPoints} frequent renter points\n`;
return result;
…
function frequentRenterPointsFor(r) {
//add frequent renter points
frequentRenterPoints++;
// add bonus for a two day new release rental
if (movieFor(r).code === "new" && r.days > 2) frequentRenterPoints++;
}
雖然我已經萃取函式,但我並不喜歡它透過更新父範圍變數來運作的方式。這種副作用會讓程式碼難以理解,所以我修改它讓它在主體中沒有副作用。
function statement(customer, movies) {
let totalAmount = 0;
let frequentRenterPoints = 0;
let result = `Rental Record for ${customer.name}\n`;
for (let r of customer.rentals) {
const thisAmount = amountFor(r);
frequentRenterPoints += frequentRenterPointsFor(r);
//print figures for this rental
result += `\t${movieFor(r).title}\t${thisAmount}\n` ;
totalAmount += thisAmount;
}
// add footer lines
result += `Amount owed is ${totalAmount}\n`;
result += `You earned ${frequentRenterPoints} frequent renter points\n`;
return result;
…
function frequentRenterPointsFor(r) { let result = 1; if (movieFor(r).code === "new" && r.days > 2) result++; return result; }
我趁機整理一下兩個萃取的函式,同時理解它們。
function amountFor(rental) { let result = 0; switch (movieFor(rental).code) { case "regular": result = 2; if (rental.days > 2) { result += (rental.days - 2) * 1.5; } return result; case "new": result = rental.days * 3; return result; case "childrens": result = 1.5; if (rental.days > 3) { result += (rental.days - 3) * 1.5; } return result; } return result; }
function frequentRenterPointsFor(rental) { return (movieFor(rental).code === "new" && rental.days > 2) ? 2 : 1; }
我還可以對這些函式做更多事,尤其是 amountFor
,這是我在書中做過的事。但對於這篇文章,我不會進一步檢視這些函式的內文。
完成後,我回到函式的內文。
function statement(customer, movies) { let totalAmount = 0; let frequentRenterPoints = 0; let result = `Rental Record for ${customer.name}\n`; for (let r of customer.rentals) { const thisAmount = amountFor(r); frequentRenterPoints += frequentRenterPointsFor(r); //print figures for this rental result += `\t${movieFor(r).title}\t${thisAmount}\n` ; totalAmount += thisAmount; } // add footer lines result += `Amount owed is ${totalAmount}\n`; result += `You earned ${frequentRenterPoints} frequent renter points\n`; return result;
我喜歡使用的一般策略是擺脫可變變數。這裡有三個,一個是收集最終字串,另外兩個計算用於該字串中的值。我對第一個沒意見,但想要消除另外兩個。要開始執行此操作,我需要拆分迴圈。首先,我簡化迴圈並內嵌常數。
function statement(customer, movies) {
let totalAmount = 0;
let frequentRenterPoints = 0;
let result = `Rental Record for ${customer.name}\n`;
for (let r of customer.rentals) {
frequentRenterPoints += frequentRenterPointsFor(r);
result += `\t${movieFor(r).title}\t${amountFor(r)}\n` ;
totalAmount += amountFor(r);
}
// add footer lines
result += `Amount owed is ${totalAmount}\n`;
result += `You earned ${frequentRenterPoints} frequent renter points\n`;
return result;
然後,我將迴圈拆分為三部分。
function statement(customer, movies) {
let totalAmount = 0;
let frequentRenterPoints = 0;
let result = `Rental Record for ${customer.name}\n`;
for (let r of customer.rentals) {
frequentRenterPoints += frequentRenterPointsFor(r);
}
for (let r of customer.rentals) {
result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
}
for (let r of customer.rentals) {
totalAmount += amountFor(r);
}
// add footer lines
result += `Amount owed is ${totalAmount}\n`;
result += `You earned ${frequentRenterPoints} frequent renter points\n`;
return result;
有些程式設計師擔心像這樣重構的效能影響,如果是這樣,請查看一篇關於軟體效能的舊但相關的文章
該拆分允許我為計算提取函式。
function statement(customer, movies) { let result = `Rental Record for ${customer.name}\n`; for (let r of customer.rentals) { result += `\t${movieFor(r).title}\t${amountFor(r)}\n`; } result += `Amount owed is ${totalAmount()}\n`; result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`; return result; function totalAmount() { let result = 0; for (let r of customer.rentals) { result += amountFor(r); } return result; } function totalFrequentRenterPoints() { let result = 0; for (let r of customer.rentals) { result += frequentRenterPointsFor(r); } return result; }
作為集合管線的愛好者,我也會調整迴圈以使用它們。
function totalFrequentRenterPoints() { return customer.rentals .map((r) => frequentRenterPointsFor(r)) .reduce((a, b) => a + b, 0) ; } function totalAmount() { return customer.rentals .reduce((total, r) => total + amountFor(r), 0); }
我不確定我比較喜歡哪兩種管線樣式。
檢視組合函式
現在讓我們看看我們在哪裡。以下是所有程式碼。
function statement(customer, movies) { let result = `Rental Record for ${customer.name}\n`; for (let r of customer.rentals) { result += `\t${movieFor(r).title}\t${amountFor(r)}\n`; } result += `Amount owed is ${totalAmount()}\n`; result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`; return result; function totalFrequentRenterPoints() { return customer.rentals .map((r) => frequentRenterPointsFor(r)) .reduce((a, b) => a + b, 0) ; } function totalAmount() { return customer.rentals .reduce((total, r) => total + amountFor(r), 0); } function movieFor(rental) { return movies[rental.movieID]; } function amountFor(rental) { let result = 0; switch (movieFor(rental).code) { case "regular": result = 2; if (rental.days > 2) { result += (rental.days - 2) * 1.5; } return result; case "new": result = rental.days * 3; return result; case "childrens": result = 1.5; if (rental.days > 3) { result += (rental.days - 3) * 1.5; } return result; } return result; } function frequentRenterPointsFor(rental) { return (movieFor(rental).code === "new" && rental.days > 2) ? 2 : 1; } }
現在我有一個編寫良好的函式。該函式的核心程式碼為 7 行,全部與格式化輸出字串有關。所有計算程式碼都移至其自己的巢狀函式組,每個函式都很小,並且清楚地命名以顯示其目的。
但我仍然無法撰寫發出 html 的函式。分解的函式全部巢狀在整體陳述函式內,這使得提取函式變得更容易,因為它們可以參照函式範圍內的變數名稱,包括彼此(例如 amountFor
呼叫 movieFor
)和提供的參數 customer
和 movie
。但我無法撰寫一個簡單的 htmlStatement
函式來參照這些函式。為了能夠使用相同的計算來支援一些不同的輸出,我需要進行一些進一步的重構。現在我到達一個點,我可以選擇進行多種重構,具體取決於我喜歡如何對程式碼進行分解。接下來,我將逐一執行這些方法,說明每個方法如何運作,然後在我完成所有四個方法後進行比較。
使用參數來決定輸出
我可以採取的一種方法是將輸出格式指定為陳述函式的參數。我將透過使用新增參數來開始此重構,提取現有的文字格式化程式碼,並在開始時撰寫一些程式碼,以便在參數指示時傳遞給提取的函式。

function statement(customer, movies, format = 'text') {
switch (format) {
case "text":
return textStatement();
}
throw new Error(`unknown statement format ${format}`);
function textStatement() {
let result = `Rental Record for ${customer.name}\n`;
for (let r of customer.rentals) {
result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
}
result += `Amount owed is ${totalAmount()}\n`;
result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
return result;
}
然後,我可以撰寫 html 產生函式並將一個子句新增到分派器。
function statement(customer, movies, format = 'text') { switch (format) { case "text": return textStatement(); case "html": return htmlStatement(); } throw new Error(`unknown statement format ${format}`); function htmlStatement() { let result = `<h1>Rental Record for <em>${customer.name}</em></h1>\n`; result += "<table>\n"; for (let r of customer.rentals) { result += ` <tr><td>${movieFor(r).title}</td><td>${amountFor(r)}</td></tr>\n`; } result += "</table>\n"; result += `<p>Amount owed is <em>${totalAmount()}</em></p>\n`; result += `<p>You earned <em>${totalFrequentRenterPoints()}</em> frequent renter points</p>\n`; return result; }
我可能會想為分派器邏輯使用資料結構。
function statement(customer, movies, format = 'text') { const dispatchTable = { "text": textStatement, "html": htmlStatement }; if (undefined === dispatchTable[format]) throw new Error(`unknown statement format ${format}`); return dispatchTable[format].call();
使用頂層函式
撰寫頂層 html 陳述函式時的問題在於計算函式巢狀在文字陳述函式內。因此,一個顯而易見的進行方式是將它們移至頂層內容。

為執行此操作,我首先尋找不參照任何其他函式的函式,在本例中為 movieFor
每當我移動函式時,我喜歡先將函式複製到新的內容中,使其符合該內容,然後再用對已移動函式的呼叫取代原始函式主體。
function topMovieFor(rental, movies) { return movies[rental.movieID]; }
function statement(customer, movies) { // [snip]
function movieFor(rental) {
return topMovieFor(rental, movies);
}
function frequentRenterPointsFor(rental) { return (movieFor(rental).code === "new" && rental.days > 2) ? 2 : 1; }
此時我可以編譯並測試,這將告訴我內容變更是否造成任何問題。完成後,我就可以內聯轉送函式。
function movieFor(rental, movies) {
return movies[rental.movieID];
}
function statement(customer, movies) { // [snip]
function frequentRenterPointsFor(rental) {
return (movieFor(rental, movies).code === "new" && rental.days > 2) ? 2 : 1;
}
amountFor
內部有類似的變更
除了內聯之外,我也將頂層函式重新命名為舊名稱,因此現在唯一的差別在於 movies
參數。
然後我對所有巢狀函式執行此操作
function statement(customer, movies) { let result = `Rental Record for ${customer.name}\n`; for (let r of customer.rentals) { result += `\t${movieFor(r, movies).title}\t${amountFor(r, movies)}\n`; } result += `Amount owed is ${totalAmount(customer, movies)}\n`; result += `You earned ${totalFrequentRenterPoints(customer, movies)} frequent renter points\n`; return result; } function totalFrequentRenterPoints(customer, movies) { return customer.rentals .map((r) => frequentRenterPointsFor(r, movies)) .reduce((a, b) => a + b, 0) ; } function totalAmount(customer, movies) { return customer.rentals .reduce((total, r) => total + amountFor(r, movies), 0); } function movieFor(rental, movies) { return movies[rental.movieID]; } function amountFor(rental, movies) { let result = 0; switch (movieFor(rental, movies).code) { case "regular": result = 2; if (rental.days > 2) { result += (rental.days - 2) * 1.5; } return result; case "new": result = rental.days * 3; return result; case "childrens": result = 1.5; if (rental.days > 3) { result += (rental.days - 3) * 1.5; } return result; } return result; } function frequentRenterPointsFor(rental, movies) { return (movieFor(rental, movies).code === "new" && rental.days > 2) ? 2 : 1; }
現在我可以輕鬆撰寫 html 陳述函式
function htmlStatement(customer, movies) { let result = `<h1>Rental Record for <em>${customer.name}</em></h1>\n`; result += "<table>\n"; for (let r of customer.rentals) { result += ` <tr><td>${movieFor(r, movies).title}</td><td>${amountFor(r, movies)}</td></tr>\n`; } result += "</table>\n"; result += `<p>Amount owed is <em>${totalAmount(customer, movies)}</em></p>\n`; result += `<p>You earned <em>${totalFrequentRenterPoints(customer, movies)}</em> frequent renter points</p>\n`; return result; }
宣告一些部分套用函式
使用此類全域函式時,參數清單可能會相當長。因此,有時宣告一個使用部分或全部已填入參數來呼叫全域函式的區域函式會很有用。然後可以在稍後使用該區域函式,這是全域函式的部分應用。在 JavaScript 中有多種執行此操作的方法。其中一種是將區域函式指定給變數。
function htmlStatement(customer, movies) { const amount = () => totalAmount(customer, movies); const frequentRenterPoints = () => totalFrequentRenterPoints(customer, movies); const movie = (aRental) => movieFor(aRental, movies); const rentalAmount = (aRental) => amountFor(aRental, movies); let result = `<h1>Rental Record for <em>${customer.name}</em></h1>\n`; result += "<table>\n"; for (let r of customer.rentals) { result += ` <tr><td>${movie(r).title}</td><td>${rentalAmount(r)}</td></tr>\n`; } result += "</table>\n"; result += `<p>Amount owed is <em>${amount()}</em></p>\n`; result += `<p>You earned <em>${frequentRenterPoints()}</em> frequent renter points</p>\n`; return result; }
另一種是將它們宣告為巢狀函式。
function htmlStatement(customer, movies) { let result = `<h1>Rental Record for <em>${customer.name}</em></h1>\n`; result += "<table>\n"; for (let r of customer.rentals) { result += ` <tr><td>${movie(r).title}</td><td>${rentalAmount(r)}</td></tr>\n`; } result += "</table>\n"; result += `<p>Amount owed is <em>${amount()}</em></p>\n`; result += `<p>You earned <em>${frequentRenterPoints()}</em> frequent renter points</p>\n`; return result; function amount() {return totalAmount(customer, movies);} function frequentRenterPoints() {return totalFrequentRenterPoints(customer, movies);} function rentalAmount(aRental) {return amountFor(aRental, movies);} function movie(aRental) {return movieFor(aRental, movies);} }
另一種方法是使用 bind
。我會讓您查詢此方法,因為我認為這些表單較容易遵循,所以我不打算在此處使用它。
使用類別
物件導向對我來說很熟悉,因此我將考慮類別和物件也就不足為奇了。ES6 為傳統 OO 導入了良好的語法。我們來看看我如何將它套用至這個範例。

我的第一步是將資料包覆在物件中,從客戶開始。
customer.es6…
export default class Customer { constructor(data) { this._data = data; } get name() {return this._data.name;} get rentals() { return this._data.rentals;} }
statement.es6…
import Customer from './customer.es6'; function statement(customerArg, movies) { const customer = new Customer(customerArg); let result = `Rental Record for ${customer.name}\n`; for (let r of customer.rentals) { result += `\t${movieFor(r).title}\t${amountFor(r)}\n`; } result += `Amount owed is ${totalAmount()}\n`; result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`; return result;
到目前為止,類別只是一個包覆在原始 JavaScript 物件上的簡單包裝器。接下來,我將對租賃執行類似的包裝器。
rental.es6…
export default class Rental { constructor(data) { this._data = data; } get days() {return this._data.days} get movieID() {return this._data.movieID} }
customer.es6…
import Rental from './rental.es6'
export default class Customer {
constructor(data) {
this._data = data;
}
get name() {return this._data.name;}
get rentals() { return this._data.rentals.map(r => new Rental(r));}
}
現在我已將類別包覆在我的簡單 json 物件中,我有一個 移動方法 的目標。與將函式移動到頂層一樣,要處理的第一個函式是不呼叫任何其他函式的函式 - movieFor
。但這個函式需要電影清單作為內容,而這需要提供給新建立的租賃物件。
statement.es6…
function statement(customerArg, movies) {
const customer = new Customer(customerArg, movies);
let result = `Rental Record for ${customer.name}\n`;
for (let r of customer.rentals) {
result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
}
result += `Amount owed is ${totalAmount()}\n`;
result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
return result;
class Customer…
constructor(data, movies) { this._data = data; this._movies = movies } get rentals() { return this._data.rentals.map(r => new Rental(r, this._movies));}
class Rental…
constructor(data, movies) { this._data = data; this._movies = movies; }
一旦我準備好支援資料,我就可以移動函數。
statement.es6…
function movieFor(rental) {
return rental.movie;
}
class Rental…
get movie() {
return this._movies[this.movieID];
}
與我先前執行的移動相同,第一步是將核心行為放入新的內容中,將其放入該內容中,並調整原始函數以呼叫新的函數。一旦這項工作完成,就很容易內嵌原始函數呼叫。
statement.es6…
function statement(customerArg, movies) {
const customer = new Customer(customerArg, movies);
let result = `Rental Record for ${customer.name}\n`;
for (let r of customer.rentals) {
result += `\t${r.movie.title}\t${amountFor(r)}\n`;
}
result += `Amount owed is ${totalAmount()}\n`;
result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
return result;
function amountFor(rental) {
let result = 0;
switch (rental.movie.code) {
case "regular":
result = 2;
if (rental.days > 2) {
result += (rental.days - 2) * 1.5;
}
return result;
case "new":
result = rental.days * 3;
return result;
case "childrens":
result = 1.5;
if (rental.days > 3) {
result += (rental.days - 3) * 1.5;
}
return result;
}
return result;
}
function frequentRenterPointsFor(rental) {
return (rental.movie.code === "new" && rental.days > 2) ? 2 : 1;
}
我可以使用相同的基本順序將兩個計算移動到租賃中。
statement.es6…
function statement(customerArg, movies) {
const customer = new Customer(customerArg, movies);
let result = `Rental Record for ${customer.name}\n`;
for (let r of customer.rentals) {
result += `\t${r.movie.title}\t${r.amount}\n`;
}
result += `Amount owed is ${totalAmount()}\n`;
result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
return result;
function totalFrequentRenterPoints() { return customer.rentals .map((r) => r.frequentRenterPoints) .reduce((a, b) => a + b, 0) ; } function totalAmount() { return customer.rentals .reduce((total, r) => total + r.amount, 0); }
class Rental…
get frequentRenterPoints() { return (this.movie.code === "new" && this.days > 2) ? 2 : 1; } get amount() { let result = 0; switch (this.movie.code) { case "regular": result = 2; if (this.days > 2) { result += (this.days - 2) * 1.5; } return result; case "new": result = this.days * 3; return result; case "childrens": result = 1.5; if (this.days > 3) { result += (this.days - 3) * 1.5; } return result; } return result; }
然後我可以將兩個總計函數移動到客戶端
statement.es6…
function statement(customerArg, movies) { const customer = new Customer(customerArg, movies); let result = `Rental Record for ${customer.name}\n`; for (let r of customer.rentals) { result += `\t${r.movie.title}\t${r.amount}\n`; } result += `Amount owed is ${customer.amount}\n`; result += `You earned ${customer.frequentRenterPoints} frequent renter points\n`; return result; }
class Customer…
get frequentRenterPoints() { return this.rentals .map((r) => r.frequentRenterPoints) .reduce((a, b) => a + b, 0) ; } get amount() { return this.rentals .reduce((total, r) => total + r.amount, 0); }
將計算邏輯移到租賃和客戶端物件後,撰寫報表的 HTML 版本就很簡單。
statement.es6…
function htmlStatement(customerArg, movies) { const customer = new Customer(customerArg, movies); let result = `<h1>Rental Record for <em>${customer.name}</em></h1>\n`; result += "<table>\n"; for (let r of customer.rentals) { result += ` <tr><td>${r.movie.title}</td><td>${r.amount}</td></tr>\n`; } result += "</table>\n"; result += `<p>Amount owed is <em>${customer.amount}</em></p>\n`; result += `<p>You earned <em>${customer.frequentRenterPoints}</em> frequent renter points</p>\n`; return result; }
沒有語法的類別
ES2015 中的類別語法有爭議,有些人認為不需要(通常會對 Java 開發人員冷嘲熱諷)。您可以採取完全相同的重構步驟來產生類似的結果
function statement(customerArg, movies) { const customer = createCustomer(customerArg, movies); let result = `Rental Record for ${customer.name()}\n`; for (let r of customer.rentals()) { result += `\t${r.movie().title}\t${r.amount()}\n`; } result += `Amount owed is ${customer.amount()}\n`; result += `You earned ${customer.frequentRenterPoints()} frequent renter points\n`; return result; } function createCustomer(data, movies) { return { name: () => data.name, rentals: rentals, amount: amount, frequentRenterPoints: frequentRenterPoints }; function rentals() { return data.rentals.map(r => createRental(r, movies)); } function frequentRenterPoints() { return rentals() .map((r) => r.frequentRenterPoints()) .reduce((a, b) => a + b, 0) ; } function amount() { return rentals() .reduce((total, r) => total + r.amount(), 0); } } function createRental(data, movies) { return { days: () => data.days, movieID: () => data.movieID, movie: movie, amount: amount, frequentRenterPoints: frequentRenterPoints }; function movie() { return movies[data.movieID]; } function amount() { let result = 0; switch (movie().code) { case "regular": result = 2; if (data.days > 2) { result += (data.days - 2) * 1.5; } return result; case "new": result = data.days * 3; return result; case "childrens": result = 1.5; if (data.days > 3) { result += (data.days - 3) * 1.5; } return result; } return result; } function frequentRenterPoints() { return (movie().code === "new" && data.days > 2) ? 2 : 1; } }
此方法使用 函數作為物件 範本。建構函數(createCustomer
和 createRental
)傳回函數參考的 JavaScript 物件(雜湊)。每個建構函數都包含一個封閉,其中包含物件的資料。由於傳回的函數物件位於相同的函數內容中,因此它們可以存取這些資料。我認為這與使用類別語法完全相同,但實作方式不同。我比較喜歡使用明確的語法,因為它比較明確 - 因此讓我的思考更清晰。
資料轉換
所有這些方法都涉及陳述列印函數呼叫其他函數來計算它們所需的資料。另一種方法是在資料結構本身中將這些資料傳遞給陳述列印函數。在此方法中,計算函數用於轉換客戶端資料結構,以便它具有列印函數所需的所有資料。

在重構術語中,這是尚未撰寫的 Split Phase 重構的一個範例,Kent Beck 去年夏天向我描述過。使用此重構,我將計算拆分為兩個階段,使用中間資料結構進行通訊。我透過引入中間資料結構開始此重構。
function statement(customer, movies) { const data = createStatementData(customer, movies); let result = `Rental Record for ${data.name}\n`; for (let r of data.rentals) { result += `\t${movieFor(r).title}\t${amountFor(r)}\n`; } result += `Amount owed is ${totalAmount()}\n`; result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`; return result; function createStatementData(customer, movies) { let result = Object.assign({}, customer); return result; }
對於此案例,我使用新增的元素豐富原始客戶端資料結構,因此從呼叫 Object.assign
開始。我也可以建立一個全新的資料結構,選擇實際上取決於轉換後的資料結構與原始資料結構的差異程度。
然後我對每一行租賃執行相同的工作
函數陳述…
function createStatementData(customer, movies) { let result = Object.assign({}, customer); result.rentals = customer.rentals.map(r => createRentalData(r)); return result; function createRentalData(rental) { let result = Object.assign({}, rental); return result; } }
請注意,我將 createRentalData
巢狀置於 createStatementData
內部,因為任何 createStatementData
的呼叫者都不需要知道內部是如何建構的。
然後,我可以開始填入轉換後的資料,從租借電影的標題開始。
function statement(customer, movies) {
const data = createStatementData(customer, movies);
let result = `Rental Record for ${data.name}\n`;
for (let r of data.rentals) {
result += `\t${r.title}\t${amountFor(r)}\n`;
}
result += `Amount owed is ${totalAmount()}\n`;
result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
return result;
//… function createStatementData(customer, movies) { // …
function createRentalData(rental) {
let result = Object.assign({}, rental);
result.title = movieFor(rental).title;
return result;
}
}
接著是計算金額,然後是總計。
function statement(customer, movies) { const data = createStatementData(customer, movies); let result = `Rental Record for ${data.name}\n`; for (let r of data.rentals) { result += `\t${r.title}\t${r.amount}\n`; } result += `Amount owed is ${data.totalAmount}\n`; result += `You earned ${data.totalFrequentRenterPoints} frequent renter points\n`; return result; function createStatementData(customer, movies) { let result = Object.assign({}, customer); result.rentals = customer.rentals.map(r => createRentalData(r)); result.totalAmount = totalAmount(); result.totalFrequentRenterPoints = totalFrequentRenterPoints(); return result; function createRentalData(rental) { let result = Object.assign({}, rental); result.title = movieFor(rental).title; result.amount = amountFor(rental); return result; } }
現在,我已讓所有計算函數將其計算結果作為資料,我可以將函數移出,使它們與陳述呈現函數分開。首先,我將所有計算函數移至 createStatementData
內部
function statement (customer, movies) { // body … function createStatementData (customer, movies) { // body … function createRentalData(rental) { … } function totalFrequentRenterPoints() { … } function totalAmount() { … } function movieFor(rental) { … } function amountFor(rental) { … } function frequentRenterPointsFor(rental) { … } } }
然後,我將 createStatementData
移至 statement
外部。
function statement (customer, movies) { … } function createStatementData (customer, movies) { function createRentalData(rental) { … } function totalFrequentRenterPoints() { … } function totalAmount() { … } function movieFor(rental) { … } function amountFor(rental) { … } function frequentRenterPointsFor(rental) { … } }
一旦我像這樣將函數分開,我就可以撰寫陳述的 HTML 版本,以使用相同的資料結構。
function htmlStatement(customer, movies) { const data = createStatementData(customer, movies); let result = `<h1>Rental Record for <em>${data.name}</em></h1>\n`; result += "<table>\n"; for (let r of data.rentals) { result += ` <tr><td>${r.title}</td><td>${r.amount}</td></tr>\n`; } result += "</table>\n"; result += `<p>Amount owed is <em>${data.totalAmount}</em></p>\n`; result += `<p>You earned <em>${data.totalFrequentRenterPoints}</em> frequent renter points</p>\n`; return result; }
我也可以將 createStatementData
移至一個獨立的模組,以進一步釐清計算資料和呈現陳述之間的界線。
statement.es6
import createStatementData from './createStatementData.es6'; function htmlStatement(customer, movies) { … } function statement(customer, movies) { … }
createStatementData.es6
export default function createStatementData (customer, movies) { function createRentalData(rental) { … } function totalFrequentRenterPoints() { … } function totalAmount() { … } function movieFor(rental) { … } function amountFor(rental) { … } function frequentRenterPointsFor(rental) { … } }
比較方法
現在是時候後退一步,看看我得到了什麼。我有一個初始的程式碼主體,寫成一個單一的內嵌函數。我希望重構此程式碼,以啟用 html 呈現,而不複製計算程式碼。我的第一步是將此程式碼拆分成數個函數,存在於原始函數內。從那裡,我探索了四條不同的路徑

top-level-functions
將所有函數寫成頂層函數
function htmlStatement(customer, movies)
function textStatement(customer, movies)
function totalAmount(customer, movies)
function totalFrequentRenterPoints(customer, movies)
function amountFor(rental, movies)
function frequentRenterPointsFor(rental, movies)
function movieFor(rental, movies)
parameter-dispatch
使用頂層函數的一個參數來陳述要發出的輸出格式
function statement(customer, movies, format)
function htmlStatement()
function textStatement()
function totalAmount()
function totalFrequentRenterPoints()
function amountFor(rental)
function frequentRenterPointsFor(rental)
function movieFor(rental)
classes
將計算邏輯移至由呈現函數使用的類別
function textStatement(customer, movies)
function htmlStatement(customer, movies)
class Customer
get amount()
get frequentRenterPoints()
取得 rentals()
類別 Rental
get amount()
get frequentRenterPoints()
取得 movie()
轉換
將計算邏輯分割成獨立的巢狀函式,產生中間資料結構供渲染函式使用
函式 statement(customer, movies)
function htmlStatement(customer, movies)
函式 createStatementData(customer, movies)
函式 createRentalData()
function totalAmount()
function totalFrequentRenterPoints()
function amountFor(rental)
function frequentRenterPointsFor(rental)
function movieFor(rental)
我將從頂層函式範例開始,因為它是概念上最簡單的替代方案,也是我的比較基準。[2]它很簡單,因為它將工作分為一組純函式,所有函式都可以在我程式碼中的任何點呼叫。這易於使用且易於測試 - 我可以透過測試案例或 REPL 輕鬆測試任何個別函式。
頂層函式的缺點是重複參數傳遞很多。每個函式都需要提供電影資料結構,而顧客層級函式也需要提供顧客結構。我並不擔心重複的輸入,但我擔心重複的讀取。每次讀取參數時,我都必須找出它們是什麼,並檢查參數是否正在變更。對於所有這些函式,顧客和電影資料都是常見的內容 - 但對於頂層函式,這種常見的內容並未明確說明。我在讀取程式並在腦海中建立執行模型時推論它,但我希望事情盡可能明確。
隨著內容的增加,這個因素變得更加重要。我這裡只有兩個資料項目,但發現更多資料項目並不少見。僅使用頂層函式,我最終會在每次呼叫上得到大量的參數清單,每個清單都會增加我的閱讀理解負擔。這可能會導致將所有這些參數捆綁到一個內容參數中,其中包含許多函式的全部內容,並最終模糊這些函式的功能。我可以透過定義局部部分套用函式來減輕所有這些痛苦,但這需要額外宣告許多函式加入組合中 — 這必須與每個客戶端程式碼重複。
其他三個替代方案的優點是,它們各自明確說明常見內容,並將其擷取到程式結構中。參數調度方法透過擷取頂層參數清單中的內容來執行此操作,然後作為所有巢狀函式的常見內容提供。這特別適用於原始程式碼,使從單一函式到巢狀函式的重構比缺乏巢狀函式的語言更簡單。
但當我需要從我的內容中得到不同的整體行為時,例如 html 格式的回應,參數調度方法就會開始搖擺不定。我需要撰寫某種調度器來決定我想呼叫哪個函式。將格式指定給一個渲染器並不算太糟,但這種調度邏輯卻是一種明顯的臭味。無論我怎麼撰寫,它仍然本質上是重複語言呼叫命名函式的核心能力。我正朝著一條道路前進,這條道路很快就會引領我走向無稽之談
function executeFunction (name, args) { const dispatchTable = { //...
這種方法有一種情境,也就是當輸出格式的選擇以資料的形式傳遞給我的呼叫者時。在這種情況下,必須對該資料項目有一個調度機制。然而,如果我的呼叫者像這樣呼叫陳述函式…
const someValue = statement(customer, movieList, 'text');
…那麼我就不應該在我的程式碼中撰寫調度邏輯。
呼叫方法是這裡的關鍵。使用文字值來表示函式選擇是一種臭味。與其使用這個 API,不如讓呼叫者在函式名稱中說出他們想要什麼,例如 textStatement
或 htmlStatement
。然後,我可以使用語言的函式調度機制,並避免自己拼湊出其他東西。
因此,有了這兩個備選方案,我身在何處?我想要一些邏輯的明確共同內容,但需要使用該邏輯呼叫不同的運算。當我感受到這種需求時,我立刻會想到使用物件導向 - 這本質上是一組在共同內容上獨立可呼叫的運算。[3]這引領我進入範例的類別版本,它允許我在客戶和租賃物件中擷取客戶和電影的共同內容。我在實例化物件時設定一次內容,然後所有進一步的邏輯都可以使用該共同內容。
物件方法就像頂層情況中的部分套用局部函式,只不過這裡的共同內容是由建構函式提供的。因此,我只撰寫局部函式,而不撰寫頂層函式。呼叫者使用建構函式表示內容,然後直接呼叫局部函式。我可以將局部方法視為物件實例的共同內容中假設頂層函式的部分套用。
使用類別會引入另一個概念,即將呈現邏輯與計算邏輯分開。原始單一函數的缺點之一是將兩者混在一起。拆分為函數會在某種程度上將它們分開,但它們仍然存在於同一個概念空間中。這有點不公平,我可以將計算函數放入一個檔案中,將呈現函數放入另一個檔案中,並透過適當的匯入陳述式將它們連結起來。但我發現,一個常見的內容會提供一個自然提示,說明如何將邏輯分組到模組中。
我已將物件描述為一組常見的部分應用程式,但還有另一種方法可以檢視它們。物件會使用輸入資料結構進行實例化,但會透過計算函數公開的計算資料豐富此資料。我透過建立這些取得器來強化這種思考方式,因此客戶會將它們視為與原始資料完全相同,套用統一存取原則。我可以將此視為從建構函數引數轉換為取得器的這個虛擬資料結構。轉換範例是相同的概念,但透過建立一個結合初始資料與所有計算資料的新資料結構來實作。就像物件將計算邏輯封裝在客戶和租賃類別中一樣,轉換方法將該邏輯封裝在 createStatementData
和 createRentalData
中。這種轉換基本清單和雜湊資料結構的方法是許多功能性思考的常見特徵。它允許 create…Data
函數共用它們需要的內容,並讓呈現邏輯以簡單的方式使用多個輸出。
將類別視為轉換與轉換方法本身之間的一個小差異是轉換計算發生的時間。此處的轉換方法會一次全部轉換,而類別會在每次呼叫時進行個別轉換。我可以輕鬆切換計算發生的時間以符合另一個時間。在類別案例中,我可以透過在建構函數中執行計算來一次執行所有計算。對於轉換案例,我可以透過在中間資料結構中傳回函數來依需求重新計算。在此處,效能差異幾乎總是微不足道的,如果這些函數中任何一個很耗費資源,我最好的方法通常是使用一個方法/函數,並在第一次呼叫後快取結果。
因此有四種方法,我比較偏好哪一種?我不喜歡撰寫分配器邏輯,所以我不會使用參數分配方法。我會考慮頂層函數,但隨著共用內容的大小增加,我對它們的喜好會迅速下降。即使只有兩個引數,我也會傾向於使用其他替代方案。在類別和轉換方法之間進行選擇比較困難,兩者都提供一種明確說明共用內容和良好區分問題的方法。我不喜歡籠中鬥,所以我可能只是讓他們玩彈珠,然後選出贏家。
進一步重構
在此探索中,我探索了排列計算和呈現函式的四種方式。軟體是一種非常有彈性的媒介,而且還有更多變化可以用在這裡,但這四種是我認為最值得討論的。
除了排列這些函式之外,還有進一步的重構。在書中範例中,我分解了 amount
和 frequentRenterPoint
計算,以支援使用新的電影類型來延伸模型。我會對呈現程式碼進行變更,例如提取標頭、行和頁尾的共用模式。但我認為這四種路徑已經足夠在這篇文章中思考。
我的結論,如果有的話,就是有不同的方式可以合理地排列可觀察到的相同計算。不同的語言鼓勵某些樣式 - 最初的書本重構是在 Java 中完成的,這極大地鼓勵了類別樣式。JavaScript 靈活支援多種樣式,這很好,因為它為程式設計師提供了選項,而且很糟糕,因為它為程式設計師提供了選項。(使用 JavaScript 進行程式設計的困難之一是對於什麼是好的樣式幾乎沒有共識。)瞭解這些不同的樣式很有用,但更重要的是要了解是什麼將它們聯繫在一起。小函式,只要命名得當,就可以組合和操作,以同時和隨著時間推移支援各種需求。常見的內容建議將邏輯分組在一起,而程式設計的藝術很大一部分在於決定如何將問題區分為一組明確的此類內容。
腳註
1: 重構目錄是在物件導向詞彙流行時編寫的,所以我使用「方法」來指函式/子常式/程序或類似內容。在 JavaScript 中,使用「函式」會比較合理,但我使用的是書中的重構名稱。
2: 參數調度會是一個更好的第一個重構,因為它的結構更接近原始的巢狀函式組。但在比較替代方案時,頂層函式案例是更好的起點。
3: 我相當喜歡 Will Cook 對「物件」定義的提議
致謝
Vitor Gomes 提醒我 ES6 中的預設參數值。
Beth Andres-Beck、Bill Wake、Chaoyang Jia、Greg Doench、Henrique Souza、Jay Fields、Kevin Yeung、Marcos Brizeno、Pete Hodgson 和 Ryan Boucher 在郵件清單上討論了這篇文章的草稿。
Ruben Bartelink 通知我們一堆拼字錯誤需要修正。
Udo Borkowski 指出範例中的錯誤。
重大修訂
2016 年 5 月 18 日:首次發布