JS this指標深度剖析

2020-07-16 10:05:12
JavaScript 函數被呼叫後會在一個特定的執行環境內執行,這個執行環境就是函數的呼叫者,或者說是呼叫函數的物件。如果函數沒有呼叫者(不是通過物件呼叫,而是直接呼叫),那麼執行環境就是全域性物件 window。

為了在函數執行過程中能夠參照(存取)執行環境,JavaScript 專門增加了一個 this 關鍵字。this 是一個指標型變數,它指向當前函數的執行環境。

在不同的場景中呼叫同一個函數,this 的指向也可能會發生變化,但是它永遠指向其所在函數的真實呼叫者(誰呼叫就指向誰);如果沒有呼叫者,this 就指向全域性物件 window。

在《JS this和呼叫物件》一節中我們曾講到 this 指標的初步使用,不了解的讀者請猛擊連結學習,本節重點對 this 指標進行深度剖析。

使用 this

this 是由 JavaScript 引擎在執行函數時自動生成的,存在於函數內的一個動態指標,指代當前呼叫物件。具體用法如下:

this[.屬性]

如果 this 未包含屬性,則傳遞的是當前物件。

this 用法靈活,其包含的值也是變化多端。例如,下面範例使用 call() 方法不斷改變函數內 this 指代物件。
var x = "window";  //定義全域性變數x,初始化字串為“window”
function a () {  //定義建構函式a
    this.x = "a";  //定義私有屬性x,初始化字元a
}
function b () {  //定義建構函式b
    this.x = "b";  //定義私有屬性x,初始化為字元b
}
function c () {  //定義普通函數,提示變數x的值
    console.log(x);
}
function f () {  //定義普通函數,提示this包含的x的值
    console.log(this.x);
}
f();  //返回字串“window”,this指向window物件
f.call(window);  //返回字串“window”,指向window物件
f.call(new a());  //返回字元a,this指向函數a的範例
f.call(new b());  //返回字元b,this指向函數b的範例
f.call(c);  //返回undefined,this指向函數c物件

下面簡單總結 this 在 5 種常用場景中的表現以及應對策略。

1. 普通呼叫

下面範例演示了函數參照和函數呼叫對 this 的影響。
var obj = {  //父物件
    name : "父物件obj",
    func : function () {
        return this;
    }
}
obj.sub_obj = {  //子物件
    name : "子物件sub_obj",
    func : obj.func
}
var who = obj.sub_obj.func();
console.log(who.name);  //返回“子物件sub_obj”,說明this代表sub_obj
如果把子物件 sub_obj 的 func 改為函數呼叫。
obj.sub_obj = {
    name : "子物件sub_obj",
    func : obj.func()  //呼叫父物件obj的方法func
}
則函數中的 this 所代表的是定義函數時所在的父物件 obj。
var who = obj.sub_obj.func;
console.log(who.name);  //返回“父物件obj”,說明this代表父物件obj

2. 範例化

使用 new 命令呼叫函數時,this 總是指代範例物件。
var obj = {};
obj.func = function () {
    if (this == obj) console.log("this = obj");
    else if (this == window) console.log("this = window");
    else if (this.contructor == arguments.callee) console.log("this = 範例物件");
}
new obj.func;  //範例化

3. 動態呼叫

使用 call 和 apply 可以強制改變 this,使其指向引數物件。
function func () {
    //如果this的建構函式等於當前函數,則表示this為範例物件
    if (this.contructor == arguments.callee) console.log("this = 範例物件");
    //如果this等於window,則表示this為window物件
    else if (this == window) console.log("this = window物件");
    //如果this為其他物件,則表示this為其他物件
    else console.log("this == 其他物件 n this.constructor =" + this.constructor);
}
func();  //this指向window物件
new func();  //this指向範例物件
cunc.call(1);  //this指向數值物件
在上面範例中,直接呼叫 func() 時,this 代表 window 物件。當使用 new 命令呼叫函數時,將建立一個新的範例物件,this 就指向這個新建立的範例物件。

