JavaScript: Object-oriented JavaScript, Prototype Chain & This


Posted by Nicolakacha on 2020-10-01

初探物件導向

物件導向(Object-oriented)的意思是:採用物件(objects)來模塑真實的實物世界,每個物件所包含的是塑造某個模型的資訊,以及這個物件可以使用的方法(methods),換成程式碼的語境說,就是資料與程式碼。

舉例來說,一隻狗可能會有形狀、叫聲等資料,並且有跑、跳、坐下等方法,透這樣就可以建構出來一個狗的範本,並透過這個範本來建立不同的狗的物件。

這就是許多程式語言中的 class,雖然 JavaScript 在設計上並沒有 class,但可以透過原型(prototype)來實現這種 class 的範本和繼承,然而從 ES6 開始,JavaScript 中也可以用 class 了,這個語法糖的底層還是原型(prototype)。透過 class,可以建立物件類別的範本,並使用 new 關鍵字來 new 出不同的 instance 物件實體。

constructor & new

下列程式碼中,先建立 Dog 這個 class,class 中的 constructor 屬性內,是這個 class 被 new 出來時會自動執行的程式碼。

此外,當一個 instance 物件被 new 出來的時候,class 中的 this 所指向的會被 new 出來的 instance 物件:

class Dog {
    constructor(name) {
        this.name = name;
        console.log(this);
        console.log(this.name);
    }
}

var a = new Dog('abc');
var b = new Dog('zzz');

// Dog Dog {name: "abc"}
// abc
// Dog {name: "zzz"}
// zzz

new 出 a 和 b 兩個物件,這兩個物件會是完全獨立的物件,但都事先具備 Dog 這個 class 的所有屬性:

class Dog {
    constructor(name) {
        this.name = name;
    }
    getName() {
        return this.name;
    }
    sayHello() {
        console.log('hello');
    }
}

var a = new Dog('abc');
console.log(a.name) // abc
a.sayHello(); // hello

var b = new Dog('zzz');
console.log(b.name) // zzz
b.sayHello(); // hello

Prototype Chain 原型鏈

就像一開始說的,class 其實是 ES6 提供的語法糖,底層的機制是 JavaScript 的原型(prototype),在不使用 class 的情況下,一樣可以用 function 和 prototype 來做到 class 類別的功能。

下面例子中,使用 Dog.prototype.sayHello 將建構 function 新增一個叫做 sayHello 的屬性,new 出來的物件都可以取用到 sayHello 這個屬性:

function Dog(name) {
    this.name = name;
}

Dog.prototype.getName = function () {
    return this.name;
}

Dog.prototype.sayHello = function () {
    console.log('say hello!');
}

var a = new Dog('abc');
var b = new Dog('zzz');
console.log(a.sayHello === b.sayHello);

__proto__ & prototype

new 出來的物件的 __proto__ 屬性,會指向其原型物件的 prototype,以下面的例子來說:

function Dog(name) {
    this.name = name;
}

Dog.prototype.getName = function () {
    return this.name;
}

Dog.prototype.sayHello = function () {
    console.log('dog', this.name);
}

Object.prototype.sayHello = function () {
    console.log('object', this.name);
}

var d = new Dog('abc');
d.sayHello()

當我們執行 d.sayHello() 的時候,會沿著原型鏈尋找 sayHello 屬性,參考過程會是這樣的:

  1. d 有沒有 sayHello
  2. d.__proto__ (Dog.prototype)有沒有 sayHello
  3. d.__proto__.__proto__ (Object.prototype) 有沒有 sayHello
  4. d.__proto__.__proto__.__proto__ (null)有沒有 sayHello

上面這種透過 proto 不斷串起來的鏈,就是原型鏈(Prototype Chain)。透過這條原型鏈,就可以引用或說繼承自己物件沒有的屬性。

hasOwnProperty()

想知道一個屬性是存在於某個 new 出來的 instance 物件上,還是存在於它屬於的原型鏈中,可以使用 hasOwnProperty() 這個方法:

console.log(d.__proto__.hasOwnProperty('sayHello'));
// true
console.log(d.hasOwnProperty('sayHello'));
// false

instanceof

若要判斷某 A 物件是不是 B 的 instance ,可以使用 instanceof:

console.log( d instanceof Dog);
// true

每個 prototype 都有一個 constructor 屬性,會指向建構函式,以上面的例子來說,Dog.prototype.constructor 會指向 Dog。

console.log(Dog.prototype.constructor);
// ƒ Dog(name) { this.name = name;}

