this

竹白記事本,學習紀錄。

tags: JavaScript 竹白記事本 函式 this

JavaScript 中的 this 關鍵字是最令人困惑的機制之一,真正掌握 this 的用法才是算是真正跨過 JS 的門檻。

this 的誤解

this 有兩個因為過度解讀字面本身的意義,而造成的誤解。

以下兩者皆為錯誤解讀

  1. 自身(Itself)
  2. 其作用域(It's Scope)

1. 自身

第一個常見的誤解是 this 參考到函式本身(the fuction itself)。

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

foo();  // ?

這個 this 的值會是什麼?

答案會是全域物件(瀏覽器下是 Window 物件、node.js 底下是 Global 物件)。

這證明了 this 並不會指向函式本身。

2. 其作用域

另一個常見的誤解是 this 以某種方式參考了函式自身作用域。

考慮一段錯誤示範的程式碼:

這段程式碼嘗試跨作用域,並使用 this 隱含地參考一個函式的語彙作用域。想利用 thisfoo()bar() 的語彙作用建立一個通道,讓 bar() 能夠取用在 foo() 內層作用域的變數 a

function foo() {
  var a = 2;
  this.bar();
}

function bar() {
  console.log( this.a );
}

foo();   // ?

這個 this 的值會是什麼?

結果當然不可能會成功,會回傳 undefined

this 的指向

說了那麼多,那麼 this 到底是什麼?

首先來看看 ECMAScript 標準規範對 this 的定義:

this 關鍵字代表的值為目前執行環境的 ThisBinding.

MDN 對 this 的定義:

在大多數情況,this 會因為函式被呼叫的方式而有所不同。

直接說結論:

  • this 是 JavaScript 的關鍵字之一。
  • this 是函式執行時,自動生成的物件。
  • this 的值與函式是在何處被宣告無關。
  • this 的值完全取決於該函式的被呼叫的方式。
  • 在大多數情況下,this 代表的就是呼叫函式的物件。

1. 一般的函式呼叫

直接呼叫函式的情況下,this 會指向全域物件。

一個簡單的範例:

var name = 'GlobalName';

function foo() {
  var name = 'Chupai';
  console.log(this.name); 
}

foo();  // "GlobalName"

放到立即函式 IIFE ,直接在函式內直接在呼叫另一個函式:

var name = 'GlobalName';

(function() {
  function foo() {
    var name = 'Chupai';
    console.log(this.name);
  }
  foo(); // "GlobalName"
})();

結果是一樣的。

無論我們把函式宣告放在立即函式內與外,結果還是一樣。

var name = 'GlobalName';

function foo() {
  var name = 'Chupai';
  console.log(this.name);
}

(function() {
  foo(); // "GlobalName"
})();

閉包 Closure:

var name = 'GlobalName';

function foo() {
  var name = 'Chupai';
  return function() {
    console.log(this.name);
  };
}

var myFoo = foo();

myFoo(); // "GlobalName"

這樣結果依然相同,並不會因為獨立的作用域改變造成 this 的不同。

回呼函式 Callback function:

var name = 'GlobalName';

function foo() {
  var name = 'Chupai';
  
  function boo() {
    console.log(this.name);
  }
  
  boo();
}

foo();  // "GlobalName"

無論在哪一層, 一般的函式呼叫 this 都會指向全域物件。

為什麼 this 會指向全域物件,關於這部分會在下方 嚴格模式 說明。

2. 物件的方法呼叫

this 會指向最後呼叫它的物件。

一個簡單的範例:

var name = 'GlobalName';

var obj = {
  name: 'Chupai',
  foo: function() {
    console.log(this.name);
  },
};

obj.foo(); // "Chupai"

obj.fooobj 的方法,因此 foo 內的 this 會指向 obj 物件。

稍微改變一下程式碼:

var name = 'GlobalName';

function foo() {
  console.log(this.name); 
}

var obj = {
  name: 'Chupai',
  foo: foo
};

foo();      // "GlobalName"
obj.foo();  // "Chupai"

函式宣告的位置不重要,重要的是呼叫的方法,obj.foo 內的 this 還是會指向 obj 物件。

繼續看下個範例:

var name = 'GlobalName';

function foo() {
  console.log(this.name); 
}

var obj = {
  name: 'Chupai',
  boo: {
    name: 'Wang',
    foo: foo
  }
};

obj.boo.foo(); // "Wang"

this 會指向最後呼叫它的那個物件,最後呼叫的物件為 obj 物件內的 boo 物件,所以會指向它。

3. 間接參考

接下來看一個容易搞錯的範例,如果將物件內的函式,賦予在一個變數上,並呼叫它:

var name = 'GlobalName';

function foo() {
  console.log(this.name);
}

var obj = {
  name: 'Chupai',
  foo: foo,
};

var callThisName = obj.foo;

callThisName(); // "GlobalName"

obj.foo 賦值給變數 callThisName 時,foo 並沒有被呼叫,也就是函式只是另一個函式的參考。因此 callThisName() 就只是一般的函式呼叫。

而當作參數傳遞中的回呼函式,也屬於間接參考:

var name = 'GlobalName';

function foo() {
  console.log(this.name);
}

var obj = {
  name: 'Chupai',
  foo: foo,
};


function boo(fn) {
  fn();
}

boo(obj.foo); // "GlobalName"

參數傳遞只是一種隱含的指定,boo(obj.foo) 的參數 fn 依舊是 obj.foo 的參考,結果如同上一段程式碼。

如果回呼函式給它的那個函式不是本身,而是內建的,結果一樣。

var name = 'GlobalName';

var obj = {
  name: 2,
  foo: foo,
};

function foo() {
  console.log(this.name);
}

setTimeout(obj.foo, 100); // "GlobalName"

事件監聽的 this

DOM 搭配事件監聽 addEventListener 時,監聽函式中的 this 會指向的則是該 DOM 物件。

var dom = document.querySelector('body');

dom.addEventListener('click', function() {
  console.log(this);  // <body>...</body>
});

改變 this 的指向

會改變 this 的指向的情況:

  • 使用 applycallbind 方法
  • new 建構一個物件實體
  • 使用 ES6 的箭頭函式

1.applycallbind 方法

在 JavaScript 有三個可以強制指定 this 的方式,分別是 applycallbind 方法。

1.1 applycall 方法

call()apply() 是能呼叫函式的方法,並且能指定 this 值:

var obj = {};

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

foo();          // "Window{}"
foo.call(obj);  // Object{}
foo.apply(obj); // Object{}

兩者第一個參數都是 this 值,也就是要綁定的物件。

而兩者差異只在於後面的參數:

var obj = {};

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

foo.call(obj, 1, 2);    // Object{} 1 2
foo.apply(obj, [1, 2]); // Object{} 1 2
  • call() 跟平常呼叫函式一樣
  • apply() 需要使用陣列將引數包起來

應用 call()apply() 解決前面所提到的函式間接參考問題:

function foo() {
  console.log(this.name);
}

var obj = {
  name: 2,
};

var boo = function() {
  foo.call(obj);
};

boo(); // 2
setTimeout(boo, 100); // 2
boo.call(window);     // 2

建立 boo 函式 在內部手動呼叫 foo.call( obj ),藉此強制以 obj 作為 this 的綁定來呼叫 foo。不管如何呼叫 boo 函式都會以 obj 手動調用 foo

此模式被稱為硬綁定(hard binding) 指的是綁定既明確又不會意外變回預設的綁定。

1.2 bind()

因為硬綁定模式如此常用,ES5 新增了一個方法,將此模式包裝了起來,bind() 會回傳一個新的函式,當被呼叫時,將提供的值設為 this 值。

這範例與上段程式碼一模一樣:

function foo() {
  console.log(this.name);
}

var obj = {
  name: 2,
};

var boo = foo.bind(obj);

boo(); // 2
setTimeout(boo, 100); // 2
boo.call(window);     // 2

後面的參數平常呼叫函式一樣:

var obj = {};

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

const myFoo = foo.bind(obj, 1, 2);

myFoo(); // Object{} 1 2

如果使用 name 屬性查看 bind() 所創建的函式,將會在函式的名稱前加上 "bound "

var obj = {};

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

const myFoo = foo.bind(obj);

console.log(myFoo.name);  // "bound foo"

2. new 建構一個物件實體

這裡必須注意,JavaScript 的 new 運算子看似與其他類別導向語言相同,但實際上跟類別導向的功能性並沒有關聯。對 JavaScript 的 new 來說,它並不會連接到類別上,也不會實體化一個類別。

當一個函式前面帶有 new 被呼叫時,會發生以下事情:

  • 會有一個無中生有的全新物件被建構出來
  • 新建構的物件會帶有 [[ prototype ]] 連結
  • 新建構的物件會被設為那個函式的呼叫的 this 繫結
  • 除非該函式回傳自己提供的替代物件,否則以這個 new 調用的函式呼叫會自動回傳這個新建構物件。
function Foo(a) {
  this.a = a;
}

var bar = new Foo(2);
console.log(bar.a); // 2

呼叫 foo() 時,前面加了 new,所以建構出一個新物件,foo() 沒有回傳值,所以建立,使新的物件 bar 被設為 foo() 呼叫的 this

此部分只要了解建構式的 this 是指向物件本身即可。

關於更多《建構式》會在這裡說明。

3. 箭頭函式

ES6 新增的箭頭函式,它本身並沒有 this,它會在定義時記住 this 值,也就在宣告它的地方的 this 是什麼,它的 this 就是什麼。

傳統函式的 this 是依呼叫的方法而定,因此當你的函式有好幾層時,會遇到一個問題:

function foo() {
  function boo() {
    console.log(this.a);
  }
  boo();
}

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

obj.foo();  // undefined

在函式內的函式會指向全域物件,所以找不到屬性 a

在 ES6 前,解決辦法是利用一個變數儲存 this 的值(常見命名 _thisthatvmself)。

function foo() {
  var _this = this;
  function boo() {
    console.log(_this.a);
  }
  boo();
}

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

obj.foo();  // 2

接下來看 ES6 新增的箭頭函式,它的 this 始終指向函式定義時的 this,而非執行時。

function foo() {
  var _this = this;
  var boo = ()=> {
    console.log(this.a);
  }
  boo();
}

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

obj.foo();  // 2

遇到回呼函式也是一樣:

function foo() {
  setTimeout(function() {
    console.log(this.a);
  }, 1000);
}

let obj = {
  a: 2,
  foo: foo,
};

obj.foo();  // undefined 
function foo() {
  setTimeout(() => {
    console.log(this.a);
  }, 1000);
}

let obj = {
  a: 2,
  foo: foo,
};

obj.foo();  // 2

3.1 不可使用的情況

箭頭函式中 this 是被綁定的,所以套用 applycallbind 的方法時是無法修改 this

var name = 'GlobalName';

var obj = {
  name: 'Chupai',
};

const foo = () => {
  console.log(this.name);
};

foo.call(obj); // "GlobalName"
foo.apply(obj); // "GlobalName"

var myFoo = foo.bind(obj);
myFoo(); // "GlobalName"

箭頭函式也不能用在建構式,會拋出錯誤。

const Foo = (a, b) => {
  this.a = a;
  this.b = b;
};

const myFoo = new Foo(1, 2); // Uncaught TypeError: Foo is not a constructor

用在監聽 DOM 上一樣會指向全域物件,因為 this 是指向所建立的物件上。

var e = document.querySelector('body');

var changeDOM = () => {
  console.log(this);  // Window{}
};

e.addEventListener('click', changeDOM);

嚴格模式

ES5 之後,新增了嚴格模式,在嚴格模式下,一般函式呼叫的 this 值都是 undefined

'use strict';
function foo() {
  console.log(this);
}

foo(); // undefined

undefined 與全域物件有什麼關係?

先來一段程式碼:

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

foo.call(undefined); // Window{}
foo.call(null); // Window{}

我們使用 call()this 值設為 undefined,結果卻回傳全域物件。

這是因為 JavaScript 的機制,當 this 值為 undefinednull 時,會將 this 值強制轉換為一個物件。

在嚴格模式下,刻意將 undefinednull 設為 this 值,會回傳正確的 this 值。

'use strict';
function foo() {
  console.log(this);
}

foo.call(undefined); // undefined
foo.call(null); // null

這就是為什麼一般函式呼叫會回傳全域物件的原因。

有的書會用「this 永遠指向最後呼叫它的那個物件」來解釋下面這段程式碼:

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

foo(); // Window{}
window.foo(); // Window{}

因為 foo() 等同 window.foo(),最後呼叫它的物件是全域物件,所以 this 指向全域物件。但這其實是不太正確的說法,this 值主要還是以函式的呼叫方式為主,foo()this 值會是 undefined,會得到全域物件是因為被強制給值了。

讓我們加上嚴格模式:

'use strict';
function foo() {
  console.log(this);
}

foo(); // undefined
window.foo(); // Window{}

window.foo() 的值還是指向全域物件,因為它是方法呼叫。

總結

1. 函式的四種呼叫方式

  • 作為函式 func(),函式的一般呼叫形式
  • 作為方法 obj.func(),將函式綁定到物件上,以提供物件導向的程式設計方式
  • 作為建構式 new Func(),用來創建新物件
  • 經由函式的 apply()call() 方法呼叫

2. 呼叫函式的方式會影響 this 的值

  • 直接作為函式來呼叫,通常 this 的值為全域物件,嚴格模式下為 undefined
  • 函式作為方法來呼叫,this 的值為被呼叫函式的所屬物件
  • 函式作為建構式來呼叫,this 的值為新建立的物件
  • 藉由 apply()call() 呼叫,this 的值由第一個參數決定

3. 箭頭函式

箭頭函式沒有自己的 this 的值,由建立時取得它。

4. bind()

所有函式都具備 bind() 來建立一個新函式,此函式會綁定傳入的引數,除此之外,綁定的函式運作如原始的函式。

5. 嚴格模式

this 值為 undefinednull 會被強制轉成全域物件,而嚴格模式下,將不會強制轉值。