快速理解 This 與箭頭函式

Kion
16 min readJul 30, 2020

--

https://www.piqsels.com/en/public-domain-photo-szkgh

目次

1. 快速理解 This 指向位置
2. 綁定 This
3. 箭頭函式與 This

在 JavaScript 裡除了閉包, This 也是前幾個難懂的概念
雖然講解 This 的文章很多,有各種例子、各種解釋的切入點
但多數文章我都看得一頭霧水
今天我就特別選一種我覺得最好懂的方式講解 This,也順便帶到 ES6 的箭頭函式

什麼是 This

當 JavaScript 代碼執行一段可執行代碼 (executable code) 時,會創建對應的執行環境 (execution context)

對於每個執行環境,都有三個重要屬性

  • 變量對象 (Variable object,VO)
  • 作用域鏈 (Scope chain)
  • This

如果對 VO 和 Scope chain 感到陌生,可以閱讀這篇文章

this 是 函數執行時 生成的內部物件,就像 VO 的概念
隨著函數被調用環境的不同,this 指向的值也會不同

眾所周知 JS 的作用域是詞法作用域
詞法作用域意即程式 正要執行前 就已被決定的作用範圍
但 this 他是 在函數執行時,依照調用環境生成的物件
因此他更像是動態作用域的概念
大部分的情況下,他指向的是函數被調用的調用點,也就是呼叫函數的物件,並非函數本身

常用的用法就像是以下的例子:

var getGender = function(){   
return this.gender;
};
var people1 = {
gender: 'female',
getGender: getGender // 調用點
};
var people2 = {
gender: 'male',
getGender: getGender // 調用點
};
console.log( people1.getGender() ); // 'female'
console.log( people2.getGender() ); // 'male'

乍看之下沒有特別的感覺,但今天換一個例子就稍嫌複雜了

var foo = function() {
this.count++;
};

foo.count = 0;

for( var i = 0; i < 5; i++ ) {
foo();
}

感覺 foo.count 應該要是 5 ,結果答案卻是 0
這就是 this 讓人搞不懂的地方

搞不懂 this 除了在寫 code 的過程不懂自己在幹嘛
也會影響 debug 速度
看來快速理解 this 勢在必行 = =!

快速理解 This 指向的位置

在學習快速理解之前,我們要先理解為什麼會有 this

this 主要用於物件導向
在物件導向中,this 單純就是指向 實例本身

class Car {
setName(name) {
this.name = name
}
getName() {
return this.name
}
}

const myCar = new Car() myCar.setName('hello') console.log(myCar.getName()) // hello

他的概念很單純,本應該是易於理解的概念才是
但今天的問題是在 JavaScript 裡面,在任何地方都可以存取到 this
所以在 JS 的 this 跟其他語言慣用的 this 有了差異,這就是為什麼 this 難懂的原因

了解 this 真正的使用方式後,我們先從 ECMAScript 規範的角度了解 JS 世界無所不在的 this
他的概念其實不會很複雜,不要被規範兩個字嚇到了

在 ECMAScript 規範中有兩種類型:

  1. 語言類型
    也就是我們熟知的 Undefined, Null, Boolean, String, Number, 和 Object
  2. 規範類型
    用來描述 ECMAScript 語言結構和 ECMAScript 語言類型,他有很多種,我們今天主要談的是:Reference

今天我們就要用 Reference 講解 this 指向的位置!

何謂 Reference

Reference 是什麼?有這種型別嗎?!

Reference 其實可以說是一個抽象的概念,不存在程式碼之中
他是 用來解釋語言底層行為邏輯而生的

Reference 由以下三點組成:

  1. base value
    屬性所在的 Object 或是 EnvironmentRecord
  2. referenced name
    屬性名稱
  3. strict reference

舉個例子:

因為 foo 是直接宣告在全域,所以 baseEnvironmentRecord

var foo = 1;

// foo 對應的 Reference 是:
var fooReference = {
base: EnvironmentRecord,
name: 'foo',
strict: false
};

