編寫可維護的JS

魯班一號發表於2019-02-28

0. 寫在前面

當你開始工作時,你不是在給你自己寫程式碼,而是為後來人寫程式碼。 —— Nichloas C. Zakas

本文主要是《編寫可維護的JS》的讀書筆記,會結合我自己的工作經驗,談一談如何寫出易於維護的JS。作者寫這本書的時候(大概2012-2013年)ES6還沒出來,考慮到當前MV*時代下,大家幾乎都在寫ES6,所以本文會針對ES6作特別說明(原書內容針對ES5)。原書作者結合自己的工作經驗(2006年開始在雅虎為期5年的工作)寫出了這本書,作者在書中濃墨重彩強調的東西,我們現在看來都稀疏平常(如:為什麼需要禁用witheval,為什麼始終使用===!==進行比較),在這些內容上我會一筆帶過,假定你已經熟知這些基本常識了。

我們知道JS語言有著先天的設計缺陷(ES6之後才好轉了不少),如何不刻意學習如何編寫優質易維護的程式碼,你很容易就寫出糟糕的程式碼(雖然它可以執行)。

關於程式碼的維護,你需要明白以下四點:

  • 軟體生命週期中80%的成本消耗在了維護上。
  • 幾乎所有的軟體維護者都不是它的最初作者。
  • 編碼規範提高了軟體的可讀性,它讓工程師能夠快速且充分地理解新的程式碼。
  • 如果你將原始碼作為產品來發布,你需要確保它是可完整打包的,且像你建立的其他產品一樣整潔。

對的,你寫的程式碼很大概率上,並不是由你來維護的。因為你可能換公司了,可能去做新專案了,也可能你壓根就不記得這段程式碼是你六個月前寫的。所以,不要抱著“我就是來搬磚的,隨便寫寫,改不動了就溜了”的態度來寫程式碼,相信讀者你也維護過別人寫的程式碼,吐槽過那難以理解沒有任何註釋的程式碼,恨不得把寫那程式碼的人拉過來打一頓。所以,請不要成為你所討厭的人。編寫出可維護的程式碼,既是職業素養的問題,也是你專業精神的體現。

關於如何編寫可維護的JS,我將從 程式設計風格程式設計實踐工程化 三個方面進行闡述。

1. 程式設計風格

程式是寫給人讀的,只是偶爾讓計算機執行一下。 —— Donald Knuth

我們會經常碰到這兩個術語:“程式設計風格”(style guideline)和“編碼規範”(code convention)。程式設計風格是編碼規範的一種,用來規約單檔案中程式碼的規劃。編碼規範還包括程式設計最佳實踐、檔案和目錄的規劃以及註釋等方面。本文集中討論JS的編碼規範。

為什麼要討論程式設計風格?每個人都有自己偏愛的程式設計風格,但更多的時候我們是作為團隊一員進行協作開發的,統一風格十分重要,因為它會促成團隊成員高水準的協作(所有的程式碼看起來極為類似)。毫無疑問,全球性的大公司都對外或者對內釋出過程式設計風格文件,如:Airbnb JavaScript Style Guide, Google JavaScript Style Guide等,你若仔細閱讀會發現它們很多規範都是相同的,只是部分細節略有差異。

在某些場景中,很難說哪種程式設計風格好,哪種程式設計風格不好,因為有些程式設計風格只是某些人的偏好。本文並不是向你灌輸我個人的風格偏好,而是提煉出了程式設計風格應當遵循的重要的通用規則。

1.1 格式化

關於縮排層次: 我不想挑起“Tab or Space”和“2 or 4 or 6 or 8 Space”的辯論,對這個話題是可以爭論上好幾個小時的,縮排甚至關係到程式設計師的價值觀。你只要記住以下三點:

  1. 程式碼一定要縮排,保持對其。
  2. 不要在同一個專案中混用Tab和Space。
  3. 保持與團隊風格的統一。

關於結尾分號: 有賴於分析器的自動分號插入(Automatic Semicolon Insertion, ASI)機制,JS程式碼省略分號也是可以正常工作的。ASI會自動尋找程式碼中應當使用分號但實際沒有分號的位置,並插入分號。大多數場景下ASI都會正確插入分號,不會產生錯誤,但ASI的分號插入規則非常複雜且很難記住,因此我推薦不要省略分號。大部分的風格指南(除了JavaScript Standard Style)都推薦不要省略分號。

關於行的長度: 大部分的語言以及JS編碼風格指南都指定一行的長度為80個字元,這個數值來源於很久之前文字編輯器的單行最多字元限制,即編輯器中單行最多隻能顯示80個字元,超過80個字元的行要麼折行,要麼被隱藏起來,這些都是我們所不希望的。我也傾向於將行長度限定在80個字元。

關於換行:當一行長度達到了單行最大字元限制時,就需要手動將一行拆成兩行。通常我們會在運算子後換行,下一行會增加兩個層次的縮排(我個人認為一個縮排也可以,但絕對不能沒有縮排)。例如:

callFunc(document, element, window, `test`, 100,
  true);
複製程式碼

在這個例子中,逗號是一個運算子,應當作為前一行的行尾。這個換行位置非常重要,因為ASI機制會在某些場景下在行結束的位置插入分號。總是將一個運算子置於行尾,ASI就不會自作主張地插入分號,也就避免了錯誤的發生。這個規則有一個例外:當給變數賦值時,第二行的位置應當和賦值運算子的位置保持對齊。比如:

var result = something + anotherThing + yetAnotherThing + somethingElse +
             anotherSomethingElse;
複製程式碼

這段程式碼裡,變數 anotherSomethingElse 和行首的 something 保持左對齊,確保程式碼的可讀性,並能一眼看清楚折行文字的上下文。

關於空行:在程式設計規範中,空行是常常被忽略的一個方面。通常來講,程式碼看起來應當像一系列可讀的段落,而不是一大段揉在一起的連續文字。有時一段程式碼的語義和另一段程式碼不相關,這時就應該使用空行將它們分隔,確保語義有關聯的程式碼展現在一起。一般來講,建議下面這些場景中新增空行:

  • 在方法之間。
  • 在方法中的區域性變數和第一條語句之間。
  • 在多行或單行註釋之前。
  • 在方法內的邏輯片段之間插入空行,提高可讀性。

1.2 命名

命名分變數、常量、函式、建構函式四類:其中變數和函式使用小駝峰命名法(首字母小寫),建構函式使用大駝峰命名法(首字母大寫),常量使用全大寫並用下劃線分割單詞。

let myAge; // 變數:小駝峰命名
const PAGE_SIZE; // 常量:全大寫,用下劃線分割單詞

function getAge() {} // 普通函式:小駝峰命名
function Person() {} // 建構函式:大駝峰命名
複製程式碼

為了區分變數和函式,變數命名應該以名字作為字首,而函式名字首應當是動詞(建構函式的命名通常是名詞)。看如下例子:

let count = 10; // Good
let getCount = 10; // Bad, look like function

function getName() {} // Good
function theName() {} // Bad, look like variable
複製程式碼

命名不僅是一門科學,更是一門技術,但通常來講,命名長度應該儘可能短,並抓住要點。儘量在變數名中體現出值的資料型別。比如,命名countlengthsize表明資料型別是數字,而命名nametitlemessage表明資料型別是字串。但用單個字元命名的變數諸如ijk通常在迴圈中使用。使用這些能夠體現出資料型別的命名,可以讓你的程式碼容易被別人和自己讀懂。

要避免使用沒有意義的命名,如:foobartmp。對於函式和方法命名來說,第一個單詞應該是動詞,這裡有一些使用動詞常見的約定:

動詞 含義
can 函式返回一個布林值
has 函式返回一個布林值
is 函式返回一個布林值
get 函式返回一個非布林值
set 函式用來儲存一個值

1.3 直接量

JS中包含一些型別的原始值:字串、數字、布林值、nullundefined。同樣也包含物件直接量和陣列直接量。這其中,只有布林值是自解釋(self-explanatory)的,其他的型別或多或少都需要思考一下它們如何才能更精確地表示出來。

關於字串:字串可以用雙引號也可以用單引號,不同的JS規範推薦都不同, 但切記不可在一個專案中混用單引號和雙引號。

關於數字:記住兩點建議:第一,為了避免歧義,請不要省略小數點之前或之後的數字;第二,大多數開發者對八進位制格式並不熟悉,也很少用到,所以最好的做法是在程式碼中禁止八進位制直接量。

// 不推薦的小數寫法:沒有小數部分
let price = 10.;

// 不推薦的小數寫法:沒有整數部分
let price = .1;

// 不推薦的寫法:八進位制寫法已經被棄用了
let num = 010;
複製程式碼

關於nullnull是一個特殊值,但我們常常誤解它,將它和undefined搞混。在下列場景中應當使用null

  • 用來初始化一個變數,這個變數可能賦值為一個物件。
  • 用來和一個已經初始化的變數比較,這個變數可以是也可以不是一個物件。
  • 當函式的引數期望是物件時,用作引數傳入。
  • 當函式的返回值期望是物件時,用作返回值傳出。

還有下面一些場景不應當使用null

  • 不要使用null來檢測是否傳入了某個引數。
  • 不要用null來檢測一個未初始化的變數。

理解null最好的方式是將它當做物件的佔位符(placeholder)。這個規則在所有的主流程式設計規範中都沒有提及,但對於全域性可維護性來說至關重要。

關於undefinedundefined是一個特殊值,我們常常將它和null搞混。其中一個讓人頗感困惑之處在於null == undefined結果是true。然而,這兩個值的用途卻各不相同。那些沒有被初始化的變數都有一個初始值,即undefined,表示這個變數等待被賦值。比如:

let person; // 不好的寫法
console.log(person === undefined); // true
複製程式碼

儘管這段程式碼能正常工作,但我建議避免在程式碼中使用undefined。這個值常常和返回”undefined”的typeof運算子混淆。事實上,typeof的行為也很讓人費解,因為不管是值是undefined的變數還是未宣告的變數,typeof運算結果都是”undefined”。比如:

// foo未被宣告
let person;
console.log(typeof person); // "undefined"
console.log(typeof foo); // "undefined"
複製程式碼

這段程式碼中,person和foo都會導致typeof返回”undefined”,哪怕person和foo在其他場景中的行為有天壤之別(在語句中使用foo會報錯,而使用person則不會報錯)。

通過禁止使用特殊值undefined,可以有效地確保只在一種情況下typeof才會返回”undefined”:當變數為宣告時。如果你使用了一個可能(或者可能不會)賦值為一個物件的變數時,則將其賦值為null

// 好的做法
let person = null;
console.log(person === null); // true
複製程式碼

將變數初始值賦值為null表明了這個變數的意圖,它最終很可能賦值為物件。typeof運算子運算null的型別時返回”object”, 這樣就可以和undefined區分開了。

關於物件直接量和陣列直接量: 請直接使用直接量語法來建立物件和陣列,避免使用ObjectArray建構函式來建立物件和陣列。

1.4 註釋

註釋是程式碼中最常見的組成部分。它們是另一種形式的文件,也是程式設計師最後才捨得花時間去寫的。但是,對於程式碼的總體可維護性而言,註釋是非常重要的一環。JS支援兩種註釋:單行註釋和多行註釋。

