this關鍵字是JavaScript函數內部的一個對象,this是一個指針,指向調用函數的對象。看似簡單的定義但卻由於在解析this引用過程中可能涉及到執行上下文、作用域鏈、閉包等複雜的機制,導致this的指向問題變得異常複雜。首先必須明白一點,任何複雜的機制都不可能輕而易舉的學懂弄通,因此,本文將與大家一起耐心回顧this對象,歸納總結this的引用機制,希望對你有所幫助。
一、函數到底執行了沒?
要向弄懂this對象,必須先搞懂函數是什麼時候執行?
先看看簡單的一個例子(例1):
function fn(){
console.log('你好');
}
fn;
fn();
let f = fn;
f();
上面的例子一共輸出 2 次 你好。
fn()、f()表達式中函數被調用執行,fn和let f=fn表達式中函數並未被調用執行。
函數執行主要看是否存在函數名(),使用不帶括號的函數名會訪問函數指針,並非調用該函數
再看看一個加入閉包機制的例子(例2):
function fn(){
let hi = '你好'
return function gn(){
console.log(hi);
}
}
fn;
fn();
let f = fn;
f();
let g = fn();
g();
上面的例子似乎較為複雜了,那一共輸出多少次 你好?
- 輸出
你好必須是函數gn被調用執行,因此關鍵在於函數gn什麼時候被調用? - 根據前一個例子,表達式
fn、f=fn沒有調用函數fn,則更不會調用函數gn。 - 根據前一個例子,表達式
fn()、f()是相同的含義,均調用了函數fn。在閉包中,調用fn返回返回一個函數gn的函數指針,但最終並沒有通過該函數指針調用gn,因此在表達式fn()、f()、g=fn()並沒有執行函數gn。 -
表達式
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?
別急,理解this的引用機制,我認為最關鍵的是理解函數 執行 的上下文。倘若連函數什麼時候執行都傻傻搞不清,那理解this對象更無從談起,來,我們開始繼續探索。
紅寶書第四版將
this對象闡述為:1.在標準函數中,this引用的是把函數當成方法調用的上下文對象
2.在箭頭函數中,this引用的是定義箭頭函數的上下文
(一)標準函數的this對象
普通函數(除箭頭函數)內部中均有一個this對象。this在函數執行時確定所指對象。這裏有兩個關鍵點:執行時、對象。
- 普通函數在執行時才能確定
this。那在定義時能確定碼?不行!記住:函數執行時確定!函數執行時確定!函數執行時確定! - 普通函數的
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。分析如下:
- 表達式
o.gn()顯然是通過對象o對函數gn進行調用,因此gn執行時的this所指向的就是o; - 表達式
let f = o.fn();將執行fn函數並將閉包函數的函數指針賦值給f變量,此時執行的函數是fn而並非是閉包函數,因此此刻fn的this指向對象o,但是閉包函數的this現在還沒有確定; - 通過調用表達式
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())
一看感覺很複雜,但本質上還是找到哪個對象調用函數進行執行,分析一波:
- 執行表達式
console.log(o1.fn())時,對象o1調用執行函數fn,因此,函數fn的this指向對象o1,所以輸出o1; - 執行表達式
console.log(o2.fn())時,對象o2調用執行函數fn,因此,o2內部函數fn的this指向對象o2,但且慢,這裏有個表達式return o1.fn(),不難看出這又通過o1調用了o1內部函數fn(該函數this指向o1對象),並將執行結果返回。因此繞個彎還是回到執行o1內部函數fn,輸出o1; - 執行表達式
console.log(o3.fn())時,對象o3調用執行函數fn,因此,o3內部函數fn的this指向對象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();
你覺得應該輸出什麼呢?我們先分析一波:
- 很明顯,表達式
o.fn();執行過程中,函數fn的this鐵定是指向對象o; - 再看
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)
你覺得這回輸出什麼呢?不斷的分析:
- 首先明確一點:參數的傳入等價於賦值。因此
gn(o.fn)等價於f = o.fn; gn(f);好傢伙,又是賦值,沒有執行函數的都是騙子! - 函數
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之間有什麼不同呢?
紅寶書第四版解釋如下:
call和apply作用是一樣的,只是傳入參數的形式不同,call向函數傳入參數需要一個個列出來,而apply需要使用參數數組進行傳入參數bind方法會創建一個新的函數實例,其this值會綁定到傳給bind的對象。
值得注意的是,call和apply方法在調用後會直接執行函數,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操作步驟解釋為:
- 在內存中創建一個新對象
- 這個新對象內部[[Prototype]]特性被賦值為構造函數的prototype屬性
- 構造函數內部的this被賦值為這個新對象(即this指向新對象)
- 執行構造函數內部的代碼(給新對象添加屬性)
- 如果構造函數返回非空對象,則返回該對象;否則,返回剛創建的新對象
上述操作流程已經很清楚了,看看下面的例子(例8):
function fn() {
this.name = "Tony";
this.showName = function (){
console.log(this.name);
}
}
let newObj = new fn();
newObj.showName();
結合紅寶書的解釋,我們可以知道在使用new關鍵字時有如下步驟:
- 生成一個新匿名對象
- 該匿名對象的[[Prototype]]特性被賦值為構造函數的prototype屬性(這塊涉及原型鏈知識)
- 構造函數
fn的內部this指向該匿名函數 - 執行
fn內部代碼,給匿名函數添加屬性name和方法showName - 返回匿名函數,並賦給newObj
(二)箭頭函數的this對象
相比於普通函數內部有一個this對象,箭頭函數內部是沒有this對象。你沒有聽錯,箭頭函數內部是沒有this對象!
那該如何確定箭頭函數的this引用呢?回顧JavaScript關於作用域鏈機制,當一個函數作用域中沒有某個變量時,則將會在作用域鏈中的逐級往後尋找,直到找到某個變量或因找不到而報錯。因此,箭頭函數內部沒有this對象,則在使用this對象時,必須要找到外層函數的this對象或者window的this對象,而箭頭函數對應的外層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:
- 函數
gn是箭頭函數,而且外層沒有其他的函數包裹,因此根據變量解析的作用域鏈規則,箭頭函數的的this就是window的this。 - 函數
fn是一個返回箭頭函數的匿名函數,根據作用域鏈規則,在查找箭頭函數this過程中,找到外層函數fn的this當做箭頭函數的this。
最後我們得出:
gn箭頭函數的this就是window的this;fn返回的箭頭函數的this就是函數fn的this。
運行上述例子,可以獲得瀏覽器以下輸出
簡單分析一下:
- 第一個輸出由表達式
o.gn()產生,由於gn箭頭函數的this就是window的this,因此hi變量就是window; - 第二個輸出由表達式
let f = o.fn(); f();產生,由於fn返回的箭頭函數的this就是函數fn的this,通過表達式o.fn()將函數fn的this指向對象o,導致箭頭函數的this也是o,最終輸出對象; - 第三個輸出由表達式
f = o.fn.call(window); f();產生,由於fn返回的箭頭函數的this就是函數fn的this,通過表達式f = o.fn.call(window)將函數fn的this指向window,導致箭頭函數的this也是window,最終輸出window。
最後請思考一個問題,可以通過call()、apply()、bind()這些方法直接改變箭頭函數的this指向嗎?
三、總結
this對象是JavaScript的比較複雜的知識點,我看過一些文章討論this對象引用問題分多類闡述或者直接給出公式,混合作用域鏈、閉包、賦值、回調、傳參等多個知識點導致理解起來過於複雜。我認為,this對象設計其實很精妙,重點要把握好函數執行時確定this的本質,再通過研究幾個特殊場景下的例子,就可以較好的理解this對象的指向問題。最後你會發現普通函數和箭頭函數本質上是一樣,唯一的區別在於普通函數有自己的this,而箭頭函數沒有自己的this。
由於作者水平有限,不正之處敬請指正。謝謝
參考材料:
- JavaScript高級程序設計(第四版)
- 掘金文章:嗨,你真的懂this嗎?
- 知乎文章:JavaScript 的 this原理是什麼?