1. 程式人生 > >js 原型鏈和繼承(轉)

js 原型鏈和繼承(轉)

部分 聲明 ole 重復 操作 param urn 構造 cti

在理解繼承之前,需要知道 js 的三個東西:

    1. 什麽是 JS 原型鏈
    1. this 的值到底是什麽
    1. JS 的 new 到底是幹什麽的

1. 什麽是 JS 原型鏈?

我們知道 JS 有對象,比如

var obj = { name: "obj" };

我們通過控制臺把 obj 打印出來:

技術分享圖片

我們會發現 obj 已經有幾個屬性(方法)了。那麽問題來了:valueOf / toString / constructor 是怎麽來?我們並沒有給 obj.valueOf 賦值呀。

上面這個圖有點難懂,我手畫一個示意圖:

技術分享圖片

我們發現控制臺打出來的結果是:

  • obj 本身有一個屬性 name (這是我們給它加的)
  • obj 還有一個屬性叫做 proto(它是一個對象)
  • obj 還有一個屬性,包括 valueOf, toString, constructor 等
  • obj.proto其實也有一個叫做proto的屬性(console.log 沒有顯示),值為 null

現在回到我們的問題:obj 為什麽會擁有 valueOf / toString / constructor 這幾個屬性?

答案: 這跟 proto有關 。

當我們「讀取」 obj.toString 時,JS 引擎會做下面的事情:

  • 看看 obj 對象本身有沒有 toString 屬性。沒有就走到下一步。
  • 看看 obj.proto 對象有沒有 toString 屬性, 發現 obj.proto
    有 toString 屬性, 於是找到了,所以 obj.toString 實際就是第 2 步中找到的 obj.proto.toString。
  • 如果 obj.proto沒有,那麽瀏覽器會繼續查看 obj.proto.proto
  • 如果 obj.proto.proto也沒有,那麽瀏覽器會繼續查看 obj.proto.proto.proto
  • 直到找到 toString 或者 proto 為 null。

上面的過程,就是「讀」屬性的「搜索過程」。而這個「搜索過程」,是連著由 proto 組成的鏈子一直走的。這個鏈子,就叫做「原型鏈」。

共享原型鏈

現在我們還有另一個對象

var obj2 = { name: "obj2" };

如圖:

技術分享圖片

那麽 obj.toString 和 obj2.toString 其實是同一東西, 也就是 obj2.proto.toString。

說白了,我們改其中的一個 proto.toString ,那麽另外一個其實也會變!

差異化

如果我們想讓 obj.toString 和 obj2.toString 的行為不同怎麽做呢?
直接賦值就好了:

obj.toString = function() {
    return "新的 toString 方法";
};

技術分享圖片

小結

  • [讀]屬性時會沿著原型鏈搜索
  • [新增]屬性時不會去看原型鏈

2. this 的值到底是什麽

你可能遇到過這樣的 JS 面試題:

var obj = {
    foo: function() {
        console.log(this);
    }
};

var bar = obj.foo;
obj.foo(); // 打印出的 this 是 obj
bar(); // 打印出的 this 是 window

請解釋最後兩行函數的值為什麽不一樣。

函數調用

JS(ES5)裏面有三種函數調用形式:

func(p1, p2);
obj.child.method(p1, p2);
func.call(context, p1, p2); // 先不講 apply

一般,初學者都知道前兩種形式,而且認為前兩種形式「優於」第三種形式。
我們方方老師大姥說了,你一定要記住,第三種調用形式,才是正常調用形式:

func.call(context, p1, p2);

其他兩種都是語法糖,可以等價地變為 call 形式:

func(p1, p2)等價於 func.call(undefined, p1, p2);

obj.child.method(p1, p2) 等價於 obj.child.method.call(obj.child, p1, p2);

至此我們的函數調用只有一種形式:

func.call(context, p1, p2);

這樣,this 就好解釋了 this 就是上面 context。

this 是你 call 一個函數時傳的 context,由於你從來不用 call 形式的函數調用,所以你一直不知道。

先看 func(p1, p2) 中的 this 如何確定:

當你寫下面代碼時;

function func() {
    console.log(this);
}

func();
等價於;

function func() {
    console.log(this);
}

func.call(undefined); // 可以簡寫為 func.call()