很多人喜歡在雙斜線後敲入一個空格,用來讓註釋文字有一定的偏移(我非常推薦你這麼做)。單行註釋有三種使用方法:

  • 獨佔一行的註釋,用來解釋下一行程式碼。這行註釋之前總是有一個空行,且縮排層級和下一行程式碼保持一致。
  • 在程式碼行的尾部的註釋。程式碼結束到註釋之間至少有一個縮排。註釋(包括之前的程式碼部分)不應當超過最大字元數限制,如果超過了,就將這條註釋放置於當前程式碼行的上方。
  • 被註釋的大段程式碼(很多編輯器都可以批量註釋掉多行程式碼)。

單行註釋不應當以連續多行註釋的形式出現,除非你註釋掉一大段程式碼。只有當需要註釋一段很長的文字時才使用多行註釋。

雖然多行註釋也可以用於註釋單行,但是我還是推薦僅在需要使用多行註釋的時候,才使用多行註釋。多行註釋一般用於以下場景:

  • 模組、類、函式開頭的註釋
  • 需要使用多行註釋

我十分推薦你使用Java風格的多行註釋,看起來十分美觀,而且很多編輯器支援自動生成,見如下示例:

/**
 * Java風格的註釋,注意*和註釋之間
 * 有一個空格,並且*左邊也有一個空格。
 * 你甚至可以加上一些@引數來說明一些東西。
 * 例如:
 *
 * @author 作者
 * @param Object person
 */
複製程式碼

何時新增註釋是程式設計師經常爭論的一個話題。一個通行的指導原則是, 當程式碼不夠清晰時新增註釋,而當程式碼很明瞭時不應當新增註釋。 基於這個原則,我推薦你在下面幾種情況下新增註釋:

  • 難以理解的程式碼: 難以理解的程式碼通常都應當加註釋。根據程式碼的用途,你可以用單行註釋、多行註釋,或者混用這兩種註釋。關鍵是讓其他人更容易讀懂這段程式碼。
  • 可能被誤認為錯誤的程式碼: 例如這段程式碼while(el && (el = el.next)) {}。在團隊開發中,總是會有一些好心的開發者在編輯程式碼時發現他人的程式碼錯誤,就立即將它修復。有時這段程式碼並不是錯誤的源頭,所以“修復”這個錯誤往往會製造其他錯誤,因此本次修改應當是可追蹤的。當你寫的程式碼有可能會被別的開發者認為有錯誤時,則需要新增註釋。
  • 瀏覽器特性hack: 這個寫過前端的都知道,有時候你不得不寫一些低效的、不雅的、徹頭徹尾的骯髒程式碼,用來讓低版本瀏覽器正常工作。

1.5 語句和表示式

關於 花括號的對齊方式 ,有兩種主要的花括號對齊風格。第一種風格是,將左花括號放置在塊語句中第一句程式碼的末尾,這種風格繼承自Java;第二種風格是將左花括號放置於塊語句首行的下一行,這種風格是隨著C#流行起來的,因為Visual Studio強制使用這種對齊方式。當前並無主流的JS程式設計規範推薦這種風格,Google JS風格指南明確禁止這種用法,以免導致錯誤的分號自動插入。我個人也推薦使用第一種花括號對齊格式。

// 第一種花括號對齊風格
if (condition) {

}

// 第二種花括號對齊風格
if (condition)
{

}
複製程式碼

關於塊語句間隔: 有下面三種風格,大部分的程式碼規範都推薦使用第二種風格:

// 第一種風格
if(condition){
  doSomething();
}

// 第二種風格
if (condition) {
  doSomething();
}

// 第三種風格
if ( condition ) {
  doSomething();
}
複製程式碼

關於switch語句,很多JS程式碼規範都沒有對此做詳細的規定,一個是而實際工作中你也會發現使用場景比較少。因為你只有在有很多條件判斷的情況下才會用switch(短條件就直接用if語句了),但是熟練的程式設計師面對很多的判斷條件一般都會用物件表查詢來解決這個問題。看如下推薦的風格程式碼:

switch (condition) {
  case `cond1`:
  case `cond2`:
    doCond1();
    break;
  case `cond3`:
    doCond3();
    break;
  default:
    doDefault();
}
複製程式碼

推薦你遵循如下的風格:

  1. switch後的條件括號需要前後各一個空格;
  2. case語句需要相對switch語句縮排一個層級;
  3. 允許多個case語句共用一個處理語句;
  4. 如果沒有預設執行程式碼,可以不用加default

關於with:JS引擎和壓縮工具無法對有with語句的程式碼進行優化,因為它們無法猜出程式碼的正確含義。在嚴格模式中,with語句是被明確禁止的,如果使用則報語法錯誤。這表明ECMAScript委員會確信with不應當繼續使用。我也強烈推薦避免使用with語句。

關於for迴圈:for迴圈有兩種,一種是傳統的for迴圈,是JS從C和Java中繼承而來,主要用於遍歷陣列成員;另外一種是for-in迴圈,用來遍歷物件的屬性。

針對for迴圈, 我推薦儘可能避免使用continue,但也沒有理由完全禁止使用,它的使用應當根據程式碼可讀性來決定。

for-in迴圈是用來遍歷物件屬性的。不用定義任何控制條件,迴圈將會有條不紊地遍歷每個物件屬性,並返回屬性名而不是值。for-in迴圈有一個問題,就是它不僅遍歷物件的例項屬性(instance property),同樣還遍歷從原型繼承來的屬性。當遍歷自定義物件的屬性時,往往會因為意外的結果而終止。出於這個原因,最好使用hasOwnProperty()方法來為for-in迴圈過濾出例項屬性。我也推薦你這麼做,除非你確實想要去遍歷物件的原型鏈,這個時候你應該加上註釋說明一下。

// 包含對原型鏈的遍歷
for (let prop in obj) {
  console.log(`key: ${prop}; value: ${obj[prop]}`);
}

for (let prop in obj) {
  if (obj.hasOwnProperty(prop)) {
    console.log(`key: ${prop}; value: ${obj[prop]}`);
  }
}
複製程式碼

關於for-in迴圈,還有一點需要注意,即for-in迴圈是用來遍歷物件的。一個常見的錯誤用法是使用for-in迴圈來遍歷陣列成員,它的結果可能不是你想要的(得到的是陣列下標),你應該使用ES6的for-of迴圈來遍歷陣列。

let arr = [`a`, `b`, `c`];

for (let i in arr) {
  console.log(i); // 0, 1, 2
}

for (let v of arr) {
  console.log(v); // `a`, `b`, `c`
}
複製程式碼

1.6 變數宣告

我們知道JS中var宣告的變數存在變數提升,對變數提升不熟悉的同學寫程式碼的時候就會產生不可意料的Bug。例如:

function func () {
  var result = 10 + result;
  var value = 10;
  return result; // return NaN
}

// 實際被解釋成
function func () {
  var result;
  var value;

  result = 10 + result;
  value = 10;
  return result;
}
複製程式碼

在某些場景中,開發者往往會漏掉變數提升,for語句就是其中一個常見的例子(因為ES5之前沒有塊級作用域):

function func (arr) {
  for (var i = 0, len = arr.length; i < len; i += 1) {}
}

// 實際被解釋成
function func (arr) {
  var i, len;
  for (i = 0, len = arr.length; i < len; i += 1) {}
}
複製程式碼

變數宣告提前意味著:在函式內部任意地方定義變數和在函式頂部定義變數是完全一樣的。 因此,一種流行的風格是將你所有變數宣告放在函式頂部而不是散落在各個角落。簡言之,依照這種風格寫出的程式碼邏輯和JS引擎解析這段程式碼的習慣是非常相似的。我也建議你總是將區域性變數的定義作為函式內第一條語句。

function func (arr) {
  var i, len;
  var value = 10;
  var result = value + 10;

  for (i = 0; len = arr.length; i < len; i += 1) {
    console.log(arr[i]);
  }
}
複製程式碼

當然,如果你有機會使用ES6,那我強烈推薦你完全拋棄var,直接用let和const來定義變數。相信我,拋棄var絕對值得的,let和const提供了塊級作用域,比var更安全可靠,行為更可預測。

1.7 函式宣告與呼叫

和變數宣告一樣,函式宣告也會被JS引擎提升。因此,在程式碼中函式的呼叫可以出現在函式宣告之前。但是,我們推薦總是先宣告JS函式然後使用函式。此外,函式宣告不應當出現在語句塊之內。例如,這段程式碼就不會按照我們的意圖來執行:

// 不好的寫法
if (condition) {
  function func () {
    alert("Hi!");
  }
} else {
  function func () {
    alert("Yo!");
  }
}
複製程式碼

這段程式碼在不同瀏覽器中的執行結果也是不盡相同的。不管condition的計算結果如何,大多數瀏覽器都會自動使用第二個宣告。而Firefox則根據condition的計算結果選用合適的函式宣告。這種場景是ECMAScript的一個灰色地帶,應當儘可能地避免。函式宣告應當在條件語句的外部使用。這種模式也是Google的JS風格指南明確禁止的。

一般情況下,對於函式呼叫寫法推薦的風格是,在函式名和左括號之間沒有空格。這樣做是為了將它和塊語句區分開發。

// 好的寫法
callFunc(params);

// 不好的寫法,看起來像一個塊語句
callFunc (params);

// 用來做對比的塊語句
while (condition) {}
複製程式碼

1.8 立即呼叫的函式

IIFE(Immediately Invoked Function Expression),意為立即呼叫的函式表示式,也就是說,宣告函式的同時立即呼叫這個函式。ES6中很少使用了,因為有模組機制,而IIFE最主要的用途就是來模擬模組隔離作用域的。下面有一些推薦的IIFE寫法:

// 不好的寫法:會讓人誤以為將一個匿名函式賦值給了這個變數
var value = function () {
  return {
    msg: `Hi`
  };
}();

// 為了讓IIFE能夠被一眼看出來,可以將函式用一對圓括號包裹起來
// 好的寫法
var value = (function () {
  return {
    msg: `Hi`
  };
}());

// 好的寫法
var value = (function () {
  return {
    msg: `Hi`
  };
})();
複製程式碼

1.9 嚴格模式

如果你在寫ES5程式碼,推薦總是使用嚴格模式。不推薦使用全域性的嚴格模式,可能會導致老的程式碼報錯。推薦使用函式級別的嚴格模式,或者在IIFE中使用嚴格模式。

1.10 相等

關於JS的強制型別轉換機制,我們不得不承認它確實很複雜,很難全部記住(主要是懶)。所以我推薦你,任何情況下,做相等比較請用===!==

1.11 eval

動態執行JS字串可不是一個好主意,在下面幾種情況中,都可以動態執行JS,我建議你應該避免這麼做,除非你精通JS,並且知道自己在做什麼。

eval("alert(`bad`)");
const func = new Function("alert bad(`bad`)");
setTimeout("alert(`bad`)", 1000);
setInterval("alert(`bad`)", 1000);
複製程式碼

1.12 原始包裝型別

JS裝箱和拆箱瞭解下,原始值是沒有屬性和方法的,當我們呼叫一個字串的方法時,JS引擎會自動把原始值裝箱成一個物件,然後呼叫這個物件的方法。但這並不意味著你應該使用原始包裝型別來建立對應的原始值,因為開發者的思路常常會在物件和原始值之間跳來跳去,這樣會增加出bug的概率,從而使開發者陷入困惑。你也沒有理由自己手動建立這些物件。

// 自動裝箱
const name = `Nicholas`;
console.log(name.toUpperCase());

// 好的寫法
const name = `Nicholas`;
const author = true;
const count = 10;

// 不好的寫法
const name = new String(`Nicholas`);
const author = new String(true);
const count = new Number(10);
複製程式碼

1.13 工具

