JavaScript 的 this 指向問題深度解析

邊城發表於2017-02-20

  JavaScript 中的 this 指向問題有很多部落格在解釋,仍然有很多人問。

  與我們常見的很多語言不同,JavaScript 函式中的 this 指向並不是在函式定義的時候確定的,而是在呼叫的時候確定的。換句話說,函式的呼叫方式決定了 this 指向。

  JavaScript 中,普通的函式呼叫方式有三種:直接呼叫、方法呼叫和 new 呼叫。除此之外,還有一些特殊的呼叫方式,比如通過 bind() 將函式繫結到物件之後再進行呼叫、通過 call()、apply() 進行呼叫等。而 es6 引入了箭頭函式之後,箭頭函式呼叫時,其 this 指向又有所不同。下面就來分析這些情況下的 this 指向。

 直接呼叫

  直接呼叫,就是通過 函式名(...) 這種方式呼叫。這時候,函式內部的 this 指向全域性物件,在瀏覽器中全域性物件是 window,在 NodeJs 中全域性物件是 global。

  來看一個例子:

// 簡單相容瀏覽器和 NodeJs 的全域性物件
const _global = typeof window === "undefined" ? global : window;

function test() {
    console.log(this === _global);    // true
}

test();    // 直接呼叫

  這裡需要注意的一點是,直接呼叫並不是指在全域性作用域下進行呼叫,在任何作用域下,直接通過 函式名(...) 來對函式進行呼叫的方式,都稱為直接呼叫。比如下面這個例子也是直接呼叫

(function(_global) {
    // 通過 IIFE 限定作用域

    function test() {
        console.log(this === _global);  // true
    }

    test();     // 非全域性作用域下的直接呼叫
})(typeof window === "undefined" ? global : window);

  bind() 對直接呼叫的影響

  還有一點需要注意的是 bind() 的影響。Function.prototype.bind() 的作用是將當前函式與指定的物件繫結,並返回一個新函式,這個新函式無論以什麼樣的方式呼叫,其 this 始終指向繫結的物件。還是來看例子:

const obj = {};

function test() {
    console.log(this === obj);
}

const testObj = test.bind(obj);
test();     // false
testObj();  // true

  那麼 bind() 幹了啥?不妨模擬一個 bind() 來了解它是如何做到對 this 產生影響的。

const obj = {};

function test() {
    console.log(this === obj);
}

// 自定義的函式,模擬 bind() 對 this 的影響
function myBind(func, target) {
    return function() {
        return func.apply(target, arguments);
    };
}

const testObj = myBind(test, obj);
test();     // false
testObj();  // true

  從上面的示例可以看到,首先,通過閉包,保持了 target,即繫結的物件;然後在呼叫函式的時候,對原函式使用了 apply 方法來指定函式的 this。當然原生的 bind() 實現可能會不同,而且更高效。但這個示例說明了 bind() 的可行性。

  call 和 apply 對 this 的影響

  上面的示例中用到了 Function.prototype.apply(),與之類似的還有 Function.prototype.call()。這兩方法的用法請大家自己通過連結去看文件。不過,它們的第一個引數都是指定函式執行時其中的 this 指向。

  不過使用 apply 和 call 的時候仍然需要注意,如果目錄函式本身是一個繫結了 this 物件的函式,那 apply 和 call 不會像預期那樣執行,比如

const obj = {};

function test() {
    console.log(this === obj);
}

// 繫結到一個新物件,而不是 obj
const testObj = test.bind({});
test.apply(obj);    // true

// 期望 this 是 obj,即輸出 true
// 但是因為 testObj 繫結了不是 obj 的物件,所以會輸出 false
testObj.apply(obj); // false

  由此可見,bind() 對函式的影響是深遠的,慎用!

 方法呼叫

  方法呼叫是指通過物件來呼叫其方法函式,它是 物件.方法函式(...) 這樣的呼叫形式。這種情況下,函式中的 this 指向呼叫該方法的物件。但是,同樣需要注意 bind() 的影響。

const obj = {
    // 第一種方式,定義物件的時候定義其方法
    test() {
        console.log(this === obj);
    }
};

// 第二種方式,物件定義好之後為其附加一個方法(函式表示式)
obj.test2 = function() {
    console.log(this === obj);
};

// 第三種方式和第二種方式原理相同
// 是物件定義好之後為其附加一個方法(函式定義)
function t() {
    console.log(this === obj);
}
obj.test3 = t;

// 這也是為物件附加一個方法函式
// 但是這個函式繫結了一個不是 obj 的其它物件
obj.test4 = (function() {
    console.log(this === obj);
}).bind({});

obj.test();     // true
obj.test2();    // true
obj.test3();    // true

// 受 bind() 影響,test4 中的 this 指向不是 obj
obj.test4();    // false

  這裡需要注意的是,後三種方式都是預定定義函式,再將其附加給 obj 物件作為其方法。再次強調,函式內部的 this 指向與定義無關,受呼叫方式的影響。

  方法中 this 指向全域性物件的情況

  注意這裡說的是方法中而不是方法呼叫中。方法中的 this 指向全域性物件,如果不是因為 bind(),那就一定是因為不是用的方法呼叫方式,比如

