/ JavaScript

JavaScript’s this: 如何運作, 它有什麼陷阱

Original post: JavaScript’s this: how it works, where it can trip you up


JavaScript’s this: how it works, where it can trip you up

在JavaScript中,特殊的this是相對地複雜,因為它處處可及,不只在物件導向。這篇部落格文章解釋this如何運作及它是如何造成問題,最後用最佳實踐來做結尾。

為了了解this,最好根據如何使用它,將它區分三個分類:

  • 函式內this是一個額外、經常暗示的參數。
  • 函式外(最上層範圍內): this參考到瀏覽器的全域變數,以及Node.js的匯出模組
  • 傳入eval()的字串: eval()取得this現有值或將它參考到全域物件,依據它是否直接或間接呼叫

讓我們檢驗這幾個分類:

1. this 位於函式內

這是最常見使用this的方式,因為函式在JavaScript中代表了所有可呼叫建構子,扮演三種不同角色:

  • 一般函式(Function)
  • 建構式(Constructor)
  • 方法(Method)

1.1 this 位於一般函式內

在一般函式內,this值依據不同的模式而有所差異:

  • 稀鬆模式(Sloppy mode): this參考到全域物件 (瀏覽器內,指的是window)
function sloppyFunc() {
	console.log(this === window); // true
}
sloppyFunc();
  • 嚴格模式(Strict mode): this值為undefined
function strictFunc() {
    'use strict';
    console.log(this === undefined); // true
}
strictFunc();

意即,this是一個隱晦參數,其值永遠都一樣。你可以,無論無何,透過call()apply()來呼叫一般函式,明確指定this的值:

function func(arg1, arg2) {
    console.log(this); // 1
    console.log(arg1); // 2
    console.log(arg2); // 3
}
func.call(1, 2, 3); // (this, arg1, arg2)
func.apply(1, [2, 3]); // (this, arrayWithArgs)

1.2. this位於建構式內

當你透過new運算子來叫用(invoke)函式時,函式就成為建構式。運算子產生一個新的物件,並透過this將此物件傳入建構式:

var savedThis;

function Constr() {
    savedThis = this;
}
var inst = new Constr();
console.log(savedThis === inst); // true

在JavaScript實作中,new運算子大致上看起來像是下列程式(更精確的實作說明較為複雜):

function newOperator(Constr, arrayWithArgs) {
    var thisValue = Object.create(Constr.prototype);
    Constr.apply(thisValue, arrayWithArgs);
    return thisValue;
}

1.3 this位於方法內

在方法中,更像似傳統物件導向語言:this參考到接收者(recivier),叫用方法的物件。

var obj = {
    method: function() {
        console.log(this === obj); // true
    }
}
obj.method();

2. this位於最上層範圍內

在瀏覽器中,最上層範圍是全域範圍(global scope),而this參考到全域範圍(就像瀏覽器一樣):

<script>
console.log(this === window); // true
</script>

在Node.js中,你通常在模組內執行程式碼。因此,最上層範圍是一種特殊的模組範圍:

// `global` (not `window`) refers to global object:
console.log(Math === global.Math); // true

// `this` doesn’t refer to the global object:
console.log(this !== global); // true
// `this` refers to a module’s exports:
console.log(this === module.exports); // true

3. this位於eval()

eval()可以直接地(用一般函式呼叫)或間接地(用其他方式)呼叫。詳細說明請參考這裡

如果eval()直接地呼叫,this參考到全域物件

> (0, eval)('this === window')
true

除此之外,如果eval()間接地呼叫,this保持相同內容與eval()相同環境。例如:

// 一般函式

function sloppyFunc() {
    console.log(eval('this') === window); // true
}
sloppyFunc();

function strictFunc() {
    'use strict';
    console.log(eval('this') === undefined); // true
}
strictFunc();

// 建構式
var savedThis;

function Constr() {
    savedThis = eval('this');
}
var inst = new Constr();
console.log(savedThis === inst); // true

// 方法
var obj = {
    method: function() {
        console.log(eval('this') === obj); // true
    }
}
obj.method();

4. this相關陷阱

這裡有三個this相關陷阱你需要小心。注意,在每種案例中,嚴格模式讓事情安全些,因為在一般函式內,this的值是undefined,當發生錯誤時,你會收到警告。

4.1. 陷阱一:忘了用new運算子

如果你叫用一個建構式,但是忘了用new運算子,意外地你用到的是一般函式。因此,this不會有正確的值。在鬆散模式中,thiswindow,所以你會產生全域變數:

function Point(x, y) {
    this.x = x;
    this.y = y;
}
var p = Point(7, 5); // we forgot new!
console.log(p === undefined); // true

// Global variables have been created:
console.log(x); // 7
console.log(y); // 5

感謝地,你在嚴格模式中,會收到警告 (this === undefined)

function Point(x, y) {
    'use strict';
    this.x = x;
    this.y = y;
}
var p = Point(7, 5);
// TypeError: Cannot set property 'x' of undefined

4.2 陷阱二:不當地抽出方法

如果你要取回一個方法的值(而不是叫用它),你會把方法變成一個函式。呼叫帶入值會得到執行函式後的結果,而不是執行方法後的結果。這類的抽出會發生在當你傳入一個方法作為參數,來呼叫一個函式或者方法。現實世界中範例中包括setTimeout()及註冊事件處理常式(event handler)。我將使用callIt函式來同步地模擬這個使用案例:

/** 像似於 setTimeout() 及 setImmediate() */
function callIt(func) {
    func();
}

如果你把鬆散模式下的方法當作函式呼叫,this參考到全域物件且全域變數會被產生:

var counter = {
    count: 0,
    // 鬆散模式的方法
    inc: function() {
        this.count++;
    }
}

callIt(counter.inc);

// 無效:
console.log(counter.count); // 0

// 取而代之,產生了一個全域變數 (在undefined套用++運算子會得到NaN):
console.log(count); // NaN

如果你把嚴格模式下的方法當作函式呼叫,thisundefined。事情也不會有效。但至少你會得到警告:

var counter = {
    count: 0,
    // 嚴格模式
    inc: function() {
        'use strict';
        this.count++;
    }
}

callIt(counter.inc);

// TypeError: Cannot read property 'count' of undefined
console.log(counter.count);

利用bind()來解決這問題:

var counter = {
    count: 0,
    inc: function() {
        this.count++;
    }
}

callIt(counter.inc.bind(counter));

// 有效!
console.log(counter.count); // 1

bind()產生一個新的函式,並且永遠接收this值,其值為counter

4.3 陷阱三:this 陰影

當你在方法內使用了一個一般函式,很容易忘了一般函式內也有它自己的this(即使不需要用到它)。因此,你無法在一般函式內參考到方法的this,因為它被隱蔽了。讓我們來看個例子,看看事情如何出錯:

var obj = {
    name: 'Jane',
    friends: ['Tarzan', 'Cheeta'],
    loop: function() {
        'use strict';
        this.friends.forEach(
            function(friend) {
                console.log(this.name + ' knows ' + friend);
            }
        );
    }
};
obj.loop();
// TypeError: Cannot read property 'name' of undefined

上述範例中,this.name失敗,因為一般函式的thisundefined,它與方法loop()內的this並不相同。這裡有三種方法來解決...this

方法一: that = this。將this指派到一個沒有被隱蔽的變數that(另一個常見的命名為self),並且使用它。

loop: function() {
    'use strict';
    var that = this; // that==this
    this.friends.forEach(function(friend) {
        console.log(that.name + ' knows ' + friend); // this-->that
    });
}

方法二bind()。用bind()來產生一個函式,該函式會永遠將this參考到正確的值(下列範例中,參考到的是方法內的this)。

loop: function() {
    'use strict';
    this.friends.forEach(function(friend) {
        console.log(this.name + ' knows ' + friend);
    }.bind(this)); /// >> bind(this) <<
}

方法三:利用forEach第二個參數。這個方法有第二個參數,其值會傳入到回呼函式(callback)的this

loop: function() {
    'use strict';
    this.friends.forEach(function(friend) {
        console.log(this.name + ' knows ' + friend);
    }, this); // this
}

5. 最佳做法

概念地,我想一般函式不要有它自已的this,並且把前述的解決方法當作幻想。ECMAScript 6用箭頭函式(arrow functions)來支援這個方法 -- 該函式沒有自己的this。在這些函式內,你可以自由的使用this,因為它沒有被隱蔽:

loop: function() {
    'use strict';
    // 傳入 forEach() 的是個箭頭函式(arrow function)
    this.friends.forEach(friend = > {
        // 這裡的`this`就是loop方法的`this`
        console.log(this.name + ' knows ' + friend);
    });
}

我不喜歡API使用this來作為一般函式的額外參數:

beforeEach(function() {
    this.addMatchers({ // this!
        toBeInRange: function(start, end) {
                ...
        }
    });
});

轉變這些暗示參數成為明示參數,讓事情更加明顯且相容於箭頭函式。

beforeEach(api = > {
    api.addMatchers({
        toBeInRange(start, end) {
                ...
        }
    });
});