博客 / 詳情

返回

this到底指向誰?

this關鍵字是JavaScript函數內部的一個對象,this是一個指針,指向調用函數的對象。看似簡單的定義但卻由於在解析this引用過程中可能涉及到執行上下文、作用域鏈、閉包等複雜的機制,導致this的指向問題變得異常複雜。首先必須明白一點,任何複雜的機制都不可能輕而易舉的學懂弄通,因此,本文將與大家一起耐心回顧this對象,歸納總結this的引用機制,希望對你有所幫助。

一、函數到底執行了沒?

要向弄懂this對象,必須先搞懂函數是什麼時候執行?
先看看簡單的一個例子(例1):

function fn(){
    console.log('你好');
}

fn;
fn();
let f = fn;
f();

上面的例子一共輸出 2 次 你好
fn()f()表達式中函數被調用執行,fnlet f=fn表達式中函數並未被調用執行。

函數執行主要看是否存在函數名(),使用不帶括號的函數名會訪問函數指針,並非調用該函數

再看看一個加入閉包機制的例子(例2):

function fn(){
    let hi = '你好'
    return function gn(){
        console.log(hi);
    }
}

fn;
fn();
let f = fn;
f();
let g = fn();
g();

上面的例子似乎較為複雜了,那一共輸出多少次 你好?

  1. 輸出 你好 必須是函數 gn 被調用執行,因此關鍵在於函數 gn 什麼時候被調用?
  2. 根據前一個例子,表達式fnf=fn 沒有調用函數 fn,則更不會調用函數 gn
  3. 根據前一個例子,表達式fn()f() 是相同的含義,均調用了函數fn。在閉包中,調用fn返回返回一個函數gn的函數指針,但最終並沒有通過該函數指針調用gn,因此在表達式fn()f()g=fn()並沒有執行函數gn
  4. 表達式g=fn(),可以將函數gn賦值給g,最後通過g()完成對函數gn的調用執行。類似於:

    let hi = ‘你好’;
    let g = function (){
            console.log(hi);
         }
     
    g();//函數執行

    因此最終該例子僅輸出一次 你好


最後看看一個對象內部的函數調用例子(例3):

let o = {
    hi:'好難呀',
    fn: function () {
        let hi = '你好'
        return function gn() {
            console.log(hi);
        }
    }
}

o.fn;
o.fn();
let f = o.fn;
f();
let g = o.fn();
g();

這個例子中,一共輸出多少次 你好?

其實無論函數放到對象內部定義還是外部定義,均可以採用前一個例子的分析步驟解析函數被調用執行的過程,因此,本例子中也僅輸出一次 你好

全局環境中定義的function,則該函數自動成為window對象的方法,即全局環境下的fn()調用等價於window.fn()調用。

二、神奇的this

what,上面講了一大堆的都還沒有講到this

image.png

別急,理解this的引用機制,我認為最關鍵的是理解函數 執行 的上下文。倘若連函數什麼時候執行都傻傻搞不清,那理解this對象更無從談起,來,我們開始繼續探索。

紅寶書第四版將this對象闡述為:

1.在標準函數中,this引用的是把函數當成方法調用的上下文對象
2.在箭頭函數中,this引用的是定義箭頭函數的上下文

(一)標準函數的this對象

普通函數(除箭頭函數)內部中均有一個this對象。this在函數執行時確定所指對象。這裏有兩個關鍵點:執行時對象

  1. 普通函數在執行時才能確定this。那在定義時能確定碼?不行!記住:函數執行時確定!函數執行時確定!函數執行時確定!
  2. 普通函數的this指向的是調用該函數的對象。那可以指向其他函數嗎?可以指向原始數據類型嗎?統統不行!記住:指向調用該函數的對象、指向調用該函數的對象、指向調用該函數的對象

雖然很多文章對this的引用分了情況討論,但我依舊認為理解上述兩個關鍵點是最重要的。來,我們通過例子進一步分析,以下將按照掘金文章:嗨,你真的懂this嗎?的分類標準進行討論。

默認綁定

默認綁定簡單説就是在全局環境中執行函數,即沒有任何對象直接調用該函數。這種情況下,函數的this將指向window(非嚴格模式)或為undefined(嚴格模式)

//非嚴格模式下,this指向window
function fn1(){
    console.log(this);
}
//嚴格模式下,this為undefined
function fn2(){
    'use strict'
    console.log(this);
}

簡單吧,可是你能準確的判斷出函數是在全局環境中執行的麼?請看下面例子(例4):

var hi = 'window'
let o = {
    hi: '對象',
    gn: function (){
        let hi = '函數';
        console.log(this.hi);
    },
    fn: function () {
        let hi = '函數'
        return function (){
            let hi = '閉包函數';
            console.log(this.hi);
        };
    }
}

o.gn();
let f = o.fn();
f();

一旦涉及對象內部方法、閉包等機制,就會導致問題變得複雜許多。你能看出一共輸出了幾次?有多少次輸出是在全局環境中執行的呢?