使用 call() 方法執行函數 func() 時,由於 call() 方法的引數值為數位 1,則 JavaScript 引擎會把數位 1 強制封裝為數值物件,此時 this 就會指向這個數值物件。

4. 事件處理

在事件處理常式彙總,this 總是指向觸發該事件的物件。
<input type="button" value="測試按鈕" />
<script>
    var button = document.getElementsByTagName("put")[0];
    var obj = {};
    obj.func = function () {
        if (this == obj) console.log("this = obj");
        if (this == window) console.log("this = window");
        if (this == button) console.log("this = button");
    }
    button.onclick = obj.func;
</script>
在上面程式碼中,func() 所包含的 this 不再指向物件 obj,而是指向按鈕 button,因為 func() 是被傳遞給按鈕的事件處理常式之後才被呼叫執行的。

如果使用 DOM2 級標準註冊事件處理常式,程式如下:
if (window.attachEvent) {  //相容IE模型
    button.attachEvent("onclick", obj.func);
} else {  //相容DOM標準模型
    button.addEventListener("click", obj.func, true);
}
在 IE 瀏覽器中,this 指向 window 物件和 button 物件,而在 DOM 標準的瀏覽器中僅指向 button 物件。因為,在 IE 瀏覽器中,attachEvent() 是 window 物件的方法,呼叫該方法時,this 會指向 window 物件。

為了解決瀏覽器相容性問題,可以呼叫 call() 或 apply() 方法強制在物件 obj 身上執行方法 func(),避免出現不同的瀏覽器對 this 解析不同的問題。
if (window.attachEvent) {
    button.attachEvent("onclick", function () {  //用閉包封裝call()方法強制執行func()
        obj.func.call(obj);
    });
} else {
    button.attachEventListener("onclick", function () {
        obj.func.call(obj);
    }, true);
}
當再次執行時,func() 中包含的 this 始終指向物件 obj。

5. 定時器

使用定時器呼叫函數。
var obj = {};
obj.func = function () {
    if (this == obj) console.log("this = obj");
    else if (this == window) console.log("this = window物件");
    else if (this.constructor == arguments.callee) console.log("this = 範例物件");
    else console.log("this == 其他物件 n this.constructor =" + this.constructor);
}
setTimeOut(obj.func, 100);
在 IE 中 this 指向 window 物件和 button 物件,具體原因與上面講解的 attachEvent() 方法相同。在符合 DOM 標準的瀏覽器中,this 指向 window 物件,而不是 button 物件。

因為方法 setTimeOut() 是在全域性作用域中被執行的,所以 this 指向 window 物件。要解決瀏覽器相容性問題,可以使用 call 或 apply 方法來實現。
setTimeOut (function () {
    obj.func.call(obj);
}, 100);

this 安全策略

由於 this 的不確定性,會給開發帶來很多風險,因此使用 this 時,應該時刻保持謹慎。鎖定 this 有以下兩種基本方法。
  • 使用私有變數儲存 this。
  • 使用 call 和 apply 強制固定 this 的值。

下面結合 3 個案例進行說明。

範例1

使用 this 作為引數來呼叫函數,可以避免產生 this 因環境變化而變化的問題。例如,下面做法是錯誤的,因為 this 會始終指向 window 物件,而不是當前按鈕物件。
<input type="button" value="按鈕1" onclick="func()" />
<input type="button" value="按鈕2" onclick="func()" />
<input type="button" value="按鈕3" onclick="func()" />
<script>
    function func() {
        console.log(this.value);
    }
</script>
如果把 this 作為引數進行傳遞,那麼它就會代表當前物件。
<input type="button" value="按鈕1" onclick="func(this)" />
<input type="button" value="按鈕2" onclick="func(this)" />
<input type="button" value="按鈕3" onclick="func(this)" />
<script>
    function func (obj) {
        console.log(obj.value);
    }
</script>

範例2

使用私有變數儲存 this,設計靜態指標。

