JavaScript夯實基礎系列(三):this

白馬笑西風發表於2019-03-18

  在JavaScript中,函式的每次呼叫都會擁有一個執行上下文,通過this關鍵字指向該上下文。函式中的程式碼在函式定義時不會執行,只有在函式被呼叫時才執行。函式呼叫的方式有四種:作為函式呼叫作為方法呼叫作為建構函式呼叫以及間接呼叫,判定this指向的規則跟函式呼叫的方式有關。

一、作為函式的呼叫

  作為函式呼叫是指函式獨立執行,函式沒有人為指定的執行上下文。在有些情況下,作為函式呼叫的形式具有迷惑性,不僅僅是簡單的函式名後面加括號來執行。

1、明確的作為函式呼叫

  明確的作為函式呼叫是指形如func(para)形式的函式呼叫。作為函式呼叫的情況下this在嚴格模式下為undefined,在非嚴格模式下指向全域性物件(在瀏覽器環境下為Window物件)如下程式碼所示:

var a = 1;
function test1 () {
    var a = 2
    return this.a
}
test1() // 1
複製程式碼
'use strict'
var a = 1;
function test1 () {
    var a = 2
    return this.a
}
test1() // Uncaught TypeError
複製程式碼

  以函式呼叫形式的函式通常不使用this,但是可以根據this來判斷當前是否是嚴格模式。如下程式碼所示,在嚴格模式下,this為undefined,strict為true;在非嚴格模式下,this為全域性物件,strict為false。

var strict = (function () {
    return !this
})()
複製程式碼

2、物件作為橋樑找到方法

  通過物件呼叫的函式稱為方法,但是通過物件找到方法並不執行屬於作為函式呼叫的情況。如下程式碼所示:

var a = 1;

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

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

var func = obj.test;

func(); // 1
複製程式碼

  上述程式碼中,obj.test是通過obj物件找到函式test,並未執行,找到函式之後將變數func指向該函式。obj物件在這個過程中只是起到一個找到test地址的橋樑作用,並不固定為函式test的執行上下文。因此var func = obj.test;執行的結果僅僅是變數func和變數test指向共同的函式體而已,因此func()仍然是作為函式呼叫,和直接呼叫test一樣。
  當傳遞迴調函式時,本質也是作為函式呼叫。如下程式碼所示:

var a = 1

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

function test(fn) {
    fn();
}

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

test( obj.func ); // 1
複製程式碼

  函式引數是以值傳遞的形式進行的,obj.func作為引數傳遞進test函式時會被複制,複製的僅僅是指向函式func的地址,obj在這個過程中起到找到函式func的橋樑作用,因此test函式執行時,裡面的fn是作為函式呼叫的。
  接收回撥的函式是自己寫的還是語言內建的沒有什麼區別,比如:

var a = 1;

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

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

setTimeout( obj.test, 1000 ); // 1
複製程式碼

  setTimeout的第一個引數是通過obj物件找到的函式test,本質上obj依然是起到找到test函式的橋樑作用,因此test依然是作為函式呼叫的。

3、間接呼叫傳遞null或undefined作為執行上下文

  函式的間接呼叫是指通過call、apply或bind函式明確指定函式的執行上下文,當我們指定null或者undefined作為間接呼叫的上下文時,函式實際是作為函式呼叫的。但是有一點需要注意:call()和apply()在嚴格模式下傳入空值則上下文為空值,並不是因為遵循作為函式呼叫在嚴格模式下執行上下文為全域性物件的規則,而是因為在嚴格模式下call()和apply()的第一個實參都會變成this的值,哪怕傳入的實參是原始值甚至是null或undefined。

var a = 1;

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

test.call( null ); // 1
複製程式碼

  間接呼叫的目的是為了指定函式的執行上下文,那麼為什麼要傳null或undefined使其作為函式呼叫呢?這是因為我們會用到這些方法的其他性質:函式call中一般不傳入空值(null或undefined);函式apply傳入空值可以起到將陣列散開作為函式引數的效果;函式bind可以用來進行函式柯里化。在ES6中,新增了擴充套件運算子‘...’,將一個陣列轉為用逗號分隔的引數序列,可以替代往apply函式傳空值的情況。但是ES6中沒有增加函式柯里化的方法,因此往函式bind中傳空值的情況將繼續使用。
  在使用apply或bind傳入空值的情況,一般是不關心this值。但是如果函式中使用了this,在非嚴格模式下能夠訪問到全域性變數,有時會違背程式碼編寫的本意。因此,使用一個真正空的值傳入其中能夠避免這類情況,如下程式碼所示:

var empty = Object.create( null );
      
function foo(a,b) {
    console.log( "a:" + a + ", b:" + b );
}
      
foo.apply( empty, [1, 2] ); // a:1, b:2
複製程式碼

二、作為方法呼叫

  當函式掛載到一個物件上,作為物件的屬性,則稱該函式為物件的方法。如果通過物件來呼叫函式時,該物件就是本次呼叫的上下文,被呼叫函式的this也就是該物件。如下程式碼所示:

var obj = {
    a: 1,
    test: test
};

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

obj.test(); // 1
複製程式碼

  在JavaScript中,物件可以擁有物件屬性,物件屬性有又可以擁有物件或者方法。函式作為方法呼叫時,this指向直接呼叫該方法的物件,其他物件僅僅是為了找到this指向的這個物件而已。如下程式碼所示:

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

var obj2 = {
    a: 2,
    test: test
};

var obj1 = {
    a: 1,
    obj2: obj2
};

obj1.obj2.test(); // 2
複製程式碼

  當方法的返回值時一個物件時,這個物件還可以再呼叫它的方法。當方法不需要返回值時,最好直接返回this,如果一個物件中的所有方法都返回this,就可以採用鏈式呼叫物件中的方法。如下程式碼所示:

function add () {
    this.a++;
    return this;
}

function minus () {
    this.a--;
    return this;
}

function print() {
    console.log( this.a );
    return this;
}

var obj = {
    a: 1,
    print: print,
    minus: minus,
    add: add
};

obj.add().minus().add().print(); // 2
複製程式碼

三、作為建構函式呼叫

  在JavaScript中,建構函式沒有任何特殊的地方,任何函式只要是被new關鍵字呼叫該函式就是建構函式,任何不被new關鍵字呼叫的都不是建構函式。
  當使用new關鍵字來呼叫函式時,會經歷以下四步:

1、建立一個新的空物件。
2、這個空物件繼承建構函式的prototype屬性。
3、建構函式將新建立的物件作為執行上下文來進行初始化。
4、如果建構函式有返回值並且是物件,則返回建構函式的返回值,否則返回新建立的物件。

  約定俗成的是:在編寫建構函式時函式名首字母大寫,且建構函式不寫返回值。因此一般來說,new關鍵字呼叫建構函式建立的新物件作為建構函式的this。如下程式碼所示:

function foo() {
    this.a = 1;
}

var bar = new foo();
console.log( bar.a ); // 1
複製程式碼

四、間接呼叫

  在JavaScript中,物件中的方法屬性僅僅儲存的是一個函式的地址,函式與物件的耦合度沒有想象中的高。通過物件來呼叫函式,函式的執行上下文(this指向)就是該物件。如果通過物件來找到函式的地址,就能指定函式的執行上下文,可以使用call()、apply()和bind()方法來實現。換而言之,任何函式可以作為任何物件的方法來呼叫,哪怕函式並不是那個物件的方法。

1、call()和apply()

  每個函式都call()和apply()方法,函式呼叫這兩個方法是可以明確指定執行上下文。從繫結上下文的角度來說這兩個方法是一樣的,第一個引數傳遞的都是指定的執行上下文。所不同的在於call()方法剩餘的引數將會作為函式的實參來使用,可以有多個;apply()則最多隻接收兩個引數,第一個是執行上下文,第二個是一個陣列,陣列中的每個元素都將作為函式的實參。如下程式碼所示:

var a = 1
function test(b,c) {
    console.log(`a:${this.a},b:${b},c:${c}`)
}
var obj = {
    a:2
}
test.call(obj,3,4) // a:2,b:3,c:4

var d = 11
function test2(b,c) {
    console.log(`b:${b},c:${c},d:${this.d}`)
}
var obj2 = {
    d:12
}
test2.apply(obj2,[13,14]) // b:13,c:14,d:12
複製程式碼

  在非嚴格模式下,call()、apply()的第一個引數傳入null或者undefined時,函式的執行上下文被替代為全域性物件,如果傳入的是基礎型別,則為替代為相應的包裝物件。在嚴格模式下,遵循的規則是傳入的值即為執行上下文,不替換,不自動裝箱。如下程式碼所示:

var a = 1
function test1 () {
    console.log(this.a)
}
test1.call(null) // 1
test1.call(undefined) // 1
test1.apply(null) // 1
test1.apply(undefined) // 1
複製程式碼

'use strict'
function test1 () {
    console.log(this)
}
test1.call(null) // null
test1.call(undefined) // undefined
test1.call(1) // 1
test1.apply(null) // null
test1.apply(undefined) // undefined
test1.apply(1) // 1
複製程式碼

  apply()有一個較為常見的用法:將陣列轉化成函式的引數序列。ES6中增加了擴充套件運算子“...”來實現該功能。如下程式碼所示:

var arr = [1,19,4,54,69,9]

var a = Math.max.apply(null,arr)
console.log(a) // 69

var b = Math.max(...arr)
console.log(b) // 69
複製程式碼

2、bind()

  bind()函式可以接收多個引數,返回一個功能相同、執行上下文確定、引數經過初始化的函式。其中第一個引數為要繫結的執行上下文,剩餘引數為返回函式的預定義值。bind()函式的作用有兩點:1、為函式繫結執行上下文;2、進行函式柯里化。如下程式碼所示:

var a = 1