團隊開發中,為了保持風格的統一,Lint工具必不可少。因為即使大家都明白要遵守統一的程式設計風格,但是寫程式碼的時候總是不經意就違背風格指南的規定了(畢竟人是會犯錯的)。這裡我推薦你使用ESLint工具進行程式碼的風格檢查,你沒必要完全重新寫配置規則,你可以繼承已有的業內優秀的JS編碼規範來針對你團隊做微調。我這裡推薦繼承自Airbnb JavaScript Style Guide,當然,你也可以繼承官方推薦的配置或者Google的JS編碼風格,其實在編碼風格上,三者在大部分的規則上是相同的,只是在一部分細節上不一致而已。

當然,如果你實在是太懶了,那瞭解一下JavaScript Standard Style,它是基於ESLint的一個JS風格檢查工具,有自己的一套風格,強制你必須遵守。可配置性沒有直接引入ESLint那麼強,如果你很懶並且能夠接受它推薦的風格,那使用StandardJS倒也無妨。

2. 程式設計實踐

構建軟體設計的方法有兩種:一種是把軟體做得很簡單以至於明顯找不到缺陷;另一種是把它做得很複雜以至於找不到明顯的缺陷。——CAR Hoare,1980年圖靈獎獲得者

第一部分我們主要討論的是JS的程式碼風格規範(style guideline),程式碼風格規範的目的是在多人協作的場景下使程式碼具有一致性。關於如何解決一般性的問題的討論是不包含在風格規範中的,那是程式設計實踐中的內容。

程式設計實踐是另外一類程式設計規範。程式碼風格規範只關心程式碼的呈現,而程式設計實踐則關心編碼的結果。你可以將程式設計實踐看作是“祕方”——它們指引開發者以某種方式編寫程式碼,這樣做的結果是已知的。如果你使用過一些設計模式比如MVC中的觀察者模式,那麼你已經對程式設計實踐很熟悉了。設計模式是程式設計實踐的組成部分,專用於解決和軟體組織相關的特定問題。

這一部分的程式設計實踐只會涵蓋很小的問題。其中一些實踐是和設計模式相關的,另外更多的內容只是增強你的程式碼總體質量的一些簡單小技巧。ESLint除了對程式碼風格進行檢查,也包含了一些關於程式設計實踐方面的警告。非常推薦大家在JS開發工作中使用這個工具,來確保不會發生那些看上去不起眼但又難於發現的錯誤。

2.1 UI層的鬆耦合

在Web開發中,UI是由三個彼此隔離又相互作用的層定義的。

  • HTML用來定義頁面的資料和語義
  • CSS用來給頁面新增樣式,建立視覺特徵
  • JS用來給頁面新增行為,使其更具互動性

關於鬆耦合,容我廢話幾句。當你能夠做到修改一個元件而不需要更改其他的元件時,你就做到了鬆耦合。對於多人大型系統來說,有很多人蔘與維護程式碼,鬆耦合對於程式碼可維護性來說至關重要。你絕對希望開發人員在修改某部分程式碼時不會破壞其他人的程式碼。當一個大系統的每個元件的內容有了限制,就做到了鬆耦合。本質上講,每個元件需要保持足夠瘦身來確保鬆耦合。元件知道的越少,就越有利於形成整個系統。

有一點需要注意:在一起工作的元件無法達到“無耦合”(no coupling)。在所有系統中,元件之間總要共享一些資訊來完成各自的工作。這很好理解,我們的目標是確保對一個元件的修改不會經常性地影響其他部分。

如果一個 Web UI是鬆耦合的,則很容易除錯。和文字或結構相關的問題,通過查詢HTML即可定位。當發生了樣式相關的問題,你知道問題出現在CSS中。最後,對於那些行為相關的問題,你直接去JS中找到問題所在,這種能力是Web介面的可維護性的核心部分。

WebPage時代,我們推崇將HTML/CSS/JS三層分離,例如禁止使用DOM的內聯屬性來繫結監聽器,<button onclick=handler>test</button>這麼寫會被噴的。但是,WebApp時代下,以React為代表性的MVVM和MVC框架(嚴格來說,React只是個專注於View層的一個框架),它們都推崇你把HTML、CSS和JS寫一塊,經常就可以看到內聯繫結事件監聽器的程式碼。

你不禁在想,難道我們在走倒退路?

歷史有時候會打轉,咋一看以為是回去了。實際上是螺旋轉了一圈,站在了一個新的起點。——玉伯《Web 研發模式演變》

傳統WebPage時代,元件化支援程度不高,語言層面和框架層面上都是如此,想想沒有原生不支援模組的JS(ES6之前的時代)和jQuery,所以為了避免增加維護成本,推崇三層分離的最佳實踐。隨著ES6與前端MV*框架的崛起,整個的前端開發模式都發生了變化。你會發現前端不僅僅是寫頁面了,寫的更多的是WebApp,應用的規模和複雜程度與WebPage時代不可同日而語。

React就是其中極為典型的代表,它提出用JSX來寫HTML,直接是將頁面結構和頁面邏輯寫在了一塊。這若放在WebPage時代,相信直接被當做反模式的典型教材;但在WebApp時代卻為大多數人接受並使用。包括React團隊提出的CSS in JS,更是想通過把CSS寫在JS中,使得前端開發完全由JS主導,元件化做的更加徹底(CSS in JS我沒有做更深的調研和理解,沒有實際大型專案的實踐經驗,所以現在我還是保持觀望態度,繼續沿用之前的SASS和LESS來做CSS開發)。

儘管兩個Web時代的開發模式發生了巨大變化,關於三層的鬆耦合設計,還是有一些通用原則你需要遵守:

將JS從CSS中抽離。 早期的IE8和更早版本的瀏覽器中允許在CSS中寫JS(不寫例子,這是反模式,記不住更好),這會帶來效能底下的問題,更可怕的是後期難以維護。不過我相信在座各位估計都接觸不到這類程式碼了,也好。

將CSS從JS中抽離。 不是說不能再JS中修改CSS,是不允許你直接去改樣式,而是通過修改類來間接的修改樣式。見如下示例:

// 不好的寫法
element.style.color = `red`;
element.style.left = `10px`;
element.style.top = `100px`;
element.style.visibility = `visible`;

// 好的寫法
.reveal {
  color: red;
  left: 10px;
  top: 100px;
  visibility: visible;
}

element.classList.add(`.reveal`);
複製程式碼

由於CSS的className可以成為CSS和JS之間通訊的橋樑。在頁面的生命週期中, JS可以隨意新增和刪除元素的className。而className所定義的樣式則在CSS程式碼之中。任何時刻,CSS中的樣式都是可以修改的而不必更新JS。JS不應當直接操作樣式,以便保持和CSS的鬆耦合。

有一種使用style屬性的情形是可以接受的:當你需要給頁面中的元素會作定位,使其相對於另外一個元素或整個頁面重新定位。這種計算是無法在CSS中完成的,因此這時是可以使用style.topstyle.leftstyle.bottomstyle.rght來對元素作正確定位的。在CSS中定義這個元素的預設屬性,而在 Javascript中修改這些預設值。

鑑於現在前端已經將HTML和JS寫在一塊的現狀,我就不談原書中如何將兩者分離的實踐了。但是,我說了這麼多廢話,請記住一點:“可預見性”(Predictability)會帶來更快的遇試和開發,並確信(而非猜測)從何入手除錯bug,這會讓問題解決得更快、程式碼總體質量更高。

2.2 避免使用全域性變數

全域性變數帶來的問題主要是:隨著程式碼量的增長,過多的全域性變數會導致程式碼難以維護,並且容易出bug。一兩個全域性變數沒什麼大問題,你幾乎不可能做到零全域性變數(除非你的JS程式碼不與任何其他JS程式碼產生聯絡,僅僅做了些自己的事情,這種情況十分少見,不代表沒有)。

如果是寫ES6程式碼,你會發現你很難去建立一個全域性變數,除非你顯式的寫window.globalVar = `something`,ES6的模組機制自動幫你做好了作用域分割,使得你寫的程式碼維護性和安全性都變高了(老JSer不得不感慨現代的前端開發者真幸福)。

如果是ES6之前的程式碼,就得注意點了。比如你在函式中沒有用var來宣告的變數會直接掛載到全域性變數中(這個應該是JS基本知識),所以一般都是通過IIFE來實現模組化,對外只暴露一個全域性變數(當然,你也可以使用RequireJS或者YUI模組載入器等三方的模組管理工具來實現模組化)。

window.global = (function () {
  var exportVar = {}; // ES5沒有let和const,故用var

  // add method and variable to exportVar

  return exportVar;
})();
複製程式碼

2.3 事件處理

我們知道事件觸發時,事件物件(event物件)會作為回撥引數傳入事件處理程式中,舉個例子:

// 不好的寫法
function handleClick(event) {
  var pop = document.getElementById(`popup`);
  popup.style.left = event.clientX + `px`;
  popup.style.top = event.clientY + `px`;
  popup.className = `reveal`;
}

// 你應該明白addListener函式的意思
addListener(element, `click`, handleClick);
複製程式碼

這段程式碼只用到了event物件的兩個屬性:clientX和clientY。在將元素顯示在頁面裡之前先用這兩個屬性個它作定位。儘管這段程式碼看起來非常簡單且沒有什麼問題,但實際上是不好的寫法,因為這種做法有其侷限性。

規則1:隔離應用邏輯

上段例項程式碼的第一個問題是事件處理程式包含了應用用邏輯(application logic)。應用邏輯是和應用相關的功能性程式碼,而不是和使用者行為相關的。上段例項程式碼中應用邏輯是在特定位置顯示一個彈出框。儘管這個互動應當是在使用者點選某個特定元素時發生,但情況並不總是如此。

將應用邏輯從所有事件處理程式中抽離出來的做法是一種最佳實踐,因為說不定什麼時候其他地方就會觸發同一段邏輯。比如,有時你需要在使用者將滑鼠移到某個元素上時判斷是否顯示彈出框,或者當按下鍵盤上的某個鍵時也作同樣的邏輯判斷。這樣多個事件的處理程式執行了同樣的邏輯,而你的程式碼卻被不小心複製了多份。

將應用邏輯放置於事件處理程式中的另一個缺點是和測試有關的。測試時需要直接觸發功能程式碼,而不必通過模擬對元素的點選來觸發。如果將應用邏輯放置於事件處理程式中,唯一的測試方法是製造事件的觸發。儘管某些測試框架可以模擬觸發事件,但實際上這不是測試的最佳方法。呼叫功能性程式碼最好的做法就是單個的函式呼叫。

你總是需要將應用邏輯和事件處理的程式碼拆分開來。如果要對上一段例項程式碼進行重構,第一步是將處理彈出框邏輯的程式碼放入一個單獨的函式中,這個函式很可能掛載於為該應用定義的一個全域性物件上。事件處理程式應當總是在一個相同的全域性物件中,因此就有了以下兩個方法。

// 好的寫法 - 拆分應用邏輯
var MyApplication = {
  handleClick: function (event) {
    this.showPopup(event);
  },

  showPopup: function (event) {
    var pop = document.getElementById(`popup`);
    popup.style.left = event.clientX + `px`;
    popup.style.top = event.clientY + `px`;
    popup.className = `reveal`;
  }
};

addListener(element, `click`, function (event) {
  MyApplication.handleClick(event);
});
複製程式碼

之前在事件處理程式中包含的所有應用邏輯現在轉移到了MyApplication.showPopup()方法中。現在MyApplication.handleClick()方法只做一件事情,即呼叫MyApplication.showPopup()。若應用邏輯被剝離出去,對同一段功能程式碼的呼叫可以在多點發生,則不需要一定依賴於某個特定事件的觸發,這顯然更加方便。但這只是拆解事件處理程式程式碼的第一步。

