深入JavaScript系列(四):徹底搞懂this

Logan70發表於2018-12-13

一、函式的呼叫

全域性環境的this指向全域性物件,在瀏覽器中就是我們熟知的window物件

說到this的種種情況,就離不開函式的呼叫,一般我們呼叫函式,無外乎以下四種方式:

  1. 普通呼叫,例如foo()
  2. 作為物件方法呼叫,例如obj.foo()
  3. 建構函式呼叫,例如new foo()
  4. 使用callapplybind等方法。

除箭頭函式外的其他函式被呼叫時,會在其詞法環境上繫結this的值,我們可以通過一些方法來指定this的值。

  1. 使用callapplybind等方法來顯式指定this的值。
    function foo() {
        console.log(this.a)
    }
    foo.call({a: 1}) // 輸出: 1
    foo.apply({a: 2}) // 輸出: 2
    // bind方法返回一個函式,需要手動進行呼叫
    foo.bind({a: 3})() // 輸出: 3
    複製程式碼
  2. 當函式作為物件的方法呼叫時,this的值將被隱式指定為這個物件。
    let obj = {
        a: 4,
        foo: function() {
            console.log(this.a)
        }
    }
    obj.foo() // 輸出: 4
    複製程式碼
  3. 當函式配合new操作符作為建構函式呼叫時,this的值將被隱式指定新構造出來的物件。

二、ECMAScript規範解讀this

上面講了幾種比較容易記憶和理解this的情況,我們來根據ECMAScript規範來簡單分析一下,這裡只說重點,一些規範內具體的實現就不講了,反而容易混淆。

其實當我們呼叫函式時,內部是呼叫函式的一個內建[[Call]](thisArgument, argumentsList)方法,此方法接收兩個引數,第一個引數提供this的繫結值,第二個引數就是函式的引數列表。

ECMAScript規範: 嚴格模式時,函式內的this繫結嚴格指向傳入的thisArgument。非嚴格模式時,若傳入的thisArgument不為undefinednull時,函式內的this繫結指向傳入的thisArgument;為undefinednull時,函式內的this繫結指向全域性的this

所以第一點中講的三種情況都是顯式或隱式的傳入了thisArgument來作為this的繫結值。我們來用虛擬碼模擬一下:

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

/* -------顯式指定this------- */
foo.call({a: 1})
foo.apply({a: 1})
foo.bind({a: 1})()
// 內部均執行
foo[[Call]]({a: 1})

/* -------函式構造呼叫------- */
new foo()
// 內部執行
let obj = {}
obj.__proto__ = foo.prototype
foo[[Call]](obj)
// 最後將這個obj返回,關於建構函式的詳細內容可翻閱我之前關於原型和原型鏈的文章

/* -------作為物件方法呼叫------- */
let obj = {
    a: 4,
    foo: function() {
        console.log(this.a)
    }
}
obj.foo()
// 內部執行
foo[[Call]]({
    a: 1,
    foo: Function foo
})
複製程式碼

那麼當函式普通呼叫時,thisArgument的值並沒有傳入,即為undefined,根據上面的ECMAScript規範,若非嚴格模式,函式內this指向全域性this,在瀏覽器內就是window。

虛擬碼模擬:

window.a = 10
function foo() {
    console.log(this.a)
}
foo() // 輸出: 10
foo.call(undefined) // 輸出: 10
// 內部均執行
foo[[Call]](undefined) // 非嚴格模式,this指向全域性物件

foo.call(null) // 輸出: 10
// 內部執行
foo[[Call]](null) // 非嚴格模式,this指向全域性物件
複製程式碼

根據上面的ECMAScript規範,嚴格模式下,函式內的this繫結嚴格指向傳入的thisArgument。所以有以下表現。

function foo() {
    'use strict'
    console.log(this)
}
foo() // 輸出:undefined
foo.call(null) // 輸出:null
複製程式碼

需要注意的是,這裡所說的嚴格模式是函式被建立時是否為嚴格模式,並非函式被呼叫時是否為嚴格模式:

window.a = 10
function foo() {
    console.log(this.a)
}
function bar() {
    'use strict'
    foo()
}
bar() // 輸出:10
複製程式碼

三、箭頭函式中的this

ES6新增的箭頭函式在被呼叫時不會繫結this,所以它需要去詞法環境鏈上尋找this

function foo() {
    return () => {
        console.log(this)
    }
}
const arrowFn1 = foo()
arrowFn1() // 輸出:window
           // 箭頭函式沒有this繫結,往外層詞法環境尋找
           // 在foo的詞法環境上找到this繫結,指向全域性物件window
           // 在foo的詞法環境上找到,並非是在全域性找到的
const arrowFn2 = foo.call({a: 1})
arrowFn2() // 輸出 {a: 1}
複製程式碼

