面試官:說說原型鏈和繼承吧

JavaScript 中沒有類的概念的,主要經過原型鏈來實現繼承。一般狀況下,繼承意味着複製操做,然而 JavaScript 默認並不會複製對象的屬性,相反,JavaScript 只是在兩個對象之間建立一個關聯(原型對象指針),這樣,一個對象就能夠經過委託訪問另外一個對象的屬性和函數,因此與其叫繼承,委託的說法反而更準確些。javascript

原型

當咱們 new 了一個新的對象實例,明明什麼都沒有作,就直接能夠訪問 toStringvalueOf 等原生方法。那麼這些方法是從哪裏來的呢?答案就是原型。前端

在控制檯打印一個空對象時,咱們能夠看到,有不少方法,已經「初始化」掛載在內置的 __proto__ 對象上了。這個內置的 __proto__ 是一個指向原型對象的指針,它會在建立一個新的引用類型對象時(顯示或者隱式)自動建立,並掛載到新實例上。當咱們嘗試訪問實例對象上的某一屬性 / 方法時,若是實例對象上有該屬性 / 方法時,就返回實例屬性 / 方法,若是沒有,就去 __proto__ 指向的原型對象上查找對應的屬性 / 方法。這就是爲何咱們嘗試訪問空對象的 toStringvalueOf 等方法依舊能訪問到的緣由,JavaScript 正式以這種方式爲基礎來實現繼承的。java

構造函數

若是說實例的 __proto__ 只是一個指向原型對象的指針,那就說明在此以前原型對象就已經建立了,那麼原型對象是何時被建立的呢?這就要引入構造函數的概念。webpack

其實構造函數也就只是一個普通的函數而已,若是這個函數可使用 new 關鍵字來建立它的實例對象,那麼咱們就把這種函數稱爲 構造函數git

// 普通函數
function person () {}

// 構造函數,函數首字母一般大寫
function Person () {}
const person = new Person();
複製代碼

原型對象正是在構造函數被聲明時一同建立的。構造函數被申明時,原型對象也一同完成建立,而後掛載到構造函數的 prototype 屬性上:程序員

原型對象被建立時,會自動生成一個 constructor 屬性,指向建立它的構造函數。這樣它倆的關係就被緊密地關聯起來了。github

細心的話,你可能會發現,原型對象也有本身的 __proto__ ,這也不奇怪,畢竟萬物皆對象嘛。原型對象的 __proto__ 指向的是 Object.prototype。那麼 Object.prototype.__proto__ 存不存在呢?實際上是不存在的,打印的話會發現是 null 。這也證實了 ObjectJavaScript 中數據類型的起源。web

分析到這裏,咱們大概瞭解原型及構造函數的大概關係了,咱們能夠用一張圖來表示這個關係:面試

原型鏈

說完了原型,就能夠來講說原型鏈了,若是理解了原型機制,原型鏈就很好解釋了。其實上面一張圖上,那條被 __proto__ 連接起來的鏈式關係,就稱爲原型鏈express

原型鏈的做用:原型鏈如此的重要的緣由就在於它決定了 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.setPrototypeOfObject.setPrototypeOf
let target = {};
let newProto = {};
Reflect.getPrototypeOf(target) === newProto; // false
Reflect.setPrototypeOf(target, newProto);
Reflect.getPrototypeOf(target) === newProto; // true
複製代碼
  • 函數都會有 prototype ,除了 Function.prototype.bind() 以外。
  • 對象都會有 __proto__ ,除了 Object.prototype 以外(其實它也是有的,之不過是 null)。
  • 全部函數都由 Function 建立而來,也就是說他們的 __proto__ 都等於 Function.prototype
  • Function.prototype 等於 Function.__proto__

原型污染

原型污染是指:攻擊者經過某種手段修改 JavaScript 對象的原型。

什麼意思呢,原理其實很簡單。若是咱們把 Object.prototype.toString 改爲這樣:

Object.prototype.toString = function () {alert('原型污染')};
let obj = {};
obj.toString();
複製代碼