規則2:不要分發事件物件

在剝離出應用邏輯之後,上段例項程式碼還存在一個問題,即event物件被無節制地分發。它從匿名的事件處理函式傳入了MyApplication.handleClick(),然後又傳入了MyApplication.showPopup()。正如上文提到的,event物件上包含很多和事件相關的額外資訊,而這段程式碼只用到了其中的兩個而已。應用邏輯不應當依賴於event物件來正確完成功能,原因如下:

  • 方法介面並沒有表明哪些資料是必要的。好的API一定是對於期望和依賴都是透明的。將event物件作為為引數並不能告訴你event的哪些屬性是有用的,用來幹什麼?
  • 因此,如果你想測試這個方法,你必須重新建立一個 event物件並將它作為引數傳入。所以,你需要確切地知道這個方法使用了哪些資訊,這樣才能正確地寫出測試程式碼。

這些問題(指介面格式不清晰和自行構造event物件來用於測試)在大型Web應用用中都是不可取的。程式碼不夠明晰就會導致bug。

最佳的辦法是讓事件處理程式使用event物件來處理事件,然後拿到所有需要的資料傳給應用邏輯。例如,MyApplication.showPopup()方法只需要兩個資料,x座標和y座標。這樣我們將方法重寫一下,讓它來接收這兩個引數。

// 好的寫法
var MyApplication = {
  handleClick: function (event) {
    this.showPopup(event.clientX, event.clientY);
  },

  showPopup: function (x, y) {
    var pop = document.getElementById(`popup`);
    popup.style.left = x + `px`;
    popup.style.top = y + `px`;
    popup.className = `reveal`;
  }
};

addListener(element, `click`, function (event) {
  MyApplication.handleClick(event);
});
複製程式碼

在這段新重寫的程式碼中,MyApplication.handleClick()x座標和y座標傳入了MyApplication.showPopup(),代替了之前傳入的事件物件。可以很清晰地看到MyApplication.showPopup()所期望傳入的引數,並且在測試或程式碼的任意位置都可以很輕易地直接呼叫這段邏輯,比如:

// 這樣呼叫非常棒
MyApplication.showPopup(10, 10);
複製程式碼

當處理事件時,最好讓事件處理程式成為接觸到event物件的唯一的函式。事件處理程式應當在進入應用邏輯之前針對event物件執行任何必要的操作,包括阻止預設事件或阻止事件冒泡,都應當直接包含在事件處理程式中。比如:

// 好的寫法
var MyApplication = {
  handleClick: function (event) {
    // 假設事件支援DOM Level2
    event.preventDefault();
    event.stopPropagation();

    // 傳入應用邏輯
    this.showPopup(event.clientX, event.clientY);
  },

  showPopup: function (x, y) {
    var pop = document.getElementById(`popup`);
    popup.style.left = x + `px`;
    popup.style.top = y + `px`;
    popup.className = `reveal`;
  }
};

addListener(element, `click`, function (event) {
  MyApplication.handleClick(event);
});
複製程式碼

在這段程式碼中,MyApplication.handleClick()是事件處理程式,因此它在將資料傳入應用邏輯之前呼叫了event.preventDefault()event.stopPropagation(),這清除地展示了事件處理程式和應用邏輯之間的分工。因為應用邏輯不需要對event產生依賴,進而在很多地方都可以輕鬆地使用相同的業務邏輯,包括寫測試程式碼。

2.4 避免“空比較”

在JS中,我們常常會看到這種程式碼:變數與null的比較(這種用法很有問題),用來判斷變數是否被賦予了一個合理的值。比如:

var Controller = {
  process: function(items) {
    if (items !== null) {
      items.sort();
      items.forEach(function(item){});
    }
  }
};
複製程式碼

在這段程式碼中,process()方法顯然希望items是一個陣列,因為我們看到items擁有sort()forEach()。這段程式碼的意圖非常明顯:如果引數items不是一個陣列,則停止接下來的操作。這種寫法的問題在於,和null的比較並不能真正避免錯誤的發生。items的值可以是1,也可以是字串,甚至可以是任意物件。這些值都和null不相等,進而會導致process()方法一旦執行到sort()時就會出錯。

僅僅和null比較並不能提供足夠的資訊來判斷後續程式碼的執行是否真的安全。好在JS為我們提供了多種方法來檢測變數的真實值。

2.4.1 檢測原始值

在JS中有5種原始型別:字串、數字、布林值、nullundefined。如果你希望一個值是字串、數字、布林值或者undefined,最佳選擇是使用typeof運算子。typeof運算子會返回一個表示值的型別的字串。

  • 對於字串,typeof返回"string"
  • 對於數字,typeof返回"number"
  • 對於布林值,typeof返回"boolean"
  • 對於undefinedtypeof返回"undefined"

對於typeof的用法,如下:

// 推薦使用,這種用法讓`typeof`看起來像運算子
typeof variable

// 不推薦使用,因為它讓`typeof`看起來像函式呼叫
typeof(variable)
複製程式碼

使用typeof來檢測上面四種原始值型別是非常安全的做法。

typeof運算子的獨特之處在於,將其用於一個未宣告的變數也不會報錯。未定義的變數和值為undefined的變數通過typeof都將返回"undefined"

最後一個原始值,null,一般不應用於檢測語句。正如上文提到的,簡單地和null比較通常不會包含足夠的資訊以判斷值的型別是否合法。但有一個例外,如果所期望的值真的是null,則可以直接和null進行比較。這時應當使用===或者!==來和null進行比較,比如:

// 如果你需要檢測null,則使用這種方法
var element = document.getElementById(`my-div`);
if (element !== null) {
  element.className = `found`;
}
複製程式碼

如果DOM元素不存在,則通過document.getElementById()得到的值為null。這個方法要麼返回一個節點,要麼返回null。由於這時null是可預見的一種輸出,則可以使用!==來檢測返回結果。

執行typeof null則返回"object",這是一種低效的判斷null的方法。如果你需要檢測null,則直接使用恆等運算子(===)或非恆等運算子(!==)。

2.4.2 檢測引用值

引用值也稱作物件(object)。在JS中除了原始值之外的值都是引用。有這樣幾種內建的引用型別:ObjectArrayDateError,數量不多。typeof運算子在判斷這些引用型別時顯得力不從心,因為所有物件都會返回"object"

typeof另外一種不推薦的用法是當檢測null的型別時,typeof運算子用於null時將全返回"object"。這看上去很怪異,被認為是標準規範的嚴重bug,因此在程式設計時要杜絕使用typeof來檢測null的型別。

檢測某個引用值的型別的最好方法是使用instanceof運算子。instanceof的基本語法是:value instanceof constructor

instanceof的一個有意思的特性是它不僅檢測構造這個物件的構造器,還檢測原型鏈。原型鏈包含了很多資訊,包括定義物件所採用的繼承模式。比如,預設情況下,每個物件都繼承自Object,因此每個物件的value instanceof Object都會返回true。因為這個原因,使用value instanceof Object來判斷物件是否屬於某個特定型別的做法並非最佳。

instanceof運算子也可以檢測自定義的型別,比如:

function Person (name) {
  this.name = name;
}

var me = new Person(`Nicholas`);
console.log(me instanceof Object); // true
console.log(me instanceof Person); // true
複製程式碼

在JS中檢測自定義型別時,最好的做法就是使用instanceof運算子,這也是唯一的方法。同樣對於內建JS型別也是如此(使用instanceof運算子)。但是,有一個嚴重的限制。

假設一個瀏覽器幀(frameA)裡的一個物件被傳入到另一個幀(frameB)中。兩個幀裡都定義了建構函式Person。如果來自幀A的物件是幀A的Person的例項,則如下規則成立。

frameAPersonInstance instanceof frameAPerson; // true
frameAPersonInstance instanceof frameBPerson; // false
複製程式碼

因為每個幀(frame)都擁有Person的一份拷貝,它被認為是該幀(frame)中的Person的拷貝例項,儘管兩個定義可能完全一樣的。這個問題不僅出現在自定義型別身上,其他兩個非常重要的內建型別也有這個問題:函式和陣列。對於這兩個型別來說,一般用不著使用instanceof

2.4.3 檢測函式

從技術上講,JS中的函式是引用型別,同樣存在Function建構函式,每個函式都是其例項,比如:

function myFunc () {}

// 不好的寫法
console.log(myFunc instanceof Function); // true

// 好的寫法
console.log(typeof myFunc === `function`); // true
複製程式碼

然而,這個方法亦不能跨幀(frame)使用,因為每個幀都有各自的Function建構函式。好在typeof運算子也是可以用於函式的,返回"function"檢測函式最好的方法是使用typeof,因為它可以跨幀(frame)使用。

typeof來檢測函式有一個限制。在IE8和更早版本的IE瀏覽器中,使用typeof來檢測DOM節點(比如document.getElementById())中的函式都返回"object"而不是"function"。比如:

// IE 8及其更早版本的IE
console.log(typeof document.getElementById); // "object"
console.log(typeof document.createElement); // "object"
console.log(typeof document.getElementByTagName); // "object"
複製程式碼

之所以出現這種怪異的現象是因為瀏覽器對DOM的實現由差異。簡言之,這些早版本的IE並沒有將DOM實現為內建的JS方法,導致內建typeof運算子將這些函式識別為物件。因為DOM是有明確定義的,瞭解到物件成員如果存在則意味著它是一個方法,開發者往往通過in運算子來檢測DOM的方法,比如:

// 檢測DOM方法
if ("querySelectorAll" in document) {
  images = document.querySelectorAll("img");
}
複製程式碼

這段程式碼檢查querySelectorAll是否定義在了document中,如果是,則使用這個方法。儘管不是最理想的方法,如果想在IE8及更早瀏覽器中檢測DOM方法是否存在,這是最安全的做法。在其他所有的情形中,typeof運算子是檢測JS函式的最佳選擇。

2.4.4 檢測陣列

JS中最古老的跨域問題之一就是在幀(frame)之間來回傳遞陣列。開發者很快發現instanceof Array在此場景中不總是返回正確的結果。正如上文提到的,每個幀(frame)都有各自的Array建構函式,因此一個幀(frame)中的例項在另外一個幀裡不會被識別。Douglas Crockford首先推薦使用“鴨式辨型”介面(duck typing)(“鴨式辨型”是由作家James Whitcomb Riley首先提出的概念,即“像鴨子一樣走路、游泳並且嘎嘎叫的鳥就是鴨子”,本質上是關注“物件能做什麼”,而不要關注“物件是什麼”,更多內容請參照《JS權威指南》(第六版)9.5,4小節)來檢測其sort()方法是否存在。

// 採用鴨式辨型的方法檢測陣列
function isArray(value) {
  return typeof value.sort === "function";
}
複製程式碼

這種檢測方法依賴一個事實,即陣列是唯一包含sort()方法的物件。當然,如果傳入isArray()的引數是一個包含sort()方法的物件,它也會返回true

關於如何在JS中檢測陣列型別已經有很多研究了,最終,Juriy Zaytsev(也被稱作Kangax)給出了一種優雅的解決方案。

function isArray(value) {
  return Object.prototype.toString.call(value) === "[object Array]";
}
複製程式碼