例如,在建構函式中把 this 儲存在私有變數中,然後在方法中使用私有變數來參照建構函式的 this,這樣在型別範例化後,方法內的 this 不會發生變化。
function Base () {  //基礎類別
    var _this = this;  //當初始化時,儲存範例物件的參照指標
    this.func = function () {
        return _this;  //返回初始化時範例物件的參照
    };
    this.name = "Base";
}
    function Sub () {  //子類
        this.name = "Sub";
    }
Sub.prototype = new Base();  //繼承基礎類別
var sub = new Sub();  //範例化子類
var _this = sub.func();
console.log(_this.name);  //this始終指向基礎類別範例,而不是子類範例

範例3

使用 call 和 apply 強制固定 this 的值。

作為一個動態指標,this 也可以被轉換為靜態指標。實現方法:使用 call() 或 apply() 方法強制指定 this 的指代物件。
//把this轉換為靜態指標
//引數obj表示預設定this所指代的物件,返回一個預備呼叫的函數
Function.prototype.pointTo = function (obj) {
    var _this = this;  //儲存當前函數物件
    return function () {  //返回一個閉包函數
        return _this.apply(obj, arguments);  //返回執行當前函數,並強制設定為指定物件
    }
}
為 Function 擴充套件一個原型方法 pointTo(),該方法將在指定的引數物件上呼叫當前函數,從而把 this 係結到指定物件上。

下面利用這個擴充套件方法,實現強制指定物件 obj1 的方法 func() 中的 this 始終指向 obj1。具體如下說明:
var obj1 = {
    name : "this = obj1"
}
obj1.func = (function () {
    return this;
}).pointTo(obj1);  //把this系結到物件obj1身上
var obj2 = {
    name : "this = obj2",
    func : obj1.func
}
var _this = obj2.func();
console.log(_this.name);  //返回“this=obj1”,說明this指向obj1,而不是obj2

可以擴充套件 new 命令的替代方法,從而間接實現自定義範例化類。
//把建構函式轉換為範例物件
//引數func表示建構函式,返回建構函式func的範例物件
function instanceFrom (func) {
    var _arg = [].slice.call(arguments, 1);  //獲取建構函式可能需要的初始化函數
    func.prototype.constructor = func;  //設定建構函式的原型結構器指向自身
    func.apply(func.prototype, _arg);  //在原型物件上呼叫建構函式
                                       //此時this指代原型物件,相當於範例物件
    return func.prototype;  //返回原型物件
}

下面使用這個範例化類函數把一個簡單的建構函式轉換為具體的範例物件。
function F () {
    this.name = "F";
}
var f = instanceFrom(F);
console.log(f.name);
call() 和 apply() 具有強大的功能,它不僅能夠執行函數,也能夠實現 new 命令的功能。

系結函數

系結函數是為了糾正函數函數的執行上下文,把 this 係結到指定物件上,避免在不同執行上下文中呼叫函數時,this 指代的物件不斷變化。
function bind(fn, context) {
    return function () {
        return fn.apply(context, arguments);
    };
}
bind() 函數接收一個函數和一個上下文環境,返回一個在給特定環境中呼叫給函數的函數,並且將返回函數的所有的引數原封不動地傳遞給呼叫函數。

這裡的 arguments 屬於內部函數,而不屬於 bind() 函數。在呼叫返回的函數時,會在給定的環境中執行被傳入的函數,並傳入所有引數。

函數系結可以在特定的環境中為指定的引數呼叫另一個函數,該特徵常與回撥函數、時間處理常式一起使用。
<button id="btn">測試按鈕</button>
<script>
    var handler = {  //事件處理物件
        message : 'handler',  //名稱
        click : function (event) {  //時間處理常式
            console.log(this.message);  //提示當前物件的message值
        }
    };
    var btn = document.getElementById('btn');
    btn.addEventListener('click', handler.click);
</script>
在上面範例中,為按鈕系結單擊事件處理常式,設計當單擊按鈕時,將顯示 handler 物件的 message 屬性值。。但是,實際測試發現,this 最後指向了 DOM 按鈕,而不是 handler。

