0%

完整理解 prototype 與 new 與 this

前言

首先來看看下面的例子

1
2
3
4
5
6
7
8
9
10
11
function Person (name) {
this.name = name
}

Person.prototype.sayName = function () {
console.log(this.name)
}

var p = new Person('Winnie')

p.sayName() // "Winnie"

相信大家在各個教程都看過這個例子~

但是這個例子融合了 prototype、new 與 this,要了解每一行在做什麼其實非常不容易

我們先從 prototype 說起

prototype

js 作為 prototype-based programming,是沒有 class 的概念的,es6 出現後,也只是以 prototype 做封裝的

也就是 js 在沒有 class 的概念下,是透過 prototype 進行繼承行為的

那 prototype 可以理解為鍊表,也就是一層一層往上繼承,直到 null 結束

因此如果 object 沒有愈存取的屬性,那 js 會不斷往上查找,直到 null object.__proto__.__proto__...

請看下圖

可以看到在 every is object 的 js 世界,連 Function 也是繼承自 Object

诶? 那 Object 跟 Funtion 差在哪?

對,function 可以執行嘛,還有一點就是 function 可以作為object 的構造函數

function 可以作為 object 的構造函數具體實現就是每個 function 都有自己的 prototype,當 function 作為普通函數執行時,是不會用到 prototype 的,但當 function 作為構造函數,實例化出來的 object 就會繼承自 function 的 prototype

因此實例化的 object 只會有 __proto__ 屬性而沒有 prototype,prototype 是 function 專屬用來當構造函數用的

說到構造函數,就得來介紹一下 new

new

new 是實例化 object 的關鍵字,上方的例子就是將 Person 作為構造函數,回傳實例化的 object

1
var p = new Person('Winnie')

那 new 到底做了什麼?

1
2
3
4
5
6
7
8
function _new(fn, ...arg) {
// step 1
const obj = Object.create(fn.prototype);
// step 2
const ret = fn.apply(obj, arg);
// step 3
return ret instanceof Object ? ret : obj;
}

step 1: 創建空物件並繼承構造函數的 prototype

1
const obj = Object.create(fn.prototype);

首先創建一个空的對象,空對象的 __proto__ 指向構造函数(fn)的 prototype

Object.create 事實上做的事就是創建一個 object,讓該 object 繼承自傳進來的 object

1
2
3
4
5
6
// 模擬 Object.create,真正的 polyfill 請去 MDN 看
Object._create = (proto) => {
const obj = {};
obj.__proto__ = proto
return obj;
}

注意直接存取 __proto__ 會影響到 JavaScript 的執行效能,我們不應該在 production 的環境上使用(source)

於是帶進去得出 object.__proto__ = fn.prototype,這可以幹嘛呢,可以用 fn prototype 上的方法阿,以上面的例子來說就是 sayName

step 2: 把空對象賦值構造函數内部的 this

1
const ret = fn.apply(obj, arg);

apply() 就是去執行這個 function,並將這個 function 的 this 指向第一個參數的 object

上面的 code 等於是執行 fn,並將 fn 中的 this 指向為 obj,而這個 fn,也就是構造函數執行後會發生什麼?

1
2
3
4
5
6
7
8
9
10
// 在這例子中 fn 其實就是 Person
function Person (name) {
// this 已指向 obj
this.name = name // obj.name = name
}
// 把 apply 看成執行函數,由於構造函數通常不會 return,因此 res 為 undefined
const res = Person();

// 而此時 obj 的屬性已改變
obj.name = "傳入的參數"

step 3: 反回剛創建的新物件

1
return ret instanceof Object ? ret : obj;

最後判斷構造函數(fn) 是否有 return 物件,沒有的話返回剛創建的新物件,但若是在構造函數中 return 了,那剛剛創建的 obect,繼承好的 prototype 則會通通不見喔(function 執行結束會垃圾回收掉)

也就是 return 的物件是沒有 prototype 的方法,this.[property] 出來的屬性也通通沒有!

所以說構造函數別亂返回物件,es6 的 class 已禁止 return,否則會報錯

this

我們來看看 MDN 的解釋: A property of an execution context,既然跟 execution context 有關,那表示跟執行環境有關,也就是 this 會根據 function 如何被呼叫而所不同

當一個 function 執行前的初始化階段(fully parse AST、創建FEC 等等),就會決定好該 EC 的 this 值,接下來就介紹會有什麼情況導致 this 的不同

1. 預設綁定 (Default Binding):

在 non-strict mode 中,當一個 FEC 被創建時,默認 this 指向 window(GEC),在 strict mode 中,則為 undefined