Kangax發現呼叫某個值的內建toString()方法在所有瀏覽器中都會返回標準的字串結果。對於陣列來說,返回的字串為"[object Array]",也不用考慮陣列例項是在哪個幀(frame)中被構造出來的。Kangax給出的解決方案很快流行起來,並被大多數JS類庫所採納。

這種方法在識別內建物件時往往十分有用,但對於自定義物件請不要用這種方法。比如,內建JSON物件使用這種方法將返回"[object JSON]"

從那時起, ECMAScript5將Array.isArray()正式引入JS。唯一的目的就是準確地檢測一個值是否為陣列。同Kangax的函式一樣, Array.isArray()也可以檢測跨幀(frame)傳遞的值,因此很多JS類庫目前都類似地實現了這個方法。

2.4.5 檢測屬性

另外一種用到null(以及undefined)的場景是當檢測一個屬性是否在物件中存在時,比如:

// 不好的寫法:檢測假值
if (object[propertyName]) {}

// 不好的寫法:和null相比較
if (object[propertyName] != null) {}

// 不好的寫法:和undefined比較
if (object[propertyName] != undefined) {}
複製程式碼

上面這段程式碼裡的每個判斷,實際上是通過給定的名字來檢査屬性的值,而非判斷給定的名字所指的屬性是否存在,因為當屬性值為假值(falsy value)時結果會出錯,比如0、””(空字串)、 false、null和undefined。畢竟,這些都是屬性的合法值。比如,如果屬性記錄了一個數字,則這個值可以是零。這樣的話,上段程式碼中的第一個判斷就會導致錯誤。以此類推,如果屬性值為null或者undefined時,三個判斷都會導致錯誤。

判斷屬性是否存在的最好的方法是使用in運算子。in運算子僅僅會簡單地判斷屬性是否存在,而不會去讀屬性的值,這樣就可以避免出現本小節中前文提到的有歧義的語句。 如果例項物件的屬性存在、或者繼承自物件的原型,in運算子都會返回true。比如:

var object = {
  count: 0,
  related: null
};

// 好的寫法
if ("count" in object) {
  // 這裡的程式碼會執行
}

// 不好的寫法:檢測假值
if (object["count"]) {
  // 這裡的程式碼不會執行
}

// 好的寫法
if ("related" in object) {
  // 這裡的程式碼會執行
}

// 好的寫法
if (object["related"] != null) {
  // 這裡的程式碼不會執行
}
複製程式碼

如果你只想檢查例項物件的某個屬性是否存在,則使用hasOwnProperty()方法。所有繼承自Object的JS物件都有這個方法,如果例項中存在這個屬性則返回true(如果這個屬屬性只存在於原型裡,則返回false)。需要注意的是,在IE8以及更早版本的IE中,DOM物件並非繼承自Object,因此也不包含這個方法。也就是說,你在呼叫DOM物件的 hasOwnProperty()方法之前應當先檢測其是否存在(假如你已經知道物件不是DOM,則可以省略這一步)。

// 對於所有非DOM物件來說,這是好的寫法
if (object.hasOwnProperty("related")) {
  // 執行這裡的程式碼
}

// 如果你不確定是否為DOM物件,則這樣來寫
if ("hasOwnProperty" in object && object.hasOwnProperty("related")) {
  // 執行這裡的程式碼
}
複製程式碼

因為存在IE8以及更早版本IE的情形,在判斷例項物件的屬性是否存在時,我更傾向於使用in運算子,只有在需要判斷例項屬性時才會用到hasOwnProperty()。不管你什麼時候需要檢測屬性的存在性,請使用in運算子或者hasOwnProperty()。這樣做可以避免很多bug。

2.5 將配置資料從程式碼中分離出來

程式碼無非是定義一些指令的集合讓計算機來執行。我們]常常將資料傳入計算機,由指令對資料進行操作,並最終產生一個結果。當不得不修改資料時問題就來了。任何時候你修改原始碼都會有引入bug的風險,且只修改一些資料的值也會帶來一些不必要的風險,因為資料是不應當影響指令的正常執行的。 精心設計的應用應當將關鍵資料從主要的原始碼中抽離出來,這樣我們修改原始碼時才更加放心。

配置資料時在應用中寫死(hardcoded)的值,比如:

  • 魔法數(magic number)
  • URL
  • 需要展現給使用者的字串(可能要做國際化)
  • 重複的值
  • 設定
  • 任何可能發生變更的值

我們時刻要記住,配置資料是可發生變更的,而且你不希望有人突然想修改頁面中展示的資訊,而導致你去修改JS原始碼。

對於這些配置資料,你可以把它們抽離成常量、或者掛載到某個物件中、或寫成配置檔案(JS中推薦JSON),通過程式讀取配置檔案中的資料,這樣即使修改了資料,你的程式程式碼不會有任何的改動,減少了出錯的可能性。

2.6 丟擲自定義錯誤

在JS中丟擲錯誤是一門藝術。摸清楚程式碼中哪裡合適丟擲錯誤是需要時間的。因此,一旦搞清楚了這一點,除錯程式碼的事件將大大縮短,對程式碼的滿意度將急劇提升。

2.6.1 錯誤的本質

當某些非期望的事情發生時程式就引發一個錯誤。也許是給一個函式傳遞了一個不正確的值,或者是數學運算碰到了一個無效的運算元。程式語言定義了一組基本的規則,當偏離了這些規則時將導致錯誤,然後開發者能修復程式碼。如果錯誤沒有被丟擲或者報告給你的話,除錯是非常困難的。如果所有的失敗都是悄無聲息的,首要的問題是那必將消耗你大量的時間才能發現它,更不要說單獨隔離並修復它了。所以,錯誤是開發者的朋友,而不是敵人

錯誤常常在非期望的地點、不恬當的時機跳出來,這很麻煩。更糟糕的是,預設的錯誤訊息通常太簡潔而無法解釋到底什麼東西出錯了。JS錯誤訊息以資訊稀少、隱晦含糊而臭名昭著(特別是在老版本的IE中),這隻會讓問題更加複雜化。想象一下,如果跳出一個錯誤能這樣描述:“由於發生這些情況,該函式呼叫失敗”。那麼,除錯任務馬上就會變得更加簡單,這正是丟擲自己的錯誤的好處。

像內建的失敗案例一樣來考慮錯誤是非常有幫助的。在程式碼某個特殊之處計劃一個失敗總比要在所有的地方都預期失敗簡單的多。在產品設計上,這是非常普遍的實踐經驗,而不僅僅是在程式碼編寫方面。汽車尚有碰撞力吸收區域,這些區域框架的設計旨在撞擊發生時以可預測的方式崩塌。知道一個碰撞到來時這些框架將如何反應——特別是,哪些部分將失敗——製造商將能保證乘客的安全。你的程式碼也可以用這種方法來建立。

2.6.2 在JS中丟擲錯誤

毫無疑問,在JS中丟擲錯誤要比在任何其他語言中做同樣的事情更加有價值,這歸咎於Web端除錯的複雜性。可以使用throw操作符,將提供的一個物件作為錯誤丟擲。任何型別的物件都可以作為錯誤丟擲,然而,Error物件是最常用的。

throw new Error(`Something bad happened.`);
複製程式碼

內建的Error型別在所有的JS實現中都是有效的,它的構造器只接受一個引數,指代錯誤訊息(message)。當以這種方式丟擲錯誤時,如果沒有通過try-catch語句來捕獲的話,瀏覽器通常直接顯示該訊息(message字串)。當今大多數瀏覽器都有一個控制檯(console),一旦發生錯誤都會在這裡輸出錯誤資訊。換言之,任何你丟擲的和沒丟擲的錯誤都被以相同的方式來對待。

缺乏經驗的開發者有時直接將一個字串作為錯誤丟擲,如:

// 不好的寫法
throw `message`;
複製程式碼

這樣做確實能夠丟擲一個錯誤,但不是所有的瀏覽器做出的響應都會按照你的預期。Firefox、Opera和Chrome都將顯示一條“uncaught exception”訊息,同時它們包含上述訊息字串。Safari和IE只是簡陋地丟擲一個“uncaught exception”錯誤,完全不提供上述訊息字串,這種方式對除錯無益。

顯然,如果願意,你可以丟擲任何型別的資料。沒有任何規則約束不能是特定的資料型別。

throw { name: `Nicholas` };
throw true;
throw 12345;
throw new Date();
複製程式碼

就一件事情需要牢記,如果沒有通過try-catch語句捕獲,丟擲任何值都將引發一個錯誤。Firefox、Opera和Chrome都會在該丟擲的值上呼叫String()函式,來完成錯誤訊息的顯示邏輯,但Safari和IE不是這樣的。針對所有的瀏覽器,唯一不出差錯的顯示自定義的錯誤訊息的方式就是用一個Error物件。

2.6.3 丟擲錯誤的好處

丟擲自己的錯誤可以使用確切的文字供瀏覽器顯示。除了行和列的號碼,還可以包含任何你需要的有助於除錯問題的資訊。我推薦總是在錯誤訊息中包含函式名稱,以及函式失敗的原因。考察下面的函式:

function getDivs (element) {
  return element.getElementsByTagName(`div`);
}
複製程式碼

這個函式旨在獲取element元素下所有後代元素中的div元素。傳遞給函式要操作的DOM元素為null值可能是件很常見的事情,但實際需要的是DOM元素。如果給這個函式傳遞null會發生什麼情況呢?你會看到一個類似“object expected”的含糊的錯誤訊息。然後,你要去看執行棧,再實際定位到原始檔中的問題。通過丟擲一個錯誤,除錯會更簡單:

function getDivs (element) {
  if (element && element.getElementsByTagName) {
    return element.getElementsByTagName(`div`);
  } else {
    throw new Error(`getDivs(): Argument must be a DOM element.`);
  }
}
複製程式碼

現在給getDivs()函式丟擲一個錯誤,任何時候只要element不滿足繼續執行的條件,就會丟擲一個錯誤明確地陳述發生的問題。如果在瀏覽器控制檯中輸出該錯誤,你馬上能開始除錯,並知道最有可能導致該錯誤的原因是呼叫函式試圖用一個值為null的DOM元素去做進一步的事情。

我傾向於認為丟擲錯誤就像給自己留下告訴自己為什麼失敗的標籤

2.6.4 何時丟擲錯誤

理解了如何丟擲錯誤只是等式的一個部分,另外一部分就是要理解什麼時候丟擲錯誤。由於JS沒有型別和引數檢查,大量的開發者錯誤地假設他們自己應該實現每個函式的型別檢查。這種做法並不實際,並且會對指令碼的整體效能造成影響。考察下面的函式,它試圖實現充分的型別檢查。

// 不好的做法:檢查了太多的錯誤
function addClass (element, className) {
  if (!element || typeof element.className !== `string`) {
    throw new Error(`addClass(): First argument must be a DOM element.`);
  }
  if (typeof className !== `string`) {
    throw new Error(`addClass(): Second argument must be a string.`);
  }
  element.className += `` + className;
}
複製程式碼

這個函式本來只是簡單地給一個給定的元素增加一個CSS類名(className),因此,函式的大部分工作變成了錯誤檢查。縱然它能在每個函式中檢查每個引數(模仿靜態語言),在JS中這麼做也會引起過度的殺傷。辨識程式碼中哪些部分在特定的情況下最有可能導致失敗,並只在那些地方丟擲錯誤才是關鍵所在。

在上例中,最有可能引發錯誤的是給函式傳遞一個null引用值。如果第二個引數是null或者一個數字或者一個布林值是不會丟擲錯誤的,因為JS會將其強制轉換為字串。那意味著導致DOM元素的顯示不符合期望,但這並不至於提高到嚴重錯誤的程度。所以,我只會檢查DOM元素。