解決方法:使用閉包進行修正。
var handler = {  //事件處理常式
    message : 'handler',  //名稱
    click : function (event) {  //時間處理常式
        console.log(this.message);  //提示當前物件的message值
    }
};
var btn = document.getElementById('btn');
btn.addEventListener('click', function () {  //使用閉包進行修正:封裝事件處理常式的呼叫
    handler.click();
});  //'handler'
改進方法:使用閉包比較麻煩,如果建立多個閉包可能會令程式碼變得難以理解和偵錯,而使用 bind() 係結函數就很方便。
var handler = {  //事件處理常式
    message : 'handler',  //名稱
    click : function (event) {  //事件處理常式
        console.log(this.message);  //提示當前物件的message值
    }
};
var btn = document.getElementById('btn');
btn.addEventListener('click', bind(handler.click, handler));  //‘handler’

使用 bind

ECMAScript 5 為 Function 新增了 bind 原型方法,用來把函數系結到指定物件上。在系結函數中,this 物件被解析為傳入的物件。具體用法如下:

function.bind(thisArg [,arg1 [,arg2 [,argN]]]);

引數說明如下:
  • function:必需引數,一個函數物件。
  • thisArg:必需引數,this 可在新函數中參照的物件。
  • arg1 [,arg2 [,argN]]:可選引數,要傳遞到新函數的引數的列表。
bind() 方法將返回與 function 函數相同的新函數,thisArg 物件和初始引數除外。

範例1

下面範例定義原始函數 check,用來檢測傳入的引數值是否在一個指定範圍內,範圍下限和上限根據當前範例物件的 min 和 max 屬性決定。然後使用 bind() 方法把 check 函數系結到物件 range 身上。如果再次呼叫這個新系結後的函數 check1,就可以根據該物件的屬性 min 和 max 來確定呼叫函數時傳入值是否在指定的範圍內。
var check = function (value ) {
    if (typeof value !== 'number') return false;
    else return value >= this.min && value <= this.max;
}
var range = {min : 10, max : 20};
var check1 = check.bind(range);
var result = check1(12);
console.log(result);  //true

範例2

在上面範例基礎上,下面範例為 obj 物件定義了兩個上下限屬性,以及一個方法 check。然後,直接呼叫 obj 物件的 check 方法,檢測 10 是否在指定範圍,返回值為 false,因為當前 min 和 max 值分別為 50 和 100。接著把 obj.check 方法系結到 range 物件,再次傳入值 10,返回值為 true,說明在指定範圍,因為此時 min 和 max 值分別為 10 和 20。
var obj = {
    min : 50,
    max : 100,
    check : function (value) {
        if (typeof value !== 'number') {
            return false;
        } else {
            return value >= this.min && value <= this.max
        }
    }
}
var result = obj.check(10);
console.log(result);  //false
var range = {min : 10, max : 20};
var check1 = obj.check.bind(range);
var result = check1(10);
console.log(result);  //true

範例3

下面範例演示了如何使用 bind() 方法為函數傳遞兩次引數值,以便實現連續引數求值計算。
var func = function (val1, val2, val3, val4) {
    console.log(val1 + " " + val2 + " " + val3 + " " + val4);
}
var obj = {};
var func1 = func.bind(obj, 12, "a");
func1 ("b", "c");  //12 a b c

鏈式語法

jQuery 框架最大亮點之一就是它的鏈式語法。實現方法:設計每一個方法的返回值都是 jQuery 物件(this),這樣呼叫方法的返回結果可以為下一次呼叫其他方法做準備。

範例

下面範例演示如何在函數中返回 this 來設計鏈式語法。分別為 String 擴充套件了 3 個方法:trim、writeln 和 log,其中 writeln 和 log 方法返回值都為 this,而 trim 方法返回值為修剪後的字串。這樣就可以用鏈式語法在一行語句中快速呼叫這 3 個 方法。
Function.prototype.method = function (name, func) {
    if (!this.prototype[name]) {
        this.prototype.[name] = func;
        return this;
    }
}
String.method = ('trim', function () {
    return this.replace(/^s+|s+$/g, '');
});
String.method = ('writeln', function () {
    console.log(this);
    return this;
});
String.method = ('log', function () {
    console.log(this);
    return this;
});
var str = "abc";
str.trim().writeln().log();