這個例子就不一樣了
我們在全域調用了 foo 物件的 bar 方法, barbasefoo

var foo = {
bar: function () {
return this;
}
};

foo.bar(); // foo

// bar 對應的 Reference 是:
var BarReference = {
base: foo,
propertyName: 'bar',
strict: false
};

從 Reference 判斷 this

其實 this 的值大部分就是 Reference 中 base 指向的位置
可能會有人覺得:知道函數是在全域還是在物件中,就能判斷 this 的值啦
但從上面 count 的例子,就知道事情不是這麼的單純
畢竟 this 與我們熟知的詞法作用域不同,他是依據調用點決定他指向的位置的

var foo = function() {
this.count++;
};

foo.count = 0;

for( var i = 0; i < 5; i++ ) {
foo();
}
console.log(foo.count); // 0

到底為什麼 foo.count 為 0 呢?
以上面的例子來說,Reference 被定義的瞬間是執行 foo() 的時候
foo() 不是任何物件所屬的方法,他在全域

// foo 對應的 Reference 是:var FooReference = {
base: EnvironmentRecord,
propertyName: 'foo',
strict: false
};

因此 this 其實就是指向全域的 count 屬性,但全域並沒有 count
所以當 foo 在執行的時候都是在對 undefined +1
因此 foo.count 為 0

Reference 怎麼定義 this 的值呢?

在規範中 11.2.3 Function Calls 就有解釋 this 值是如何被決定的
我整理了一下他人擷取的流程,大概就是這樣:

  1. 計算 MemberExpression 的結果賦值給 ref
  2. 判斷 ref 是不是一個 Reference 類型
  • 如果 ref 是 Reference, 那麼 this 的值為 base
    但如果 baseEnvironmentRecord ,那麼 this 的值為 window (瀏覽器,也就是指向全域)或 global(Node 環境)或 undefined (嚴格模式)
  • 如果 ref 不是 Reference,那麼 this 的值為 undefined

上述的過程的重點無非就是兩個:MemberExpression 和 Reference

  1. MemberExpression 值判斷
    MemberExpression 就是:表達式,舉凡函數、變量、屬性訪問、創建實例…..皆是
    簡單理解的話,其實就是 () 左邊的部分
    例如: foo.bar() ,MemberExpression 就是 foo.bar
    通常只要不要太奇怪,像是: (foo.bar = foo.bar)() ,牽扯到取值
    他的 MemberExpression 有被處理過,返回的值就不是 Reference 了, this 為 undefined
  2. Reference base 值判斷
    呼叫函式的當下所屬的物件

因此一般情況下,我們可以跳過 MemberExpression 值判斷
直接判斷 Reference 即可!

儘管如此 this 值還是很容易混淆
再看一個例子:

var bar = function() {
console.log( this.a );
};
var foo = function() {
var a = 123;
this.bar();
};
foo(); // undefined

還不熟悉 this 的你或許會誤判 console 出來的會是 123
讓我們來一一拆解!

foo() 在全域被調用,因此他的 Reference 為:

// foo 對應的 Reference 是:var FooReference = {
base: EnvironmentRecord,
propertyName: 'foo',
strict: false
};

因為在瀏覽器環境 this 為 window
this.bar() 也就是指 window.bar()

目前為止,都還可以理解
this.a 為什麼不是 123 呢?

仔細看在 foo() 裡的 this.bar()
由於 this 為 window 我們就先將this.bar() 替換成 window.bar()
如此一來 bar 的 Reference 就應該是:

// bar 對應的 Reference 是:var BarReference = {
base: EnvironmentRecord,
propertyName: 'bar',
strict: false
};

this 仍是 window ,全域環境並沒有 window.a 這個變數,因此回傳 undefined

進階一點,如果我們把例子改一下:

var bar = function() {
console.log( this.a );
};
var foo = function() {
var a = 123;
this.bar();
};
var obj = {
a: 'hi',
fooObj: foo,
bar: bar
}
var fooMethod = obj.fooObj;foo(); // undefined
obj.fooObj(); // 'hi'
fooMethod(); // undefined

