javascript
JavaScript 原型链和继承面试题
JavaScript 原型鏈和繼承問題
JavaScript 中沒有類的概念的,主要通過原型鏈來實現繼承。通常情況下,繼承意味著復制操作,然而 JavaScript默認并不會復制對象的屬性,相反,JavaScript只是在兩個對象之間創建一個關聯(原型對象指針),這樣,一個對象就可以通過委托訪問另一個對象的屬性和函數,所以與其叫繼承,委托的說法反而更準確些。
原型
- 當我們 new 了一個新的對象實例,明明什么都沒有做,就直接可以訪問 toString 、valueOf 等原生方法。那么這些方法是從哪里來的呢?答案就是原型。
- 在控制臺打印一個空對象時,我們可以看到,有很多方法,已經“初始化”掛載在內置的 proto 對象上了。這個內置的 proto 是一個指向原型對象的指針,它會在創建一個新的引用類型對象時(顯示或者隱式)自動創建,并掛載到新實例上。當我們嘗試訪問實例對象上的某一屬性 / 方法時,如果實例對象上有該屬性 / 方法時,就返回實例屬性 / 方法,如果沒有,就去 proto 指向的原型對象上查找對應的屬性 / 方法。這就是為什么我們嘗試訪問空對象的 toString 和 valueOf 等方法依舊能訪問到的原因,JavaScript 正式以這種方式為基礎來實現繼承的。
構造函數
如果說實例的 proto 只是一個指向原型對象的指針,那就說明在此之前原型對象就已經創建了,那么原型對象是什么時候被創建的呢?這就要引入構造函數的概念。
其實構造函數也就只是一個普通的函數而已,如果這個函數可以使用 new 關鍵字來創建它的實例對象,那么我們就把這種函數稱為 構造函數。
- 原型對象正是在構造函數被聲明時一同創建的。構造函數被申明時,原型對象也一同完成創建,然后掛載到構造函數的 prototype 屬性上:
原型對象被創建時,會自動生成一個 constructor 屬性,指向創建它的構造函數。這樣它倆的關系就被緊密地關聯起來了。
細心的話,你可能會發現,原型對象也有自己的 proto ,這也不奇怪,畢竟萬物皆對象嘛。原型對象的 proto 指向的是 Object.prototype。那么 Object.prototype.proto 存不存在呢?其實是不存在的,打印的話會發現是 null 。這也證明了 Object 是 JavaScript 中數據類型的起源。
分析到這里,我們大概了解原型及構造函數的大概關系了,我們可以用一張圖來表示這個關系:
原型鏈
說完了原型,就可以來說說原型鏈了,如果理解了原型機制,原型鏈就很好解釋了。其實上面一張圖上,那條被 proto 鏈接起來的鏈式關系,就稱為原型鏈。
原型鏈的作用:原型鏈如此的重要的原因就在于它決定了 JavaScript 中繼承的實現方式。當我們訪問一個屬性時,查找機制如下:
- 訪問對象實例屬性,有則返回,沒有就通過 proto 去它的原型對象查找。
- 原型對象找到即返回,找不到,繼續通過原型對象的 proto 查找。
- 一層一層一直找到 Object.prototype ,如果找到目標屬性即返回,找不到就返回 undefined,不會再往下找,因為在往下找 proto 就是 null 了。
通過上面的解釋,對于構造函數生成的實例,我們應該能了解它的原型對象了。JavaScript 中萬物皆對象,那么構造函數肯定也是個對象,是對象就有 proto ,那么構造函數的 proto 是什么?
現在才想起來所有的函數可以使用 new Function() 的方式創建,那么這個答案也就很自然了,有點意思,再來試試別的構造函數。
這也證明了,所有函數都是 Function 的實例。等一下,好像有哪里不對,那么 Function.proto 豈不是。。。
按照上面的邏輯,這樣說的話,Function 豈不是自己生成了自己?其實,我們大可不必這樣理解,因為作為一個 JS 內置對象,Function 對象在你腳本文件都還沒生成的時候就已經存在了,哪里能自己調用自己,這個東西就類似于玄學中的“道”和“乾坤”,你能說明它們是誰生成的嗎,天地同壽日月同庚不生不滅。。。算了,在往下扯就要寫成修仙了=。=
至于為什么 Function.proto 等于 Function.prototype 有這么幾種說法:
- 為了保持與其他函數保持一致
- 為了說明一種關系,比如證明所有的函數都是 Function 的實例。
- 函數都是可以調用 call bind 這些內置 API 的,這么寫可以很好的保證函數實例能夠使用這些 API。
注意點:
關于原型、原型鏈和構造函數有幾點需要注意:
- proto 是非標準屬性,如果要訪問一個對象的原型,建議使用 ES6 新增的 Reflect.getPrototypeOf 或者 Object.getPrototypeOf() 方法,而不是直接 obj.proto,因為非標準屬性意味著未來可能直接會修改或者移除該屬性。同理,當改變一個對象的原型時,最好也使用 ES6 提供的 Reflect.setPrototypeOf 或 Object.setPrototypeOf。
- 函數都會有 prototype ,除了 - Function.prototype.bind() 之外。
對象都會有 proto ,除了 Object.prototype 之外(其實它也是有的,之不過是 null)。 - 所有函數都由 Function 創建而來,也就是說他們的 proto 都等于 Function.prototype。
- Function.prototype 等于 Function.proto 。
原型污染
- 原型污染是指:攻擊者通過某種手段修改 JavaScript 對象的原型。
- 什么意思呢,原理其實很簡單。如果我們把 Object.prototype.toString 改成這樣:
那么當我們運行這段代碼的時候瀏覽器就會彈出一個 alert,對象原生的 toString 方法被改寫了,所有對象當調用 toString 時都會受到影響。
你可能會說,怎么可能有人傻到在源碼里寫這種代碼,這不是搬起石頭砸自己的腳么?沒錯,沒人會在源碼里這么寫,但是攻擊者可能會通過表單或者修改請求內容等方式使用原型污染發起攻擊,來看下面一種情況:
如果服務器中有上述的代碼片段,攻擊者只要將 cookie 設置成{proto: {admin: 1}} 就能完成系統的侵入。
原型污染的解決方案
在看原型污染的解決方案之前,我們可以看下 lodash 團隊之前解決原型污染問題的手法:
使用 Object.create(null), 方法創建一個原型為 null 的新對象,這樣無論對 原型做怎樣的擴展都不會生效:
繼承
終于可以來說說繼承了,先來看看繼承的概念,看下百度上是怎么說的:
繼承是面向對象軟件技術當中的一個概念,與多態、封裝共為面向對象的三個基本特征。繼承可以使得子類具有父類的屬性和方法或者重新定義、追加屬性和方法等。
這段對于程序員來說,這個解釋還是比較好理解的。接著往下翻,我看到了一條重要的描述:
子類的創建可以增加新數據、新功能,可以繼承父類全部的功能,但是不能選擇性的繼承父類的部分功能。繼承是類與類之間的關系,不是對象與對象之間的關系。
這就尷尬了,JavaScript 里哪里來的類,只有對象。那照這么說豈不是不能實現純正的繼承了?所以才會有開頭那句話:與其叫繼承,委托的說法反而更準確些。
但是 JavaScript 是非常靈活的, 靈活這一特點給它帶來很多缺陷的同時,也締造出很多驚艷的優點。沒有原生提供類的繼承不要緊,我們可以用更多元的方式來實現 JavaScript 中的繼承,比如說利用 Object.assign:
甚至我們還可以使用深拷貝對象的方式來完成類似繼承的操作……JS 中實現繼承的手法多種多樣,但是看看上面的代碼不難發現一些問題:
- 封裝性不強,過于凌亂,寫起來十分不便。
- 根本無法判斷子對象是從何處繼承而來。
有沒有辦法解決這些問題呢?我們可以使用 JavaScript 中繼承最常用的方式:原型繼承
原型鏈繼承
原型鏈繼承,就是讓對象實例通過原型鏈的方式串聯起來,當訪問目標對象的某一屬性時,能順著原型鏈進行查找,從而達到類似繼承的效果。
// 父類 function SuperType (colors = ['red', 'blue', 'green']) {this.colors = colors; }// 子類 function SubType () {} // 繼承父類 SubType.prototype = new SuperType(); // 以這種方式將 constructor 屬性指回 SubType 會改變 constructor 為可遍歷屬性 SubType.prototype.constructor = SubType;let superInstance1 = new SuperType(['yellow', 'pink']); let subInstance1 = new SubType(); let subInstance2 = new SubType(); superInstance1.colors; // => ['yellow', 'pink'] subInstance1.colors; // => ['red', 'blue', 'green'] subInstance2.colors; // => ['red', 'blue', 'green'] subInstance1.colors.push('black'); subInstance1.colors; // => ['red', 'blue', 'green', 'black'] subInstance2.colors; // => ['red', 'blue', 'green', 'black']上述代碼使用了最基本的原型鏈繼承使得子類能夠繼承父類的屬性,原型繼承的關鍵步驟就在于:將子類原型和父類原型關聯起來,使原型鏈能夠銜接上,這邊是直接將子類原型指向了父類實例來完成關聯。
上述是原型繼承的一種最初始的狀態,我們分析上面代碼,會發現還是會有問題:
- 在創建子類實例的時候,不能向超類型的構造函數中傳遞參數。
- 這樣創建的子類原型會包含父類的實例屬性,造成引用類型屬性同步修改的問題。
組合繼承
組合繼承使用 call 在子類構造函數中調用父類構造函數,解決了上述兩個問題:
然而它還是存在問題:父類的構造函數被調用了兩次(創建子類原型時調用了一次,創建子類實例時又調用了一次),導致子類原型上會存在父類實例屬性,浪費內存。
寄生組合繼承
針對組合繼承存在的缺陷,又進化出了“寄生組合繼承”:使用 Object.create(Parent.prototype) 創建一個新的原型對象賦予子類從而解決組合繼承的缺陷:
// 寄生組合繼承實現function Parent(value) {this.value = value; }Parent.prototype.getValue = function() {console.log(this.value); }function Child(value) {Parent.call(this, value) }Child.prototype = Object.create(Parent.prototype, {constructor: {value: Child,enumerable: false, // 不可枚舉該屬性writable: true, // 可改寫該屬性configurable: true // 可用 delete 刪除該屬性} })const child = new Child(1) child.getValue(); child instanceof Parent; 復制代碼寄生組合繼承的模式是現在業內公認的比較可靠的 JS 繼承模式,ES6 的 class 繼承在 babel 轉義后,底層也是使用的寄生組合繼承的方式實現的。
繼承關系判斷
當我們使用了原型鏈繼承后,怎樣判斷對象實例和目標類型之間的關系呢?
instanceof
我們可以使用 instanceof 來判斷二者間是否有繼承關系,instanceof 的字面意思就是:xx 是否為 xxx 的實例。如果是則返回 true 否則返回 false:
instanceof 本質上是通過原型鏈查找來判斷繼承關系的,因此只能用來判斷引用類型,對基本類型無效,我們可以手動實現一個簡易版 instanceof:
function _instanceof (obj, Constructor) {if (typeof obj !== 'object' || obj == null) return false;let construProto = Constructor.prototype;let objProto = obj.__proto__;while (objProto != null) {if (objProto === construProto) return true;objProto = objProto.__proto__;}return false; }Object.prototype.isPrototypeOf(obj)
還可以利用 Object.prototype.isPrototypeOf 來間接判斷繼承關系,該方法用于判斷一個對象是否存在于另一個對象的原型鏈上:
總結
以上是生活随笔為你收集整理的JavaScript 原型链和继承面试题的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 软件项目实施总结
- 下一篇: php网页采集器 源码,PHP采集器的简