new 背後在做的事

將變數 a 賦值為 new func ,實際上是宣告一個 function ,並在 function 內做以下幾件事:

  1. 建立一個變數 x
  2. 用 .call 來呼叫被宣告的 function ,並把 this 強制設定成 x,如果有參數也傳進去
  3. 把 a 的 __proto__ 屬性指向 func.prototype
  4. 回傳 x

以下面的例子來說,可以寫成這樣:

function Dog(name) {
    this.name = name;
}

Dog.prototype.getName = function () {
    return this.name;
}

Dog.prototype.sayHello = function () {
    console.log('dog', this.name);
}

Object.prototype.sayHello = function () {
    console.log('object', this.name);
}

// 用 new 方法
var d = new Dog('abc');

// 自己實作 new 方法
function newDog(name) {
    var obj = {};
    Dog.call(obj, name);
    obj.__proto__ = Dog.prototype;
    return obj;
}

// dog abc
d.sayHello();

Inheritance 繼承

需要用到共同屬型時,不需要重新建立 class 的各種屬性,可以利用繼承的方法,來存取父層的屬型,例如黑狗也屬於一種狗,可以建立一個狗的 class,狗裡面有基本的屬型有跑、跳、睡覺覺等,而黑狗 class 只需要繼承狗 class,就預設帶有這些屬性了。

使用 extend 關鍵字來繼承 class

class Dog {
    constructor(name) {
        this.name = name;
    }
    sayHello() {
        console.log(this.name);
    }
}

class BlackDog extends Dog {
    test() {
        console.log('test!', this.name);
    }
}

const d = new BlackDog('hello');
d.sayHello(); //hello

子層的 class 在改動自身 constructor 屬性的時候,一定要先呼叫 super(),並把父層 class constructor 需要的參數傳進去,否則父層 class 的某個屬性是需要用到初始化完的父層 constructor 的,就會壞掉惹:

class Dog {
    constructor(name) {
        this.name = name;
    }
    sayHello() {
        console.log(this.name);
    }
}

class BlackDog extends Dog {
    constructor(name) {
        super(name);
        this.sayHello();
    }
    test() {
        console.log('test!', this.name);
    }
}

const d = new BlackDog('hello');
//hello

What is this?

最開始介紹物件導向和 class 的時候有提到,當一個 instance 物件被 new 出來時,class 中的 this 所指向的,會是被 new 出來的 instance 物件本身,因為想要在 constructor 內設定 name 屬性,就一定需要這個 this,不然就無法設定屬性。

如果不在我們宣告的物件裡面,this 代表的會是什麼呢?讓我們一起來看看吧。

以下的範例都是預設在瀏覽器下的非嚴格模式

Global context 下的 this

在全域環境下,裡面的 this 的預設值可能會是 global 或 window(視執行環境視瀏覽器或 node.js 而定),而在嚴格模式下,this 的預設值是 undefined:

console.log(this === window); // true
a = 37;
console.log(window.a); // 37

「預設值」這種說法不是很直覺,我們試著用物件導向的觀點來理解,在全域宣告 test function,其實就是在全域 window 物件內建立的一個 test 屬性,指向 function test,這個屬性指向一個 test function:

function test() {
    console.log('test', this)
}
// 上面的寫法意思和下面一樣

window.test = function test() {
    console.log(this);
}

console.log(window.test) 
// 可以得到 test() {console.log('test', this);}
// 發現原來 test 是 window 下面的一個屬性

發現原來 test 是 window 下面的一個屬性

Function context 下的 this

在 function 裡面,this 的值是什麼取決於該 function 是怎麼被呼叫的,也就是說,function 沒有被呼叫的時候,它的預設綁定就是 window, global 或 undefined,一旦某個 function 被呼叫的時候,裡面的 this 其實可以理解成,是指誰在 call 這個 function:

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

obj.foo() 
// 原來是 obj 在 call,obj 就是 this
bar() 
// 沒人 call call 我,window 就是 this

上面 bar() 這種呼叫函式的方法,其實是 .call() 的語法糖,要更詳細了解呼叫 function 背後在做的事,就要深入理解 .call().apply()bind() 在幹嘛。

.call & .apply

當我們在呼叫 hello 這個函式的時候,其實可以寫成下面三種方法,.call 和 .apply 的第一個參數就是設定 this,之後的參數是帶入 function 的 arguments。.call 和 .aplly 的差別在於,第二個參數是以陣列的方式傳入:

function hello(a, b){
  console.log(this, a, b)
}

hello(1, 2) // window 1 2
hello.call(undefined, 1, 2) // window 1 2
hello.apply(undefined, [1, 2]) // window 1 2

透過 .call 和 .apply 的第一個參數,我們就可以設定 function 中 this 的值,但 this 會把所有的型別都轉成物件,不論字串、數字都一樣:

function hello(a, b){
  console.log(this, a, b)
}

hello(1, 2) // window 1 2
hello.call('hello', 1, 2) // String {"hello"} 1 2
hello.apply('nicolas', [1, 2]) // String {"nicolas"} 1 2

我們可以把一般呼叫 function 的方式都轉成 call 的形式,在呼叫的 function 之前的東西,就是我們後面指定 this 的值:

const obj = {
  value: 1,
  hello: function() {
    console.log(this.value)
  }
}

obj.hello() // 1
obj.hello.call(obj) // 轉成 call

透過這種拆解呼叫 function 的方法,我們就可以找出在大部分情況下的 this 了:

function hello() {
  console.log(this)
}

var a = { 
    value: 1, hello 
}

var b = { 
    value: 2, hello 
}

hello() // window
a.hello() // a { value: 1, hello}
b.hello.call(a) // a { value: 1, hello}

bind()

除了 .call 和 .apply 外,我們還可以利用 bind() 來強制綁定 this 的值,但一旦綁定之後就不能再用別的方法更改了

const obj = {
    a: 123,
    test: function() {
        console.log(this);
    }
}

const bindTest = obj.test.bind(obj);
bindTest() 
// 強制綁定 this 為 obj
// { a: 123, test: function}

除了 function 和物件之外,我們還有可能在什麼地方看到 this 呢?

事件監聽的 this

對某 DOM 元素進行事件監聽時,handler function 的 this 是觸發這個 function 的 DOM 元素,也就是 e.currentTarget,想成是誰在觸發這個 function 就比較好理解了:

If attaching a handler function to an element using addEventListener(), the value of this inside the handler is a reference to the element. — MDN

document.querySelector('.btn').addEventListener('click', function(){
    console.log(this); // 觸發這個 function 的物件
})

Arrow Function 中的 this

arrow function 本身其實沒有 this,如果在 arrow function 內看到 this,它其實和 arrow function 外部的 this 是同一個東西,

下面的例子中,this 是在 instance 被建立時被被定義成所屬的物件 test(),所以 arrow function 的 this 和 console.log('run this:', this) 的 this 是同一個,也會是 test():

class Test {
    run() {
        console.log('run this:', this);
        setTimeout(() => {
            console.log(this); // this 已經在上面被定義成 Test {}
        }, 100);
    };
};

const t = new Test();
t.run();
var x = 10
var obj = {
  x: 20,
  fn: function() {
    var test = function() {
      console.log(this.x)
    }
    test()
  }
}

obj.fn()

// 10

總結:this 的四種綁定方式

預設綁定(Default Binding)

在全域下的變數和宣告的 function,可以看做全域物件的屬性,當物件被呼叫的當下如果沒有指定 this 的值,this 會自動指定為全域物件。

function abc() {
    console.log(this)
}
abc() // window

隱含綁定(Implicit Binding)

若該 function 有被某物件指定為屬性並呼叫,this 就是呼叫 function 的物件。

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

var obj = {
    x = 2;
    foo: func;
}

obj.foo(); 
// obj.foo.call(obj)  //2

顯式綁定(Explicit Binding)

透過 .call()、.apply() 或 .bind() 方法來手動指定的值。

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

func.bind('123')(); // String {"123"}
func.call('aaa'); // String {"aaa"}
func.apply('zzz'); //  String {"zzz"}

new 的綁定

new 出來的 this 應該是我們最熟悉的了,下面這個例子中, this 就是 instance 物件 {name: "Nicolas"}

class Person {
    constructor(name) {
        this.name = name;
        console.log(this);
    }
}

var a = new Person('Nicolas');
// {name: "Nicolas"}
var b = new Person('Matt');
// {name: "Matt"}

#this #prototype #javascript #物件導向 #原型鏈







Related Posts

部署 Node.js app 在 AWS EC2(Nginx + MySQL)

部署 Node.js app 在 AWS EC2(Nginx + MySQL)

[JavaScript] JavaScript 入門

[JavaScript] JavaScript 入門

[第六週] HTML 常用標籤 (下)

[第六週] HTML 常用標籤 (下)


Comments