這次我們這次我們將 foo 引入 obj
結果就和一開始在全域完全不同了
因為 fooObj()obj 內的 method,因此他的 Reference 是:

// fooObj 對應的 Reference 是:var BarReference = {
base: obj,
propertyName: 'fooObj',
strict: false
};

this 值為 obj,因此 this.bar() 即為 obj.bar()
this.aobj.a 並非上一個例子指向的 window.a

但為什麼 fooMethod()undefined 呢?

我們會對此混淆,是因為被他的值所影響
會認為說,他是 obj.fooObj() ,所以他的 this 理當是 obj 才是
但事實上,在執行 fooMethod() 時,不管他的值為何
他就是一個宣告在全域的變數,他的 base 就是 EnvironmentRecord

// fooMethod 對應的 Reference 是:var fooMethodReference = {
base: EnvironmentRecord,
propertyName: 'fooMethod',
strict: false
};

this 值為 windowthis.bar() 即為 window.bar()
this.awindow.a,因此找不到值

使用 call() 判斷 this

如果從 Reference 判斷還是有點太模糊
我也有看到有另外一種理解的方法是用 call() 的方式

函式在 JavaScript 中,其實是一個可被呼叫(Callable)的 物件
除了可執行、擁有例如 apply()bind()call() 之類的方法外
也擁有一些函式特有的屬性,像是:

  • name:函式的名稱
  • length:可接收參數的數量

function 有三種呼叫方式:

  1. func(p1, p2)
  2. obj.child.method(p1, p2)
  3. func.call(context, p1, p2) // 這裡不談 apply()

其中 call() 的寫法是可以與 1 2 的寫法互換的!

func() // 1
func.call(undefined) // 2
func.call() // 3
// 1 = 2 = 3
// this 為 windows

仔細看一下 call() ,他的結構是這樣子的:

func.call(context, 參數1, 參數2)

其中 this 就是我們 call 一個函數時所傳的 context
因此,將我們慣用的函數寫法轉成 call() 就能知道 this 了
但要怎麼知道 context 的值呢?

直接把 func() 前面的值搬過去就可以了!

也就是說

  1. func(obj) => func.call(undefined,obj)
    this => window
  2. obj.foo() => obj.foo.call(obj)
    this => obj