切記,箭頭函式中不會繫結this,由於JS採用詞法作用域,所以箭頭函式中的this只取決於其定義時的環境。

window.a = 10
const foo = () => {
    console.log(this.a)
}
foo.call({a: 20}) // 輸出: 10

let obj = {
    a: 20,
    foo: foo
}
obj.foo() // 輸出: 10

function bar() {
    foo()
}
bar.call({a: 20}) // 輸出: 10
複製程式碼

四、回撥函式中的this

當函式作為回撥函式時會產生一些怪異的現象:

window.a = 10
let obj = {
    a: 20,
    foo: function() {
        console.log(this.a)
    }
}

setTimeout(obj.foo, 0) // 輸出: 10
複製程式碼

我覺得這麼解釋比較好理解:obj.foo作為回撥函式,我們其實在傳遞函式的具體值,而並非函式名,也就是說回撥函式會記錄傳入的函式的函式體,達到觸發條件後進行執行,虛擬碼如下:

setTimeout(obj.foo, 0)
//等同於,先將傳入回撥函式記錄下來
let callback = obj.foo
// 達到觸發條件後執行回撥
callback()
// 所以foo函式並非作為物件方法呼叫,而是作為函式普通呼叫
複製程式碼

要想避免這種情況,有三種方法,第一種方法是使用bind返回的指定好this繫結的函式作為回撥函式傳入:

setTimeout(obj.foo.bind({a: 20}), 0) // 輸出: 20
複製程式碼

第二種方法是儲存我們想要的this值,就是常見的,具體命名視個人習慣而定。

let _this = this
let self = this
let me = this
複製程式碼

第三種方法就是使用箭頭函式

window.a = 10
function foo() {
    return () => {
        console.log(this.a)
    }
}
const arrowFn = foo.call({a: 20})
arrowFn() // 輸出:20
setTimeout(arrowFn, 0) // 輸出:20
複製程式碼

五、總結

  1. 箭頭函式中沒有this繫結,this的值取決於其建立時所在詞法環境鏈中最近的this繫結
  2. 非嚴格模式下,函式普通呼叫,this指向全域性物件
  3. 嚴格模式下,函式普通呼叫,thisundefined
  4. 函式作為物件方法呼叫,this指向該物件
  5. 函式作為建構函式配合new呼叫,this指向構造出的新物件
  6. 非嚴格模式下,函式通過callapplybind等間接呼叫,this指向傳入的第一個引數

    這裡注意兩點:

    1. bind返回一個函式,需要手動呼叫,callapply會自動呼叫
    2. 傳入的第一個引數若為undefinednullthis指向全域性物件
  7. 嚴格模式下函式通過callapplybind等間接呼叫,this嚴格指向傳入的第一個引數

有時候文字的表述是蒼白無力的,真正理解之後會發現:this不過如此。

六、小練習

例子來自南波的JavaScript之例題中徹底理解this

// 例1
var name = 'window'

var person1 = {
  name: 'person1',
  show1: function () {
    console.log(this.name)
  },
  show2: () => console.log(this.name),
  show3: function () {
    return function () {
      console.log(this.name)
    }
  },
  show4: function () {
    return () => console.log(this.name)
  }
}
var person2 = { name: 'person2' }

person1.show1()  // ?
person1.show1.call(person2)  // ?

person1.show2()  // ?
person1.show2.call(person2)  // ?

person1.show3()()  // ?
person1.show3().call(person2)  // ?
person1.show3.call(person2)()  // ?

person1.show4()()  // ?
person1.show4().call(person2)  // ?
person1.show4.call(person2)()  // ?
複製程式碼

選中下方檢視答案:

person1 // 函式作為物件方法呼叫,this指向物件

person2 // 使用call間接呼叫函式,this指向傳入的person2

window // 箭頭函式無this繫結,在全域性環境找到this,指向window

window // 間接呼叫改變this指向對箭頭函式無效

window // person1.show3()返回普通函式,相當於普通函式呼叫,this指向window

person2 // 使用call間接呼叫函式,this指向傳入的person2

window // person1.show3.call(person2)仍然返回普通函式

person1 // person1.show4呼叫物件方法,this指向person1,返回箭頭函式,this在person1.show4呼叫時的詞法環境中找到,指向person1

person1 // 間接呼叫改變this指向對箭頭函式無效

person2 // 改變了person1.show4呼叫時this的指向,所以返回的箭頭函式的內this解析改變

系列文章

深入ECMAScript系列目錄地址(持續更新中...)

歡迎前往閱讀系列文章,如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。

菜鳥一枚,如果有疑問或者發現錯誤,可以在相應的 issues 進行提問或勘誤,與大家共同進步。

相關文章