// 好的寫法
function addClass (element, className) {
  if (!element || typeof element.className !== `string`) {
    throw new Error(`addClass(): First argument must be a DOM element.`);
  }
  element.className += `` + className;
}
複製程式碼

如果一個函式只被已知的實體呼叫,錯誤檢查很可能沒有必要(這個案例是私有函式);如果不能提前確定函式會被呼叫的所有地方,你很可能需要一些錯誤檢查。這就更有可能從丟擲自己的錯誤中獲益。丟擲錯誤最佳的地方是在工具函式中,如addClass()函式,它是通用指令碼環境中的一部分,會在很多地方使用,更準確的案例是JS類庫。

針對已知條件引發的錯誤,所有的JS類庫都應該從它們的公共介面裡丟擲錯誤。如jQuery、YUI和Dojo等大型的庫,不可能預料你在何時何地呼叫了它們的函式。當你做錯事的時候通知你是它們的責任,因為你不可能進入庫程式碼中去除錯錯誤的原因。函式呼叫棧應該在進入庫程式碼介面時就終止,不應該更深了。沒有比看到由一打庫程式碼中函式呼叫時發生一個錯誤更加糟糕的事情了吧,庫的開發者應該承擔起防止類似情況發生的責任。

私有JS庫也類似。許多Web應用程式都有自己專用的內建的JS庫或“拿來”一些有名的開源類庫(類似jQuery)。類庫提供了對髒的實現細節的抽象,目的是讓開發者用得更爽。丟擲錯誤有助於對開發者安全地隱藏這些髒的實現細節。

這裡有一些關於丟擲錯誤很好的經驗法則:

  • 一旦修復了一個很難除錯的錯誤,嘗試增加一兩個自定義錯誤。當再次發生錯誤時,這將有助於更容易地解決問題。
  • 如果正在編寫程式碼,思考一下:“我希望[某些事情]不會發生,如果發生,我的程式碼會一團糟糕”。這時,如果“某些事情”發生,就丟擲一個錯誤。
  • 如果正在編寫的程式碼別人(不知道是誰)也會使用,思考一下他們使用的方式,在特定的情況下丟擲錯誤。

請牢記,我們目的不是防止錯誤,而是在錯誤發生時能更加容易地除錯。

2.6.5 try-catch語句

應用程式邏輯總是知道呼叫某個特定函式的原因,因此也是最合適處理錯誤的。千萬不要將try-catch中的catch塊留空,你應該總是寫點什麼來處理錯誤。例如,不要像下面這樣做:

try {
  somethingThatMightCauseAnError();
} catch (ex) {
  // do nothing
}
複製程式碼

如果知道可能要發生錯誤,那肯定知道如何從錯誤中恢復。確切地說,如何從錯誤中恢復在開發模式中與實際放到生產環境中是不一樣的,這沒關係。最重要的是,你實實在在地在處理錯誤,而不是忽略它。

2.6.6 錯誤型別

ECMA-262規範指出了7種錯誤型別。當不同錯誤條件發生時,這些型別在JS引擎中都有用到,當然我們也可以手動建立它們。

  1. Error: 所有錯誤的基本型別。實際上引擎從來不會丟擲該型別的錯誤。
  2. EvalError: 通過eval()函式執行程式碼發生錯誤時丟擲。
  3. RangeError: 一個數字超出它的邊界時丟擲——例如,試圖建立一個長度為-20的陣列(new Array(-20);)。該錯誤在正常的程式碼執行中非常罕見。
  4. ReferenceError: 期望的物件不存在時丟擲——例如,試圖在一個null物件引用上呼叫一個函式。
  5. SyntaxError: 程式碼有語法錯誤時丟擲。
  6. TypeError: 變數不是期望的型別時丟擲。例如,new 10`prop` in true
  7. URIError: 給encodeURI()encodeURIComponent()decodeURI()或者decodeURIComponent()等函式傳遞格式非法的URI字串時丟擲。

理解錯誤的不同型別可以幫助我們更容易地處理它。所有的錯誤型別都繼承自Error,所以用instanceof Error檢查其型別得不到任何有用的資訊。通過檢查特定的錯誤型別可以更可靠地處理錯誤。

try {
  // 有些程式碼引發了錯誤
} catch (ex) {
  if (ex instanceof TypeError) {
    // 處理TypeError錯誤
  } else if (ex instanceof ReferenceError) {
    // 處理ReferenceError錯誤
  } else {
    // 其他處理
  }
}
複製程式碼

如果丟擲自己的錯誤,並且是資料型別而不是一個錯誤,你可以非常輕鬆地區分自己的錯誤和瀏覽器的錯誤型別的不同。但是,丟擲實際型別的錯誤與丟擲其他型別的物件相比,有幾大優點。

首先,如上討論,在瀏覽器正常錯誤處理機制中會顯示錯誤訊息。其次,瀏覽器給丟擲的Error物件附加了一些額外的資訊。這些資訊不同瀏覽器各不相同,但它們為錯誤提供瞭如行、列號等上下文資訊,在有些瀏覽器中也提供了堆疊和原始碼資訊。當然,如果用了Error的構造器,你就喪失了區分自己丟擲的錯誤和瀏覽器錯誤的能力。

解決方案就是建立自己的錯誤型別,讓它繼承自Error。這種做法允許你提供額外的資訊,同時可區別於瀏覽器丟擲的錯誤。可以用如下的模式來建立自定義的錯誤型別。

function MyError (message) {
  this.message = message;
}
MyError.prototype = new Error();
複製程式碼

這段程式碼有兩個重要的部分:message屬性,瀏覽器必須要知道的錯誤訊息字串;設定prototype為Error的一個例項,這樣對JS引擎而言就標識它是一個錯誤物件了。接下來就可以丟擲一個MyError的例項物件,使得瀏覽器能像處理原生錯誤一樣做出響應。

throw new MyError(`Hello World!`);
複製程式碼

提醒一下,該方法在IE8和更早的瀏覽器中不顯示錯誤訊息。相反,會看見那個通用的“Exception thrown but not caught”訊息。這個方法最大的好處是,自定義錯誤型別可以檢測自己的錯誤。

try {
  // 有些程式碼引發了錯誤
} catch (ex) {
  if (ex instanceof MyError) {
    // 處理自己的錯誤
  } else {
    // 其他處理
  }
}
複製程式碼

如果總是捕獲你自己丟擲的所有錯誤,那麼IE的那點兒小愚蠢也不足為道了。在一個正確的錯誤處理系統中獲得的好處是巨大的。該方法可以給出更多、更靈活的資訊,告知開發者如何正確地處理錯誤。

2.7 不是你的物件不要動

JS獨一無二之處在於任何東西都不是神聖不可侵犯的。預設情況下,你可以修改任何你可以觸及的物件。它(解析器)根本就不在乎這些物件是開發者定義的還是預設執行環境的一部分——只要是能訪問到的物件都可以修改。在一個開發者獨自開發的專案中,這不是問題,開發者確切地知道正在修改什麼,因為他對所有程式碼都瞭如指掌。然而,在一個多人開發的專案中,物件的隨意修改就是個大問題了。

2.7.1 什麼是你的物件

當你的程式碼建立了這些物件時,你擁有這些物件。建立了物件的程式碼也許沒必要一定由你來編寫,但只要維護程式碼是你的責任,那麼就是你擁有這些物件。舉例來說,YUI團隊擁有該YUI物件,Dojo團隊擁有該dojo物件。即使編寫程式碼定義該物件的原始作者離開了,各自對應的團隊仍然是這些物件的擁有者。

當在專案中使用一個JS類庫,你個人不會自動變成這些物件的擁有者。在一個多人開發的專案中,每個人都假設庫物件會按照它們的文件中描述的一樣正常工作。如果你在使用YUI,修改了其中的物件,那麼這就給你自己的團隊設定了一個陷阱。這必將導致一些問題,有些人可能會掉進去。

請牢記,如果你的程式碼沒有建立這些物件,不要修改它們, 包括:

  • 原生物件(Object、Array等等)
  • DOM物件(例如,document)
  • 瀏覽器物件模型(BOM)物件(例如,window)
  • 類庫的物件

上面所有這些物件是你專案執行環境的一部分。由於它們已經存在了,你可以直接使用這些或者用其來構建某些新的功能,而不應該去修改它們。

2.7.2 原則

企業軟體需要一致而可靠的執行環境使其方便維護。在其他語言中,考慮將已存在的物件作為庫用來完成開發任務。在JS中,我們可以將已存在的物件視為一種背景,在這之上可以做任何事情。你應該把已存在的JS物件如一個使用工具函式庫一樣來對待。

  • 不覆蓋方法
  • 不新增方法
  • 不刪除方法

當專案中只有你一個開發者時,因為你瞭解它們,對它們有預期,這些種類的修改很容易處理。當與一個團隊一起在做一個大型的專案時,像這些情況的修改會導致大量的混亂,也會浪費很多時間。

不覆蓋方法

在JS中,有史以來最糟糕的實踐是覆蓋一個非自己擁有的物件的方法,JS中覆蓋一個已存在的方法是難以置信的容易。即使那個神聖的document.getElementById()方法也不例外,可以被輕而易舉地覆蓋。也許你看過類似下面的模式(這種做法也叫“函式劫持”):

// 不好的寫法
document._originalGetElementById = document.getElementById;
document.getElementById = function (id) {
  if (id === `window`) {
    return window;
  } else {
    return document._originalGetElementById(id);
  }
}
複製程式碼

上例中,將一個原生方法document.getElementById()的“指標”儲存在document._originalGetElementById中,以便後續使用。然後,document.getElementById()被一個新的方法覆蓋了。新方法有時也會呼叫原始的方法,其中有一種情況不呼叫。這種“覆蓋加可靠退化”的模式至少和覆蓋原生方法一樣不好,也許會更糟,因為document.getElementById()時而符合預期,時而不符合。 在一個大型的專案中,一個此類問題就會導致浪費大量時間和金錢。

不新增方法

在JS中為已存在的物件新增方法是很簡單的。只需要建立一個函式賦值給一個已存在的物件的屬性,使其成為方法即可。這種做法可以修改所有型別的物件。

// 不好的寫法 - 在DOM物件上增加了方法
document.sayImAwesome = function () {
  alert("You`re awesome.");
}
// 不好的寫法 - 在原生物件上增加了方法
Array.prototype.reverseSort = function () {
  return this.sort().reverse();
}
// 不好的寫法 - 在庫物件上增加了方法
YUI.doSomething = function () {
  // 程式碼
}
複製程式碼

幾乎不可能阻止你為任何物件新增方法(ES5新增了三個方法可以做到,後面會介紹)。為非自己擁有的物件增加方法一個大問題,會導致命名衝突。因為一個物件此刻沒有某個方法不代表它未來也沒有。 更糟糕的是如果將來原生的方法和你的方法行為不一致,你將陷入一場程式碼維護的噩夢。

我們要從Prototype JS類庫的發展歷史中吸取教訓。從修改各種各樣的JS物件角度而言Prototype非常著名。它很隨意地為DOM和原生的物件增加方法。實際上,庫的大多數程式碼定義為擴充套件已存在的物件,而不是自己建立物件。Prototype的開發者將該庫看作是對JS的補充。在小於1.6的版本中,Prototype實現了一個document.getElementsByClassName()方法。也許你認識該方法,因為在HTML5中是官方定義的,它標準化了Prototype的用法。