按理說打印出來的 this 應該就是 undefined 了吧,但是瀏覽器裏有一條規則:

如果你傳的 context 就 null 或者 undefined,那麽 window 對象就是默認的 context(嚴格模式下默認 context 是 undefined)

因此上面的打印結果是 window。如果你希望這裏的 this 不是 window,很簡單:

func.call(obj); // 那麽裏面的 this 就是 obj 對象了

回到題目:

var obj = {
    foo: function() {
        console.log(this);
    }
};

var bar = obj.foo;
obj.foo(); // 轉換為 obj.foo.call(obj),this 就是 obj
bar();
// 轉換為 bar.call()
// 由於沒有傳 context
// 所以 this 就是 undefined
// 最後瀏覽器給你一個默認的 this —— window 對象

[ ] 語法

function fn() {
    console.log(this);
}
var arr = [fn, fn2];
arr[0](); // 這裏面的 this 又是什麽呢?

我們可以把 arr0 想象為 arr.0( ),雖然後者的語法錯了,但是形式與轉換代碼裏的 obj.child.method(p1, p2) 對應上了,於是就可以愉快的轉換了:

arr[0]();

假想為 arr.0()
然後轉換為 arr.0.call(arr)
那麽裏面的 this 就是 arr 了 :)

小結:

  • this 就是你 call 一個函數時,傳入的第一個參數。
  • 如果你的函數調用不是 call 形式, 請將其轉換為 call 形式

碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 調試,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug。

3. JS 的 new 到底是幹什麽的?

我們聲明一個士兵,具有如下屬性:

var 士兵 = {
    ID: 1, // 用於區分每個士兵
    兵種: "美國大兵",
    攻擊力: 5,
    生命值: 42,
    行走: function() {
        /*走倆步的代碼*/
    },
    奔跑: function() {
        /*狂奔的代碼*/
    },
    死亡: function() {
        /*Go die*/
    },
    攻擊: function() {
        /*糊他熊臉*/
    },
    防禦: function() {
        /*護臉*/
    }
};

我們制造一個士兵, 只需要這樣:

兵營.制造(士兵);

如果需要制造 100 個士兵怎麽辦呢?

循環 100 次吧:

var 士兵們 = []
var 士兵
for(var i=0; i<100; i++){
  士兵 = {
    ID: i, // ID 不能重復
    兵種:"美國大兵",
    攻擊力:5,
    生命值:42,
    行走:function(){ /*走倆步的代碼*/},
    奔跑:function(){ /*狂奔的代碼*/  },
    死亡:function(){ /*Go die*/    },
    攻擊:function(){ /*糊他熊臉*/   },
    防禦:function(){ /*護臉*/       }
  }
  士兵們.push(士兵)
}

兵營.批量制造(士兵們)

哎呀,看起來好簡單

質疑

上面的代碼存在一個問題:浪費了很多內存

  • 行走、奔跑、死亡、攻擊、防禦這五個動作對於每個士兵其實是一樣的,只需要各自引用同一個函數就可以了,沒必要重復創建 100 個行走、100 個奔跑……
  • 這些士兵的兵種和攻擊力都是一樣的,沒必要創建 100 次。
  • 只有 ID 和生命值需要創建 100 次,因為每個士兵有自己的 ID 和生命值。

改進

通過第一節可以知道 ,我們可以通過原型鏈來解決重復創建的問題:我們先創建一個「士兵原型」,然後讓「士兵」的 proto 指向「士兵原型」。

var 士兵原型 = {
  兵種:"美國大兵",
  攻擊力:5,
  行走:function(){ /*走倆步的代碼*/},
  奔跑:function(){ /*狂奔的代碼*/  },
  死亡:function(){ /*Go die*/    },
  攻擊:function(){ /*糊他熊臉*/   },
  防禦:function(){ /*護臉*/       }
}

var 士兵們 = []
var 士兵
for(var i=0; i<100; i++){
  士兵 = {
    ID: i, // ID 不能重復
    生命值:42
  }

  /*實際工作中不要這樣寫,因為 __proto__ 不是標準屬性*/
  士兵.__proto__ = 士兵原型

  士兵們.push(士兵)
}

兵營.批量制造(士兵們)

優雅?

