JS凍結物件的《人間詞話》 完美實現究竟有幾層境界?

LucasHC發表於2017-06-06

王國維在《人間詞話》裡談到了治學經驗,他說:“古今之成大事業、大學問者,必經過三種之境界。”

巧合的是,最近受 git chat / git book 邀請,做了一個分享。
其中談到JS中凍結一個物件幾種由淺入深的實踐。想想也暗合國學大師所謂的三重境界。

這篇文章由淺入深討論JS中物件的一些鎖定特性。但都是一些基礎語法的實現,相信即便是前端小白也可以大體領會。不過需要讀者預先了解JS中物件的特性,尤其是物件自身屬性的描述符:configurable、writable...

另外,如果您對JS中物件操作、不可變資料、函數語言程式設計感興趣,同樣推薦我的其他一些相關文章:

等等。

昨夜西風凋碧樹 獨上高樓 望盡天涯路

第一種境界:“昨夜西風凋碧樹,獨上高樓,望盡天涯路。”
該詞句出自晏殊的《蝶戀花》,原意是說,“我”上高樓眺望所見的更為蕭颯的秋景,西風黃葉,山闊水長,案書何達?

王國維此句中解成:做學問成大事業者,首先要有執著的追求,登高望遠,瞰察路徑,明確目標與方向,瞭解事物的概貌。

我們就從最基本的場景說起,究竟為什麼要凍結一個物件?

  • 場景一:
    我們造了一個輪子,對外暴露一個物件,開放出來給第三方使用。同時需要保證這個對外暴露的物件完全安全,不能被業務程式碼所改寫覆蓋或下鉤子(hook)函式。

  • 場景二:
    如果你看過Vue 2.* 版本原始碼,你會發現凍結一個物件的操作頻繁出現。

我們先來看凍結物件的第一層實現 —— 擴充套件特性鎖:

他包含了兩個基本方法:

  • Object.isExtensible
  • Object.preventExtensions

如果一個物件可以新增新的屬性,則這個物件是可擴充套件的。擴充套件特性鎖就是讓這個物件變的不可擴充套件,也就是不能再有新的屬性。

Object.isExtensible

MDN上內容概述:

概述
    Object.isExtensible() 方法判斷一個物件是否是可擴充套件的(是否可以在它上面新增新的屬性)。
語法
    Object.isExtensible(obj)
引數
    obj 需要檢測的物件複製程式碼

例如,我們正常使用物件字面量宣告的物件都是可擴充套件的:

var person1 = {};
person1.name = "Lucas";
console.log(person1);
// {name: "Lucas"}複製程式碼

同時:

Object.isExtensible(person1) === true; // true複製程式碼

你可能要問了,那麼使用Object.create方法宣告的物件,並對該物件屬性進行配置是什麼情況呢?
我們知道,用上面物件字面量宣告的物件相當於:

var person1 = Object.create({},{
    "name":{
        value : "Lucas",
        configurable : true, //不可配置
        enumerable : true , //可列舉
        writable : true //可寫
    }
});複製程式碼

即便嘗試將configurable設定為false:

var person1 = Object.create({},{
    "name":{
        value : "Lucas",
        configurable : false, //不可配置
        enumerable : true, //可列舉
        writable : true //可寫
    }
});複製程式碼

仍然得到:

Object.isExtensible(person1) === true; // true複製程式碼

Object.preventExtensions

當然,我們還是有方法可以使得一個物件變的不可擴充套件。

MDN上內容概述:

概述
    Object.preventExtensions() 方法讓一個物件變的不可擴充套件,也就是永遠不能再新增新的屬性。
語法
    Object.preventExtensions(obj)
引數
    obj 將要變得不可擴充套件的物件複製程式碼

幾個注意點包括但不限於:

  • 不可擴充套件的物件的屬性通常仍然可以被刪除。
  • 嘗試給一個不可擴充套件物件新增新屬性的操作將會失敗,不過可能是靜默失敗,也可能會丟擲 TypeError 異常(嚴格模式下)。
  • Object.preventExtensions 只能阻止一個物件不能再新增新的自身屬性,仍然可以為該物件的原型新增屬性。

比如:

var person1 = {
    name: "Lucas"
}

Object.preventExtensions(person1);
person1.age = 18;
// 非嚴格模式下,這裡不會有報錯,屬於靜默失敗
person1.age // undefined
// 擴充套件新屬性失敗了複製程式碼

仍然可以向原型鏈新增屬性:

person1.__proto__.age = 18;
person1.age // 18
// 可以從原型鏈上取到複製程式碼

同樣也可以複寫一些屬性:

person1.name = "Eros";
person1.name // "Eros"複製程式碼