Prototype的document.getElementsByClassName()方法返回包含了指定CSS類名的元素的一個陣列。Prototype在陣列上也增加了一個方法,Array.prototype.each(),它在該陣列上迭代並在每個元素上執行一個函式。這讓開發者可以編寫如下程式碼:

document.getElementsByClassName(`selected`).each(doSomething);
複製程式碼

在HTML5標準化該方法和瀏覽器開始原生地實現之前,程式碼是沒有問題的。當Prototype團隊知道原生的document.getElementsByClassName()即將到來,所以他們增加了一些防守性的程式碼,如下:

if (!document.getElementsByClassName) {
  document.getElementsByClassName = function (classes) {
    // 非原生實現
  };
}
複製程式碼

故Prototype只是在document.getElementsByClassName()不存在的時候定義它。這看上去好像問題就此解決了,但還有一個重要的事實是:HTML5的document.getElementsByClassName()不返回一個陣列,所以each()方法根本不存在。原生的DOM方法使用了一個特殊化的集合型別稱為NodeList。document.getElementsByClassName()返回一個NodeList來匹配其他的DOM方法的呼叫。

如果瀏覽器中原生實現了document.getElementsByClassName()方法,那麼由於NodeList沒有each()方法,無論是原生的或是Prototype增加的each()方法,在執行時都將引發一個JS錯誤。最後的結局是Prototype的使用者不得不既要升級類庫程式碼還要修改他們自己的程式碼,真是一場維護的噩夢。

從Prototype的錯誤中可以學到,你不可能精確預測JS將來會如何變化。標準已經進化了,它們經常會從諸如Prototype這樣的庫程式碼中獲得一些線索來決定下一代標準的新功能。事實上,原生的Array.prototype.forEach()方法在ECMAScript5有定義,它與Prototype的each()方法行為非常類似。問題是你不知道官方的功能與原生會有什麼樣的不同,甚至是微小的區別也將導致很大的問題。

大多數JS庫程式碼有一個外掛機制,允許為程式碼庫安全地新增一些功能。如果想修改,最佳最可維護的方式是建立一個外掛

不刪除方法

刪除JS方法和新增方法一樣簡單。當然,覆蓋一個方法也是刪除已存在的方法的一種方式。最簡單的刪除一個方法的方式就是給對應的名字賦值為null。

// 不好的寫法 - 刪除了DOM方法
document.getElementById = null;
複製程式碼

將一個方法設定為null,不管它以前是怎麼定義的,現在它已經不能被呼叫到了。如果方法是在物件的例項上定義的(相對於物件的原型而言),也可以使用delete操作符來刪除。

var person = {
  name: `Nicholas`
};

delete person.name;
console.log(person.name); // undefined
複製程式碼

上例中,從person物件中刪除了name屬性。delete操作符只能對例項的屬性和方法起作用。如果在prototype的屬性或方法上使用delete是不起作用的。例如:

// 不影響
delete document.getElementById;
console.log(document.getElementById(`myelement`)); // 仍然能工作
複製程式碼

因為document.getElementById()是原型上的一個方法,使用delete是無法刪除的。但是,仍然可以用對其賦值為null的方式來阻止被呼叫。

無需贅述,刪除一個已存在物件的方法是糟糕的實踐。不僅有依賴那個方法的開發者存在,而且使用該方法的程式碼有可能已經存在了。刪除一個在用的方法會導致執行時錯誤。如果你的團隊不應該使用某個方法,將其標識為“廢棄”,可以用文件或者用靜態程式碼分析器。刪除一個方法絕對應該是最後的選擇。

反之,不刪除你擁有物件的方法實際上是比較好的實踐。從庫程式碼或原生物件上刪除方法是非常難的事情,因為第三方程式碼正依賴於這些功能。在很多案例中,庫程式碼和瀏覽器都會將有bug或不完整的方法保留很長一段時間,因為刪除它們以後會在數不勝數的網站上導致錯誤。

2.7.3 更好的途徑

修改非自己擁有的物件是解決某些問題很好的方案。在一種“無公害”的狀態下,它通常不會發生;發生的原因可能是開發者遇到了一個問題,然而又通過修改物件解決了這個問題。儘管如此,解決一個已知問題的方案總是不止一種的。大多是電腦科學知識已經在靜態型別語言環境中進化出瞭解決難題方案,如Java。可能有一些方法,所謂的設計模式,不直接修改這些物件而是擴充套件這些物件。

在JS之外,最受歡迎的物件擴充的形式是繼承。如果一種型別的物件已經做到了你想要的大多數工作,那麼繼承自它,然後再新增一些功能即可。在JS中有兩種基本的形式:基於物件的繼承和基於型別的繼承。

在JS中,繼承仍然有一些很大的限制。首先,不能從DOM或BOM物件繼承。其次,由於陣列索引和length屬性之間錯綜複雜的關係,繼承自Array是不能正常工作的。

基於物件的繼承

在基於物件的繼承中,也經常叫做原型繼承,一個物件繼承另外一個物件是不需要呼叫建構函式的。ES5的Object.create()方法是實現這種繼承的最簡單的方式。例如:

var person = {
  name: `Nicholas`,
  sayName: function () {
    console.log(this.name);
  }
};

var myPerson = Object.create(person);
myPerson.sayName(); // "Nicholas"
複製程式碼

這個例子建立了一個新物件myPerson,它繼承自person。這種繼承方式就如同myPerson的原型設定為person,從此myPerson可以訪問person的屬性和方法,而不需要同名變數在新的物件上再重新定義一遍。例如,重新定義myPerson.sayName()會自動切斷對person.sayName()的訪問:

myPerson.sayName = function () {
  console.log(`Anonymous`);
};

myPerson.sayName(); // "Anonymous"
person.sayName(); // "Nicholas"
複製程式碼

Object.create()方法可以指定第二個引數,該引數物件中的屬性和方法將新增到新的物件中。例如:

var myPerson = Object.create(person, {
  name: {
    value: `Greg`
  }
});

myPerson.sayName(); // "Greg"
person.sayName(); // "Nicholas"
複製程式碼

這個例子建立的myPerson物件擁有自己的name屬性值,所以呼叫sayName()顯示的是“Greg”而不是“Nicholas”。

一旦以這種方式建立了一個新物件,該新物件完全可以隨意修改。畢竟,你是該物件的擁有者,在自己的專案中你可以任意新增方法,覆蓋已存在方法,甚至是刪除方法(或者阻止它們的訪問)。

基於型別的繼承

基於型別的繼承和基於物件的繼承工作方式是差不多的,它從一個已存在的物件繼承,這裡的繼承是依賴於原型的。因此,基於型別的繼承是通過建構函式實現的,而非物件。這意味著,需要訪問被繼承物件的建構函式。比起JS中原生的型別,在開發者定義了建構函式的情況下,基於型別的繼承是最合適的。同時,基於型別的繼承一般需要兩步:首先,原型繼承;然後,構造器繼承。構造器繼承是呼叫超類的建構函式時傳入新建的物件作為其this的值。例如:

function Person (name) {
  this.name = name;
}

function Author (name) {
  Person.call(this, name); // 繼承構造器
}

Author.prototype = new Person();
複製程式碼

這段程式碼裡,Author型別繼承自Person。屬性name實際上是由Person類管理的,所以Person.call(this, name)允許Person構造器繼續定義該屬性。Person構造器是在this上執行的,this指向一個Author物件,所以最終的name定義在這個Author物件上。

對比基於物件的繼承,基於型別的繼承在建立新物件時更加靈活。定義了一個型別可以讓你建立多個例項物件,所有的物件都是繼承自一個通用的超類。新的型別應該明確定義需要使用的屬性和方法,它們與超類中的應該完全不同。

門面模式

門面模式是一種流行的設計模式,它為一個已存在的物件建立一個新的介面。門面是一個全新的物件,其背後有一個已存在的物件在工作。門面有時也叫包裝器,它們用不同的介面來包裝已存在的物件。你的用例中如果繼承無法滿足要求,那麼下一步驟就應該建立一個門面,這比較合乎邏輯。

jQuery和YUI的DOM介面都使用了門面。如上所述,你無法從DOM物件上繼承,所以唯一的能夠安全地為其新增功能的選擇就是建立一個門面。下面是一個DOM物件包裝器程式碼示例:

function DOMWrapper (element) {
  this.element = element;
}

DOMWrapper.prototype.addClass = function (className) {
  this.element.className += ` ` + className;
}

DOMWrapper.prototype.remove = function () {
  this.element.parentNode.removeChild(this.element);
}

// 用法
var wrapper = new DOMWrapper(document.getElementById(`my-div`));
wrapper.addClass(`selected`);
wrapper.remove();
複製程式碼

DOMWrapper型別期望傳遞給其構造器的是一個DOM元素。該元素會儲存起來以便以後引用,它還定義了一些操作該元素的方法。addClass()方法是為那些還未實現HTML5的classList屬性的元素增加className的一個簡單的方法。remove()方法封裝了從DOM中刪除一個元素的操作,遮蔽了開發者要訪問該元素父節點的需求。

從JS的可維護性而言,門面是非常合適的方式,自己可以完全控制這些介面。你可以允許訪問任何底層物件的屬性或方法,反之亦然,也就是有效地過濾對該物件的訪問。你也可以對已有的方法進行改造,使其更加簡單易用(上段示例程式碼就是一個案例)。底層的物件無論如何改變,只要修改門面,應用程式就能繼續正常工作。

門面實現一個特定介面,讓一個物件看上去像另一個物件,就稱作一個介面卡。門面和介面卡唯一的不同是前者建立新介面,後者實現已存在的介面

2.7.4 關於Polyfill的註解

隨著ES5和和HTML5的特性逐漸被各種瀏覽器實現。JS polyfills(也稱為shim)變得流行起來了。 polyfill是對某種功能的模擬,這些功能在新版本的瀏覽器中有完整的定義和原生實現。例如,ES5為陣列增加了forEach()函式。該方法在 ES3中有模擬實現,這樣就可以在老版本瀏覽器中用上這個方法了。 polyfills的關鍵在於它們的模擬實現要與瀏覽器原生實現保持完全相容。正是由於少部分瀏覽器原生實現這些功能,才需要儘可能的檢測不同情況下它們這些功能的處理是否符合標準。

為了達到目的,polyfills經常會給非自己擁有的物件新增一些方法。我不是polyfills的粉絲,不過對於別人使用它們,我表示理解。相相比其他的物件修改而言,polyfills是有界限的,是相對安全的。因為原生實現中是存在這些方法並能工作的,有且僅當原生方法不存在時,polyfills才新增這些方法,並且它們和原生版本方法的行為是完全一致的。

polyfills的優點是,如果瀏覽器提供原生實現,可以非常輕鬆地移除它們。如果你使用了polyfills,你需要搞清楚哪些瀏覽器提供了原生實現。並確保polyfills的實現和瀏覽器原生實現保持完全一致,並再三檢查類庫是否提供驗證這些方法正確性的測試用例。polyfills的缺點是,和瀏覽器的原生實現相比,它們的實現可能不精確,這會給你帶來很多麻煩,還不如不實現它。

從最佳的可維護性角度而言,避免使用polyfills,相反可以在已存在的功能之上建立門面來實現。這種方法給了你最大的靈活性,當原生實現中有bug時這種做法(避免使用polyfills)就顯得特別重要。這種情況下,你根本不想直接使用原生的API,不然無法將原生實現帶有的bug隔離開來。

2.7.5 阻止修改