function func(b,c) {
    console.log(`a:${this.a},b:${b},c:${c}`)
}

var obj = {
    a: 2
}

var test = func.bind(obj,3)

test(4) // a:2,b:3,c:4
複製程式碼

  bind()方法是ES5加入的,但是我們可以很輕易的在ES3中通過apply()模擬出來,下面程式碼是MDN上的bind()的polyfill。

if (!Function.prototype.bind) {
    Function.prototype.bind = function(oThis) {
        if (typeof this !== "function") {
            // 可能的與 ECMAScript 5 內部的 IsCallable 函式最接近的東西,
            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 &&oThis ? this : oThis),
                    aArgs.concat( Array.prototype.slice.call( arguments ) )
                );
            };

        fNOP.prototype = this.prototype;
        fBound.prototype = new fNOP();

        return fBound;
    };
}
複製程式碼

五、規則的優先順序

  函式的呼叫有時不只一種,那麼不同呼叫方式的規則的優先順序就最終決定了this的指向。那就讓我們來比較不同呼叫方式的規則優先順序。如下程式碼所示,當函式作為方法呼叫的時候,this指向呼叫方法的物件,當作為函式呼叫時,this指向在非嚴格模式下指向全域性物件,在嚴格模式下指向undefined。因此,方法呼叫的優先順序高於函式呼叫。

var a = 1

var obj = {
    a:2,
    test:test
}

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

var b = obj.test

obj.test() // 2
b() // 1
複製程式碼

  如下程式碼所示是函式作為方法呼叫分別和間接呼叫、建構函式呼叫作對比。由程式碼可知:函式作為方法呼叫優先順序分別小於間接呼叫和建構函式呼叫。

function test(para) {
    this.a = para
}

var obj1 = {
    test: test
}

var obj2 = {}
      
obj1.test( 2 )
console.log( obj1.a ) // 2

obj1.test.call( obj2, 3 )
console.log( obj2.a ) // 3

var bar = new obj1.test( 4 )
console.log( obj1.a ) // 2
console.log( bar.a ) // 4
複製程式碼

  new關鍵字後面是一個函式,而call()和apply()並不是返回一個函式,而是依照傳入引數來執行函式,因此形如new foo.call(obj)的程式碼是不被允許的。ES5中的bind()返回的是一個函式,可以與new關鍵字同時使用。如下程式碼所示,bind()返回的函式用作建構函式,將忽略傳入bind()的this值,原始函式會以建構函式的形式呼叫,傳入的引數也會原封不動的傳入原始函式。

function test(something) {
    this.a = something;
}

var obj = {};

var bar = test.bind( obj );
bar( 2 );
console.log( obj.a ); // 2

var baz = new bar( 3 );
console.log( obj.a ); // 2
console.log( baz.a ); // 3
複製程式碼

  總之,建構函式的優先順序大於間接呼叫,間接呼叫的優先順序大於方法呼叫,方法呼叫的優先順序大於函式呼叫。

六、詞法this

  this關鍵字沒有作用域限制,函式的this指向呼叫該函式的物件,在巢狀函式匯中,如果想訪問外層函式的this值,可以將外層函式的this賦值給一個變數,用詞法作用域來代替傳統的this機制。如下程式碼所示:

function foo() {
    var self = this // 詞法上捕獲`this`
    setTimeout( function(){
        console.log( self.a )
    }, 1000 )
}

var obj = {
    a: 2
};

foo.call( obj ) // 2
複製程式碼

  ES6新增了箭頭函式,箭頭函式體內的this物件,就是定義時所在的物件,而不是使用時所在的物件。如下程式碼所示,箭頭函式能夠將this固化,箭頭函式內部沒有繫結this的機制,其內部的this就是外層程式碼塊的this。傳統的this機制讓很多人與詞法作用域混淆,因此有了將this賦值給變數的行為,ES6只是將這種行為加以標準化而已。

var a = 21

function test() {
    setTimeout(() => {
        console.log('a:', this.a)
    }, 1000)
}

test.call({ a: 42 }) // 42
複製程式碼

七、總結

  JavaScript中的this機制跟詞法作用域沒有關係,根據函式呼叫的方式不同,確定this指向的規則也不相同。在確定this指向時可以遵循以下步驟:

1、函式是否為建構函式呼叫,即函式跟在new關鍵字後面,如果是,this就是新構建的物件。
2、函式是否為間接呼叫,即通過call()、apply()或者bind()呼叫,如果是,this就是明確指定的物件。
3、函式是否為作為方法呼叫,即通過物件來呼叫函式,如果是,this就是該物件。
4、否則,即為作為函式的呼叫,在非嚴格模式下,this指向全域性物件,在嚴格模式下,this為undefined。

  可以將外層函式的this賦值給一個變數,使得內層函式以詞法作用域的規則來訪問該this。ES6新增的箭頭函式便是使用詞法作用域來決定this繫結的。
如需轉載,煩請註明出處:www.cnblogs.com/lidengfeng/…

相關文章