那麼當咱們運行這段代碼的時候瀏覽器就會彈出一個 alert,對象原生的 toString 方法被改寫了,全部對象當調用 toString 時都會受到影響。

你可能會說,怎麼可能有人傻到在源碼裏寫這種代碼,這不是搬起石頭砸本身的腳麼?沒錯,沒人會在源碼裏這麼寫,可是攻擊者可能會經過表單或者修改請求內容等方式使用原型污染髮起攻擊,來看下面一種狀況:

'use strict';
 
const express = require('express');
const bodyParser = require('body-parser')
const cookieParser = require('cookie-parser');
const path = require('path');
 
const isObject = obj => obj && obj.constructor && obj.constructor === Object;
 
function merge(a, b) {
    for (var attr in b) {
        if (isObject(a[attr]) && isObject(b[attr])) {
            merge(a[attr], b[attr]);
        } else {
            a[attr] = b[attr];
        }
    }
    return a
}
 
function clone(a) {
    return merge({}, a);
}
 
// Constants
const PORT = 8080;
const HOST = '0.0.0.0';
const admin = {};
 
// App
const app = express();
app.use(bodyParser.json())
app.use(cookieParser());
 
app.use('/', express.static(path.join(__dirname, 'views')));
app.post('/signup', (req, res) => {
    var body = JSON.parse(JSON.stringify(req.body));
    var copybody = clone(body)
    if (copybody.name) {
        res.cookie('name', copybody.name).json({
            "done": "cookie set"
        });
    } else {
        res.json({
            "error": "cookie not set"
        })
    }
});
app.get('/getFlag', (req, res) => {
    var аdmin = JSON.parse(JSON.stringify(req.cookies))
    if (admin.аdmin == 1) {
        res.send("hackim19{}");
    } else {
        res.send("You are not authorized");
    }
});
app.listen(PORT, HOST);
console.log(`Running on http://${HOST}:${PORT}`);
複製代碼

若是服務器中有上述的代碼片斷,攻擊者只要將 cookie 設置成{__proto__: {admin: 1}} 就能完成系統的侵入。

原型污染的解決方案

在看原型污染的解決方案以前,咱們能夠看下 lodash 團隊以前解決原型污染問題的手法:

代碼很簡單,只要是碰到有 constructor 或者 __proto__ 這樣的敏感詞彙,就直接退出執行了。這固然是一種防止原型污染的有效手段,固然咱們還有其餘手段:

  1. 使用 Object.create(null), 方法建立一個原型爲 null 的新對象,這樣不管對 原型作怎樣的擴展都不會生效:
const obj = Object.create(null);
obj.__proto__ = { hack: '污染原型的屬性' };
console.log(obj); // => {}
console.log(obj.hack); // => undefined
複製代碼
  1. 使用 Object.freeze(obj) 凍結指定對象,使之不能被修改屬性,成爲不可擴展對象:

    Object.freeze(Object.prototype);
    
    Object.prototype.toString = 'evil';
    
    console.log(Object.prototype.toString);
    // => ƒ toString() { [native code] }
    複製代碼
  2. 創建 JSON schema ,在解析用戶輸入內容時,經過 JSON schema 過濾敏感鍵名。

  3. 規避不安全的遞歸性合併。這一點相似 lodash 修復手段,完善了合併操做的安全性,對敏感鍵名跳過處理。

繼承

終於能夠來講說繼承了,先來看看繼承的概念,看下百度上是怎麼說的:

繼承面向對象軟件技術當中的一個概念,與多態封裝共爲面向對象的三個基本特徵。繼承可使得子類具備父類的屬性方法或者從新定義、追加屬性和方法等。

這段對於程序員來講,這個解釋仍是比較好理解的。接着往下翻,我看到了一條重要的描述:

子類的建立能夠增長新數據、新功能,能夠繼承父類所有的功能,可是不能選擇性的繼承父類的部分功能。繼承是類與類之間的關係,不是對象與對象之間的關係。

這就尷尬了,JavaScript 裏哪裏來的類,只有對象。那照這麼說豈不是不能實現純正的繼承了?因此纔會有開頭那句話:與其叫繼承,委託的說法反而更準確些。

