標簽(空格分隔): JAVASCRIPT DEEP
本文總結js里不太好理解的幾個概念:prototype, _proto_, new, Object.create, instanceof, typeof。
對象和實例
先來說明實例和對象,簡單來說對象就是一個概念(抽象的),實例就是物體(實體)。
下面直接上代碼:
// 為了區分對象和原型,下面所有的對象統統用大寫
function A(name,age){this.name=name; this.age=age;}
a = new A("test", 10);
console.log(a.name, a.age); // => "test" 10
a就是一個對象,通過new
這個關鍵字把實例變成了一個實例。new
后面再說,這里先只考慮prototype
。
在js里,幾乎一切都是實例,而并非一切都是對象,可以簡單地認為有prototype
這個屬性的都是對象。
這里只是粗略地說法,比如你強行給一個變量設置一個
prototype
屬性,其仍然不是一個對象,其仍然不能執行new操作。只能做99%的情況下,如果有prototype
的屬性,就可以認為它是一個對象。
對象可以被實例化,對象可以被繼承。對象本身也是原型,其只不過是被更高層的對象實例化出來的而已。
說到底prototype
也無非就是對象的一個屬性而已。這是一個特殊的屬性,我們可以簡單理解為該屬性里放著對象A專門給其實例和后代的實例準備的內容。反之,只有prototype
里面的內容才會繼承給實例和子對象,A本身的方法并不會被繼承。對于一個對象,其顯式地通過A.prototype.someFunc
來調用prototype
屬性里的內容,而對于一個實例,則可以直接采用a.someFunc
的方式調用其中的內容。
另外,為了方便開發者訪問實例的對象的prototype
屬性,很多瀏覽器都實現了__proto__
這個好用的關鍵字。在js中,任何內容都有__proto__
這一屬性。之前說了js里幾乎一切都是實例,不過也有例外,比如數字、字符串等基本類型。雖然在使用instanceof
的時候可能會返回false,但是這些基礎變量也都有__proto
屬性,對應到了合適的類型上,這么做可能主要是為了使用方便吧。需要注意的是,__proto
并非js標準,有些場景下可能會報錯。
下面我們來具體看一下prototype
屬性的特點。代碼如下:
function A(){};
A.prototype.test = () => console.log("A.prototype.test");
let a = new A();
A.prototype.test(); // => A.prototype.test
//A.test(); // => Error...
A.test = () => console.log("A.test");
A.test() // => A.test
a.test(); // A.prototype.test
a.__proto__.test(); // A.prototype.test
a.test = () => console.log("a.test");
a.test(); // a.test
a.__proto__.test(); // A.prototype.test
可以看到,對于對象A,它是無法直接通過A.test
訪問到·prototype
里的內容的。對于實例a,如果有test則直接使用自己的test,如果沒有找到則會去其對象的prototype
(即__proto__
)里找。(當然如果還沒找到會繼續找其父對象的prototype
)
到現在為止,基本可以理清prototype
的含義了,基本就是一個特殊屬性,主要是服務于對象和實例之間的聯系以及對象之間的繼承關系。
繼承關系
上面分析了prototype
屬性的作用,但是只分析了其在對象和實例之間的紐帶作用。而它更主要的作用是用于實現繼承,js里的繼承關系就是基于prototype
實現的。盡管現在已經引入了class,extends等這些關鍵字,不過這僅僅是語法糖而已,實際還是通過prototype
等關鍵字實現的。
開始之前,我們先確認一下怎么才算繼承。
既然要繼承,子對象肯定要有父對象所有的屬性,而且子對象實例化出來的變量應該也是父對象的實例。
具體的實現方法可以參考廖雪峰的JS入門教程,這里給出另一種寫法,本質是一樣的。
function A(){}
function B(props){
A.call(this,props);
// ...
}
B.prototype = Object.create(A.prototype);
// 修復
B.prototype.constructor = B;
為啥非要通過F=>new F來轉換一遍呢?
直接復制過去了PrimaryStudent
和Student
就沒區別了啊,你想給PrimaryStudent
加一個新方法,你會發現Student
也有了該方法。
為啥非要修復最后一個constructor?
事實上,這一步即便不執行,在大部分場景下也不會出問題。為什么一定要保證prototype
里的constructor
指向對象本身呢?這類似C++里的多態,具體分析可以參考下面的鏈接Stack Overflow關于prototype.constructor的討論。大致就是在基類中操作的某些通用函數需要知道處理誰。
這樣做完以后,所有從B實例化出去的變量都能夠直接使用A的方法,且都是A的實例化。
b = new B()
b.__proto__ === B.prototype // true
b.__protot__.__proto__ === A.prototype // true
b instanceof B // true
b instanceof A // true
如下:
console.log(A.prototype);// => {constructor: f}
//constructor是個函數?
A.prototype.constructor === A // true wtf?
我們發現對象的prototype
屬性有一個constructor
屬性等于對象本身。
一等公民——Function
很多文章里都會說,函數是js里的一等公民,之前也就簡單理解為函數比較重要罷了,不過實際拿prototype
和__proto__
試了一下發現,Function
確實比較特殊。
具體看下面代碼:
Object.__proto__ === Function.prototype; // true
Function.__proto__ === Function.prototype; // true
Function.__proto__ === Object.__proto__; // true
Function.__proto__.__proto__ === Object.prototype; //true
Function instanceof Object; // true
Object instanceof Function; // true
可以簡單總結為
- Function和Object都是Function的實例。
- Function繼承自Object。
- Function是Object類型。
- Object是Function類型。
這么設計肯定有原因的,至于具體原因這里就不深入探討了,不過確實只有Function有這些屬性。其他內置的類型,例如Number,Date之類的都沒有這些等式。
從這里可以看到,函數在js里確實比較特殊,這也是為什么經常會看到函數被用作橋梁而其他的類型就不會。
對象擴展
有了prototype
的加持,我們可以隨意地對一個現有對象進行擴展,比如下面這段代碼就是給Date加了一個format
方法,功能與moment.js
里的format
類似。
Date.prototype["Format"] = function(fmt) {
fmt = fmt || "YYYY-MM-dd hh:mm:ss";
let o = {
"M+": this.getMonth() + 1, //月份
"d+": this.getDate(), //日
"h+": this.getHours(), //小時
"m+": this.getMinutes(), //分
"s+": this.getSeconds(), //秒
"q+": Math.floor((this.getMonth() + 3) / 3), //季度
S: this.getMilliseconds() //毫秒
};
if (/(y+)/.test(fmt))
fmt = fmt.replace(
RegExp.$1,
(this.getFullYear() + "").substr(4 - RegExp.$1.length)
);
for (let k in o)
if (new RegExp("(" + k + ")").test(fmt))
fmt = fmt.replace(
RegExp.$1,
RegExp.$1.length == 1
? o[k]
: ("00" + o[k]).substr(("" + o[k]).length)
);
return fmt;
}
}
有了這個擴展,我們就可以寫出下面的代碼了:
let d = new Date();
console.log(d.format("yyyy-MM-dd:hh"));
// => 2017-09-09:10
console.log(Date.format("yyyy-MM-dd:hh"));
// TypeError
類似地,我們同樣可以對對象本身進行擴展:
JSON["safeParse"] = function(
text,
reviver
) {
try {
return JSON.parse(text, reviver);
} catch (e) {
return null;
}
}
}
// parse
JSON.parse("{x:")
// => null
上面兩個例子可以看到,我們需要分清應用的場景,需要針對需求進行擴展。
這么擴展的代價是什么?
這種擴展方式很簡單粗暴,不過這也會給我們帶來一些副作用(雖然大部分情況下都不會涉及)。
先看一下下面的代碼:
// somewhere unkown
Object.prototype.hello = () => console.log("hello");
// doing sth
let a = {};
a.name = "test";
a.age = "21";
a.roler = "adc";
for(let k in a) {
console.log(a[k].toString());
}
// => test
// => 21
// => adc
// => () => console.log("hello");
這里調用toString()
主要是為了清晰地看到問題所在————我們擴展的對象會被in
操作符所遍歷,這個在數組上也會有類似的問題(不過數組對象可以使用of
來避免這個問題,這也是為什么大部分教程里都會建議使用of
來遍歷數組的原因)。前面知道所有的對象都來自Object
,所以對Object
的擴展會影響到所有的in
關鍵字!!!
那怎么解決呢?
為了避免這個問題,后面有了hasOwnProperty
這個函數。我們只需要簡單地加一行代碼就行了。
for(let k in a) {
if(!a.hasOwnProperty(k))continue;
console.log(a[k].toString());
}
在in
關鍵字存在的地方務必都加上這一判斷。如果沒有加這個關鍵字,即便現在程序沒有出錯,但是后續開發中一旦有人對關聯的對象做了擴展,這塊代碼就有可能出錯或者輸出跟預期不符,可以設想把上面的hello
函數換成Object.prototype.hello = "nevermore"
,這個時候程序不會報錯,只是輸出錯了。這個時候就看可能出現一些詭異的bug,項目大的時候很難調試。
從上面我們可以看到,利用prototype
進行擴展會給系統帶來不小的隱患,尤其是對基礎對象(Object, Funtiong, Array)進行擴展的時候,因為我們永遠也沒法保證其他人的代碼都是安全的。
怎么解決這一隱患呢?
為了解決這一問題,又有了defineProperty
這一函數。借用這個函數我們可以更加安全地擴展對象,這里簡單地介紹一下,更加詳細的內容請參考MDN文檔
該函數原型如下:Object.defineProperty(obj, prop, descriptor)
- obj: 我們需要擴展的對象,例如:Date.prototype
- prop: 我們需要擴展的函數(或變量)名稱,例如:"format"
- descriptor: 設置這一新屬性的特征。例如:
{enumerable: false, value: () => console.log("hello")}
表示不允許該屬性被枚舉到,即不會被in
之類的迭代器遍歷到,且數值設置為一個函數。這個參數有下面6個配置項。- configurable: 是否允許修改或者刪除屬性特征。
- enumerable: 是否允許迭代器遍歷
- value: 屬性的值。
- writable: 是否支持修改屬性值。這里的修改和configurable的修改管理的內容不一樣,這里是確定屬性值是否允許修改,而上面是確認配置項是否允許修改。如果配置項允許修改我們可以再次調用
defineProperty
來把writable
修改成true
然后再修改該字段內容也是可以的。 - get 見set
- set get/set是一對
getter
和setter
,它們是一套和value/writable互斥的配置,不能同時設置,否則會報錯。其中get
跟getter
完全一致,就是相當于吧該屬性變成了一個getter
。setter
則會在該屬性被修改的時候被調用。
關于上面講到的get/set可以參考下面的代碼:
let _am_ = 0;
Object.defineProperty(Object.prototype, "game", {
get: function() { return Date.now() },
set: function(nV) { _am_ = nV + 1; },
});
let a = {};
setInterval(()=>(a.game = a.game) && console.log(`game: ${a.game}`) || console.log(`_am_: ${_am_}`), 1000);
// => game: 1504956090106
// => _am_: 1504956090090
// => game: 1504956091110
// => _am_: 1504956091111
這里就展示get=>set的工作過程,每一次出現a.game=?
的操作的時候就會觸發set,可以嘗試把上面的賦值操作去掉,就會發現set不會被觸發(這個時候a.game還是會變化的)。
判斷對象
我們經常會遇到需要判斷參數的類型的場景,比如我們有一個下面的函數:
// 判斷請求的token的格式
handler = rgx => req => rgx.test(req.header("token"));
handler(()=>{});
// Error
為了安全起見,這個時候我們勢必希望能夠判斷rgx的類型。在使用nodejs做服務的時候,類型判斷就是最討厭的問題之一。雖然js動態語言的性質導致了這一問題不可能完全解決,不過js還是提供了一些手段來做基本的類型判斷。
typeof: typeof關鍵字會返回實例的對象,代碼如下:
typeof function(){} // => "function"
typeof 0 // => "number"
typeof null // => "object"
typeof undefined // => "undefined"
typoef "" // => "string"
typeof new RegExp(/^\d{11}$/) // => "object"
基本的類型typeof就可以確定了,不過上面的正則返回的是更底層的"object"。
instanceof: 該關鍵字就是用于判斷類型的。
/\d+/g instanceof RegExp // => true
/\d+/g instanceof Object // => true
關于instanceof的用法跟其他語言里基本一樣,這里不再贅述。
除了上面兩種寫法,還有下面這種寫法:
let typeOf = Object.prototype.toString;
typeOf.call(/^\d{11}$/) // => [object RegExp]
typeOf.apply(/^\d{11}$/) // => [object RegExp]
采用這種方法就可以找到一個對象更細致的類型了,具體哪些就不列了,有興趣自己去嘗試吧。關于call和apply的用法可以參考下面鏈接理解call和apply