const obj = {
    test() {
        console.log(this === obj);
    }
};

const t = obj.test;
t();    // false

  t 就是 obj 的 test 方法,但是 t() 呼叫時,其中的 this 指向了全域性。

  之所以要特別提出這種情況,主要是因為常常將一個物件方法作為回撥傳遞給某個函式之後,卻發現執行結果與預期不符——因為忽略了呼叫方式對 this 的影響。比如下面的例子是在頁面中對某些事情進行封裝之後特別容易遇到的問題:

class Handlers {
    // 這裡 $button 假設是一個指向某個按鈕的 jQuery 物件
    constructor(data, $button) {
        this.data = data;
        $button.on("click", this.onButtonClick);
    }

    onButtonClick(e) {
        console.log(this.data);
    }
}

const handlers = new Handlers("string data", $("#someButton"));
// 對 #someButton 進行點選操作之後
// 輸出 undefined
// 但預期是輸出 string data

  很顯然 this.onButtonClick 作為一個引數傳入 on() 之後,事件觸發時,是對這個函式進行的直接呼叫,而不是方法呼叫,所以其中的 this 會指向全域性物件。要解決這個問題有很多種方法

// 這是在 es5 中的解決辦法之一
var _this = this;
$button.on("click", function() {
    _this.onButtonClick();
});

// 也可以通過 bind() 來解決
$button.on("click", this.onButtonClick.bind(this));

// es6 中可以通過箭頭函式來處理,在 jQuery 中慎用
$button.on("click", e => this.onButtonClick(e));

  不過請注意,將箭頭函式用作 jQuery 的回撥時造成要小心函式內對 this 的使用。jQuery 大多數回撥函式(非箭頭函式)中的 this 都是表示呼叫目標,所以可以寫 $(this).text() 這樣的語句,但 jQuery 無法改變箭頭函式的 this 指向,同樣的語句語義完全不同。

 new 呼叫

  在 es6 之前,每一個函式都可以當作是建構函式,通過 new 呼叫來產生新的物件(函式內無特定返回值的情況下)。而 es6 改變了這種狀態,雖然 class 定義的類用 typeof 運算子得到的仍然是 "function",但它不能像普通函式一樣直接呼叫;同時,class 中定義的方法函式,也不能當作建構函式用 new 來呼叫。

  而在 es5 中,用 new 呼叫一個建構函式,會建立一個新物件,而其中的 this 就指向這個新物件。這沒有什麼懸念,因為 new 本身就是設計來建立新物件的。

var data = "Hi";    // 全域性變數

function AClass(data) {
    this.data = data;
}

var a = new AClass("Hello World");
console.log(a.data);    // Hello World
console.log(data);      // Hi

var b = new AClass("Hello World");
console.log(a === b);   // false

 箭頭函式中的 this

  先來看看 MDN 上對箭頭函式的說明

An arrow function expression has a shorter syntax than a function expression and does not bind its own this, arguments, super, or new.target. Arrow functions are always anonymous. These function expressions are best suited for non-method functions, and they cannot be used as constructors.

  這裡已經清楚了說明了,箭頭函式沒有自己的 this 繫結。箭頭函式中使用的 this,其實是直接包含它的那個函式或函式表示式中的 this。比如

const obj = {
    test() {
        const arrow = () => {
            // 這裡的 this 是 test() 中的 this,
            // 由 test() 的呼叫方式決定
            console.log(this === obj);
        };
        arrow();
    },

    getArrow() {
        return () => {
            // 這裡的 this 是 getArrow() 中的 this,
            // 由 getArrow() 的呼叫方式決定
            console.log(this === obj);
        };
    }
};

obj.test();     // true

const arrow = obj.getArrow();
arrow();        // true

  示例中的兩個 this 都是由箭頭函式的直接外層函式(方法)決定的,而方法函式中的 this 是由其呼叫方式決定的。上例的呼叫方式都是方法呼叫,所以 this 都指向方法呼叫的物件,即 obj。

  箭頭函式讓大家在使用閉包的時候不需要太糾結 this,不需要通過像 _this 這樣的區域性變數來臨時引用 this 給閉包函式使用。來看一段 Babel 對箭頭函式的轉譯可能能加深理解:

// ES6
const obj = {
    getArrow() {
        return () => {
            console.log(this === obj);
        };
    }
}    
// ES5,由 Babel 轉譯
var obj = {
    getArrow: function getArrow() {
        var _this = this;
        return function () {
            console.log(_this === obj);
        };
    }
};

  另外需要注意的是,箭頭函式不能用 new 呼叫,不能 bind() 到某個物件(雖然 bind() 方法呼叫沒問題,但是不會產生預期效果)。不管在什麼情況下使用箭頭函式,它本身是沒有繫結 this 的,它用的是直接外層函式(即包含它的最近的一層函式或函式表示式)繫結的 this。

相關文章