理性分析 JavaScript 中的 this

haloislet發表於2018-02-27

在每一個方法中,關鍵字 this 表示隱式引數。 —— 《Java 核心技術 卷Ⅰ》


this 是什麼?

瞭解 python 的同學可能會知道,python 建構函式中總是會出現 self 引數。這個引數用來表示建立的例項物件。

class Student(object):
    def __init__(self, name, score):
        self.name = name
        self.score = score
複製程式碼

在 JavaScript 和 Java 中這個引數被隱藏了。我們不必在引數列表中顯式宣告這個引數,就可以在函式中使用這個引數。這個引數就是 this 。

function Student(name, score){
	this.name = name
	this.score = score
}
var studentA = new Student('a', 100)
console.log(studentA.name, studentA.score) // a 100
複製程式碼

隱式引數

援引 《Java 核心技術 卷Ⅰ》 中的一句話:在每一個方法中,關鍵字 this 表示隱式引數。 所謂的隱式引數,就是沒有在引數列表中顯式宣告的引數。隱式引數和引數列表中定義的顯式引數統稱為形式引數。與形式引數相對應的是實際引數。

形式引數和實際引數

形式引數,簡稱形參。形參就是在定義函式的時候使用的引數,用來接收呼叫該函式時傳遞的引數。如上述程式碼中的 name ,score 引數都是形參。

實際引數,簡稱實參,實參就是呼叫該函式時傳遞的引數。如上述程式碼中的 'a' , 100 都是實參。

為什麼 this 的值是在呼叫時確定的?

《 你不知道的JavaScript(上卷)》中提了一個問題,問:為什麼採用詞法作用域的 JavaScript 中的 this 的值是在呼叫時確定的?

在理解了形參和實參之後,我們便能很好地理解這個問題了。

因為 this 是一個形參,形參的值是由實參決定的。而傳參這個操作時在呼叫時發生的,所以 this 的值是在呼叫時確定的。


this 的值

既然 this 的值是由實參的值決定的,那麼這個實參的值到底是什麼呢?

參考 《Java 核心技術 卷Ⅰ》 中的一句話:隱式引數的值是出現在函式名之前的物件。當作為建構函式時,this 用來表示建立的例項物件。來看兩個例子:

function bar () {
  console.log(this.name)
}
var foo = {
  name: 'foo',
  bar: bar
}
foo.bar() // foo
複製程式碼

this 指向函式名(bar)之前的 foo 物件

function Student(name, score){
	this.name = name
	this.score = score
}
var studentA = new Student('a', 100)
console.log(studentA.name, studentA.score) // a 100
複製程式碼

this 指向建立的例項物件 studentA

call apply bind

JavaScript 也提供了幾個函式去改變 this 的值。這幾個函式都會返回一個原函式的拷貝,並在這個拷貝上傳遞 this 的值。所以從結果上看,我們可以看到原有的 this 會被覆蓋。

function bar () {
  console.log(this.name)
}
var foo = {
  name: 'foo',
  bar: bar
}
var obj = {
  name: 'obj',
}
foo.bar.call(obj) // obj
複製程式碼

this 指向新的物件 obj 。

為什麼 this 指向了全域性物件?

《 你不知道的JavaScript(上卷)》中描述了一種現象:this 丟失了原來的繫結物件,指向了全域性物件。書中稱為隱式丟失。來看示例:

function foo() { 
	console.log( this.a )
}
var obj = { 
	a: 2,
	foo: foo 
}
var bar = obj.foo // 賦值操作
var a = "oops, global" 
bar() // "oops, global"
複製程式碼

JavaScript 只有值傳遞,沒有引用傳遞。在賦值操作的時候,其實是將一個引用的拷貝賦值給另外一個變數。var bar = obj.foo 在這個語句中,沒有傳參操作,所以 this 的值是由 bar 函式在呼叫時傳遞的那個實參決定的。這個實參如未顯式指定,那麼便是指向全域性物件。所以上述程式碼中的 this 指向了全域性物件。

同樣的,我們在函式傳參的過程中,經常發現隱式丟失問題,原因也是中間發生了一次賦值操作。程式碼示例如下:

var name = 'global'
function bar () {
  console.log(this.name)
}
var foo = {
  name: 'foo',
  bar: bar
}
function callFunc(func){
  func()
}
callFunc(foo.bar) // global
複製程式碼

在傳參的過程中,發生了func = foo.bar的賦值操作,導致最後 this 的值指向了全域性物件。

但是如果我們使用 bind 繫結了 this 的值,那麼在發生賦值操作時,this 的值將不再改變。來看下面例子。

再談 bind

