本文分享自華為雲社群《3月閱讀周·你不知道的JavaScript | 無人不識又無人不迷糊的this》,作者: 葉一一。
關於this
this關鍵字是JavaScript中最複雜的機制之一。它是一個很特別的關鍵字,被自動定義在所有函式的作用域中。
為什麼要用this
隨著開發者的使用模式越來越複雜,顯式傳遞上下文物件會讓程式碼變得越來越混亂,使用this則不會這樣。
比如下面的例子:
function identify() { return this.name.toUpperCase(); } function speak() { var greeting = "Hello, I'm " + identify.call(this); console.log(greeting); } var me = { name: 'Kyle', }; var you = { name: 'Reader', }; console.log(identify.call(me)); console.log(identify.call(you)); speak.call(me); speak.call(you);
列印一下結果:
上面的程式碼可以在不同的上下文物件(me和you)中重複使用函式identify()和speak(),不用針對每個物件編寫不同版本的函式。如果不使用this,那就需要給identify()和speak()顯式傳入一個上下文物件。
誤解
有兩種常見的對於this的解釋,但是它們都是錯誤的。
1、指向自身
人們很容易把this理解成指向函式自身。
那麼為什麼需要從函式內部引用函式自身呢?常見的原因是遞迴(從函式內部呼叫這個函式)或者可以寫一個在第一次被呼叫後自己解除繫結的事件處理器。
看下面這段程式碼,思考foo會被呼叫了多少次?
function foo(num) { console.log('foo: ' + num); // 記錄foo被呼叫的次數 this.count++; } foo.count = 0; var i; for (i = 0; i < 10; i++) { if (i > 5) { foo(i); } } // foo被呼叫了多少次? console.log(foo.count);
列印結果:
console.log語句產生了4條輸出,證明foo(..)確實被呼叫了4次,但是foo.count仍然是0。顯然從字面意思來理解this是錯誤的。
執行foo.count = 0時,的確向函式物件foo新增了一個屬性count。但是函式內部程式碼this.count中的this並不是指向那個函式物件。
2、它的作用域
第二種常見的誤解是,this指向函式的作用域。這個問題有點複雜,因為在某種情況下它是正確的,但是在其他情況下它卻是錯誤的。
this在任何情況下都不指向函式的詞法作用域。
function foo() { var a = 2; this.bar(); } function bar() { console.log(this.a); } foo();
直接列印上面的程式碼會得到一個報錯:
這段程式碼試圖透過this.bar()來引用bar()函式。這是不可能實現的,使用this不可能在詞法作用域中查到什麼。
每當開發者想要把this和詞法作用域的查詢混合使用時,一定要提醒自己,這是無法實現的。
this到底是什麼
this的繫結和函式宣告的位置沒有任何關係,只取決於函式的呼叫方式。
當一個函式被呼叫時,會建立一個活動記錄(有時候也稱為執行上下文)。這個記錄會包含函式在哪裡被呼叫(呼叫棧)、函式的呼叫方式、傳入的引數等資訊。this就是這個記錄的一個屬性,會在函式執行的過程中用到。
this全面解析
呼叫位置
在理解this的繫結過程之前,首先要理解呼叫位置:呼叫位置就是函式在程式碼中被呼叫的位置(而不是宣告的位置)。
尋找呼叫位置就是尋找“函式被呼叫的位置”。最重要的是要分析呼叫棧(就是為了到達當前執行位置所呼叫的所有函式)。呼叫位置就在當前正在執行的函式的前一個呼叫中。
透過下面的程式碼來看什麼是呼叫棧和呼叫位置:
function baz() { // 當前呼叫棧是:baz // 因此,當前呼叫位置是全域性作用域 console.log('baz'); bar(); // <-- bar的呼叫位置 } function bar() { // 當前呼叫棧是baz -> bar // 因此,當前呼叫位置在baz中 console.log('bar'); foo(); // <-- foo的呼叫位置 } function foo() { // 當前呼叫棧是baz -> bar -> foo // 因此,當前呼叫位置在bar中 console.log('foo'); } baz(); // <-- baz的呼叫位置
列印的結果如下:
繫結規則
來看看在函式的執行過程中呼叫位置如何決定this的繫結物件。
首先必須找到呼叫位置,然後判斷需要應用下面四條規則中的哪一條。
充分理解四條規則之後,再理解多條規則都可用時它們的優先順序如何排列。
1、預設繫結
首先要介紹的是最常用的函式呼叫型別:獨立函式呼叫。可以把這條規則看作是無法應用其他規則時的預設規則。
var a = 2; function foo() { console.log(this.a); } foo(); // 2
列印結果是2。也就是當呼叫foo()時,this.a被解析成了全域性變數a。函式呼叫時應用了this的預設繫結,因此this指向全域性物件。
2、隱式繫結
另一條需要考慮的規則是呼叫位置是否有上下文物件,或者說是否被某個物件擁有或者包含,不過這種說法可能會造成一些誤導。
思考下面的程式碼:
function foo() { console.log(this.a); } var obj = { a: 2, foo: foo, }; obj.foo(); // 2
當foo()被呼叫時,它的前面確實加上了對obj的引用。當函式引用有上下文物件時,隱式繫結規則會把函式呼叫中的this繫結到這個上下文物件。因為呼叫foo()時this被繫結到obj,因此this.a和obj.a是一樣的。
3、顯式繫結
JavaScript提供的絕大多數函式以及你自己建立的所有函式都可以使用call(..)和apply(..)方法。
它們的第一個引數是一個物件,是給this準備的,接著在呼叫函式時將其繫結到this。
因為可以直接指定this的繫結物件,因此我們稱之為顯式繫結。
思考下面的程式碼:
function foo() { console.log(this.a); } var obj = { a: 2, }; foo.call(obj); // 2
透過foo.call(..),我們可以在呼叫foo時強制把它的this繫結到obj上。
4、new繫結
在傳統的面向類的語言中,“建構函式”是類中的一些特殊方法,使用new初始化類時會呼叫類中的建構函式。通常的形式是這樣的:
something = new MyClass(..);
在JavaScript中,建構函式只是一些使用new運算子時被呼叫的函式。它們並不會屬於某個類,也不會例項化一個類。實際上,它們甚至都不能說是一種特殊的函式型別,它們只是被new運算子呼叫的普通函式而已。
優先順序
1、四條規則的優先順序
new繫結 > 顯式繫結 > 隱式繫結 > 預設繫結。
2、判斷this
可以根據優先順序來判斷函式在某個呼叫位置應用的是哪條規則。可以按照下面的順序來進行判斷:
(1)函式是否在new中呼叫(new繫結)?如果是的話this繫結的是新建立的物件。
var bar = new foo();
(2)函式是否透過call、apply(顯式繫結)或者硬繫結呼叫?如果是的話,this繫結的是指定的物件。
var bar = foo.call(obj2);
(3)函式是否在某個上下文物件中呼叫(隱式繫結)?如果是的話,this繫結的是那個上下文物件。
var bar = obj1.foo();
(4)如果都不是的話,使用預設繫結。如果在嚴格模式下,就繫結到undefined,否則繫結到全域性物件。
var bar = foo();
繫結例外
在某些場景下this的繫結行為會出乎意料,你認為應當應用其他繫結規則時,實際上應用的可能是預設繫結規則。
被忽略的this
如果你把null或者undefined作為this的繫結物件傳入call、apply或者bind,這些值在呼叫時會被忽略,實際應用的是預設繫結規則:
function foo() { console.log(this.a); } var a = 2; foo.call(null); // 2
那麼什麼情況下會傳入null呢?
一種非常常見的做法是使用apply(..)來“展開”一個陣列,並當作引數傳入一個函式。類似地,bind(..)可以對引數進行柯里化(預先設定一些引數),這種方法有時非常有用。
間接引用
另一個需要注意的是,你有可能(有意或者無意地)建立一個函式的“間接引用”,在這種情況下,呼叫這個函式會應用預設繫結規則。
間接引用最容易在賦值時發生:
function foo() { console.log(this.a); } var a = 2; var o = { a: 3, foo: foo }; var p = { a: 4 }; o.foo(); // 3 (p.foo = o.foo)(); // 2
賦值表示式p.foo = o.foo的返回值是目標函式的引用,因此呼叫位置是foo()而不是p.foo()或者o.foo()。根據我們之前說過的,這裡會應用預設繫結。
軟繫結
如果可以給預設繫結指定一個全域性物件和undefined以外的值,那就可以實現和硬繫結相同的效果,同時保留隱式繫結或者顯式繫結修改this的能力。
function foo() { console.log('name: ' + this.name); } var obj = { name: 'obj' }, obj2 = { name: 'obj2' }, obj3 = { name: 'obj3' }; var fooOBJ = foo.softBind(obj); fooOBJ(); // name: obj obj2.foo = foo.softBind(obj); obj2.foo(); // name: obj2 <---- 看!! ! fooOBJ.call(obj3); // name: obj3 <---- 看! setTimeout(obj2.foo, 10); // name: obj <---- 應用了軟繫結
可以看到,軟繫結版本的foo()可以手動將this繫結到obj2或者obj3上,但如果應用預設繫結,則會將this繫結到obj。
this詞法
ES6中介紹了一種無法使用這些規則的特殊函式型別:箭頭函式。箭頭函式並不是使用function關鍵字定義的,而是使用被稱為“胖箭頭”的運算子=>定義的。箭頭函式不使用this的四種標準規則,而是根據外層(函式或者全域性)作用域來決定this。
箭頭函式的詞法作用域:
function foo() { // 返回一個箭頭函式 return a => { //this繼承自foo() console.log(this.a); }; } var obj1 = { a: 2, }; var obj2 = { a: 3, }; var bar = foo.call(obj1); bar.call(obj2); // 2, 不是3!
foo()內部建立的箭頭函式會捕獲呼叫時foo()的this。由於foo()的this繫結到obj1,bar(引用箭頭函式)的this也會繫結到obj1,箭頭函式的繫結無法被修改。
總結
我們來總結一下本篇的主要內容:
- this實際上是在函式被呼叫時發生的繫結,它指向什麼完全取決於函式在哪裡被呼叫。
- 如果要判斷一個執行中函式的this繫結,就需要找到這個函式的直接呼叫位置。找到之後就可以順序應用下面這四條規則來判斷this的繫結物件。
- ES6中的箭頭函式並不會使用四條標準的繫結規則,而是根據當前的詞法作用域來決定this,具體來說,箭頭函式會繼承外層函式呼叫的this繫結(無論this繫結到什麼)。這其實和ES6之前程式碼中的self = this機制一樣。
點選關注,第一時間瞭解華為雲新鮮技術~