有人指出創建一個士兵的代碼分散在兩個地方很不優雅,於是我們用一個函數把這兩部分聯系起來:

function 士兵(ID){
  var 臨時對象 = {};
  臨時對象.__proto__ = 士兵.原型;
  臨時對象.ID = ID;
  臨時對象.生命值 = 42;

  return 臨時對象;
}

士兵.原型 = {
  兵種:"美國大兵",
  攻擊力:5,
  行走:function(){ /*走倆步的代碼*/},
  奔跑:function(){ /*狂奔的代碼*/  },
  死亡:function(){ /*Go die*/    },
  攻擊:function(){ /*糊他熊臉*/   },
  防禦:function(){ /*護臉*/       }
}

// 保存為文件:士兵.js

 然後就可以愉快地引用「士兵」來創建士兵了:

var 士兵們 = []
for(var i=0; i<100; i++){
  士兵們.push(士兵(i))
}

兵營.批量制造(士兵們)

JS 之父看到大家都這麽搞,覺得何必呢,我給你們個糖吃,於是 JS 之父創建了 new 關鍵字,可以讓我們少寫幾行代碼:

技術分享圖片

只要你在士兵前面使用 new 關鍵字,那麽可以少做四件事情:

  1. 不用創建臨時對象,因為 new 會幫你做(你使用「this」就可以訪問到臨時對象);
  2. 不用綁定原型,因為 new 會幫你做(new 為了知道原型在哪,所以指定原型的名字 prototype);
  3. 不用 return 臨時對象,因為 new 會幫你做;
  4. 不要給原型想名字了,因為 new 指定名字為 prototype。

這一次用 new 來寫

function 士兵(ID){
  this.ID = ID
  this.生命值 = 42
}

士兵.prototype = {
  兵種:"美國大兵",
  攻擊力:5,
  行走:function(){ /*走倆步的代碼*/},
  奔跑:function(){ /*狂奔的代碼*/  },
  死亡:function(){ /*Go die*/    },
  攻擊:function(){ /*糊他熊臉*/   },
  防禦:function(){ /*護臉*/       }
}

// 保存為文件:士兵.js
然後是創建士兵(加了一個 new 關鍵字):

var 士兵們 = []
for(var i=0; i<100; i++){
  士兵們.push(new 士兵(i))
}

兵營.批量制造(士兵們)

new 的作用,就是省那麽幾行代碼。(也就是所謂的語法糖)

註意 constructor 屬性

new 操作為了記錄「臨時對象是由哪個函數創建的」,所以預先給「士兵.prototype」加了一個 constructor 屬性:

士兵.prototype = {
    constructor: 士兵
};

如果你重新對「士兵.prototype」賦值,那麽這個 constructor 屬性就沒了,所以你應該這麽寫:

士兵.prototype.兵種 = "美國大兵";
士兵.prototype.攻擊力 = 5;
士兵.prototype.行走 = function() {
    /*走倆步的代碼*/
};
士兵.prototype.奔跑 = function() {
    /*狂奔的代碼*/
};
士兵.prototype.死亡 = function() {
    /*Go die*/
};
士兵.prototype.攻擊 = function() {
    /*糊他熊臉*/
};
士兵.prototype.防禦 = function() {
    /*護臉*/
};

或者你也可以自己給 constructor 重新賦值:

士兵.prototype = {
    constructor: 士兵,
    兵種: "美國大兵",
    攻擊力: 5,
    行走: function() {
        /*走倆步的代碼*/
    },
    奔跑: function() {
        /*狂奔的代碼*/
    },
    死亡: function() {
        /*Go die*/
    },
    攻擊: function() {
        /*糊他熊臉*/
    },
    防禦: function() {
        /*護臉*/
    }
};

四、繼承

繼承的本質就是上面的講的原型鏈

1)借助構造函數實現繼承

function Parent1() {
    this.name = "parent1";
}

Parent1.prototype.say = function() {};

function Child1() {
    Parent1.call(this);
    this.type = "child";
}

console.log(new Child1());

打印結果:

技術分享圖片

這個主要是借用 call 來改變 this 的指向,通過 call 調用 Parent ,此時 Parent 中的 this 是指 Child1。有個缺點,從打印結果看出 Child1 並沒有 say 方法,所以這種只能繼承父類的實例屬性和方法,不能繼承原型屬性/方法。