ES5引入了幾個方法來防止對物件的修改。理解這些能力很重要,因此現在可以做到這樣的事情:鎖定這些物件,保證任何人不能有意或無意地修改他們不想要的功能。當前(2018年)的瀏覽器都支援ES5的這些功能,有三種鎖定修改的級別:

  • 防止擴充套件(Object.preventExtension()):禁止為物件“新增”屬性和方法,但已存在的屬性和方法是可以被修改或刪除
  • 密封(Object.seal()):類似“防止擴充套件”,而且禁止為物件“刪除”已存在的屬性和方法
  • 凍結(Object.freeze()):類似“密封”,而且禁止為物件“修改”已存在的屬性和方法(所有欄位均只讀)

每種鎖定的型別都擁有兩個方法:一個用來實施操作,另一個用來檢測是否應用了相應的操作。如防止擴充套件,Object.preventExtension()Object.isExtensible()兩個函式可以使用。你可以在MDN上檢視相關方法的使用,這裡就不贅述了。

使用ES5中的這些方法是保證你的專案不經過你同意鎖定修改的極佳的做法。如果你是一個程式碼庫的作者,很可能想鎖定核心庫某些部分來保證它們不被意外修改,或者想強迫允許擴充套件的地方繼續存活著。如果你是一個應用程式的開發者,鎖定應用程式的任何不想被修改的部分。這兩種情況中,在全部定義好這些物件的功能之後,才能使用上述的鎖定方法。一旦一個物件被鎖定了,它將無法解鎖。

2.8 瀏覽器嗅探

瀏覽器嗅探在Web開發領域始終是一個熱點話題,不管你是寫JS或CSS或HTML,總會遇到跨瀏覽器做相容的情況(雖然目前情況已經比之前好太多,但面對新API介面的使用,依然存在瀏覽器嗅探的情況)。下面介紹下基於UA檢測的歷史,來說明為什麼UA檢測不合理。

2.8.1 UA檢測

最早的瀏覽器嗅探即使用者代理(user-agent)檢測,服務端(以及後來的客戶端)根據user-agent字串來確定瀏覽器的型別。在此期間,伺服器會完全根據user-agent字串遮蔽某些特定的瀏覽器檢視網站內容。其中獲益最大的瀏覽器就是網景瀏覽器。不可否認,網景(在當時)是最強大的瀏覽器,以至於很多網站都認為只有網景瀏覽器才會正常展現他們的網頁。網景瀏覽器的user-agent字串是Mozilla/2.0 (Win95; I)。當IE首次釋出,基本上就被迫沿用了網景瀏覽器user-agent字串的很大一部分,以此確保伺服器能夠為這款新的瀏覽器提供服務。因為絕大多數的使用者代理檢測的過程都是查詢“Mozilla”字串和斜線之後的版本號,IE瀏覽器的user-agent字串設定成Mozilla/2.0 (compatible; MSIE 3.0; Windows 95),是不是覺得很雞賊。IE採用了這樣的使用者代理字串,這意味著每個瀏覽器型別檢測也會把這款新的瀏覽器識別為網景的Navigator瀏覽器。這也使得新生瀏覽器部分複製現有瀏覽器使用者代理字串成為了一種趨勢。Chrome發行版的使用者代理字串包含了Safari的一部分,而Safari的使用者代理字串又相應包含了Firefox的一部分,Firefox又依次包含了Netscape(網景)使用者代理字串的一部分。

基於UA檢測是極其不靠譜的,並且維護困難,基於如下原因:

  • UA可以偽造,一個宣告為Chrome的瀏覽器它可能是其他瀏覽器
  • 每次有新的瀏覽器出現,或者已有的瀏覽器版本升級,原先基於UA檢測的程式碼都要更新,維護成本和出錯機率極大

所以我建議你儘可能避免檢測UA,即使在不得不這樣做的情況下。

2.8.2 特性檢測

我們希望有一種更聰明的基於瀏覽器條件(進行檢測)的方法,於是一種叫特性檢測的技術變得流行起來。特性檢測的原理是為特定瀏覽器的特性進行測試,並僅當特性存在時即可應用特性檢測,例如:

// 不好的寫法
if (navigator.userAgent.indexOf("MSIE 7") > -1) { }

// 好的寫法
if (document.getElementById) {}
複製程式碼

因為特性檢測不依賴於所使用的瀏覽器,而僅僅依據特性是否存在,所以並不一定需要新瀏覽器的支援。例如,在DOM早期的時候,並非所有瀏覽器都支援document.getElementById(),所以根據ID獲取元素的程式碼看起來就有些冗餘。

// 好的寫法
// 僅為舉例說明特性檢測,現代瀏覽器都支援getElementById
function getById (id) {
  var el = null;

  if (document.getElementById) { // DOM
    el = document.getElementById(id);
  } else if (document.all) { // IE
    el = document.all[id];
  } else if (document.layers) { // Netscape <= 4
    el = document.layers[id];
  }

  return el;
}
複製程式碼

這種方法同樣適用於當今最新的瀏覽器特性檢測,瀏覽器已經實驗性地實現了這些最新的特性,而規範還正在最後確定中。常見的Polyfill就是特性檢測的應用,例如:

if (!Array.isArray) {
  Array.isArray = function (arr) {
    return Object.prototype.toString.call(arr) === `[object Array]`
  }
}
複製程式碼

2.8.3 避免特性推斷

一種不當的使用特性檢測的情況是“特性推斷”(Feature Inference)。特性推斷嘗試使用多個特性但僅驗證了其中之一。根據一個特性的存在推斷另一個特性是否存在。問題是,推斷是假設並非事實,而且可能會導致維護性的問題。例如,如下是一些使用特性推斷的舊程式碼:

// 不好的寫法 - 使用特性推斷
function getById (id) {
  var el = null;

  if (document.getElementsByTagName) { // DOM
    el = document.getElementById(id);
  } else if (window.ActiveXObject) { // IE
    el = document.all[id];
  } else { // Netscape <= 4
    el = document.layers[id];
  }

  return el;
}
複製程式碼

該函式是最糟糕的特性推斷,其中做出瞭如下幾個推斷:

  • 如果document.getElementsByTagName()存在,則document.getElementById也存在。實際上,這個假設是從一個DOM方法的存在推斷出所有方法都存在。
  • 如果window.ActiveXObject存在,則document.all也存在。這個推斷基本上斷定window.ActiveXObject僅僅存在於IE,且document.all也僅存在於IE,所以如果你判斷一個存在,其他的也必定存在。實際上,Opera的一些版本也支援document.all
  • 如果這些推斷都不成立,則一定是Netscape Navigator 4或者更早的版本。這看似正確,但及其不嚴格。

你不能從一個特性的存在推斷出另一個特性是否存在。最好的情況下兩者有薄弱的聯絡,最壞的情況下兩者根本沒有直接關係。也就好比說是,“如果它看起來像一個鴨子,就必定像鴨子一樣嘎嘎地叫。”

2.8.4 避免瀏覽器推斷

在某些時候,使用者代理檢測和特性檢測讓許多Web開發人員很困惑。於是寫出來的程式碼就變成了這樣:

// 不好的寫法
if (document.all) {
  id = document.uniqueID;
} else {
  id = Math.random();
}
複製程式碼

這段程式碼的問題是,通過檢測document.all,間接地判斷瀏覽器是否為IE。一旦確定了瀏覽器是IE,便假設可以安全地使用IE所特有的document.uniqueID。然而,你所做的所有探測僅僅說明document.all是否存在,而並不能用於判斷瀏覽器是否是IE。正因為document.all的存在並不意味著document.uniqueID也是可用的,因此這是一個錯誤的隱式推斷,可能會導致程式碼不能正常執行。

為了更清楚地表述該問題,程式碼被修改成這樣:

var isIE = navigator.userAgent.indexOf("MSIE") > -1;
複製程式碼

修改為如下這樣:

// 不好的寫法
var isIE = !!document.all;
複製程式碼

這種轉變體現了一種對“不要使用使用者代理檢測”的誤解。雖然不是直接檢測特定的瀏覽器,但是通過特性檢測從而推斷出是某個瀏覽器同樣是很糟糕的做法。這叫做瀏覽器推斷,是一種錯誤的實踐。

到了某個階段,開發者意識到document.all實際上並不是判斷瀏覽器是否為IE的最佳方法。之前的程式碼加上了更多的特性檢測,如下所示:

var isIE = !!document.all && document.uniqueID;
複製程式碼

這種方法屬於“自作聰明”型的。嘗試通過越來越多的已知特性推斷某些事情太困難了。更糟糕的是,你沒辦法阻止其他瀏覽器實現相同的功能,最終導致這段程式碼返回不可靠的結果。

2.8.5 應當如何取捨

特性推斷和瀏覽器推斷都是糟糕的做法,應當不惜一切代價避免使用。純粹的特性檢測是一種很好的做法,而且幾乎在任何情況下,都是你想要的結果。通常,你僅需要在使用前檢測特性是否可用。不要試圖推斷特性間的關係,否則最終得到的結果也是不可靠的。

迄今為止我不會說從來不要使用使用者代理檢測,因為我的確相信有合理的使用場景,但同時我也不相信會有很多使用場景。如果你想使用使用者代理嗅探,記住這點:這麼做唯一安全的方式是針對舊的或者特定版本的瀏覽器。而絕不應當針對最新版本或者未來的測覽器。

我個人的建議是儘可能地使用特性檢測。如果不能這麼做的時候,可以退而求其次,考慮使用使用者代理檢測。永遠不要使用瀏瀏覽器推斷,因為你會被這樣維護性很差的程式碼纏身,而且隨著新的瀏覽器出現,你需要不斷地更新程式碼

3. 工程化

我相當樂意花一整天的時間通過程式設計把一個任務實現自動化,除非這個任務手動只需要10秒鐘就能完成。——Douglas Adams, Last Chance to See

前端工程化是隨著Web前端專案規模的不斷增大而逐漸受到前端工程師的重視,前端工程化主要應該從模組化、元件化、規範化、自動化四個方面來思考。我這裡側重講解下自動化的工作,現代前端(以SPA為代表的WebApp時代,與傳統的WebPage時代相區別)的專案一般都包括了很多需要自動化的工作,比如:

  • 轉碼:ES6程式碼通過Babel轉換成ES5,TS轉成ES5;LESS、SASS轉成CSS
  • 壓縮:主要是JS和CSS的壓縮,也包括靜態資源(主要是圖片)的壓縮
  • 檔案合併:合併多個JS檔案或者CSS檔案,減少HTTP請求
  • 環境:開發環境、測試環境、生產環境的自動化流程都是不同的
  • 部署:靜態資源自動上CDN、自動釋出等

這裡只是列出了一部分需要自動化的工作,實際情況不同專案會有不同的定製化需求。我也相信現在肯定每人會手動執行這些工作,一般都會用webpack這類構建工具做這些工作。要寫出可維護的JS(這裡應該是更寬泛意義上的前端專案,不僅僅是JS),像上面這些自動化的流程(思考下你現在專案中有沒有每次都要你手動操作的工作,考慮如何將它自動化)都應該用程式碼完成自動化,避免人工干預(人是會犯錯的,而且,偷懶不是程式設計師的美德嗎)。

前端工程化是個十分寬泛的話題,足以寫另外一篇博文來介紹了,感興趣的同學,我推薦一本書《前端工程化:體系設計與實踐》,這本書2018年1月出版的,內容也是與時俱進,值得細細品嚐。知乎也有關於前端工程化的討論,不妨看看大咖們的觀點。

文章首發於我的部落格,本文采用知識共享署名 4.0 國際許可協議進行許可。

相關文章