原作: https://github.com/ryanmcdermott/clean-code-javascript
原作者: https://github.com/ryanmcdermott
譯者: https://github.com/trylovetom
- 介紹(Introduction)
- 變數(Variables)
- 函數(Functions)
- 物件(Objects)與資料結構(Data Structures)
- 類別(Classes)
- 物件導向基本原則(SOLID)
- 測試(Testing)
- 並發(Concurrency)
- 錯誤處理(Error Handling)
- 格式化(Formatting)
- 註解(Comments)
- 翻譯(Translation)
透過計算閱讀程式碼時的咒罵次數,來評估軟體品質
文章作者根據 Robert C. Martin 的《無暇的程式碼》,撰寫一份適用於 JavaScript 的原則。本文不是風格指南(Style Guide),而是教導你撰寫出可閱讀、可重複使用與可重構的 JS 程式碼。
注意!你不必嚴格遵守每一項原則,有些甚至不被大眾所認同。雖然這只是份指南,卻是來自《無暇的程式碼》作者的多年結晶。
軟體工程只發展了五十年,仍然有很多地方值得去探討。當軟體與建築一樣古老時,也許會有一些墨守成規的原則。但現在,先讓這份指南當試金石,作為你和團隊的 JS 程式碼標準。
還有一件事情:知道這些原則,並不會立刻讓你成為出色的開發者,長期奉行它們,不代表你能高枕無憂不再犯錯。但是,千里之行,始於足下,時常與志同道合們進行討論(Code Review),改善不完備之處。不要因為自己寫出來的程式碼很糟糕而害怕分享,而是要畏懼自己居然寫出了這樣的程式碼!
譯者序
獻給傲嬌文創的所有工程師夥伴,與熱愛 JavaScript 的各位。
《無暇的程式碼》是一本好書,是不可否認這個事實。我熱愛著 JS,當找到這份 JS 版本的後,我無比開心立刻著手翻譯,因此有了這份《無暇的程式碼 JavaScript》。對於 Clean Code 的書名採用博碩文化的翻譯,也是對原版譯者的尊敬。本翻譯中會穿插一些我對文章中註解,也請讀者原諒我的叨擾。另外專業術語的翻譯有可能會有出入,我會標示出英文原文,避免讀者誤解。如有翻譯或是理解上的錯誤,煩請聯絡我,謝謝。(聯絡方式在上方,我的 GitHub 中。)
糟糕的:
const yyyymmdstr = moment().format('YYYY/MM/DD');
適當的:
const currentDate = moment().format('YYYY/MM/DD');
糟糕的:
getUserInfo();
getClientData();
getCustomerRecord();
適當的:
getUser();
使用易於閱讀與搜尋的名稱非常重要,因為我們要閱讀的程式碼遠比自己寫得多。使用沒有意義的名稱,會導致程式碼難以理解,對後續閱讀者是個糟糕的體驗。另外使用以下工具,可以協助你找出未命名的常數:
糟糕的:
// 86400000 代表什麼意義?
setTimeout(blastOff, 86400000);
適當的:
// 宣告(Declare)一個有意義的常數(constants)
const MILLISECONDS_IN_A_DAY = 86400000;
setTimeout(blastOff, MILLISECONDS_IN_A_DAY);
糟糕的:
const address = 'One Infinite Loop, Cupertino 95014';
const cityZipCodeRegex = /^[^,\\]+[,\\\s]+(.+?)\s*(\d{5})?$/;
saveCityZipCode(
address.match(cityZipCodeRegex)[1],
address.match(cityZipCodeRegex)[2]
);
適當的:
const address = 'One Infinite Loop, Cupertino 95014';
const cityZipCodeRegex = /^[^,\\]+[,\\\s]+(.+?)\s*(\d{5})?$/;
const [, city, zipCode] = address.match(cityZipCodeRegex) || [];
saveCityZipCode(city, zipCode);
譯者附註
address.match(cityZipCodeRegex)
取出了字串中的 city 與 zipCode 並以陣列(Array)的方式輸出。在糟糕的範例中,你不會知道哪個是 city,哪個是 zipCode。在適當的範例中,則清楚地解釋了。
清晰(Explicit)的表達會比隱藏(Implicit)更好。
糟糕的:
const locations = ['Austin', 'New York', 'San Francisco'];
locations.forEach(l => {
doStuff();
doSomeOtherStuff();
// ...
// ...
// ...
// 等等,這 `l` 是…?
dispatch(l);
});
適當的:
const locations = ['Austin', 'New York', 'San Francisco'];
locations.forEach(location => {
doStuff();
doSomeOtherStuff();
// ...
// ...
// ...
dispatch(location);
});
譯者附註
在糟糕的範例中,程式碼的作者認為從 locations
取出的都是地址,所以選用縮減後的 l
作為名稱。不過這只有作者自己這麼認為,其他人可不一定知道。避免「我認為」、「我以為」、「我覺得」,這樣的心理作用。
如果你的類別與物件名稱是有關聯意義的,就不用在內部變數上再次重複。
糟糕的:
const Car = {
carMake: 'Honda',
carModel: 'Accord',
carColor: 'Blue'
};
function paintCar(car) {
car.carColor = 'Red';
}
適當的:
const Car = {
make: 'Honda',
model: 'Accord',
color: 'Blue'
};
function paintCar(car) {
car.color = 'Red';
}
使用預設參數較整潔,但請注意,當參數為 undefined
時才會作用,其他種類的虛值(Falsy)不會,像是 ''
、false
、null
、0
、NaN
等。
糟糕的:
function createMicrobrewery(name) {
const breweryName = name || 'Hipster Brew Co.';
// ...
}
適當的:
function createMicrobrewery(name = 'Hipster Brew Co.') {
// ...
}
譯者附註
預設參數非常好用,可以結合工廠模式做出很多應用。另外建議統一使用 undefined
代替 null
當作空值的回傳值。
此處原文為 Arguments,但是函數的參數定義應該為 Parameter,而呼叫函數時傳遞的引數才是 Arguments。
限制函數的參數數量非常重要,因為能讓你更容易地測試。過多的參數代表著過多的組合,會導致你不得不編寫出大量測試。
一個至二個是最理想的,盡可能避免大於三個以上。如果你有超過兩個以上的參數,代表你的函數做太多事情。如果無法避免時,可以有效地使用物件替代大量的參數。
為了讓你可以清晰地表達,預期使用哪些物件的屬性(Properties),可以使用 ES2015/ES6 提供的解構(Destructuring)語法。使用這種語法有以下優點:
- 函數需要物件的哪些屬性,可以像參數一樣清晰地表達。
- 解構語法會複製來自物件的原始型態(Primitive),這能幫助你避免邊際效應(Side Effect)。注意!巢狀物件與陣列,並不會被解構語法複製。
- 使用解構語法能讓物件屬性被程式碼檢查器(Linter)作用,提醒你哪些屬性未被使用到。
糟糕的:
function createMenu(title, body, buttonText, cancellable) {
// ...
}
適當的:
function createMenu({ title, body, buttonText, cancellable }) {
// ...
}
createMenu({
title: 'Foo',
body: 'Bar',
buttonText: 'Baz',
cancellable: true
});
譯者附註
這種方法非常適合用於工廠模式,結合上一章的預設參數,譯者推薦的使用方式如下:
function createMenu({
title = 'Default Title', // 傳遞的物件不齊全,使用預設屬性
body = '',
buttonText = 'My Button',
cancellable = true
} = {}) { // 如未傳遞任何參數使用預設空物件,使用 `= {}` 可避免 TypeError: Cannot destructure property `...` of 'undefined' or 'null'.
return {
title,
body,
buttonText,
cancellable
}
}
const myMenu = createMenu({
title: 'Foo',
body: 'Bar',
buttonText: 'Baz',
cancellable: true
});
想了解更多工廠模式與 ES6 的結合,可參考連結文章:JavaScript Factory Functions with ES6+。
這是個非常重要的原則,當你的函數做超過一件事情時,它會更難以被撰寫、測試與理解。當你隔離(Isolate)你的函數到只做一件事情時,它能更容易地被重構(Refactor)與清晰地閱讀。如果嚴格遵守此項原則,你將會領先許多開發者。
糟糕的:
function emailClients(clients) {
clients.forEach(client => {
const clientRecord = database.lookup(client);
if (clientRecord.isActive()) {
email(client);
}
});
}
適當的:
function emailActiveClients(clients) {
clients.filter(isActiveClient).forEach(email);
}
function isActiveClient(client) {
const clientRecord = database.lookup(client);
return clientRecord.isActive();
}
糟糕的:
function addToDate(date, month) {
// ...
}
const date = new Date();
// 難以從函數名稱看出到底加入了什麼
addToDate(date, 1);
適當的:
function addMonthToDate(month, date) {
// ...
}
const date = new Date();
addMonthToDate(1, date);
譯者附註
建議函數命名以動詞開頭,像是 doSomething()
、setupUserProfile()
。
當你的函數需要的抽象多於一層時,代表你的函數做太多事情了。將其分解以利重用與測試。
糟糕的:
function parseBetterJSAlternative(code) {
const REGEXES = [
// ...
];
const statements = code.split(' ');
const tokens = [];
REGEXES.forEach(REGEX => {
statements.forEach(statement => {
// ...
});
});
const ast = [];
tokens.forEach(token => {
// lex...
});
ast.forEach(node => {
// parse...
});
}
適當的:
function parseBetterJSAlternative(code) {
const tokens = tokenize(code);
const syntaxTree = parse(tokens);
syntaxTree.forEach(node => {
// parse...
});
}
function tokenize(code) {
const REGEXES = [
// ...
];
const statements = code.split(' ');
const tokens = [];
REGEXES.forEach(REGEX => {
statements.forEach(statement => {
tokens.push(/* ... */);
});
});
return tokens;
}
function parse(tokens) {
const syntaxTree = [];
tokens.forEach(token => {
syntaxTree.push(/* ... */);
});
return syntaxTree;
}
譯者附註
這個原則與前面提到的「一個函數只做一件事情(單一性)」概念相似。
絕對避免重複的程式碼,重複的程式碼代表者更動邏輯時,需要同時修改多處。
想像一下你經營著一家餐廳,需要持續追蹤存貨:番茄、洋蔥、大蒜與各種香料等。如果你有多份紀錄表,當使用蕃茄做完一道料理時,需要更新多份記錄表,可能會忘記更新其中一份。如果你只有一份記錄表,就不會有此問題!
通常重複的程式碼,是因為有兩個稍微不同的東西。它們之間絕大部分相同,但些微不同之處,迫使你使用多個函數處理相似的事情。如果出現重複的程式碼,可以使用函數、模組或是類別來抽象化處理。
正確的抽象化是非常關鍵的,這也是為什麼你應該遵循類別(Classes)章節中,物件導向基本原則 SOLID 的原因。請小心,較差的抽象化會比重複的程式碼更糟!這麼說吧,如果你有把握做出好的抽象化,盡情放手去做。別讓程式碼出現重複的地方,不然你會需要修改更多的程式碼。
糟糕的:
function showDeveloperList(developers) {
developers.forEach(developer => {
const expectedSalary = developer.calculateExpectedSalary();
const experience = developer.getExperience();
const githubLink = developer.getGithubLink();
const data = {
expectedSalary,
experience,
githubLink
};
render(data);
});
}
function showManagerList(managers) {
managers.forEach(manager => {
const expectedSalary = manager.calculateExpectedSalary();
const experience = manager.getExperience();
const portfolio = manager.getMBAProjects();
const data = {
expectedSalary,
experience,
portfolio
};
render(data);
});
}
適當的:
function showEmployeeList(employees) {
employees.forEach(employee => {
const expectedSalary = employee.calculateExpectedSalary();
const experience = employee.getExperience();
const data = {
expectedSalary,
experience
};
switch (employee.type) {
case 'manager':
data.portfolio = employee.getMBAProjects();
break;
case 'developer':
data.githubLink = employee.getGithubLink();
break;
}
render(data);
});
}
譯者附註
剛讀完這個原則時,我非常遵守,但是個人龜毛的個性,造成了不少麻煩,我會在開發時不斷的思考是否會出現了重複的程式碼,甚至考慮到了之後的重用性。代價就是過度設計(Over Engineering)造成功能開發窒礙難行,設計了良好架構,但實際上並未被使用到。最後總結了一個建議作為附加原則:一開始撰寫程式碼先以功能開發優先,當你發現有兩個以上的地方重複時,再來考慮要不要重構。
糟糕的:
const menuConfig = {
title: null,
body: 'Bar',
buttonText: null,
cancellable: true
};
function createMenu(config) {
config.title = config.title || 'Foo';
config.body = config.body || 'Bar';
config.buttonText = config.buttonText || 'Baz';
config.cancellable =
config.cancellable !== undefined ? config.cancellable : true;
}
createMenu(menuConfig);
適當的:
const menuConfig = {
title: 'Order',
// 使用者漏掉了 'body'
buttonText: 'Send',
cancellable: true
};
function createMenu(config) {
config = Object.assign(
{
title: 'Foo',
body: 'Bar',
buttonText: 'Baz',
cancellable: true
},
config
);
// config 現在等同於: {title: 'Order', body: 'Bar', buttonText: 'Send', cancellable: true}
// ...
}
createMenu(menuConfig);
當你的函數使用了旗標當作參數時,代表函數做不只一件事情,依照不同旗標路徑切分你的函數。
糟糕的:
function createFile(name, temp) {
if (temp) {
fs.create(`./temp/${name}`);
} else {
fs.create(name);
}
}
適當的:
function createFile(name) {
fs.create(name);
}
function createTempFile(name) {
createFile(`./temp/${name}`);
}
當函數作用在除了回傳值外的地方,像是讀寫文件、修改全域變數或是將你的錢轉帳到其他人帳戶,則稱為副作用。
程式在某些情況下是需要副作用的,像是上面所提到的例子。這時你應該將這些功能集中在一起,不要同時有多個函數或是類別同時操作資源,應該只用一個服務(Service)完成這些事情。
常見的問題像是:
- 在沒有任何架構下,同時多個物件中分享共有狀態。
- 可變的狀態,且可以被任何人寫入
- 副作用發生的地方沒有被集中。
如果你能避免這些問題,你會比大多數的工程師快樂。
糟糕的:
// 全域變數被以下函數使用
// 假如有其他的函數使用了這個名稱,現在他變成了陣列,將會被破壞而出錯。
let name = 'Ryan McDermott';
function splitIntoFirstAndLastName() {
name = name.split(' ');
}
splitIntoFirstAndLastName();
console.log(name); // ['Ryan', 'McDermott'];
適當的:
function splitIntoFirstAndLastName(name) {
return name.split(' ');
}
const name = 'Ryan McDermott';
const newName = splitIntoFirstAndLastName(name);
console.log(name); // 'Ryan McDermott';
console.log(newName); // ['Ryan', 'McDermott'];
在 JavaScript 中,原始資料類型傳遞數值(Value),物件/陣列傳遞參照(Reference)。在本案例中,你的函數改變了購物車清單 cart
中的陣列,像是你增加了一個商品,其他使用購物車清單的函數將會被影響。這做法有好有壞,讓我解釋一下問題所在:
使用者按下付款按鈕後,將會呼叫 purchase
函數,產生一個網路請求傳送購物車清單陣列到伺服器。因為較差的網路連線,必須多嘗試幾次。此時使用者不小心又按下加入購物車按鈕,因為是參考的關係,請求將會送出新加入的商品。
較好的解決辦法是 addItemToCart
函數,執行前複製新的一份購物車清單,修改複製的資料後再回傳。這能確保其他的函數的購物車清單沒有任何機會被被參考所影響。
使用這方法前,有兩個警告要告知:
-
當採用這種做法後,你會發現,需要修改輸入物件的情況非常少。大多數的程式碼可以在沒有副作用的情況下重構!
-
複製大型物件,需要花費高昂的效能。幸好,我們有好的函數庫,可以提升複製物件與陣列的速度與減少記憶體使用。
糟糕的:
const addItemToCart = (cart, item) => {
cart.push({ item, date: Date.now() });
};
適當的:
const addItemToCart = (cart, item) => {
return [...cart, { item, date: Date.now() }];
};
譯者附註
譯者這邊另外再提供一個案例,函數 checkIs18Age
用來檢查是否成年。第一個寫法中,引用了全域(global)變數 minimum
,這功能看起來能正常運作沒問題,但是今天如果有其他函數 setMinAge
修改了全域變數 minimum
,函數 checkIs18Age
將會因為副作用的關係變的無法預期,甚至失去它的作用。
較好的寫法是,採用純函數(pure function),使用一個可預期的變數,來避免副作用影響。
let minimum = 18
// impure with side effect
const checkIs18Age = age => age >= minimum
const setMinAge = age => minimum = age
// pure
const checkIs18Age = age => {
let minimum = 18
return age >= minimum
}
另外使用解構的方式複製資料,只會複製第一層而已。
// 巢狀解構只能複製第一層
const obj1 = { subObj: { message: 'Hey' } }
const obj2 = { ...obj1 }
obj2.subObj.message = 'Yo'
console.log(obj1.subObj.message) // 'Yo'
如果要複製巢狀結構,你需要深度複製。可以使用一些函數庫 loadsh 的 _.CloneDeep
或是 ramda 的 clone
。或是使用 JSON.parse(JSON.stringify(object))
來實現。不過使用 JSON 的話,會失去 Function 的複製。
// 使用 JSON 來深度複製
const obj1 = { subObj: { message: 'Hey' } }
const obj2 = JSON.parse(JSON.stringify(obj1))
obj2.subObj.message = 'Yo'
console.log(obj1.subObj.message) // 'Hey'
在 JavaScript 中弄髒全域是個不好的做法,因為你可能會影響到其他函數庫或是 API。舉個例子,如果妳想要在 JavaScript 的原生陣列方法,擴展 diff
方法,用 B 陣列來去除 A 陣列中的元素(Element)。常見做法你可能會在 Array.prototype
中增加一個全新的函數,如果其他函數庫也有自己的 diff
實現的話將會互相影響。這就是為什麼我們需要使用 ES2015/ES6 的類別,來擴展的原因。
糟糕的:
Array.prototype.diff = function diff(comparisonArray) {
const hash = new Set(comparisonArray);
return this.filter(elem => !hash.has(elem));
};
適當的:
class SuperArray extends Array {
diff(comparisonArray) {
const hash = new Set(comparisonArray);
return this.filter(elem => !hash.has(elem));
}
}
JavaScript 不是像 Haskell 一樣的函數式語言,但它具有類似特性。函數式程式設計更加乾淨且容易被測試。當你在寫程式時,盡量選擇此設計方式。
糟糕的:
const programmerOutput = [
{
name: 'Uncle Bobby',
linesOfCode: 500
},
{
name: 'Suzie Q',
linesOfCode: 1500
},
{
name: 'Jimmy Gosling',
linesOfCode: 150
},
{
name: 'Gracie Hopper',
linesOfCode: 1000
}
];
let totalOutput = 0;
for (let i = 0; i < programmerOutput.length; i++) {
totalOutput += programmerOutput[i].linesOfCode;
}
適當的:
const programmerOutput = [
{
name: 'Uncle Bobby',
linesOfCode: 500
},
{
name: 'Suzie Q',
linesOfCode: 1500
},
{
name: 'Jimmy Gosling',
linesOfCode: 150
},
{
name: 'Gracie Hopper',
linesOfCode: 1000
}
];
const totalOutput = programmerOutput.reduce(
(totalLines, output) => totalLines + output.linesOfCode,
0
);
糟糕的:
if (fsm.state === 'fetching' && isEmpty(listNode)) {
// ...
}
適當的:
function shouldShowSpinner(fsm, listNode) {
return fsm.state === 'fetching' && isEmpty(listNode);
}
if (shouldShowSpinner(fsmInstance, listNodeInstance)) {
// ...
}
糟糕的:
function isDOMNodeNotPresent(node) {
// ...
}
if (!isDOMNodeNotPresent(node)) {
// ...
}
適當的:
function isDOMNodePresent(node) {
*// ...*
}
if (isDOMNodePresent(node)) {
*// ...*
}
當你第一次聽到時,這聽起來是不可能的任務。大部分的人會說:「怎麼可能不使用 if
語法?」事實上你可以使用多態性(Polymorphism) 達到相同的效果。第二個問題來了,「為什麼我們需要這樣做呢?」依據前面概念,為了保持程式碼的乾淨,當類別或是函數出現 if
語法,代表你的函數做了超過一件事情。記住,一個函數只做一件事情!
糟糕的:
class Airplane {
// ...
getCruisingAltitude() {
switch (this.type) {
case '777':
return this.getMaxAltitude() - this.getPassengerCount();
case 'Air Force One':
return this.getMaxAltitude();
case 'Cessna':
return this.getMaxAltitude() - this.getFuelExpenditure();
}
}
}
適當的:
class Airplane {
// ...
}
class Boeing777 extends Airplane {
// ...
getCruisingAltitude() {
return this.getMaxAltitude() - this.getPassengerCount();
}
}
class AirForceOne extends Airplane {
// ...
getCruisingAltitude() {
return this.getMaxAltitude();
}
}
class Cessna extends Airplane {
// ...
getCruisingAltitude() {
return this.getMaxAltitude() - this.getFuelExpenditure();
}
}
JavaScript 為弱型別語言,代表函數應能處理任何型別的引數(argument)。有時這會帶給你一些麻煩,讓你需要做型別檢查。有很多方法可以避免這種問題發生,第一種就是統一所有的 API。
糟糕的:
function travelToTexas(vehicle) {
if (vehicle instanceof Bicycle) {
vehicle.pedal(this.currentLocation, new Location('texas'));
} else if (vehicle instanceof Car) {
vehicle.drive(this.currentLocation, new Location('texas'));
}
}
適當的:
function travelToTexas(vehicle) {
vehicle.move(this.currentLocation, new Location('texas'));
}
譯者附註
此範例統一了所有的車輛移動的參數、方法與實作,所以不再需要區分不同的類別的車輛呼叫不同的方法。
假設你需要型別檢查原始數值,像是字串與整數且你無法使用多態性處理,考慮使用 TypeScript 吧。他是提供標準 JavaScript 靜態類型的的最佳選擇。手動型別檢查需要很多額外處理,你得到的是虛假的型別安全,且失去的可讀性。保持你的 JavaScript 程式碼的整潔、寫好測試與足夠的程式碼審查(Code Review)。如果加上使用 TypeScript 會是更好的選擇。
糟糕的:
function combine(val1, val2) {
if (
(typeof val1 === 'number' && typeof val2 === 'number') ||
(typeof val1 === 'string' && typeof val2 === 'string')
) {
return val1 + val2;
}
throw new Error('Must be of type String or Number');
}
適當的:
function combine(val1, val2) {
return val1 + val2;
}
現代瀏覽器在運行時幫你做了很多優化。大多數的情況,你所做的優化都是在浪費你的時間。這裏有些很好的資源,去了解哪些優化是無用的。
糟糕的:
// 在舊的瀏覽器中並不會快取(cache)`list.length`,每次迭代(iteration)時的重新計算相當損耗效能。
// 這在新瀏覽器中已被優化,你不用手動去快取。
for (let i = 0, len = list.length; i < len; i++) {
// ...
}
適當的:
for (let i = 0; i < list.length; i++) {
// ...
}
譯者附註
簡單來說,你不用在意程式語言層面上優化,因為這部分會因為版本更新而得到優化。但不要因此放棄所有的優化,演算法的優化才是你該注意的地方!
沒有任何理由保留無用的程式碼,如果他們沒有被使用到,移除它!讓它們被保留在版本歷史中。
糟糕的:
function oldRequestModule(url) {
// ...
}
function newRequestModule(url) {
// ...
}
const req = newRequestModule;
inventoryTracker('apples', req, 'www.inventory-awesome.io');
適當的:
function newRequestModule(url) {
// ...
}
const req = newRequestModule;
inventoryTracker('apples', req, 'www.inventory-awesome.io');
使用 getters
與 setters
來存取物件中資料,會比單純使用屬性(property)來的好。因為:
- 當你想要在取得物件屬性時做更多事情,你不用找出所有的程式碼修改。
- 透過
set
可以建立規則進行資料校驗。 - 封裝內部邏輯。
- 存取時增加日誌(logging)與錯誤處理(error handling)。
- 你可以延遲載入你的物件屬性,像是來自伺服器的資料。
糟糕的:
function makeBankAccount() {
// ...
return {
balance: 0
// ...
};
}
const account = makeBankAccount();
account.balance = 100;
適當的:
function makeBankAccount() {
// 私有變數
let balance = 0;
// 'getter',經由下方的返回物件對外公開
function getBalance() {
return balance;
}
// 'setter',經由下方的返回物件對外公開
function setBalance(amount) {
// ... 更新前先進行驗證
balance = amount;
}
return {
// ...
getBalance,
setBalance
};
}
const account = makeBankAccount();
account.setBalance(100);
可以透過閉包(closures)來私有化參數(使用於 ES5 以下)。
糟糕的:
const Employee = function(name) {
this.name = name;
};
Employee.prototype.getName = function getName() {
return this.name;
};
const employee = new Employee('John Doe');
console.log(`Employee name: ${employee.getName()}`); // Employee name: John Doe
delete employee.name;
console.log(`Employee name: ${employee.getName()}`); // Employee name: undefined
適當的:
function makeEmployee(name) {
return {
getName() {
return name;
}
};
}
const employee = makeEmployee('John Doe');
console.log(`Employee name: ${employee.getName()}`); // Employee name: John Doe
delete employee.name;
console.log(`Employee name: ${employee.getName()}`); // Employee name: John Doe
ES5 的類別定義非常難以閱讀、繼承、建造與定義方法。假設你需要繼承(請注意你有可能不需要),偏好使用 ES2015/ES6 的類別。除非你需要大型且複雜的物件,不然使用小型函數會比類別更好。
糟糕的:
const Animal = function(age) {
if (!(this instanceof Animal)) {
throw new Error('Instantiate Animal with `new`');
}
this.age = age;
};
Animal.prototype.move = function move() {};
const Mammal = function(age, furColor) {
if (!(this instanceof Mammal)) {
throw new Error('Instantiate Mammal with `new`');
}
Animal.call(this, age);
this.furColor = furColor;
};
Mammal.prototype = Object.create(Animal.prototype);
Mammal.prototype.constructor = Mammal;
Mammal.prototype.liveBirth = function liveBirth() {};
const Human = function(age, furColor, languageSpoken) {
if (!(this instanceof Human)) {
throw new Error('Instantiate Human with `new`');
}
Mammal.call(this, age, furColor);
this.languageSpoken = languageSpoken;
};
Human.prototype = Object.create(Mammal.prototype);
Human.prototype.constructor = Human;
Human.prototype.speak = function speak() {};
適當的:
class Animal {
constructor(age) {
this.age = age;
}
move() {
// ...
}
}
class Mammal extends Animal {
constructor(age, furColor) {
super(age);
this.furColor = furColor;
}
liveBirth() {
// ...
}
}
class Human extends Mammal {
constructor(age, furColor, languageSpoken) {
super(age, furColor);
this.languageSpoken = languageSpoken;
}
speak() {
// ...
}
}
這個模式(pattern)在 JavaScript 中非常有用,你可以在很多函數庫中看到,像是 jQuery 與 Lodash。它可以讓你的程式碼表達的更好。
基於這個原因,方法鏈可以讓你的程式碼看起來更加簡潔。在你的類別函數中,只需要回傳 this
在每一個函數中,你就可以鏈結所有類別中的方法。
糟糕的:
class Car {
constructor(make, model, color) {
this.make = make;
this.model = model;
this.color = color;
}
setMake(make) {
this.make = make;
}
setModel(model) {
this.model = model;
}
setColor(color) {
this.color = color;
}
save() {
console.log(this.make, this.model, this.color);
}
}
const car = new Car('Ford', 'F-150', 'red');
car.setColor('pink');
car.save();
適當的:
class Car {
constructor(make, model, color) {
this.make = make;
this.model = model;
this.color = color;
}
setMake(make) {
this.make = make;
// 注意:回傳 this 以鏈結
return this;
}
setModel(model) {
this.model = model;
// 注意:回傳 this 以鏈結
return this;
}
setColor(color) {
this.color = color;
// 注意:回傳 this 以鏈結
return this;
}
save() {
console.log(this.make, this.model, this.color);
// 注意:回傳 this 以鏈結
return this;
}
}
const car = new Car('Ford', 'F-150', 'red').setColor('pink').save();
正如四人幫的設計模式,可以的話你應該優先使用組合而不是繼承。有許多好理由去使用繼承或是組合。重點是,如果你主觀認定是繼承,嘗試想一下組合能否替問題帶來更好的解法。你應該偏好使用組合更甚於繼承。
什麼時候使用繼承?這取決於你手上的問題,不過這有一些不錯的參考,說明什麼什麼時候繼承比組合更好用:
- 你的繼承為是一種(is-a)的關係,而不是有一個(has-a)。例如「人類是一種動物 Human -> Animal」vs.「使用者有一個使用者資料 User -> UserDetails」。
- 你能重複使用基類(base blasses)的程式碼。例如,人類能像動物一樣移動。
- 你希望能通過修改基類的程式碼來進行全域修改。例如,改變所有動物移動時的熱量消耗。
糟糕的:
class Employee {
constructor(name, email) {
this.name = name;
this.email = email;
}
// ...
}
// 因為僱員有稅率金資料,而不是一種僱員
class EmployeeTaxData extends Employee {
constructor(ssn, salary) {
super();
this.ssn = ssn;
this.salary = salary;
}
// ...
}
適當的:
class EmployeeTaxData {
constructor(ssn, salary) {
this.ssn = ssn;
this.salary = salary;
}
// ...
}
class Employee {
constructor(name, email) {
this.name = name;
this.email = email;
}
setTaxData(ssn, salary) {
this.taxData = new EmployeeTaxData(ssn, salary);
}
// ...
}
正如 Clean Code 所述:「永遠不要有超過一個理由來修改一個類型」。給一個類別塞滿許多功能,如同你在航班上只能帶一個行李箱一樣。這樣做的問題是,你的類別不會有理想的內聚性,將會有太多理由來對它進行修改。最小化需要修改一個類別的次數很重要,因為一個類別有太多的功能的話,一旦你修改一小部分,將會很難弄清楚它會對程式碼的其他模組造成什麼影響。
糟糕的:
class UserSettings {
constructor(user) {
this.user = user;
}
changeSettings(settings) {
if (this.verifyCredentials()) {
// ...
}
}
verifyCredentials() {
// ...
}
}
適當的:
class UserAuth {
constructor(user) {
this.user = user;
}
verifyCredentials() {
// ...
}
}
class UserSettings {
constructor(user) {
this.user = user;
this.auth = new UserAuth(user);
}
changeSettings(settings) {
if (this.auth.verifyCredentials()) {
// ...
}
}
}
Bertrand Meyer 說過,「軟體實體(類別、模組、函數)應為開放擴展,但是關閉修改」。這原則基本上說明你應該同意使用者增加功能,而不用修改現有程式碼。
糟糕的:
class AjaxAdapter extends Adapter {
constructor() {
super();
this.name = 'ajaxAdapter';
}
}
class NodeAdapter extends Adapter {
constructor() {
super();
this.name = 'nodeAdapter';
}
}
class HttpRequester {
constructor(adapter) {
this.adapter = adapter;
}
fetch(url) {
if (this.adapter.name === 'ajaxAdapter') {
return makeAjaxCall(url).then(response => {
// 轉換回應並回傳
});
} else if (this.adapter.name === 'nodeAdapter') {
return makeHttpCall(url).then(response => {
// 轉換回應並回傳
});
}
}
}
function makeAjaxCall(url) {
// 發送請求並回傳 promise
}
function makeHttpCall(url) {
// 發送請求並回傳 promise
}
適當的:
class AjaxAdapter extends Adapter {
constructor() {
super();
this.name = 'ajaxAdapter';
}
request(url) {
// 發送請求並回傳 promise
}
}
class NodeAdapter extends Adapter {
constructor() {
super();
this.name = 'nodeAdapter';
}
request(url) {
// 發送請求並回傳 promise
}
}
class HttpRequester {
constructor(adapter) {
this.adapter = adapter;
}
fetch(url) {
return this.adapter.request(url).then(response => {
// transform response and return
});
}
}
這是一個驚人但簡單的概念,正式的定義為:「假如類別 S 是類別 T 的子類別,那麼類別 T 的物件(Object)可以被替換成類別 S 的物件(例如,類別 S 的物件可作為類別 T 的物件的替代品),而不需要改變任何程式的屬性(正確性、被執行的任務等)。
最好的解釋是這樣,假如你有父類別與子類別,父類別與子類別可以互換,而沒有問題發生。讓我們看看一個經典的正方形與長方形的案例。在數學上,正方形是長方形的一種,但如果你使用是一種(is-a)的關係用繼承來實現,你很快會發現問題。
糟糕的:
class Rectangle {
constructor() {
this.width = 0;
this.height = 0;
}
setColor(color) {
// ...
}
render(area) {
// ...
}
setWidth(width) {
this.width = width;
}
setHeight(height) {
this.height = height;
}
getArea() {
return this.width * this.height;
}
}
class Square extends Rectangle {
setWidth(width) {
this.width = width;
this.height = width;
}
setHeight(height) {
this.width = height;
this.height = height;
}
}
function renderLargeRectangles(rectangles) {
rectangles.forEach(rectangle => {
rectangle.setWidth(4);
rectangle.setHeight(5);
const area = rectangle.getArea(); // 糟糕:結果為 25,應該為 20 才正確
rectangle.render(area);
});
}
const rectangles = [new Rectangle(), new Rectangle(), new Square()];
renderLargeRectangles(rectangles);
適當的:
class Shape {
setColor(color) {
// ...
}
render(area) {
// ...
}
}
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
getArea() {
return this.width * this.height;
}
}
class Square extends Shape {
constructor(length) {
super();
this.length = length;
}
getArea() {
return this.length * this.length;
}
}
function renderLargeShapes(shapes) {
shapes.forEach(shape => {
const area = shape.getArea();
shape.render(area);
});
}
const shapes = [new Rectangle(4, 5), new Rectangle(4, 5), new Square(5)];
renderLargeShapes(shapes);
譯者附註
setWidth
與 setHeight
方法無法被繼承,使用上的不一致,更造成了結果錯誤。
JavaScript 沒有介面(interfaces),所以這個原則比較不像其他語言一樣嚴格。不過它在 JavaScript 這種缺少類型的語言來說一樣重要。
ISP 原則是「客戶端不應該被強制依賴他們不需要的介面。」因為 JavaScript 是一種弱型別的語言,所以介面是一種隱式(implicit)的協議。
巨大的設定物件(objects)是這個原則的好範例,不需要客戶去設定大量的選項是有好處的,因為多數的情況下,他們不需要全部的設定。讓他們可以被選擇,可以防止出現一個過胖的介面。
糟糕的:
class DOMTraverser {
constructor(settings) {
this.settings = settings;
this.setup();
}
setup() {
this.rootNode = this.settings.rootNode;
this.animationModule.setup();
}
traverse() {
// ...
}
}
const $ = new DOMTraverser({
rootNode: document.getElementsByTagName('body'),
animationModule() {} // 大多數的情況下,執行 traverse 時,我們其實不需要使用 animation。
// ...
});
適當的:
class DOMTraverser {
constructor(settings) {
this.settings = settings;
this.options = settings.options;
this.setup();
}
setup() {
this.rootNode = this.settings.rootNode;
this.setupOptions();
}
setupOptions() {
if (this.options.animationModule) {
// ...
}
}
traverse() {
// ...
}
}
const $ = new DOMTraverser({
rootNode: document.getElementsByTagName('body'),
options: { // animationModule 可被選擇要不要使用
animationModule() {}
}
});
這原則說明兩個必要事情:
- 高層級的模組(modules)不應該依賴於低層級的模組。它們兩者必須依賴於抽象。
- 抽象(abstract)不應該依賴於具體實現(implement),具體實現則需要應依賴於抽象。
這原則一開始很難理解,但如果你使用過 AngularJS,你應該已經知道,使用依賴注入(Dependency Injection)來實現這個原則。雖然不是同一種概念,但透過依賴注入讓高層級模組遠離低層級模組的細節與設定。這樣做的巨大好處是,降低模組間的耦合。耦合是很糟的開發模式,因為會導致程式碼難以重構(refactor)。
如上所示,JavaScript 沒有介面,所以被依賴的抽象是隱式協議。也是就說,一個物件/類別的屬性直接暴露給另外一個。在以下的範例中,任何的請求模組(Request Module)的隱式協議 InventoryTracker
都會有一個 requestItems
的方法。
糟糕的:
class InventoryRequester {
constructor() {
this.REQ_METHODS = ['HTTP'];
}
requestItem(item) {
// ...
}
}
class InventoryTracker {
constructor(items) {
this.items = items;
// 糟糕的:我們建立了一種依賴,依賴於特定(InventoryRequester)請求模組的實現。
// 我們實際上只有 `requestItems` 方法依賴於名為 `request` 的請求方法。
this.requester = new InventoryRequester();
}
requestItems() {
this.items.forEach(item => {
this.requester.requestItem(item);
});
}
}
const inventoryTracker = new InventoryTracker(['apples', 'bananas']);
inventoryTracker.requestItems();
適當的:
class InventoryTracker {
constructor(items, requester) {
this.items = items;
this.requester = requester;
}
requestItems() {
this.items.forEach(item => {
this.requester.requestItem(item);
});
}
}
class InventoryRequesterV1 {
constructor() {
this.REQ_METHODS = ['HTTP'];
}
requestItem(item) {
// ...
}
}
class InventoryRequesterV2 {
constructor() {
this.REQ_METHODS = ['WS'];
}
requestItem(item) {
// ...
}
}
// 通過外部創建時將依賴注入,我們可以輕鬆地用全新的 WebSockets 的請求模組替換。
const inventoryTracker = new InventoryTracker(
['apples', 'bananas'],
new InventoryRequesterV2()
);
inventoryTracker.requestItems();
譯者附註
在糟糕的案例中,InventoryTracker
沒有提供替換請求模組的可能,InventoryTracker
依賴於 InventoryRequester
。造成耦合,測試將會被難以撰寫,修改程式碼時將會牽一髮而動全身。
測試比發布更加重要。當發布時,如果你沒有測試或是測試不夠充分,你會無法確認有沒有任何功能被破壞。測試的量,由團隊決定,但是擁有 100% 的測試覆蓋率(包含狀態與分支),是你為什麼能有高度自信與內心平靜的原因。所以你需要一個偉大的測試框架,也需要一個好的覆蓋率(coverage)工具。
沒有任何藉口不寫測試。這裡有很多好的 JS 測試框架,選一個你的團隊喜歡的。選擇好之後,接下來的目標是為任何新功能或是模組撰寫測試。如果你喜好測試驅動開發(Test Driven Development)的方式,那就太棒了,重點是確保上線前或是重構之前,達到足夠的覆蓋率。
譯者附註
測試是一種保障,當你趕著修正錯誤時,測試會告訴你會不會改了 A 壞了 B。確保每次上線前的功能皆可正常運作。另外測試有分種類,詳情見連結測試的種類。
糟糕的:
import assert from 'assert';
describe('MakeMomentJSGreatAgain', () => {
it('handles date boundaries', () => {
let date;
date = new MakeMomentJSGreatAgain('1/1/2015');
date.addDays(30);
assert.equal('1/31/2015', date);
date = new MakeMomentJSGreatAgain('2/1/2016');
date.addDays(28);
assert.equal('02/29/2016', date);
date = new MakeMomentJSGreatAgain('2/1/2015');
date.addDays(28);
assert.equal('03/01/2015', date);
});
});
適當的:
import assert from 'assert';
describe('MakeMomentJSGreatAgain', () => {
it('handles 30-day months', () => {
const date = new MakeMomentJSGreatAgain('1/1/2015');
date.addDays(30);
assert.equal('1/31/2015', date);
});
it('handles leap year', () => {
const date = new MakeMomentJSGreatAgain('2/1/2016');
date.addDays(28);
assert.equal('02/29/2016', date);
});
it('handles non-leap year', () => {
const date = new MakeMomentJSGreatAgain('2/1/2015');
date.addDays(28);
assert.equal('03/01/2015', date);
});
});
譯者附註
如果你單個測試,測試過多的功能或是概念,當這個測試出錯的時候,你將會難以找到出錯的程式碼。
回呼函式不怎麼簡潔,他們會導致過多的巢狀。在 ES2016/ES6,Promises 已經是內建的全局類型(global type)。使用它們吧!
糟糕的:
import { get } from 'request';
import { writeFile } from 'fs';
get(
'https://en.wikipedia.org/wiki/Robert_Cecil_Martin',
(requestErr, response) => {
if (requestErr) {
console.error(requestErr);
} else {
writeFile('article.html', response.body, writeErr => {
if (writeErr) {
console.error(writeErr);
} else {
console.log('File written');
}
});
}
}
);
適當的:
import { get } from 'request';
import { writeFile } from 'fs';
get('https://en.wikipedia.org/wiki/Robert_Cecil_Martin')
.then(response => {
return writeFile('article.html', response);
})
.then(() => {
console.log('File written');
})
.catch(err => {
console.error(err);
});
Promises 是回呼函式的一種非常簡潔的替代品,但是 ES2017/ES8 帶來了 async 與 await,提供了一個更簡潔的方案。你需要的只是一個前綴為 async
關鍵字的函數,接下來你編寫邏輯時就不需要使用 then
函數鍊。如果你能使用 ES2017/ES8 的進階功能的話,今天就使用它吧!
糟糕的:
import { get } from 'request-promise';
import { writeFile } from 'fs-promise';
get('https://en.wikipedia.org/wiki/Robert_Cecil_Martin')
.then(response => {
return writeFile('article.html', response);
})
.then(() => {
console.log('File written');
})
.catch(err => {
console.error(err);
});
適當的:
import { get } from 'request-promise';
import { writeFile } from 'fs-promise';
async function getCleanCodeArticle() {
try {
const response = await get(
'https://en.wikipedia.org/wiki/Robert_Cecil_Martin'
);
await writeFile('article.html', response);
console.log('File written');
} catch (err) {
console.error(err);
}
}
拋出錯誤是一件好事情!代表運行時可以成功辨識程式中的錯誤,通過停止執行目前堆疊(stack)上的執行函數,結束(Node.js 中的)目前程序(process),並在控制台中用一個堆疊追蹤(stack trace)提醒你。
捕捉到一個錯誤時而不做任何處理,會讓你失去修復或是反應錯誤的能力。將錯誤紀錄於控制台(console.log
)也不怎麼好,因為你往往會迷失在控制台大量的記錄之中。如果你使用 try/catch
包住程式碼,代表你預期這裡可能會出錯,因此當錯誤發生時你必須要有個處理方法。
糟糕的:
try {
functionThatMightThrow();
} catch (error) {
console.log(error);
}
適當的:
try {
functionThatMightThrow();
} catch (error) {
// 可以這樣(會比 console.log 更吵)
console.error(error);
// 或這種方法
notifyUserOfError(error);
// 另外一種方法
reportErrorToService(error);
// 或是全部都做!
}
原因如上節所述,不要忽略任何捕捉到的錯誤。
糟糕的:
getdata()
.then(data => {
functionThatMightThrow(data);
})
.catch(error => {
console.log(error);
});
適當的:
getdata()
.then(data => {
functionThatMightThrow(data);
})
.catch(error => {
// 可以這樣(會比 console.log 更吵)
console.error(error);
// 或這種方法
notifyUserOfError(error);
// 另外一種方法
reportErrorToService(error);
// 或是全部都做!
});
格式化是很主觀的,就像其他規則一樣沒有硬性規定,沒有必要為了格式而爭論,這裡有大量的自動化格式工具,選一個就是了!對工程師來說,爭論格式就是在浪費時間與金錢。
針對自動格式化工具不能涵蓋的問題,這裡有一些指南。
JavaScript 是無型別的,所以從大小寫可以看出關於變數、函數等很多的事情。這些規則是很主觀的,所以你的團隊可以自由選擇。重點是,不管選了去什麼,就保持一致。
糟糕的:
const DAYS_IN_WEEK = 7;
const daysInMonth = 30;
const songs = ['Back In Black', 'Stairway to Heaven', 'Hey Jude'];
const Artists = ['ACDC', 'Led Zeppelin', 'The Beatles'];
function eraseDatabase() {}
function restore_database() {}
class animal {}
class Alpaca {}
適當的:
const DAYS_IN_WEEK = 7;
const DAYS_IN_MONTH = 30;
const SONGS = ['Back In Black', 'Stairway to Heaven', 'Hey Jude'];
const ARTISTS = ['ACDC', 'Led Zeppelin', 'The Beatles'];
function eraseDatabase() {}
function restoreDatabase() {}
class Animal {}
class Alpaca {}
如果一個函數呼叫另外一個,在程式碼中兩個函數的垂直位置應該靠近。理想情況下,呼叫函數應於被呼叫函數的正上方。我們傾向於從上到下的閱讀方式,就像看報紙一樣。基於這個原因,讓你的程式碼可以依照這種方式閱讀。
糟糕的:
class PerformanceReview {
constructor(employee) {
this.employee = employee;
}
lookupPeers() {
return db.lookup(this.employee, 'peers');
}
lookupManager() {
return db.lookup(this.employee, 'manager');
}
getPeerReviews() {
const peers = this.lookupPeers();
// ...
}
perfReview() {
this.getPeerReviews();
this.getManagerReview();
this.getSelfReview();
}
getManagerReview() {
const manager = this.lookupManager();
}
getSelfReview() {
// ...
}
}
const review = new PerformanceReview(employee);
review.perfReview();
適當的:
class PerformanceReview {
constructor(employee) {
this.employee = employee;
}
perfReview() {
this.getPeerReviews();
this.getManagerReview();
this.getSelfReview();
}
getPeerReviews() {
const peers = this.lookupPeers();
// ...
}
lookupPeers() {
return db.lookup(this.employee, 'peers');
}
getManagerReview() {
const manager = this.lookupManager();
}
lookupManager() {
return db.lookup(this.employee, 'manager');
}
getSelfReview() {
// ...
}
}
const review = new PerformanceReview(employee);
review.perfReview();
註解是代表的辯解,而不是要求。多數情況下,好的程式碼本身就是文件。
糟糕的:
function hashIt(data) {
// The hash
let hash = 0;
// Length of string
const length = data.length;
// Loop through every character in data
for (let i = 0; i < length; i++) {
// Get character code.
const char = data.charCodeAt(i);
// Make the hash
hash = (hash << 5) - hash + char;
// Convert to 32-bit integer
hash &= hash;
}
}
適當的:
function hashIt(data) {
let hash = 0;
const length = data.length;
for (let i = 0; i < length; i++) {
const char = data.charCodeAt(i);
hash = (hash << 5) - hash + char;
// Convert to 32-bit integer
hash &= hash;
}
}
有了版本控制,舊的程式碼留在歷史紀錄中就好。
糟糕的:
doStuff();
// doOtherStuff();
// doSomeMoreStuff();
// doSoMuchStuff();
適當的:
doStuff();
記住,使用版本控制!不需要無用的、註解掉的程式碼,尤其是日誌式的註解。使用 git log
來保存歷史紀錄。
糟糕的:
/**
* 2016-12-20: Removed monads, didn't understand them (RM)
* 2016-10-01: Improved using special monads (JP)
* 2016-02-03: Removed type-checking (LI)
* 2015-03-14: Added combine with type-checking (JR)
*/
function combine(a, b) {
return a + b;
}
適當的:
function combine(a, b) {
return a + b;
}
譯者附註
在註解中寫歷史紀錄並沒有在版本控制中來得有效,另外補充有關歷史紀錄如何撰寫的規範(如何撰寫 Git Commit Message)。
它們只會增加干擾。讓函數與變數的名稱沿著合適的縮排與格式化,為你的程式碼帶來良好的視覺結構。
糟糕的:
////////////////////////////////////////////////////////////////////////////////
// Scope Model Instantiation
////////////////////////////////////////////////////////////////////////////////
$scope.model = {
menu: 'foo',
nav: 'bar'
};
////////////////////////////////////////////////////////////////////////////////
// Action setup
////////////////////////////////////////////////////////////////////////////////
const actions = function() {
// ...
};
適當的:
$scope.model = {
menu: 'foo',
nav: 'bar'
};
const actions = function() {
// ...
};
以下為所有的翻譯版本。
- French: GavBaros/clean-code-javascript-fr
- Brazilian Portuguese: fesnt/clean-code-javascript
- Spanish: andersontr15/clean-code-javascript
- Spanish: tureey/clean-code-javascript
- Simplified Chinese:
- Traditional Chinese: AllJointTW/clean-code-javascript
- German: marcbruederlin/clean-code-javascript
- Korean: qkraudghgh/clean-code-javascript-ko
- Polish: greg-dev/clean-code-javascript-pl
- Russian:
- Vietnamese: hienvd/clean-code-javascript/
- Japanese: mitsuruog/clean-code-javascript/
- Indonesia: andirkh/clean-code-javascript/
- Italian: frappacchio/clean-code-javascript/