2)借助原型鏈實現繼承

/**
 * 借助原型鏈實現繼承
 */
function Parent2() {
    this.name = "parent2";
    this.play = [1, 2, 3];
}

function Child2() {
    this.type = "child2";
}
Child2.prototype = new Parent2();

console.log(new Child2());

var s1 = new Child2();
var s2 = new Child2();

打印:

技術分享圖片

通過一講的,我們知道要共享莫些屬性,需要 對象.proto = 父親對象的.prototype,但實際上我們是不能直接 操作proto,這時我們可以借用 new 來做,所以
Child2.prototype = new Parent2(); <=> Child2.prototype.proto = Parent2.prototype; 這樣我們借助 new 這個語法糖,就可以實現原型鏈繼承。但這裏有個總是,如打印結果,我們給 s1.play 新增一個值 ,s2 也跟著改了。所以這個是原型鏈繼承的缺點,原因是 s1.pro 和 s2.pro指向同一個地址即 父類的 prototype。

3)組合方式實現繼承

/**
 * 組合方式
 */

function Parent3() {
    this.name = "parent3";
    this.play = [1, 2, 3];
}

Parent3.prototype.say = function() {};

function Child3() {
    Parent3.call(this);
    this.type = "child3";
}

Child3.prototype = new Parent3();

var s3 = new Child3();
var s4 = new Child3();
s3.play.push(4);
console.log(new Child3());
console.log(s3.play, s4.play);

打印:

技術分享圖片

將 1 和 2 兩種方式組合起來,就可以解決 1 和 2 存在問題,這種方式為組合繼承。這種方式有點缺點就是我實例一個對象的時, 父類 new 了兩次,一次是 var s3 = new Child3()對應 Child3.prototype = new Parent3()還要 new 一次。

4)組合繼承的優化 1

function Parent4() {
    this.name = "parent4";
    this.play = [1, 2, 3];
}

Parent4.prototype.say = function() {};

function Child4() {
    Parent4.call(this);
    this.type = "child4";
}

Child4.prototype = Parent4.prototype;

var s5 = new Child4();
var s6 = new Child4();

這邊主要為 Child4.prototype = Parent4.prototype, 因為我們通過構造函數就可以拿到所有屬性和實例的方法,那麽現在我想繼承父類的原型對象,所以你直接賦值給我就行,不用在去 new 一次父類。其實這種方法還是有問題的,如果我在控制臺打印以下兩句:

技術分享圖片

從打印可以看出,此時我是沒有辦法區分一個對象 是直接 由它的子類實例化還是父類呢?我們還有一個方法判斷來判斷對象是否是類的實例,那就是用 constructor,我在控制臺打印以下內容:

技術分享圖片

咦,你會發現它指向的是父類 ,這顯然不是我們想要的結果, 上面講過我們 prototype 裏面有一個 constructor, 而我們此時子類的 prototype 指向是 父類的 prototye ,而父類 prototype 裏面的 contructor 當然是父類自己的,這個就是產生該問題的原因。

組合繼承的優化 2

/**
 * 組合繼承的優化2
 */

function Parent5() {
    this.name = "parent4";
    this.play = [1, 2, 3];
}

Parent5.prototype.say = function() {};

function Child5() {
    Parent5.call(this);
    this.type = "child4";
}

Child5.prototype = Object.create(Parent5.prototype);

這裏主要使用Object.create(),它的作用是將對象繼承到proto屬性上。舉個例子:

var test = Object.create({ x: 123, y: 345 });
console.log(test); //{}
console.log(test.x); //123
console.log(test.__proto__.x); //3
console.log(test.__proto__.x === test.x); //true

那大家可能說這樣解決了嗎,其實沒有解決,因為這時 Child5.prototype 還是沒有自己的 constructor,它要找的話還是向自己的原型對象上找最後還是找到 Parent5.prototype, constructor 還是 Parent5 ,所以要給 Child5.prototype 寫自己的 constructor:

Child5.prototype = Object.create(Parent5.prototype);
Child5.prototype.constructor = Child5;

參考

  • 什麽是 JS 原型鏈?
  • this 的值到底是什麽?一次說清楚
  • JS 的 new 到底是幹什麽的?

js 原型鏈和繼承(轉)