《編寫可維護的JavaScript》讀書筆記之程式設計實踐-不是你的物件不要動
不是你的物件不要動
JavaScript 獨一無二之處在於任何東西都不是神聖不可侵犯的。預設情況下,可以修改任何可以觸及的物件。解析器根本不在乎這些物件是開發者定義的還是預設執行環境的一部分——只要是能訪問到的物件都可以修改。在一個開發者獨自開發的專案中,這不是問題,開發者確切地知道正在修改什麼,因為他對所有程式碼都瞭如指掌。然而,在一個多人開發的專案中,物件的隨意修改就是個大問題了。
什麼是你的
- 當你的程式碼建立了這些物件時;
- 維護這些物件是你的責任。
【舉例】:YUI 團隊擁有 YUI 物件,Dojo 團隊擁有 dojo 物件。即使編寫程式碼定義該物件的原始作者離開了,各自對應的團隊仍然是這些物件的擁有者。
【注意】:在專案中使用 JavaScript 類庫,個人不會成為這些物件的擁有者。
【理解】:不是你的物件請不要修改。比如在一個多人開發的專案中。每個人都假設庫物件會按照它們的文件中描述的一樣正常工作。如果你修改了其中的物件,這就給你的團隊設定了一個陷阱。這必將導致一些問題,有些人可能會因此掉進去。
【牢記】:如果你的程式碼沒有建立這些物件,不要修改它們,包括:
- 原生物件(Object、Array等等)。
- DOM 物件(例如,document)。
- 瀏覽器物件模型(BOM)物件(例如,window)。
- 類庫的物件。
【牢記】:不要修改已有執行環境:上面所有物件都是專案執行環境的一部分,由於它們已經存在了,可以直接使用或者通過它們構建新的功能,而不應該去修改它們。
原則
在 JavaScript 中,可以將已存在的物件視為一種背景,在這之上可以做任何事情。把已存在的 JavaScript 物件當作一個實用工具函式庫來對待。
- 不覆蓋方法。
- 不新增方法。
- 不刪除方法。
不覆蓋方法
在 JavaScript 中,有史以來最糟糕的實踐是覆蓋一個非自己擁有的物件的方法。
// 不好的寫法
document.getElementById = function() {
return null; // 引起混亂
};
【注意】:沒有任何方法能阻止覆蓋 DOM 方法。更嚴重的是,頁面中所有指令碼都可以覆蓋其他指令碼的方法。
【示例2】:
// 不好的寫法:這種做法亦被稱為函式劫持
document._originalGetElementById = document.getElmentById;
document.getElementById = function(id) {
if (id === 'window') {
return window;
} else {
return document._originalGetElementById(id);
}
};
【說明】:這種“覆蓋加可靠退化”的模式至少和覆蓋原生方法一樣不好,也許會更糟,因為 document.getElementById() 時而符合預期,時而不符合。
不新增方法
在 JavaScript 中為已存在的物件新增方法是很簡單的。只需要建立一個函式賦值給一個已存在的物件的屬性,使其成為方法即可。這種做法可以修改所有型別的物件。
【示例】:
// 不好的寫法:在 DOM 物件上增加了方法
document.sayImAwesome = function() {
alert("You're awesome.");
};
// 不好的寫法:在原生物件上增加了方法
Array.prototype.reverseSort = function() {
return this.sort().reverse();
};
// 不好的寫法:在庫物件上增加了方法
YUI.doSomething = function() {
// 程式碼
};
【說明】:幾乎不可能阻止開發者為任何物件新增方法。為非自己擁有的物件增加方法一個大問題——導致命名衝突。因為一個物件此刻沒有某個方法,不代表它未來也沒有。更糟糕的是如果將來原生的方法和你的方法行為不一致,你將陷入一場程式碼維護的噩夢。
【好的實踐】:大多數 JavaScript 庫程式碼有一個外掛機制,允許為程式碼庫安全地新增一些功能。如果想修改,最佳最可維護的方式是建立一個外掛。
不刪除方法
刪除 JavaScript 方法和新增方法一樣簡單。當然,覆蓋一個方法也是刪除已存在的方法的一種方式。最簡單的刪除一個方法的方式就是給對應的名字賦值為 null。
【示例】:
// 不好的寫法:刪除了 DOM 方法
document.getElementById = null;
【其他方式】:如果方法是在物件的例項上定義的(相對於物件的原型而言),也可以使用 delete 操作符來刪除。
var person = {
name: "Nicholas"
};
delete person.name;
console.log(person.name); // 未定義
【注意】:delete 操作符只能對例項的屬性和方法起作用,如果在 prototype 的屬性或方法上使用 delete 是不起作用的。
// 不影響
delete document.getElementById;
console.log(document.getElementById("myelement")); // 仍然能工作
【說明】:雖然使用 delete 無法刪除,但是仍然可以用對其賦值為 null 的方式來阻止被呼叫。
【最後】:刪除一個已存在物件的方法是糟糕的實踐,不僅有依賴那個方法的開發者存在,而且使用該方法的程式碼有可能已經存在。刪除一個在用的方法會導致執行時錯誤。如果你的團隊不應該使用某個方法,將其標識為“廢棄”,可以用文件或者用靜態程式碼分析器。刪除一個方法絕對應該是最後的選擇。
更好的途徑
修改非自己擁有的物件是解決某些問題很好的方案。在“一種無公害”的狀態下,通常不會發生;但真正發生了,我們就要採用方法來儘可能解決它。
【解決思路】:不直接修改這些物件而是擴充套件這些物件。
【注意】:在 JavaScript 中,繼承仍然有一些很大的限制。首先,不能從 DOM 或 BOM 物件繼承。其次,由於陣列索引和 length 屬性之間錯綜複雜的關係,繼承自 Array 是不能正常工作的。
基於物件的繼承
在基於物件的繼承中,也經常叫作原型繼承,一個物件繼承另外一個物件是不需要呼叫建構函式的。
【方式1】:ECMAScript5 的 Object.create()。
var person = {
name: "Nicholas",
sayName: function() {
alert(this.name);
}
};
var myPerson = Object.create(person);
myPerson.sayName(); // 彈出"Nicholas"
【說明】:這種繼承方式就如同把 myPerson 的原型設定為 person,從此 myPerson 可以訪問 person 的屬性和方法,而不需要同名變數在新的物件上再重新定義一遍。例如,重新定義 myPerson.sayName() 會自動切斷對 person.sayName() 的訪問。
myPerson.sayName = function() {
alert("Anonymous");
};
myPerson.sayName(); // 彈出 "Anonymous"
person.sayName(); // 彈出 "Nicholas"
【關於切斷的理解】:先找物件自身是否有該方法,若不存在,則沿著原型鏈繼續尋找。因此可理解為切斷。
【說明】:Object.create() 方法可以指定第二個引數,該引數物件中的屬性和方法將新增到新的物件中。
var myPerson = Object.create(person, {
name: {
value: "Greg"
}
});
myPerson.sayName(); // 彈出"Greg"
person.sayName(); // 彈出"Nicholas"
【說明】:一旦以這種方式建立了一個新物件,該新物件完全可以隨意修改。畢竟,你是該物件的擁有者,在自己的專案中你可以任意新增方法,覆蓋已存在方法,甚至是刪除方法(或者阻止它們的訪問)。
基於型別的繼承
基於型別的繼承是通過建構函式實現的,而非物件。這意味著,需要訪問被繼承物件的建構函式。
【示例】:
function MyError(message) {
this.message = message;
}
MyError.prototype = new Error();
【說明】:MyError 類繼承自 Error(所謂的超類)。給 MyError.prototype 賦值為一個 Error 的例項。然後,每個 MyError 例項從 Error 那裡繼承了它的屬性和方法,instanceof 也能正常工作。
var error = new MyError("Something bad happened.");
console.log(error instanceof Error); // true
console.log(error instanceof MyError); // true
【步驟】:比起 JavaScript 中原生的型別,在開發者定義了建構函式的情況下,基於型別的繼承是最合適的。
- 原型繼承;
- 構造器繼承:呼叫超類的建構函式時傳入新建的物件作為其 this 的值。
function Person(name) {
this.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 物件上。
【優點】:對比基於物件的繼承,基於型別的繼承在建立新物件時更加靈活。定義一個型別可以讓你建立多個例項物件,所有的物件都是繼承自一個通用的超類。新的型別應該明確定義需要使用的屬性和方法,它們與超類中的應該完全不同。
門面模式
門面模式是一種流行的設計模式,它為一個已存在的物件建立一個新的介面。門面是一個全新的物件,其背後有一個已存在的物件在工作。所有有時也叫包裝器,用不同的介面來包裝已存在的物件。
【示例】:當你的用例繼承已經無法滿足要求時,那麼下一步驟就應該建立一個門面,這比較合乎邏輯。
function DOMWrapper(element) {
this.element = element;
}
DOMWrapper.prototype.addClass = function(className) {
elemenet.className += " " + className;
};
DOMWrapper.prototype.remove = function() {
this.element.parentNode.removeChild(this.element);
};
// 用法
var wrapper = new DOMWrapper(document.getElementById("my-div"));
// 新增一個 className
wrapper.addClass("selected");
// 刪除元素
wrapper.remove();
【說明】:
- jQuery 和 YUI 的 DOM 介面都是用了門面。如上所述,你無法從 DOM 物件上繼承,所以唯一的能夠安全地為其新增功能的選擇就是建立一個門面。
- 從 JavaScript 的可維護性而言,門面是非常合適的方式,自己可以完全控制這些介面。可以允許訪問任何底層物件的屬性或方法,反之亦然,也就是有效地過濾對該物件的訪問。也可以對已有的方法進行改造,使其更加簡單易用。底層物件無論如何改變,只要修改門面,應用程式就能繼續正常工作。
關於 Polyfill 的註解
隨著 ECMAScript5 和 HTML5 的特性逐漸被各種瀏覽器實現。JavaScript polyfills(也被稱為 shim)變得流行起來了。
【概念】:polyfill 是對某種功能的模擬,這些功能在新版本的瀏覽器中有完整的定義和原生實現。例如,ECMAScript5 為陣列增加了 forEach() 函式。該函式在 ECMAScript3 中有模擬實現,這樣就可以在老版本瀏覽器中用上這個方法了。
【關鍵】:polyfill 的模擬實現要與瀏覽器原生實現保持完全相容。正是由於少部分瀏覽器原生實現這些功能,才需要儘可能的檢測不同情況下這些功能的處理是否符合標準。
【使用須知】:
- 搞清楚哪些瀏覽器提供了原生實現;
- 確保 polyfills 的實現和瀏覽器原生實現保持完全一致;
- 檢查類庫是否提供驗證這些方法正確性的測試用例。
【優點】:
- 為了達到目的,polyfills 經常會給非自己擁有的物件新增一些方法,但相比其他物件修改而言,polyfill 是有界限的,是相對安全的。因為原生實現中是存在這些方法並能工作的,有且僅當原生不存在時,polyfills 才新增這些方法,並且它們和原生版本方法的行為是完全一致的。
- 如果瀏覽器提供原生實現,可以非常輕鬆地移除 polyfills 新增的方法。
【缺點】:和瀏覽器的原生實現相比,polyfill 的實現可能不精確。
【推薦】:從最佳的可維護性角度而言,避免使用 polyfills,相反可以在已存在的功能之上建立門面來實現。這種方法給了你最大的靈活性,當原生實現中有 bug 時避免使用 polyfills 就顯得特別重要。這種情況下,你根本不想直接使用原生的 API,不然無法將原生實現帶有的 bug 隔離開來。
阻止修改
ECMAScript5 引入了幾個方法來防止對物件的修改。
【鎖定級別】:
- 防止擴充套件:禁止為物件“新增”屬性和方法,但已存在的屬性和方法是可以被修改或刪除。
- 密封:在“防止擴充套件”的基礎上,禁止為物件“刪除”已存在的屬性和方法。
- 凍結:在“密封”的基礎上,禁止為物件“修改”已存在的屬性和方法(所有欄位均只讀)。
【說明】:每種鎖定的型別都擁有兩個方法:一個用來實施操作,另一個用來檢測是否應用了相應的操作。
- 防止擴充套件
var person = {
name: "Nicholas"
};
// 鎖定物件
Object.preventExtension(person);
console.log(Object.isExtensible(person)); // false
person.age = 25; // 正常情況悄悄地失敗,除非在 strict 模式下丟擲錯誤
- 密封物件
// 鎖定物件
Object.seal(person);
console.log(Object.isExtensible(person)); // false
console.log(Object.isSealed(person)); // true
delete person.name; // 正常情況悄悄地失敗,除非在 strict 模式下丟擲錯誤
person.age = 25; // 同上
- 凍結物件
// 鎖定物件
Object.freeze(person);
console.log(Object.isExtensible(person)); // false
console.log(Object.isSealed(person)); // true
console.log(Object.isFrozen(person)); // true
person.name = "Greg"; // 正常情況悄悄地失敗,除非在 strict 模式下丟擲錯誤
person.age = 25; // 同上
delete person.name; // 同上
【好的實踐】:使用 ECMAScript5 中的這些方法,是保證你的專案在不經過你同意時鎖定修改的極佳做法。
- 程式碼庫的作者可以鎖定核心庫某些部分來保證它們不被意外修改,或者想強迫允許擴充套件的地方繼續存活著。
- 應用程式的開發者,鎖定應用程式任何不想被修改的部分。
【注意】:在上述兩種情況中,在全部定義好這些物件的功能之後,才能使用上述的鎖定方法。一旦一個物件被鎖定了,它將無法解鎖。
【推薦】:如果決定將你的物件鎖定修改,強烈推薦使用嚴格模式。因為在非嚴格模式下,試圖修改不可修改的物件總是悄無聲息地失敗,這在除錯期間非常令人沮喪。通過使用嚴格模式,同樣的嘗試將丟擲一個錯誤,使得不能修改的原因更加明顯。
【未來】:將來,原生 JavaScript 物件和 DOM 物件很有可能都將統一內建使用 ECMAScript5 的鎖定修改的保護功能。