GEC 的 this 則指向自己(GEC.this = GEC)

2. 隱含式綁定 (Implicit Binding)

(source)

1
2
3
4
5
6
7
8
9
10
11
function func() {
console.log( this.a );
}

var obj = {
a: 2,
foo: func
};

func(); // undefined
obj.foo(); // 2

如果 function 做為某個物件屬性的參考呼叫時,那 this 就會指向該物件

1
2
3
4
5
6
7
8
9
10
11
function func() {
console.log( this.a );
}

var obj = {
a: 2,
foo: func
};

var func2 = obj.foo;
func2(); // undefined

怎麼將 obj.foo 獨立出來 this 就輸出 undefined?

因為 func2 指向 func,且呼叫時沒有作為物件的屬性呼叫,此時 this 為預設綁定指向 window,全域沒 a 屬性因此輸出 undefined

3. 顯式綁定 (Explicit Binding)

bind、call、apply 三兄弟,都可以指定 this 對象

  • fn.bind(obj, arg1, arg2,…): 回傳綁定 this 為的函數,並不會真的執行 fn,只是將回傳函數的 this 指定為 obj,注意一但被綁定,this 無法再被修改
  • fn.call(obj, arg1, arg2,…): 執行 fn,將 this 指定為 obj
  • fn.apply(obj, [arg1, args,…]): 執行 fn,將 this 指定為 obj,第二個參數必須為陣列

4. 「new」關鍵字綁定

1
2
3
4
5
6
function foo(a) {
this.a = a;
}

var obj = new foo( 123 );
console.log( obj.a ); // 123

請看上方解釋,new 後的 function 會做為構造函數,將新建的空物件透過 apply 或 call 指定為 this 去執行構造函數,最後回傳該物件

es5 繼承

懂了上述介紹的 new 與 prototype 與 this,我們可以進一步實現繼承

屬性繼承

1
2
3
4
5
6
7
8
function Person (name, age) {
this.name = name
this.age = age
}

Person.prototype.getName = function () {
console.log(this.name)
}

此時如果要產生一個構造函數 Men 可以繼承 Person 的屬性,且擁有自己的屬性的話,該怎麼做?

在 new 的部分有解釋過了,通過在一個構造函數内執行另外一個構造函數,可以讓該類得到另一個構造函數的所有屬性

1
2
3
4
5
6
7
function Man (name, age, gender) {
Person.call(this, name, age)
this.gender = gender;
}

const martin = new Man("Martin", 20, "male")
console.log(martin) // {name: "jack", age: 20, gender: "male"}

那現在用 martin 呼叫 getName 是會報錯的,因為我們只將 Person 的屬性套用到 Men 上,沒有繼承到 Person 的方法

接著就來介紹方法繼承

方法繼承

通常一個構造函數的方法都會定義在 prototype 中,目的是為了共享方法,若定義在構造函數內,那每次用 apply 執行構造函數來繼承屬性時,都會創造一個新的函數,造成資源的浪費

1
2
3
4
5
6
7
8
9
10
11
12
function Person (name, age) {
this.name = name
this.age = age
this.getName = function () {
console.log(this.name)
}
}

const p1 = new Person("p1", 10)
const p2 = new Person("p2", 10)

console.log(p1.getName==p2.getName) // false

那如何繼承 prototype 上的方法呢?

1
2
3
4
// step 1
Man.prototype = Object.create(Person.prototype)
// step 2
Man.prototype.constructor = Man

step 1

1
Man.prototype = Object.create(Person.prototype)

既然我們都把方法定義在 prototype 中,那只要讓 Man 的 prototype 繼承自 Person 的 prototype 就可以啦

這個步驟其實就是將 Man 原本的 prototype 指向為新的 object,而這個 object 的 __proto__ 指向Person.prototype

還記得 Object.create 嗎? 來複習一下

1
2
const obj = {};
obj.__proto__ = proto

step 2

1
Man.prototype.constructor = Man

每個 prototype 內都有個屬性 constructor 指向構造函數,那現在改掉 Man 的 prototype 後就需要將 contructor 指回原本的構造函數,免得在類型判斷時出錯

最終 es5 繼承方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Person (name, age) {
this.name = name
this.age = age
}

Person.prototype.getName = function () {
console.log(this.name)
}
function Man (name, age, gender) {
Person.call(this, name, age)
this.gender = gender;
}
Man.prototype = Object.create(Person.prototype)
Man.prototype.constructor = Man

// 注意如果 Man 的 prototype 有方法,需在繼承完 Person 之後,避免被覆蓋掉