可是 JavaScript 是很是靈活的, 靈活這一特色給它帶來不少缺陷的同時,也締造出不少驚豔的優勢。沒有原生提供類的繼承沒關係,咱們能夠用更多元的方式來實現 JavaScript 中的繼承,好比說利用 Object.assign

let person = { name: null, age: null };
let man = Object.assign({}, person, { name: 'John', age: 23 });
console.log(man);  // => { name: 'John', age: 23 }
複製代碼

利用 callapply

let person = {
    name: null,
    sayName: function () {
        console.log(this.name);
    },
    sayAge: function () {
        console.log(this.age);
    }
};
let man = { name: 'Man', age: 23 };
person.sayName.call(man); // => Man
person.sayAge.apply(man); // => 23
複製代碼

甚至咱們還可使用深拷貝對象的方式來完成相似繼承的操做……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']
複製代碼

上述代碼使用了最基本的原型鏈繼承使得子類可以繼承父類的屬性,**原型繼承的關鍵步驟就在於:將子類原型和父類原型關聯起來,使原型鏈可以銜接上,**這邊是直接將子類原型指向了父類實例來完成關聯。

上述是原型繼承的一種最初始的狀態,咱們分析上面代碼,會發現仍是會有問題:

  1. 在建立子類實例的時候,不能向超類型的構造函數中傳遞參數。
  2. 這樣建立的子類原型會包含父類的實例屬性,形成引用類型屬性同步修改的問題。

組合繼承

組合繼承使用 call 在子類構造函數中調用父類構造函數,解決了上述兩個問題:

// 組合繼承實現

function Parent(value) {
    this.value = value;
}

Parent.prototype.getValue = function() {
    console.log(this.value);
}

function Child(value) {
    Parent.call(this, value)
}

Child.prototype = new Parent();

const child = new Child(1)
child.getValue();
child instanceof Parent;
複製代碼

然而它仍是存在問題:父類的構造函數被調用了兩次(建立子類原型時調用了一次,建立子類實例時又調用了一次),致使子類原型上會存在父類實例屬性,浪費內存。

寄生組合繼承

針對組合繼承存在的缺陷,又進化出了「寄生組合繼承」:使用 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 繼承模式,ES6class 繼承在 babel 轉義後,底層也是使用的寄生組合繼承的方式實現的。

繼承關係判斷

當咱們使用了原型鏈繼承後,怎樣判斷對象實例和目標類型之間的關係呢?

instanceof

咱們可使用 instanceof 來判斷兩者間是否有繼承關係,instanceof 的字面意思就是:xx 是否爲 xxx 的實例。若是是則返回 true 不然返回 false

function Parent () {}
function Child () {}
Child.prototype = new Parent();
let parent = new Parent();
let child = new Child();

parent instanceof Parent; // => true
child instanceof Child; // => true
child instanceof Parent; // => true
child instanceof Object; // => true
複製代碼

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 來間接判斷繼承關係,該方法用於判斷一個對象是否存在於另外一個對象的原型鏈上:

function Foo() {}
function Bar() {}
function Baz() {}

Bar.prototype = Object.create(Foo.prototype);
Baz.prototype = Object.create(Bar.prototype);

var baz = new Baz();

console.log(Baz.prototype.isPrototypeOf(baz)); // true
console.log(Bar.prototype.isPrototypeOf(baz)); // true
console.log(Foo.prototype.isPrototypeOf(baz)); // true
console.log(Object.prototype.isPrototypeOf(baz)); // true
複製代碼

本篇文章已收錄入 前端面試指南專欄

相關參考

往期內容推薦

  1. 完全弄懂節流和防抖
  2. 【基礎】HTTP、TCP/IP 協議的原理及應用
  3. 【實戰】webpack4 + ejs + express 帶你擼一個多頁應用項目架構
  4. 瀏覽器下的 Event Loop
  5. 面試官:說說執行上下文吧
  6. 面試官:說說做用域和閉包吧
  7. 面試官:說說 JS 中的模塊化吧
  8. 面試官:說說 JS 中的模塊化吧
相關文章
相關標籤/搜索