按照前一章節分析,可以知道一共有兩次輸出(若是不理解可以回看第一節),分別為表達式o.gn()f()輸出對象,window。分析如下:

  1. 表達式o.gn()顯然是通過對象o對函數gn進行調用,因此gn執行時的this所指向的就是o
  2. 表達式let f = o.fn();將執行fn函數並將閉包函數的函數指針賦值給f變量,此時執行的函數是fn而並非是閉包函數,因此此刻fnthis指向對象o,但是閉包函數的this現在還沒有確定;
  3. 通過調用表達式f();,讓閉包函數執行,此刻閉包函數並不是某個對象調用執行,因此是運行在全局環境中,所以閉包函數的this將指向window(非嚴格模式)

因此,不管函數如何賦值,只要該函數並未執行,this指針就不會確定所指對象。第一個關鍵點就是理解函數是什麼時候執行的!第二個關鍵點就是找到函數是如何被調用的!

隱式綁定

隱式綁定是指通過某個對象調用函數時,函數的this就指向該對象。簡單説就是誰調用函數,函數就指誰。
我們看看下面這個例子,該例子出自知乎文章:JavaScript 的 this原理是什麼?(例5)

const o1 = {
    text: 'o1',
    fn: function() {
        return this.text
    }
}
const o2 = {
    text: 'o2',
    fn: function() {
        return o1.fn()
    }
}
const o3 = {
    text: 'o3',
    fn: function() {
        var fn = o1.fn
        return fn()
    }
}

console.log(o1.fn())
console.log(o2.fn())
console.log(o3.fn())

一看感覺很複雜,但本質上還是找到哪個對象調用函數進行執行,分析一波:

  1. 執行表達式console.log(o1.fn())時,對象o1調用執行函數fn,因此,函數fnthis指向對象o1,所以輸出o1
  2. 執行表達式console.log(o2.fn())時,對象o2調用執行函數fn,因此,o2內部函數fnthis指向對象o2,但且慢,這裏有個表達式return o1.fn(),不難看出這又通過o1調用了o1內部函數fn(該函數this指向o1對象),並將執行結果返回。因此繞個彎還是回到執行o1內部函數fn,輸出o1
  3. 執行表達式console.log(o3.fn())時,對象o3調用執行函數fn,因此,o3內部函數fnthis指向對象o3,但o3內部函數fn並沒有直接用this,而是通過賦值操作獲取了o1內部的fn函數,並執行fn函數。注意,這裏有個坑,最後執行fn函數是沒有對象調用的,因此fn函數的this指向window,這個跟例4類似,若不理解可以回頭看例4。
一定要分清默認綁定和隱式綁定的場景,關鍵點還是在於判斷出函數執行的時間,然後找出哪個對象調用了該函數。

結合回調函數再看一個例子(例6):

var hi = 'window'
let o = {
    hi: '對象',
    fn: function () {
        let hi = '函數';
        setInterval(function(){
            console.log(this.hi);
        },1000);
    }
}

o.fn();

你覺得應該輸出什麼呢?我們先分析一波:

  1. 很明顯,表達式o.fn();執行過程中,函數fnthis鐵定是指向對象o
  2. 再看fn函數裏面,執行了setInterval函數,特別是還傳入了匿名函數作為回調函數,匿名函數在每一秒執行過程中並沒有任何對象調用它,因此匿名函數的this指向window,最終輸出window

結合傳參再看一個例子(例7):

var hi = 'window'
let o = {
    hi: '對象',
    fn: function () {
        let hi = '函數';
        console.log(this);
    }
}

function gn(fn){
    fn();
}

gn(o.fn)

你覺得這回輸出什麼呢?不斷的分析:

  1. 首先明確一點:參數的傳入等價於賦值。因此gn(o.fn)等價於f = o.fn; gn(f);好傢伙,又是賦值,沒有執行函數的都是騙子!
  2. 函數gn內部執行傳入的函數fn,並沒有發生對象調用,因此此刻執行的環境就是全局環境,輸出window
賦值、回調、閉包都是this的頭號大敵,一定等確定函數真的執行了,再去找關聯的對象。

顯示綁定

顯示綁定是指通過call、apply、bind對函數的this進行重定向,直接指定函數this所指的對象。

var hi = 'window'
let o = {
    hi: '對象',
}

function fn() {
        let hi = '函數';
        console.log(this.hi);
    }

fn.call(o);

通過fn函數的call方法,可以將全局環境中執行的fn函數內部this強行指向對象o,因此輸出:對象
讓我們思考一下,關於方法call、apply、bind之間有什麼不同呢?

紅寶書第四版解釋如下:

  1. callapply作用是一樣的,只是傳入參數的形式不同,call向函數傳入參數需要一個個列出來,而apply需要使用參數數組進行傳入參數
  2. bind方法會創建一個新的函數實例,其this值會綁定到傳給bind的對象。

值得注意的是,callapply方法在調用後會直接執行函數,bind方法則不會,但是bind方法將會一直綁定固定的this給新創建的實例。bind的具體用法如下:

var hi = 'window'
let o = {
    hi: '對象',
}

function fn() {
        let hi = '函數';
        console.log(this.hi);
    }

let f = fn.bind(o);

f();//無論f如何調用,f內部的this始終指向對象o,輸出:對象
fn();//this 依舊按照正常綁定規則進行綁定,輸出:window

new綁定

new關鍵字會出現在使用構造函數創建特定類型對象中,且看一下紅寶書對於new關鍵字的操作解釋:

紅寶書第四版將new操作步驟解釋為:

  1. 在內存中創建一個新對象
  2. 這個新對象內部[[Prototype]]特性被賦值為構造函數的prototype屬性
  3. 構造函數內部的this被賦值為這個新對象(即this指向新對象)
  4. 執行構造函數內部的代碼(給新對象添加屬性)
  5. 如果構造函數返回非空對象,則返回該對象;否則,返回剛創建的新對象

上述操作流程已經很清楚了,看看下面的例子(例8):

    function fn() {
            this.name = "Tony";
            this.showName = function (){
                console.log(this.name);
            }
        }

    let newObj = new fn();

    newObj.showName();

結合紅寶書的解釋,我們可以知道在使用new關鍵字時有如下步驟:

  1. 生成一個新匿名對象
  2. 該匿名對象的[[Prototype]]特性被賦值為構造函數的prototype屬性(這塊涉及原型鏈知識)
  3. 構造函數fn的內部this指向該匿名函數
  4. 執行fn內部代碼,給匿名函數添加屬性name和方法showName
  5. 返回匿名函數,並賦給newObj

(二)箭頭函數的this對象

image.png

相比於普通函數內部有一個this對象,箭頭函數內部是沒有this對象。你沒有聽錯,箭頭函數內部是沒有this對象!
那該如何確定箭頭函數的this引用呢?回顧JavaScript關於作用域鏈機制,當一個函數作用域中沒有某個變量時,則將會在作用域鏈中的逐級往後尋找,直到找到某個變量或因找不到而報錯。因此,箭頭函數內部沒有this對象,則在使用this對象時,必須要找到外層函數的this對象或者windowthis對象,而箭頭函數對應的外層this關係是在箭頭函數定義時確定的,因此無論箭頭函數是在哪裏調用,箭頭函數所能找到的this已經在定義時就確定了。

我們通過例子來找箭頭函數的this(例9):

var hi = 'window'
let o = {
    hi:'對象',
    gn:()=>{
        let hi = '箭頭函數';
        console.log(this.hi);
    },
    fn:function () {
        let hi = '函數'
        return ()=>{
            let hi = '箭頭函數';
            console.log(this.hi);
        };
    }
}

o.gn();
let f = o.fn();
f();
f = o.fn.call(window);
f();

先尋找箭頭函數的this

  1. 函數gn是箭頭函數,而且外層沒有其他的函數包裹,因此根據變量解析的作用域鏈規則,箭頭函數的的this就是windowthis
  2. 函數fn是一個返回箭頭函數的匿名函數,根據作用域鏈規則,在查找箭頭函數this過程中,找到外層函數fnthis當做箭頭函數的this

最後我們得出:

  1. gn箭頭函數的this就是windowthis;
  2. fn返回的箭頭函數的this就是函數fnthis
    運行上述例子,可以獲得瀏覽器以下輸出

image.png

簡單分析一下:

  1. 第一個輸出由表達式o.gn()產生,由於gn箭頭函數的this就是windowthis,因此hi變量就是window;
  2. 第二個輸出由表達式let f = o.fn(); f();產生,由於fn返回的箭頭函數的this就是函數fnthis,通過表達式o.fn()將函數fnthis指向對象o,導致箭頭函數的this也是o,最終輸出對象;
  3. 第三個輸出由表達式f = o.fn.call(window); f();產生,由於fn返回的箭頭函數的this就是函數fnthis,通過表達式f = o.fn.call(window)將函數fnthis指向window,導致箭頭函數的this也是window,最終輸出window

最後請思考一個問題,可以通過call()、apply()、bind()這些方法直接改變箭頭函數的this指向嗎?

三、總結

this對象是JavaScript的比較複雜的知識點,我看過一些文章討論this對象引用問題分多類闡述或者直接給出公式,混合作用域鏈、閉包、賦值、回調、傳參等多個知識點導致理解起來過於複雜。我認為,this對象設計其實很精妙,重點要把握好函數執行時確定this的本質,再通過研究幾個特殊場景下的例子,就可以較好的理解this對象的指向問題。最後你會發現普通函數和箭頭函數本質上是一樣,唯一的區別在於普通函數有自己的this,而箭頭函數沒有自己的this

由於作者水平有限,不正之處敬請指正。謝謝

參考材料:

  1. JavaScript高級程序設計(第四版)
  2. 掘金文章:嗨,你真的懂this嗎?
  3. 知乎文章:JavaScript 的 this原理是什麼?
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.