若傳的 contextnullundefined
window
對象就是默認的 context(嚴格模式下默認 contextundefined

我們再複習一次 this 的要點:

  • this 是函數執行時,生成的內部物件,就像 VO 的概念
  • 隨著函數被調用環境的不同,this 指向的值也會不同
  • 多數的情況下, this 代表的就是呼叫函數的物件

簡而言之,要判斷 this 就是拋開一切直接看函式引用點

  • a() => a 前面沒有東西,他在全域!!!!this 為 undefined
  • obj.a() => a 前面有東西,他是 obj 內的方法!!!this 為 obj
  • b.obj.a() => a 前面有東西,他是 b.obj 內的方法!!!this 為 b.obj

如此一來,我們就能看懂 this、寫對 this 了!

綁定 This

既然我們了解了 this 怎麼運作那又為什麼要操作 this 值呢?
原因是我們可能在某些情況下丟失 this 值,像是:

  1. React
    我們一般會在 React 的 constructor 裡面綁定好 method,否則無法確保 this 是指向我們想要他指向的 instance
    這是 React 原碼的問題導致我們必須綁定他,常規的 this 寫法是會丟失的
  2. 監聽事件
    監聽事件 callback 中的 this 指向的是事件的元素
    但一旦進到 event loop 的 queue 中等待被觸發,他在執行時 this 就已經丟失了,不再是指向預設的事件元素

知道了會有需要操作 this 的情形,那要怎麼綁定 this 呢?

剛剛講到 function 這個物件有三種方法可以調用,分別是:

  1. func.call(context, 參數1, 參數2)
  2. func.apply(context,[參數1, 參數2])
  3. func.bind(context, 參數1, 參數2)

其中, call()apply() 是明確綁定, bind() 是硬綁定

明確綁定

call()apply()第一個參數是 this 指向的值
若是傳入基本類型的值(String、Number、Boolean)則會包裝在他的對象類型中(new String(..)new Boolean(..)

他們的用法一樣,只是參數不同:

  1. call() 傳遞的參數要是基本類型
  2. apply() 傳遞的參數要是 array

那我們先以 call() 舉例他的使用方法:

var bar = function() {
console.log( this.a );
};
var foo = function() {
var a = 123;
this.bar();
};
var obj = {
a: 'hi',
fooObj: foo,
bar: bar
}
// 等於 foo.call(undefined),全域沒有 a 所以 undefined
foo(); // undefined
// 若綁定 this 指向 obj,obj 有 a 所以是 hi
foo.call(obj) // 'hi'

乍看之下 call()apply() 可以解決剛剛講到的問題
但事實上,他只能指定無法解決丟失的問題,還是可能被改變、覆蓋
因此我們的重點會放在 bind() 上!

硬綁定

起初在解決丟失問題時
是透過新增一個 function ,明確綁定 this 值解決

function foo() {
console.log( this.a );
}
var obj = {
a: 2
};
var bar = function() {
foo.call( obj );
};
bar(); // 2
setTimeout( bar, 100 ); // 2

後來因為真的太常使用!因此出現了 bind() 這個語法糖!
bind()call() 感覺用法差不多,第一個參數都是 this 值,後面接參數
但如果還記得上面的歷史故事
他們差別就在於: bind() 會回傳一個函式

function foo(something) {
console.log( this.a, something );
return this.a + something;
}

var obj = {
a: 2
};

var bar = foo.bind( obj );

var b = bar( 3 ); // 2 3
console.log( b ); // 5

bind() 會在函式呼叫前先將 this 綁定至某個物件
使它不管怎麼被呼叫都能有固定的 this
與事後更改 this 值的明確綁定不同
更能確保 this 的值不會丟失,比明確綁定更加可靠!

箭頭函式與 This

從 ES6 開始,新增了一種叫做 「箭頭函式」 的表示式

const func = function (x) { return x + 1 }

相當於

const func = (x) => { return x + 1 }

為什麼會特別提到他呢?
因為箭頭函式有兩個重要的特性:

  • 更簡短的函式寫法
  • this 變數強制綁定

箭頭函數的 this 為執行函數的位置
不是指所屬物件,而是他本身被呼叫的位置

聽起來跟我們所了解的知識完全不一樣
這是因為箭頭函式根本沒有 this,所以說是綁定也有點勉強?!

因此無論是使用 'use strict' 或是 bind() 都無法改變 this 的內容,也不能作為物件建構子 (constructor)來使用
在宣告它的地方的 this 是什麼,它的 this 就是什麼
例如:

var tmp = 'a'var obj = {
tmp: 'b',
func: () => console.log(this.tmp)
}
console.log(tmp) // a
obj.func() // a

由於 obj.func 是在全域環境中建立,this 就被綁定到全域環境中
因此兩個執行結果都是全域變數 tmp 的值 a

要記得箭頭函式是沒有 this 的
所以在使用箭頭函數時,務必要小心考量
像是在 React 是可以使用箭頭函數來綁定實例的
但 Vue 使用箭頭函數,反而會讓原本指向實例的 this 丟失

希望可以透過以上的舉例讓大家更了解 this 及如何綁定 this !

拍個手讓我知道,這個文章對你們有幫助 ♥(´∀` )人

參考資料

  1. 淺談 JavaScript 頭號難題 this:絕對不完整,但保證好懂
  2. JavaScript深入之從ECMAScript規範解讀this
  3. this 的值到底是什麼?一次說清楚

--

--

Kion

程式就是利用自動化與排程的特性解決問題 文章分類總覽: https://hackmd.io/@Kion/SyvyEks0L