也可以刪除已有屬性:

person1.name; //  "Eros",
delete person1.name;
person1.name; // undefined複製程式碼

通過以上方法,我們實現了對一個物件屬性擴充套件的凍結。但是同樣也認識到,這並不是全面的保護:例如可以隨意改動去覆蓋已有屬性,在物件原型鏈上增加屬性也還是難以遮蔽。

衣帶漸寬終不悔 為伊消得人憔悴

第二種境界:“衣帶漸寬終不悔,為伊消得人憔悴。”
這引用的是北宋柳永《蝶戀花》最後兩句詞,原詞是表現作者對愛的艱辛和愛的無悔。若把“伊”字理解為詞人所追求的理想和畢生從事的事業,亦無不可。王國維則別有用心,以此兩句來比喻成大事業、大學問者,不是輕而易舉,隨便可得的,必須堅定不移,經過一番辛勤勞動,廢寢忘食,孜孜以求,直至人瘦頻寬也不後悔。

下面介紹一個更深一層的做法:密封特性。

密封物件是指那些不能新增新的屬性,不能刪除已有屬性,以及不能修改已有屬性的可列舉性(enumerable)、可配置性(configurable)、可寫性(writable),但可能可以修改已有屬性的值的物件。

他同樣包含了兩個基本方法:

  • Object.isSealed
  • Object.seal

Object.isSealed

MDN上內容概述:

概述 
    Object.isSealed() 方法判斷一個物件是否是密封的(sealed)。
語法 
    Object.isSealed(obj)
引數
    obj 將要檢測的物件複製程式碼

正常物件字面量宣告的物件是不被密封的:

var person1 = {
    name: "Lucas"
}

Object.isSealed(person1); // false複製程式碼

當將這個物件禁止擴充套件時,它也不會變成密封的:

var person1 = {
    name: "Lucas"
}
Object.preventExtensions(person1);
Object.isSealed(person1); // false複製程式碼

但是在此基礎上,使用Object.defineProperty方法,把屬性變得不可配置(configurable),則這個物件也就成了密封物件:

var person1 = {
    name: "Lucas"
}

Object.defineProperty(person1, "name", {configurable : false});

Object.isSealed(person1); // true複製程式碼

此時,我們有:

Object.getOwnPropertyDescriptor(person1, 'name');
// 得到:
Object {
    value: "Lucas", 
    writable: true, 
    enumerable: true, 
    configurable: false
}複製程式碼

根據這個getOwnPropertyDescriptor,我們可以更加深入的理解密封特性:被密封的物件,就是在不可擴充套件基礎上講屬性描述符configurable設定為false; 同時,被密封的物件,仍然有機會改變屬性的值。只不過對於此物件本身而言,不可以再擴充套件新的屬性,不可以更改已有屬性的配置資訊。

Object.seal

相對應我們也有一個方法將一個物件密封。

MDN上內容概述:

概述
    Object.seal() 方法可以讓一個物件密封,並返回被密封后的物件。
語法
    Object.seal(obj)
引數
    obj 將要被密封的物件複製程式碼

比如:

var person1 = {
    name: "Lucas"
}
Object.getOwnPropertyDescriptor(person1, 'name');
// 得到:
Object {
    value: "Lucas", 
    writable: true, 
    enumerable: true, 
    configurable: true
}複製程式碼

將此物件密封后:

Object.seal(person1);
Object.getOwnPropertyDescriptor(person1, 'name');
// 得到:
Object {
    value: "Lucas", 
    writable: true, 
    enumerable: true, 
    configurable: false
}複製程式碼

也就是說:

person1.age = 18;
person1.age; // undefined
// 擴充套件新屬性失敗

// 同時呼叫defineProperty失敗
Object.defineProperty(person1,"name",{get : function(){return "g";}});
// 丟擲異常複製程式碼

任何除更改屬性值以外的操作,非嚴格模式下都會靜默失敗,如上並如下:

delete person1.name;
person1.name; // "Lucas"複製程式碼

而更改屬性值可以成功:

person1.name = "Eros";
person1.name; // "Eros"複製程式碼

怎麼理解這樣的現象呢?牢記,被密封的物件擁有如下的屬性描述符:

Object {
    value: "Lucas", 
    writable: true, 
    enumerable: true, 
    configurable: false
}複製程式碼

而刪除屬性屬於configurable,更改屬性才屬於writable;

一點延伸

藉助於此,我們其實已經可以完成凍結物件的第三重境界:達到即密封又不可修改原屬性值。因為可以這樣做:

var person1 = {name: "Lucas"};
Object.defineProperty(person1, "name", {configurable: false, writable: false});
Object.preventExtensions(o);複製程式碼

總結下就是設定:

configurable: false + writable: false + preventExtensions

或者因為

configurable: false+ preventExtensions = seal

所以也可以設定:

seal + writable: false

眾裡尋他千百度,驀然回首,那人卻在,燈火闌珊處

第三種境界:“眾裡尋他千百度,驀然回首,那人卻在,燈火闌珊處。”
這是引用南宋辛棄疾《青玉案》詞中的最後四句。梁啟超稱此詞“自憐幽獨,傷心人別有懷抱”。這是藉詞喻事,與文學賞析已無交涉。王國維已先自表明,“吾人可以無勞糾葛”。他以此詞最後的四句為“境界”之第三,即最終最高境界。

這雖不是辛棄疾的原意,但也可以引出悠悠的遠意:做學問、成大事業者,要達到第三境界,必須有專注的精神。反覆追尋、研究,下足功夫,自然會豁然貫通,有所發現,有所發明,就能夠從必然王國進入自由王國。

上邊那種凍結物件的方法,其實也有原生實現,可謂:“眾裡尋他千百度,驀然回首,那人卻在,燈火闌珊處”

我們這裡所說的一個物件的凍結(frozen)是指它不可擴充套件,所有屬性都是不可配置的(non-configurable),且所有資料屬性(data properties)都是不可寫的(non-writable)。

或者說,凍結物件是指那些不能新增新的屬性,不能修改已有屬性的值,不能刪除已有屬性,以及不能修改已有屬性的可列舉性、可配置性、可寫性的物件。也就是說,這個物件永遠是不可變的。

同樣,包含了兩個基本方法:

  • Object.isFrozen
  • Object.freeze

Object.isFrozen

MDN上內容概述:

概述
    Object.isFrozen() 方法判斷一個物件是否被凍結(frozen)。
語法
    Object.isFrozen(obj)
引數
    obj 被檢測的物件複製程式碼

Object.freeze 方法

MDN上內容概述:

概述
    Object.freeze() 方法可以凍結一個物件。
語法
    Object.freeze(obj)
引數
    obj 將要被凍結的物件複製程式碼

可以先理解為,這是最高一層的凍結物件:

var person1 = {
    name: "Lucas"
}

Object.freeze(person1);複製程式碼

此時,我們有:

Object.getOwnPropertyDescriptor(person1, 'name')

Object {
    value: "Lucas", 
    writable: false, 
    enumerable: true, 
    configurable: false
}

// 對凍結物件的任何操作都會失敗
person1.name = "Eros"; // 改寫屬性值,非嚴格模式下靜默失敗;
person1.age = 18; // 擴充套件屬性值,非嚴格模式下靜默失敗;
Object.defineProperty(person1,"name",{value: "Eros"}); // 使用defineProperty會直接報錯複製程式碼

改寫屬性值,擴充套件新屬性,呼叫defineProperty,全部都會失敗。

但是,這種層面的凍結,只是淺凍結。如果物件裡面還巢狀有物件,那麼這個內部物件絲毫不受影響。

var person1 = {
    name: "Lucas",
    family: {
        brother: "Eros"
    }
}

Object.freeze(person1);

person1.family.brother = "Tim";
person1.family.brother // "Tim"複製程式碼

終極實現

那麼,如果我們想深層次凍結一個物件呢?思路和深拷貝暗合,使用遞迴:

Object.prototype.deepFreeze = Object.prototype.deepFreeze || function (o){
    var prop, propKey;
    Object.freeze(o); // 首先凍結第一層物件
    for (propKey in o){
        prop = o[propKey];
        if(!o.hasOwnProperty(propKey) || !(typeof prop === "object") || Object.isFrozen(prop)){
            continue;
        }
        deepFreeze(prop); // 遞迴
    }
}複製程式碼

這樣子,我們再回過頭來看:

var person1 = {
    name: "Lucas",
    family: {
        brother: "Eros"
    }
}

Object.deepFreeze(person1);

person1.family.brother = "Tim";
person1.family.brother // "Eros"複製程式碼

已經達到了深層次物件屬性的凍結。

總結

本文先後介紹了關於凍結一個物件的三種進階方法。他們層層遞進,卻又相互關聯。關係如圖:

JS凍結物件的《人間詞話》 完美實現究竟有幾層境界?
關係圖

文章部分概念粘取了MDN語法介紹和Tomson的文章。

在《文學小言》一文中,王國維把上述三境界說成“三種之階級”。並說:“未有不閱第一第二階級而能遽躋第三階級者,文學亦然。此有文學上之天才者,所以又需莫大之修養也。”

與大家共勉。

Happy coding!

PS: 作者Github倉庫,歡迎通過程式碼各種形式交流。

相關文章