當你開始工做時,你不是在給你本身寫代碼,而是爲後來人寫代碼。 —— Nichloas C. Zakasjavascript
本文主要是《編寫可維護的JS》的讀書筆記,會結合我本身的工做經驗,談一談如何寫出易於維護的JS。做者寫這本書的時候(大概2012-2013年)ES6還沒出來,考慮到當前MV*時代下,你們幾乎都在寫ES6,因此本文會針對ES6做特別說明(原書內容針對ES5)。原書做者結合本身的工做經驗(2006年開始在雅虎爲期5年的工做)寫出了這本書,做者在書中濃墨重彩強調的東西,咱們如今看來都稀疏日常(如:爲何須要禁用with
和eval
,爲何始終使用===
和!==
進行比較),在這些內容上我會一筆帶過,假定你已經熟知這些基本常識了。html
咱們知道JS語言有着先天的設計缺陷(ES6以後纔好轉了很多),如何不刻意學習如何編寫優質易維護的代碼,你很容易就寫出糟糕的代碼(雖然它能夠運行)。前端
關於代碼的維護,你須要明白如下四點:java
對的,你寫的代碼很大機率上,並非由你來維護的。由於你可能換公司了,可能去作新項目了,也可能你壓根就不記得這段代碼是你六個月前寫的。因此,不要抱着「我就是來搬磚的,隨便寫寫,改不動了就溜了」的態度來寫代碼,相信讀者你也維護過別人寫的代碼,吐槽過那難以理解沒有任何註釋的代碼,巴不得把寫那代碼的人拉過來打一頓。因此,請不要成爲你所討厭的人。編寫出可維護的代碼,既是職業素養的問題,也是你專業精神的體現。webpack
關於如何編寫可維護的JS,我將從 編程風格 、編程實踐 和 工程化 三個方面進行闡述。git
程序是寫給人讀的,只是偶爾讓計算機執行一下。 —— Donald Knuth程序員
咱們會常常碰到這兩個術語:「編程風格」(style guideline)和「編碼規範」(code convention)。編程風格是編碼規範的一種,用來規約單文件中代碼的規劃。編碼規範還包括編程最佳實踐、文件和目錄的規劃以及註釋等方面。本文集中討論JS的編碼規範。github
爲何要討論編程風格?每一個人都有本身偏心的編程風格,但更多的時候咱們是做爲團隊一員進行協做開發的,統一風格十分重要,由於它會促成團隊成員高水準的協做(全部的代碼看起來極爲相似)。毫無疑問,全球性的大公司都對外或者對內發佈過編程風格文檔,如:Airbnb JavaScript Style Guide, Google JavaScript Style Guide等,你若仔細閱讀會發現它們不少規範都是相同的,只是部分細節略有差別。web
在某些場景中,很難說哪一種編程風格好,哪一種編程風格很差,由於有些編程風格只是某些人的偏好。本文並非向你灌輸我我的的風格偏好,而是提煉出了編程風格應當遵循的重要的通用規則。編程
關於縮進層次: 我不想挑起「Tab or Space」和「2 or 4 or 6 or 8 Space」的辯論,對這個話題是能夠爭論上好幾個小時的,縮進甚相當繫到程序員的價值觀。你只要記住如下三點:
關於結尾分號: 有賴於分析器的自動分號插入(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
保持左對齊,確保代碼的可讀性,並能一眼看清楚折行文本的上下文。
關於空行:在編程規範中,空行是經常被忽略的一個方面。一般來說,代碼看起來應當像一系列可讀的段落,而不是一大段揉在一塊兒的連續文本。有時一段代碼的語義和另外一段代碼不相關,這時就應該使用空行將它們分隔,確保語義有關聯的代碼展示在一塊兒。通常來說,建議下面這些場景中添加空行:
命名分變量、常量、函數、構造函數四類:其中變量和函數使用小駝峯命名法(首字母小寫),構造函數使用大駝峯命名法(首字母大寫),常量使用全大寫並用下劃線分割單詞。
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
複製代碼
命名不只是一門科學,更是一門技術,但一般來說,命名長度應該儘量短,並抓住要點。儘可能在變量名中體現出值的數據類型。好比,命名count
、length
和size
代表數據類型是數字,而命名name
、title
和message
代表數據類型是字符串。但用單個字符命名的變量諸如i
、j
和k
一般在循環中使用。使用這些可以體現出數據類型的命名,可讓你的代碼容易被別人和本身讀懂。
要避免使用沒有意義的命名,如:foo
、bar
和tmp
。對於函數和方法命名來講,第一個單詞應該是動詞,這裏有一些使用動詞常見的約定:
動詞 | 含義 |
---|---|
can | 函數返回一個布爾值 |
has | 函數返回一個布爾值 |
is | 函數返回一個布爾值 |
get | 函數返回一個非布爾值 |
set | 函數用來保存一個值 |
JS中包含一些類型的原始值:字符串、數字、布爾值、null
和undefined
。一樣也包含對象直接量和數組直接量。這其中,只有布爾值是自解釋(self-explanatory)的,其餘的類型或多或少都須要思考一下它們如何才能更精確地表示出來。
關於字符串:字符串能夠用雙引號也能夠用單引號,不一樣的JS規範推薦都不一樣, 但切記不可在一個項目中混用單引號和雙引號。
關於數字:記住兩點建議:第一,爲了不歧義,請不要省略小數點以前或以後的數字;第二,大多數開發者對八進制格式並不熟悉,也不多用到,因此最好的作法是在代碼中禁止八進制直接量。
// 不推薦的小數寫法:沒有小數部分
let price = 10.;
// 不推薦的小數寫法:沒有整數部分
let price = .1;
// 不推薦的寫法:八進制寫法已經被棄用了
let num = 010;
複製代碼
關於null
:null
是一個特殊值,但咱們經常誤解它,將它和undefined
搞混。在下列場景中應當使用null
。
還有下面一些場景不該當使用null
。
null
來檢測是否傳入了某個參數。null
來檢測一個未初始化的變量。理解null
最好的方式是將它當作對象的佔位符(placeholder)。這個規則在全部的主流編程規範中都沒有說起,但對於全局可維護性來講相當重要。
關於undefined
:undefined
是一個特殊值,咱們經常將它和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
區分開了。
關於對象直接量和數組直接量: 請直接使用直接量語法來建立對象和數組,避免使用Object
和Array
構造函數來建立對象和數組。
註釋是代碼中最多見的組成部分。它們是另外一種形式的文檔,也是程序員最後才捨得花時間去寫的。可是,對於代碼的整體可維護性而言,註釋是很是重要的一環。JS支持兩種註釋:單行註釋和多行註釋。
不少人喜歡在雙斜線後敲入一個空格,用來讓註釋文本有必定的偏移(我很是推薦你這麼作)。單行註釋有三種使用方法:
單行註釋不該當以連續多行註釋的形式出現,除非你註釋掉一大段代碼。只有當須要註釋一段很長的文本時才使用多行註釋。
雖然多行註釋也能夠用於註釋單行,可是我仍是推薦僅在須要使用多行註釋的時候,才使用多行註釋。多行註釋通常用於如下場景:
我十分推薦你使用Java風格的多行註釋,看起來十分美觀,並且不少編輯器支持自動生成,見以下示例:
/** * Java風格的註釋,注意*和註釋之間 * 有一個空格,而且*左邊也有一個空格。 * 你甚至能夠加上一些@參數來講明一些東西。 * 例如: * * @author 做者 * @param Object person */
複製代碼
什麼時候添加註釋是程序員常常爭論的一個話題。一個通行的指導原則是, 當代碼不夠清晰時添加註釋,而當代碼很明瞭時不該當添加註釋。 基於這個原則,我推薦你在下面幾種狀況下添加註釋:
while(el && (el = el.next)) {}
。在團隊開發中,老是會有一些好心的開發者在編輯代碼時發現他人的代碼錯誤,就當即將它修復。有時這段代碼並非錯誤的源頭,因此「修復」這個錯誤每每會製造其餘錯誤,所以本次修改應當是可追蹤的。當你寫的代碼有可能會被別的開發者認爲有錯誤時,則須要添加註釋。關於 花括號的對齊方式 ,有兩種主要的花括號對齊風格。第一種風格是,將左花括號放置在塊語句中第一句代碼的末尾,這種風格繼承自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();
}
複製代碼
推薦你遵循以下的風格:
關於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'
}
複製代碼
咱們知道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更安全可靠,行爲更可預測。
和變量聲明同樣,函數聲明也會被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) {}
複製代碼
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'
};
})();
複製代碼
若是你在寫ES5代碼,推薦老是使用嚴格模式。不推薦使用全局的嚴格模式,可能會致使老的代碼報錯。推薦使用函數級別的嚴格模式,或者在IIFE中使用嚴格模式。
關於JS的強制類型轉換機制,咱們不得不認可它確實很複雜,很難所有記住(主要是懶)。因此我推薦你,任何狀況下,作相等比較請用===
和!==
。
動態執行JS字符串可不是一個好主意,在下面幾種狀況中,均可以動態執行JS,我建議你應該避免這麼作,除非你精通JS,而且知道本身在作什麼。
eval("alert('bad')");
const func = new Function("alert bad('bad')");
setTimeout("alert('bad')", 1000);
setInterval("alert('bad')", 1000);
複製代碼
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);
複製代碼
團隊開發中,爲了保持風格的統一,Lint工具必不可少。由於即便你們都明白要遵照統一的編程風格,可是寫代碼的時候老是不經意就違背風格指南的規定了(畢竟人是會犯錯的)。這裏我推薦你使用ESLint工具進行代碼的風格檢查,你不必徹底從新寫配置規則,你能夠繼承已有的業內優秀的JS編碼規範來針對你團隊作微調。我這裏推薦繼承自Airbnb JavaScript Style Guide,固然,你也能夠繼承官方推薦的配置或者Google的JS編碼風格,其實在編碼風格上,三者在大部分的規則上是相同的,只是在一部分細節上不一致而已。
固然,若是你實在是太懶了,那瞭解一下JavaScript Standard Style,它是基於ESLint的一個JS風格檢查工具,有本身的一套風格,強制你必須遵照。可配置性沒有直接引入ESLint那麼強,若是你很懶而且可以接受它推薦的風格,那使用StandardJS倒也無妨。
構建軟件設計的方法有兩種:一種是把軟件作得很簡單以致於明顯找不到缺陷;另外一種是把它作得很複雜以致於找不到明顯的缺陷。——CAR Hoare,1980年圖靈獎得到者
第一部分咱們主要討論的是JS的代碼風格規範(style guideline),代碼風格規範的目的是在多人協做的場景下使代碼具備一致性。關於如何解決通常性的問題的討論是不包含在風格規範中的,那是編程實踐中的內容。
編程實踐是另一類編程規範。代碼風格規範只關心代碼的呈現,而編程實踐則關心編碼的結果。你能夠將編程實踐看做是「祕方」——它們指引開發者以某種方式編寫代碼,這樣作的結果是已知的。若是你使用過一些設計模式好比MVC中的觀察者模式,那麼你已經對編程實踐很熟悉了。設計模式是編程實踐的組成部分,專用於解決和軟件組織相關的特定問題。
這一部分的編程實踐只會涵蓋很小的問題。其中一些實踐是和設計模式相關的,另外更多的內容只是加強你的代碼整體質量的一些簡單小技巧。ESLint除了對代碼風格進行檢查,也包含了一些關於編程實踐方面的警告。很是推薦你們在JS開發工做中使用這個工具,來確保不會發生那些看上去不起眼但又難於發現的錯誤。
在Web開發中,UI是由三個彼此隔離又相互做用的層定義的。
關於鬆耦合,容我廢話幾句。當你可以作到修改一個組件而不須要更改其餘的組件時,你就作到了鬆耦合。對於多人大型系統來講,有不少人蔘與維護代碼,鬆耦合對於代碼可維護性來講相當重要。你絕對但願開發人員在修改某部分代碼時不會破壞其餘人的代碼。當一個大系統的每一個組件的內容有了限制,就作到了鬆耦合。本質上講,每一個組件須要保持足夠瘦身來確保鬆耦合。組件知道的越少,就越有利於造成整個系統。
有一點須要注意:在一塊兒工做的組件沒法達到「無耦合」(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.top
、style.left
、style.bottom
和style.rght
來對元素做正肯定位的。在CSS中定義這個元素的默認屬性,而在 Javascript中修改這些默認值。
鑑於如今前端已經將HTML和JS寫在一塊的現狀,我就不談原書中如何將二者分離的實踐了。可是,我說了這麼多廢話,請記住一點:「可預見性」(Predictability)會帶來更快的遇試和開發,並確信(而非猜想)從何入手調試bug,這會讓問題解決得更快、代碼整體質量更高。
全局變量帶來的問題主要是:隨着代碼量的增加,過多的全局變量會致使代碼難以維護,而且容易出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;
})();
複製代碼
咱們知道事件觸發時,事件對象(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。在將元素顯示在頁面裏以前先用這兩個屬性個它做定位。儘管這段代碼看起來很是簡單且沒有什麼問題,但其實是很差的寫法,由於這種作法有其侷限性。
上段實例代碼的第一個問題是事件處理程序包含了應用用邏輯(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()
。若應用邏輯被剝離出去,對同一段功能代碼的調用能夠在多點發生,則不須要必定依賴於某個特定事件的觸發,這顯然更加方便。但這只是拆解事件處理程序代碼的第一步。
在剝離出應用邏輯以後,上段實例代碼還存在一個問題,即event
對象被無節制地分發。它從匿名的事件處理函數傳入了MyApplication.handleClick()
,而後又傳入了MyApplication.showPopup()
。正如上文提到的,event
對象上包含不少和事件相關的額外信息,而這段代碼只用到了其中的兩個而已。應用邏輯不該當依賴於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
產生依賴,進而在不少地方均可以輕鬆地使用相同的業務邏輯,包括寫測試代碼。
在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爲咱們提供了多種方法來檢測變量的真實值。
在JS中有5種原始類型:字符串、數字、布爾值、null
和undefined
。若是你但願一個值是字符串、數字、布爾值或者undefined
,最佳選擇是使用typeof
運算符。typeof
運算符會返回一個表示值的類型的字符串。
typeof
返回"string"
。typeof
返回"number"
。typeof
返回"boolean"
。undefined
,typeof
返回"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
,則直接使用恆等運算符(===
)或非恆等運算符(!==
)。
引用值也稱做對象(object
)。在JS中除了原始值以外的值都是引用。有這樣幾種內置的引用類型:Object
、Array
、Date
和Error
,數量很少。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
。
從技術上講,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函數的最佳選擇。
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類庫目前都相似地實現了這個方法。
另一種用到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。
代碼無非是定義一些指令的集合讓計算機來執行。咱們]經常將數據傳入計算機,由指令對數據進行操做,並最終產生一個結果。當不得不修改數據時問題就來了。任什麼時候候你修改源代碼都會有引入bug的風險,且只修改一些數據的值也會帶來一些沒必要要的風險,由於數據是不該當影響指令的正常運行的。 精心設計的應用應當將關鍵數據從主要的源碼中抽離出來,這樣咱們修改源碼時才更加放心。
配置數據時在應用中寫死(hardcoded)的值,好比:
咱們時刻要記住,配置數據是可發生變動的,並且你不但願有人忽然想修改頁面中展現的信息,而致使你去修改JS源碼。
對於這些配置數據,你能夠把它們抽離成常量、或者掛載到某個對象中、或寫成配置文件(JS中推薦JSON),經過程序讀取配置文件中的數據,這樣即便修改了數據,你的程序代碼不會有任何的改動,減小了出錯的可能性。
在JS中拋出錯誤是一門藝術。摸清楚代碼中哪裏合適拋出錯誤是須要時間的。所以,一旦搞清楚了這一點,調試代碼的事件將大大縮短,對代碼的滿意度將急劇提高。
當某些非指望的事情發生時程序就引起一個錯誤。也許是給一個函數傳遞了一個不正確的值,或者是數學運算碰到了一個無效的操做數。編程語言定義了一組基本的規則,當偏離了這些規則時將致使錯誤,而後開發者能修復代碼。若是錯誤沒有被拋出或者報告給你的話,調試是很是困難的。若是全部的失敗都是悄無聲息的,首要的問題是那必將消耗你大量的時間才能發現它,更不要說單獨隔離並修復它了。因此,錯誤是開發者的朋友,而不是敵人。
錯誤經常在非指望的地點、不恬當的時機跳出來,這很麻煩。更糟糕的是,默認的錯誤消息一般太簡潔而沒法解釋到底什麼東西出錯了。JS錯誤消息以信息稀少、隱晦含糊而臭名昭著(特別是在老版本的IE中),這隻會讓問題更加複雜化。想象一下,若是跳出一個錯誤能這樣描述:「因爲發生這些狀況,該函數調用失敗」。那麼,調試任務立刻就會變得更加簡單,這正是拋出本身的錯誤的好處。
像內置的失敗案例同樣來考慮錯誤是很是有幫助的。在代碼某個特殊之處計劃一個失敗總比要在全部的地方都預期失敗簡單的多。在產品設計上,這是很是廣泛的實踐經驗,而不只僅是在代碼編寫方面。汽車尚有碰撞力吸取區域,這些區域框架的設計旨在撞擊發生時以可預測的方式崩塌。知道一個碰撞到來時這些框架將如何反應——特別是,哪些部分將失敗——製造商將能保證乘客的安全。你的代碼也能夠用這種方法來建立。
毫無疑問,在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對象。
拋出本身的錯誤能夠使用確切的文本供瀏覽器顯示。除了行和列的號碼,還能夠包含任何你須要的有助於調試問題的信息。我推薦老是在錯誤消息中包含函數名稱,以及函數失敗的緣由。考察下面的函數:
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元素去作進一步的事情。
我傾向於認爲拋出錯誤就像給本身留下告訴本身爲何失敗的標籤。
理解了如何拋出錯誤只是等式的一個部分,另一部分就是要理解何時拋出錯誤。因爲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)。類庫提供了對髒的實現細節的抽象,目的是讓開發者用得更爽。拋出錯誤有助於對開發者安全地隱藏這些髒的實現細節。
這裏有一些關於拋出錯誤很好的經驗法則:
請牢記,咱們目的不是防止錯誤,而是在錯誤發生時能更加容易地調試。
應用程序邏輯老是知道調用某個特定函數的緣由,所以也是最合適處理錯誤的。千萬不要將try-catch中的catch塊留空,你應該老是寫點什麼來處理錯誤。例如,不要像下面這樣作:
try {
somethingThatMightCauseAnError();
} catch (ex) {
// do nothing
}
複製代碼
若是知道可能要發生錯誤,那確定知道如何從錯誤中恢復。確切地說,如何從錯誤中恢復在開發模式中與實際放到生產環境中是不同的,這不要緊。最重要的是,你實實在在地在處理錯誤,而不是忽略它。
ECMA-262規範指出了7種錯誤類型。當不一樣錯誤條件發生時,這些類型在JS引擎中都有用到,固然咱們也能夠手動建立它們。
eval()
函數執行代碼發生錯誤時拋出。new Array(-20);
)。該錯誤在正常的代碼執行中很是罕見。new 10
或'prop' in true
。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的那點兒小愚蠢也不足爲道了。在一個正確的錯誤處理系統中得到的好處是巨大的。該方法能夠給出更多、更靈活的信息,告知開發者如何正確地處理錯誤。
JS獨一無二之處在於任何東西都不是神聖不可侵犯的。默認狀況下,你能夠修改任何你能夠觸及的對象。它(解析器)根本就不在意這些對象是開發者定義的仍是默認執行環境的一部分——只要是能訪問到的對象均可以修改。在一個開發者獨自開發的項目中,這不是問題,開發者確切地知道正在修改什麼,由於他對全部代碼都瞭如指掌。然而,在一個多人開發的項目中,對象的隨意修改就是個大問題了。
當你的代碼建立了這些對象時,你擁有這些對象。建立了對象的代碼也許不必必定由你來編寫,但只要維護代碼是你的責任,那麼就是你擁有這些對象。舉例來講,YUI團隊擁有該YUI對象,Dojo團隊擁有該dojo對象。即便編寫代碼定義該對象的原始做者離開了,各自對應的團隊仍然是這些對象的擁有者。
當在項目中使用一個JS類庫,你我的不會自動變成這些對象的擁有者。在一個多人開發的項目中,每一個人都假設庫對象會按照它們的文檔中描述的同樣正常工做。若是你在使用YUI,修改了其中的對象,那麼這就給你本身的團隊設置了一個陷阱。這必將致使一些問題,有些人可能會掉進去。
請牢記,若是你的代碼沒有建立這些對象,不要修改它們, 包括:
上面全部這些對象是你項目執行環境的一部分。因爲它們已經存在了,你能夠直接使用這些或者用其來構建某些新的功能,而不該該去修改它們。
企業軟件須要一致而可靠的執行環境使其方便維護。在其餘語言中,考慮將已存在的對象做爲庫用來完成開發任務。在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或不完整的方法保留很長一段時間,由於刪除它們之後會在數不勝數的網站上致使錯誤。
修改非本身擁有的對象是解決某些問題很好的方案。在一種「無公害」的狀態下,它一般不會發生;發生的緣由多是開發者遇到了一個問題,然而又經過修改對象解決了這個問題。儘管如此,解決一個已知問題的方案老是不止一種的。大可能是計算機科學知識已經在靜態類型語言環境中進化出瞭解決難題方案,如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的可維護性而言,門面是很是合適的方式,本身能夠徹底控制這些接口。你能夠容許訪問任何底層對象的屬性或方法,反之亦然,也就是有效地過濾對該對象的訪問。你也能夠對已有的方法進行改造,使其更加簡單易用(上段示例代碼就是一個案例)。底層的對象不管如何改變,只要修改門面,應用程序就能繼續正常工做。
門面實現一個特定接口,讓一個對象看上去像另外一個對象,就稱做一個適配器。門面和適配器惟一的不一樣是前者建立新接口,後者實現已存在的接口。
隨着ES5和和HTML5的特性逐漸被各類瀏覽器實現。JS polyfills(也稱爲shim)變得流行起來了。 polyfill是對某種功能的模擬,這些功能在新版本的瀏覽器中有完整的定義和原生實現。例如,ES5爲數組增長了forEach()
函數。該方法在 ES3中有模擬實現,這樣就能夠在老版本瀏覽器中用上這個方法了。 polyfills的關鍵在於它們的模擬實現要與瀏覽器原生實現保持徹底兼容。正是因爲少部分瀏覽器原生實現這些功能,才須要儘量的檢測不一樣狀況下它們這些功能的處理是否符合標準。
爲了達到目的,polyfills常常會給非本身擁有的對象新增一些方法。我不是polyfills的粉絲,不過對於別人使用它們,我表示理解。相相比其餘的對象修改而言,polyfills是有界限的,是相對安全的。由於原生實現中是存在這些方法並能工做的,有且僅當原生方法不存在時,polyfills才新增這些方法,而且它們和原生版本方法的行爲是徹底一致的。
polyfills的優勢是,若是瀏覽器提供原生實現,能夠很是輕鬆地移除它們。若是你使用了polyfills,你須要搞清楚哪些瀏覽器提供了原生實現。並確保polyfills的實現和瀏覽器原生實現保持徹底一致,並再三檢查類庫是否提供驗證這些方法正確性的測試用例。polyfills的缺點是,和瀏覽器的原生實現相比,它們的實現可能不精確,這會給你帶來不少麻煩,還不如不實現它。
從最佳的可維護性角度而言,避免使用polyfills,相反能夠在已存在的功能之上建立門面來實現。這種方法給了你最大的靈活性,當原生實現中有bug時這種作法(避免使用polyfills)就顯得特別重要。這種狀況下,你根本不想直接使用原生的API,否則沒法將原生實現帶有的bug隔離開來。
ES5引入了幾個方法來防止對對象的修改。理解這些能力很重要,所以如今能夠作到這樣的事情:鎖定這些對象,保證任何人不能有意或無心地修改他們不想要的功能。當前(2018年)的瀏覽器都支持ES5的這些功能,有三種鎖定修改的級別:
Object.preventExtension()
):禁止爲對象「添加」屬性和方法,但已存在的屬性和方法是能夠被修改或刪除Object.seal()
):相似「防止擴展」,並且禁止爲對象「刪除」已存在的屬性和方法Object.freeze()
):相似「密封」,並且禁止爲對象「修改」已存在的屬性和方法(全部字段均只讀)每種鎖定的類型都擁有兩個方法:一個用來實施操做,另外一個用來檢測是否應用了相應的操做。如防止擴展,Object.preventExtension()
和Object.isExtensible()
兩個函數能夠使用。你能夠在MDN上查看相關方法的使用,這裏就不贅述了。
使用ES5中的這些方法是保證你的項目不通過你贊成鎖定修改的極佳的作法。若是你是一個代碼庫的做者,極可能想鎖定核心庫某些部分來保證它們不被意外修改,或者想強迫容許擴展的地方繼續存活着。若是你是一個應用程序的開發者,鎖定應用程序的任何不想被修改的部分。這兩種狀況中,在所有定義好這些對象的功能以後,才能使用上述的鎖定方法。一旦一個對象被鎖定了,它將沒法解鎖。
瀏覽器嗅探在Web開發領域始終是一個熱點話題,無論你是寫JS或CSS或HTML,總會遇到跨瀏覽器作兼容的狀況(雖然目前狀況已經比以前好太多,但面對新API接口的使用,依然存在瀏覽器嗅探的狀況)。下面介紹下基於UA檢測的歷史,來講明爲何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,即便在不得不這樣作的狀況下。
咱們但願有一種更聰明的基於瀏覽器條件(進行檢測)的方法,因而一種叫特性檢測的技術變得流行起來。特性檢測的原理是爲特定瀏覽器的特性進行測試,並僅當特性存在時便可應用特性檢測,例如:
// 很差的寫法
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]'
}
}
複製代碼
一種不當的使用特性檢測的狀況是「特性推斷」(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
。你不能從一個特性的存在推斷出另外一個特性是否存在。最好的狀況下二者有薄弱的聯繫,最壞的狀況下二者根本沒有直接關係。也就比如說是,「若是它看起來像一個鴨子,就一定像鴨子同樣嘎嘎地叫。」
在某些時候,用戶代理檢測和特性檢測讓許多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;
複製代碼
這種方法屬於「自做聰明」型的。嘗試經過愈來愈多的已知特性推斷某些事情太困難了。更糟糕的是,你沒辦法阻止其餘瀏覽器實現相同的功能,最終致使這段代碼返回不可靠的結果。
特性推斷和瀏覽器推斷都是糟糕的作法,應當不惜一切代價避免使用。純粹的特性檢測是一種很好的作法,並且幾乎在任何狀況下,都是你想要的結果。一般,你僅須要在使用前檢測特性是否可用。不要試圖推斷特性間的關係,不然最終獲得的結果也是不可靠的。
迄今爲止我不會說歷來不要使用用戶代理檢測,由於個人確相信有合理的使用場景,但同時我也不相信會有不少使用場景。若是你想使用用戶代理嗅探,記住這點:這麼作惟一安全的方式是針對舊的或者特定版本的瀏覽器。而毫不應當針對最新版本或者將來的測覽器。
我我的的建議是儘量地使用特性檢測。若是不能這麼作的時候,能夠退而求其次,考慮使用用戶代理檢測。永遠不要使用瀏瀏覽器推斷,由於你會被這樣維護性不好的代碼纏身,並且隨着新的瀏覽器出現,你須要不斷地更新代碼。
我至關樂意花一成天的時間經過編程把一個任務實現自動化,除非這個任務手動只須要10秒鐘就能完成。——Douglas Adams, Last Chance to See
前端工程化是隨着Web前端項目規模的不斷增大而逐漸受到前端工程師的重視,前端工程化主要應該從模塊化、組件化、規範化、自動化四個方面來思考。我這裏側重講解下自動化的工做,現代前端(以SPA爲表明的WebApp時代,與傳統的WebPage時代相區別)的項目通常都包括了不少須要自動化的工做,好比:
這裏只是列出了一部分須要自動化的工做,實際狀況不一樣項目會有不一樣的定製化需求。我也相信如今確定每人會手動執行這些工做,通常都會用webpack這類構建工具作這些工做。要寫出可維護的JS(這裏應該是更寬泛意義上的前端項目,不只僅是JS),像上面這些自動化的流程(思考下你如今項目中有沒有每次都要你手動操做的工做,考慮如何將它自動化)都應該用代碼完成自動化,避免人工干預(人是會犯錯的,並且,偷懶不是程序員的美德嗎)。
前端工程化是個十分寬泛的話題,足以寫另一篇博文來介紹了,感興趣的同窗,我推薦一本書《前端工程化:體系設計與實踐》,這本書2018年1月出版的,內容也是與時俱進,值得細細品嚐。知乎也有關於前端工程化的討論,不妨看看大咖們的觀點。
文章首發於個人博客,本文采用知識共享署名 4.0 國際許可協議進行許可。