bind 和 call ,apply 有一點不同的是 call,apply 返回的是呼叫結果,而 bind 返回的是繫結 this 後的函式物件。那麼當繫結 this 後的函式作為實參傳入函式時,與未繫結 this 的結果就完全不同了。

來看下面的例子。

var name = 'global'
function bar () {
  console.log(this.name)
}
var foo = {
  name: 'foo',
  bar: bar
}
function callFunc(func){
  func()
}
callFunc(foo.bar.bind(foo)) // foo
複製程式碼

將 bar 函式中的 this 繫結到 foo 再傳入 callFunc 函式中,最後列印的結果是 foo 。

實際上, bind 函式內部維護了一個閉包,使得呼叫始終發生在函式內部,來保證 this 的值不變。來看 MDN 提供的 ployfill

if (!Function.prototype.bind) {
  Function.prototype.bind = function(oThis) {
    if (typeof this !== 'function') {
      // closest thing possible to the ECMAScript 5
      // internal IsCallable function
      throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable')
    }

    var aArgs   = Array.prototype.slice.call(arguments, 1),
        fToBind = this,
        fNOP    = function() {},
        fBound  = function() {
          return fToBind.apply(this instanceof fNOP
                 ? this
                 : oThis,
                 aArgs.concat(Array.prototype.slice.call(arguments)))
        };

    if (this.prototype) {
      // Function.prototype doesn't have a prototype property
      fNOP.prototype = this.prototype
    }
    fBound.prototype = new fNOP()

    return fBound
  }
}
複製程式碼
// return 部分
 return fToBind.apply(this instanceof fNOP
                 ? this
                 : oThis,
                 aArgs.concat(Array.prototype.slice.call(arguments)))
複製程式碼

在 return 的時候使用了 apply 函式來改變 this ,若未發生 new 操作,那麼這個 this 的值將繫結到 bind 函式提供的那個物件。

new 操作

當發生 new 操作時,this 將繫結到這個例項物件。 從上面這個 ploy fill 可以看出 new 操作中的 this 值會覆蓋原有 this 的值。來看例子

function bar () {
  this.name = 'bar'
}
var foo = {
  name: 'foo',
}

var a = bar.bind(foo)
a()
console.log(foo.name) // bar
var b = new a()
console.log(b.name) // bar
複製程式碼

當執行 new 操作之前,a 函式中的 this 指向 foo。當執行 new 操作之後,a 函式中的 this 指向了 b 。

new 操作會返回一個重新繫結 this 後的新物件。所以當發生 new 操作之後,原有的 this 發生了改變。具體步驟如下:

  1. 建立(或者說構造)一個全新的物件。
  2. 這個新物件會被執行 [[ 原型 ]] 連線。
  3. 這個新物件會繫結到函式呼叫的 this 。
  4. 如果函式沒有返回其他物件,那麼 new 表示式中的函式呼叫會自動返回這個新物件。

箭頭函式中的 this

箭頭函式中的 this 繼承了父作用域的 this。

var name = 'global'
var foo = {
  name: 'foo',
  bar: () => {
	console.log(this.name)
  }
}

foo.bar() // global
複製程式碼

箭頭函式的父作用域為全域性作用域,全域性作用域的 this 指向全域性物件,所以 this 指向了全域性物件。

var name = 'global'
var foo = {
  name: 'foo',
  bar: function () {
	setTimeout(() => {
	  console.log(this.name)
	},100)
  }
}

foo.bar() // foo
複製程式碼

箭頭函式的父作用域為 bar 函式,在呼叫時,父作用域 bar 函式中的 this 指向了 foo 函式,所以箭頭函式中的 this 指向了 foo 。

嚴格模式下的 this

嚴格模式下禁止 this 指向全域性物件。在嚴格模式下當 this 指向全域性物件的時候會變成 undefined 。


總結

  1. this 指向建立的例項物件或函式名之前的物件。如未指定,便是指向全域性物件。
  2. 由於 call 、apply 、bind 函式會返回一個原函式的拷貝,並在這個拷貝上傳遞 this 值。所以當使用 call 、apply 、bind 函式會覆蓋原有的 this 值。
  3. new 操作可以覆蓋 call、apply、bind 繫結的 this 值。

tips

  • 嚴格模式下禁止 this 指向全域性物件。在嚴格模式下當 this 指向全域性物件的時候會變成 undefined 。
  • 在發生賦值操作時,由於引用複製, this 的值指向被賦值變數的呼叫物件。
  • ES 6 中新增箭頭函式,可以繼承父作用域的 this ,可以解決 this 隱式丟失的問題。

相關知識點

  • 詞法作用域和動態作用域
  • 閉包
  • 作用域和作用域鏈
  • 嚴格模式
  • ES6 新增特性
  • 引用傳遞和值傳遞

相關文章