物件導向Javascript - what is this

 Fri, 28 Mar 2008 20:00:09 +0800

在Javascript中,我們常常會假設this就是目前的execution context的一個reference。在ECMA262第三版中提到:「There is a this value associated with every active execution context. The this value depends on the caller and the type of code being executed and is determined when control enters the execution context. The this value associated with an execution context is immutable.」。換句話說,this的值,是依據程式碼的種類與呼叫者在進入execution context時決定的。怎麼決定呢?ECMA262第三版接下來說明...

Global Code
The this value is the global object.

Eval Code
The this value is the same as the this value of the calling context.

Function Code
The caller provides the this value. If the this value provided by the caller is not an object (including the case where it is null), then the this value is the global object.

Global Code跟Eval Code的狀況都很容易理解,但是Function Code的狀況是怎樣呢?

簡單地說(其實不簡單,因為這些定義分散在ECMA262各處),如果function是透過一個reference來呼叫,則在呼叫時會把reference的物件傳給這個function當作this,否則就會傳null,而當是null的狀況時,function會決定它的this是Global物件。

如果使用new運算子,會先產生一個Object物件,把它當作this傳給constructor函數,然後執行constructor。(我簡化過,但是大體上的意思是這樣)

還是用程式來看最清楚:(以下的例子出現的訊息都是在Firefox下執行的結果,在IE下執行可能會有些不一樣)

function test() {
alert(this);
}
test();
var a = new test();

在網頁執行這一段javascript,會先跳出"[object Window]",然後跳出"[object Object]"。可以看出,把test直接當作function跟當作constructor呼叫,結果是不太一樣的。

再來看一下用function實作物件導向javascript的狀況:

function test() {
function test1() {
alert(this);
}
this.test2 = function() {
alert(this);
}
this.test3 = function() {
test1();
}
test1();
this.test2();
this.test3();
}
test();
var a = new test();
a.test2();
a.test3();

呼叫test()時,因為this就是Global object,也就是window。所以跳出三次"[object Window]"。執行到var a = new test()時,傳給constructor的this是一個new Object,所以依序跳出"[object Window]"、"[object Object]"、"[object Window]"。執行a.test2()會跳出"[object Object]",而執行a.test3()則跳出"[object Window]"。

可以看出來,test1()雖然在test外無法直接執行,但是透過test3()間接呼叫,結果仍然是"[object Window]"。(也可以這樣想,其實第一個例子可以把它看作在function window()裡面運作的)

如果用json的方式定義方法呢?用一個例子測測看:

var test = {
"test1":function(){alert(this);}
};
test.test1();

很明顯地,透過test這個reference來呼叫,結果就是"[object Object]"。(這裡是不能用var a = new test()喔,否則會出現沒有constructor的錯誤訊息。)

如果想在用function定義出的私有方法中使用外面這層的this,那要怎麼辦?很簡單,其實透過scope就解決了:

function test() {
var ref = this;
function test1() {
alert(ref);
}
this.test2 = function() {
test1();
}
}
var a = new test();
a.test2();

執行a.test2()的結果,會跳出"[object Object]",因為在a.test2()中呼叫test1(),而在test1()中用到了ref這個變數,這個變數在test1()中並未定義,所以就往外一層找,就找到了....它的值在外面一層被assign成外面這一層的this,所以在test1()中可以使用到。

使用this常出的錯誤,在同時使用物件導向的javascript與setInterval/setTimeout時最常看到。(之前在論壇碰到很多這類的問題)用下面這個例子來看:(它會停不下來喔,使用請小心)

function test() {
this.tid = null;
this.count = 0;
this.a = function () {
this.tid = setInterval(this.b,300);
}
this.b = function() {
if(this.count>3) {
clearInterval(this.tid);
this.count = 0;
} else {
alert("round: " + this.count);
this.count++;
}
}
}
var a = new test();
a.a();

把this.b傳給setInterval,沒問題,但是從setInterval執行的時候,你會發現第一次會跳出"[object Window] : undefined",接下來會跳出"[object Window] : NaN"。看出問題了嗎?傳給b()的this並不適你想像中的喔。

為了簡化問題,我們用另一個例子來看到底是怎麼回事:

var a;
function test() {
var _ref = this;
this.test1 = function() {
window['a'] = this.test2;
}
this.test2 = function() {
alert(this);
alert(_ref);
}
}
var b = new test();
b.test1();
alert(a);
a();

執行的結果,依序會是跳出

function() {
alert(this);
alert(_ref);
}

的對話框,然後是跳出"[object Window]",再來跳出"[object Object]"的訊息對話框。

執行b.test1()時,會把b.test2 assign給變數a。assign給他甚麼東西呢?就是第一次跳出來對話框所顯示的function內容,接著執行這個function,因為是在Global scope執行,執行的時候也沒有透過reference,所以pass給他的this就是[object Window]了。碰到_ref時,因為在這個function裡面沒有定義,所以會到scope chain上一層找,這時會發現在function test()的第一行有var _ref = this,而透過var b = new test()時,傳給constructor的this是[object Object],所以呈現出上面的結果。所以要之前setTimeout/setInterval的例子可以正確執行,按照這個規則推想,就應該把它改成:

function test() {
this.tid = null;
this.count = 0;
var _ref = this;
this.a = function () {
this.tid = setInterval(this.b,300);
}
this.b = function() {
if(_ref.count>3) {
clearInterval(_ref.tid);
_ref.count = 0;
} else {
alert("round: " + _ref.count);
_ref.count++;
}
}
}
var a = new test();
a.a();

這樣就可以正確執行了。但是老實說,這個方法還不夠好。有一個問題,就是tid、count、b()其實透過上面例子裡的變數a就可以存取或執行,在執行setTimeout/setInterval這樣的動作時,如果不小心動到了,就有可能造成非預期的結果,要更好的封裝,那應該要這樣寫;

function test() {
var _tid = null;
var _count = 0;
this.a = function () {
_tid = setInterval(b,300);
}
function b() {
if(_count>3) {
clearInterval(_tid);
_count = 0;
} else {
alert("round: " + _count);
_count++;
}
}
}
var a = new test();
a.a();

現在,唯一可以由外面存取的,只剩下a()方法了,這樣可以讓程式更可靠一點。(要保證在跑interval的時候不被干擾,其實還應該加一個var _lock,透過檢查這個變數,讓還在進行interval時不能啟動另一個interval,要怎麼做,就留給大家自己思考吧,應該很簡單。)


簡單總結一下:

  1. 變數的scope是依照程式的上下文關係決定的。換句話說,可以把看作是依照程式文本的靜態結構來決定的。(因為這是一個直譯的語言?)
  2. 函數中this的值,是在執行函數時決定的,所以要搞清楚函數在怎樣的狀況下執行,你才能知道what is this


2008-3-31 補充

前面出現:

function() {
alert(this);
alert(_ref);
}

對話框的狀況,用這個例子來看更清楚:

function test() {
this.test01 = function() {
alert(this);
}
this.test02 = function() {
delegate(this.test01);
delegate(test03);
}
function test03() {
alert(this);
}
}
function delegate(func) {
alert(func);
func();
} 
var a = new test();
a.test01();
a.test02();

執行的結果會先跳出[object Object],然後是:

function() {
alert(this);
}

接著是[object Window];然後是:

function test03() {
alert(this);
}

最後是[object Window]。

從這個觀察可以得到一個簡單的結論:透過func參數傳入delegate函數的function,傳過去的是它reference到的function expression。所以執行這個參數,其實是在執行這個function expression,這就像在執行一個函數,並沒有透過任何參考來執行,所以this都會變成Global object,也就是[object Window]。

我之前學到的有一點錯誤,以為透過setInterval/setTimeout執行傳過去的function,this會參考到[object Window],是因為這兩個是window物件的方法。但是實際上的原因並不是因為setInterval/setTimeout是window物件的方法,而是因為傳過去的是function expression,執行它的時候,傳給它的this是global object,也就是window的緣故。

(之前在看ECMA262 Edition3的時候,就有一點疑問,為什麼function expression可以有一個optional的function name呢?不是在用var a = function() {.....}的時候就是用function expression,那function name怎麼跑出來的???結果在上面的例子就跑出來了....恍然大悟。其實有名字的function也可以直接執行....像這樣:

(function test1(){
alert(this);
})();

只是如果是匿名的function expression,我們也不會把它加上一個function name,結果在這裡就看到了....)