詳解前端 this
JavaScript 中的?this
,因其靈活的指向、復雜的使用場景一直是面試中的熱點,不論是初級還是中高級開發者,這都是一道必考題。這個概念雖然基礎,但是非常重要,是否能深刻理解?this
,是前端 JavaScript 中進階的重要一環。this
?指向多變,很多隱蔽的 bug 都緣于它。與此同時,this
?強大靈活,如果能熟練駕馭,就會寫出更簡潔、優雅的代碼。
社區上對于?this
?的講解雖然不少,但缺乏統一梳理。本節課,讓我們直面?this
?的方方面面,并通過例題真正領會與掌握?this
。
this
?相關知識點如下:
this 到底指向誰
曾經在面試阿里某重點部門時,面試官從多個角度考察過我對?this
?的理解:全局環境下的this
、箭頭函數的?this
、構造函數的?this
、this
?的顯隱性和優先級,等等。盡管我能一一作答,可是最后的問題:請用一句話總結?this
?的指向,注意只用一句話。?我卻犯難了。
有一種廣泛流傳的說法是:
誰調用它,
this
?就指向誰。
也就是說,this
?的指向是在調用時確定的。這么說沒有太大的問題,可是并不全面。面試官要求我用更加規范的語言進行總結,那么他到底在等什么樣的回答呢?
我們還要回到 JavaScript 中一個最基本的概念分析——執行上下文,這個概念,我們會在下一講《老司機也會在閉包相關知識點翻車》中進行擴展。
事實上,調用函數會創建新的屬于函數自身的執行上下文。執行上下文的調用創建階段會決定?this
?的指向。到此,我們可以得出的一個結論:
this
?的指向,是在調用函數時根據執行上下文所動態確定的。
具體環節和規則,可以先“死記硬背”以下幾條規律,后面再慢慢一一分析:
- 在函數體中,簡單調用該函數時(非顯式/隱式綁定下),嚴格模式下?
this
?綁定到?undefined
,否則綁定到全局對象?window
/global
; - 一般構造函數?
new
?調用,綁定到新創建的對象上; - 一般由?
call
/apply
/bind
?方法顯式調用,綁定到指定參數的對象上; - 一般由上下文對象調用,綁定在該對象上;
- 箭頭函數中,根據外層上下文綁定的?
this
?決定?this
?指向。
當然,真實環境多樣,我們來逐一梳理。
實戰例題分析
例題組合 1:全局環境下的 this
這種情況相對簡單直接,函數在瀏覽器全局環境中被簡單調用,非嚴格模式下?this
?指向?window
;在?use strict
?指明嚴格模式的情況下就是?undefined
。我們來看例題,請描述打印結果:
function f1 () {
console.log(this)
}
function f2 () {
'use strict'
console.log(this)
}
f1() // window
f2() // undefined
這樣的題目比較基礎,但是需要候選人格外注意其變種,請再看一道題目:
const foo = {
bar: 10,
fn: function() {
console.log(this)
console.log(this.bar)
}
}
var fn1 = foo.fn
fn1()
這里?this
?仍然指向的是?window
。雖然?fn
?函數在?foo
?對象中作為方法被引用,但是在賦值給?fn1
?之后,fn1
?的執行仍然是在?window
?的全局環境中。因此輸出?window
?和?undefined
,它們相當于:
console.log(window)
console.log(window.bar)
還是上面這道題目,如果調用改變為:
const foo = {
bar: 10,
fn: function() {
console.log(this)
console.log(this.bar)
}
}
foo.fn()
將會輸出:
{bar: 10, fn: ?}
10
因為這個時候?this
?指向的是最后調用它的對象,在?foo.fn()
?語句中?this
?指向?foo
?對象。請記住:
在執行函數時,如果函數中的?this
?是被上一級的對象所調用,那么?this
?指向的就是上一級的對象;否則指向全局環境。
例題組合 2:上下文對象調用中的 this
如上結論,面對下題時我們便不再困惑:
const student = {
name: 'Lucas',
fn: function() {
return this
}
}
console.log(student.fn() === student)
最終結果將會返回?true
。
當存在更復雜的調用關系時,請看例題:
const person = {
name: 'Lucas',
brother: {
name: 'Mike',
fn: function() {
return this.name
}
}
}
console.log(person.brother.fn())
在這種嵌套的關系中,this
?指向最后調用它的對象,因此輸出將會是:Mike
。
到此,this
?的上下文對象調用已經理解得比較清楚了。我們再看一道更高階的題目:
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())
答案是:o1
、o1
、undefined
,你答對了嗎?
我們來一一分析。
- 第一個?
console
?最簡單,o1
?沒有問題。難點在第二個和第三個上面,關鍵還是看調用?this
?的那個函數。 - 第二個?
console
?的?o2.fn()
,最終還是調用?o1.fn()
,因此答案仍然是?o1
。 - 最后一個,在進行?
var fn = o1.fn
?賦值之后,是“裸奔”調用,因此這里的?this
?指向?window
,答案當然是?undefined
。
如果面試者回答順利,可以緊接著追問,如果我們需要讓:
console.log(o2.fn())
輸出?o2
,該怎么做?
一般開發者可能會想到使用?bind/call/apply
?來對?this
?的指向進行干預,這確實是一種思路。但是我接著問,如果不能使用?bind/call/apply
,有別的方法嗎?
這樣可以考察候選人基礎掌握的深度以及隨機應變的思維能力。答案為:
const o1 = {
text: 'o1',
fn: function() {
return this.text
}
}
const o2 = {
text: 'o2',
fn: o1.fn
}
console.log(o2.fn())
還是應用那個重要的結論:this
?指向最后調用它的對象,在?fn
?執行時,掛到?o2
?對象上即可,我們提前進行了賦值操作。
例題組合 3:bind/call/apply 改變 this 指向
上文提到 bind/call/apply,在這個概念上,比較常見的基礎考察點是:bind/call/apply 三個方法的區別。
這樣的問題相對基礎,我們直接上答案:一句話總結,他們都是用來改變相關函數?this
?指向的,但是?call/apply
?是直接進行相關函數調用;bind
?不會執行相關函數,而是返回一個新的函數,這個新的函數已經自動綁定了新的?this
?指向,開發者需要手動調用即可。再具體的?call/apply
?之間的區別主要體現在參數設定上,這里不再展開。
用代碼來總結:
const target = {}
fn.call(target, 'arg1', 'arg2')
相當于:
const target = {}
fn.apply(target, ['arg1', 'arg2'])
相當于:
const target = {}
fn.bind(target, 'arg1', 'arg2')()
具體基礎用法這里不再科普,如果讀者尚不清楚,需要自己補充一下知識點。
我們來看一道例題分析:
const foo = {
name: 'lucas',
logName: function() {
console.log(this.name)
}
}
const bar = {
name: 'mike'
}
console.log(foo.logName.call(bar))
將會輸出?mike
,這不難理解。但是對 call/apply/bind 的高級考察往往會結合構造函數以及組合式實現繼承。實現繼承的話題,我們會單獨講到。構造函數的使用案例,我們結合接下來的例題組合進行分析。
例題組合 4:構造函數和 this
這方面最直接的例題為:
function Foo() {
this.bar = "Lucas"
}
const instance = new Foo()
console.log(instance.bar)
答案將會輸出?Lucas
。但是這樣的場景往往伴隨著下一個問題:new
?操作符調用構造函數,具體做了什么?以下供參考:
- 創建一個新的對象;
- 將構造函數的?
this
?指向這個新對象; - 為這個對象添加屬性、方法等;
- 最終返回新對象。
以上過程,也可以用代碼表述:
var obj = {}
obj.__proto__ = Foo.prototype
Foo.call(obj)
當然,這里對?new
?的模擬是一個簡單基本版的,更復雜的情況我們會在原型、原型鏈相關的第2-5課《面向對象和原型——永不過時的話題》中講述。
需要指出的是,如果在構造函數中出現了顯式?return
?的情況,那么需要注意分為兩種場景:
function Foo(){
this.user = "Lucas"
const o = {}
return o
}
const instance = new Foo()
console.log(instance.user)
將會輸出?undefined
,此時?instance
?是返回的空對象?o
。
function Foo(){
this.user = "Lucas"
return 1
}
const instance = new Foo()
console.log(instance.user)
將會輸出?Lucas
,也就是說此時?instance
?是返回的目標對象實例?this
。
結論:如果構造函數中顯式返回一個值,且返回的是一個對象,那么?this
?就指向這個返回的對象;如果返回的不是一個對象,那么?this
?仍然指向實例。
例題組合 5:箭頭函數中的 this 指向
首先我們再來溫習一下相關結論。
結論:箭頭函數使用?this
?不適用以上標準規則,而是根據外層(函數或者全局)上下文來決定。
來看題目:
const foo = {
fn: function () {
setTimeout(function() {
console.log(this)
})
}
}
console.log(foo.fn())
這道題中,this
?出現在?setTimeout()
?中的匿名函數里,因此?this
?指向?window
?對象。如果需要?this
?指向?foo
?這個 object 對象,可以巧用箭頭函數解決:
const foo = {
fn: function () {
setTimeout(() => {
console.log(this)
})
}
}
console.log(foo.fn())
// {fn: ?}
單純箭頭函數中的?this
?非常簡單,但是綜合所有情況,結合?this
?的優先級考察,這時候?this
?指向并不好確定。請繼續閱讀。
例題組合 6:this 優先級相關
我們常常把通過?call
、apply
、bind
、new
?對?this
?綁定的情況稱為顯式綁定;根據調用關系確定的?this
?指向稱為隱式綁定。
那么顯式綁定和隱式綁定誰的優先級更高呢?
請看例題:
function foo (a) {
console.log(this.a)
}
const obj1 = {
a: 1,
foo: foo
}
const obj2 = {
a: 2,
foo: foo
}
obj1.foo.call(obj2)
obj2.foo.call(obj1)
輸出分別為 2、1,也就是說?call
、apply
?的顯式綁定一般來說優先級更高。
function foo (a) {
this.a = a
}
const obj1 = {}
var bar = foo.bind(obj1)
bar(2)
console.log(obj1.a)
上述代碼通過?bind
,將?bar
?函數中的?this
?綁定為?obj1
?對象。執行?bar(2)
?后,obj1.a
?值為 2。即經過?bar(2)
?執行后,obj1
?對象為:{a: 2}
。
當再使用?bar
?作為構造函數時:
var baz = new bar(3)
console.log(baz.a)
將會輸出 3。我們看?bar
?函數本身是通過?bind
?方法構造的函數,其內部已經對將?this
?綁定為?obj1
,它再作為構造函數,通過?new
?調用時,返回的實例已經與?obj1
?解綁。 也就是說:
new
?綁定修改了?bind
?綁定中的?this
,因此?new
?綁定的優先級比顯式?bind
?綁定更高。
我們再看:
function foo() {
return a => {
console.log(this.a)
};
}
const obj1 = {
a: 2
}
const obj2 = {
a: 3
}
const bar = foo.call(obj1)
console.log(bar.call(obj2))
將會輸出 2。由于?foo()
?的?this
?綁定到?obj1
,bar
(引用箭頭函數)的?this
?也會綁定到?obj1
,箭頭函數的綁定無法被修改。
如果將?foo
?完全寫成箭頭函數的形式:
var a = 123
const foo = () => a => {
console.log(this.a)
}
const obj1 = {
a: 2
}
const obj2 = {
a: 3
}
var bar = foo.call(obj1)
console.log(bar.call(obj2))
將會輸出?123
。
這里我再“抖個機靈”,僅僅將上述代碼的第一處變量?a
?的賦值改為:
const a = 123
const foo = () => a => {
console.log(this.a)
}
const obj1 = {
a: 2
}
const obj2 = {
a: 3
}
var bar = foo.call(obj1)
console.log(bar.call(obj2))
答案將會輸出為?undefined
,原因是因為使用?const
?聲明的變量不會掛載到?window
?全局對象當中。因此?this
?指向?window
?時,自然也找不到?a
?變量了。關于?const
?或者?let
?等聲明變量的方式不再本課的主題當中,我們后續也將專門進行介紹。
到這里,讀者是否有“融會貫通”的感覺了呢?如果還有困惑,也不要灰心。進階的關鍵就是基礎,基礎需要反復學習,“死記硬背”后才能慢慢體會。
開放例題分析
不知道實戰例題分析是否已經把你繞暈了。事實上,this
?的指向涉及的規范繁多,優先級也較為混亂。刻意刁難并不是很好的面試做法,一些細節候選人如果沒有記住也不是太大的問題。作為面試官,我往往會另辟蹊徑,出一些開放性題目。
其中,最典型的一道題目為:實現一個?bind
?函數。
作為面試者,我也曾經在頭條的面試流程中被問到模擬?bind
。這道題并不新鮮,部分讀者也會有自己的解答思路,而且社區上關于原生?bind
?的研究也很多。但是,我們這里想強調的是,可能有一些細節被大家忽略了。在回答時,我往往先實現一個初級版本,然后根據 ES5-shim 源碼進一步說明。
Function.prototype.bind = Function.prototype.bind || function (context) {
var me = this;
var args = Array.prototype.slice.call(arguments, 1);
return function bound () {
var innerArgs = Array.prototype.slice.call(arguments);
var finalArgs = args.concat(innerArgs);
return me.apply(context, finalArgs);
}
}
這樣的實現已經非常不錯了。但是,就如同之前?this
?優先級分析所示:bind
?返回的函數如果作為構造函數,搭配?new
?關鍵字出現的話,我們的綁定?this
?就需要“被忽略”。
為了實現這樣的規則,開發者就應該需要考慮如何區分這兩種調用方式。具體來講?bound
?函數中就要進行?this instanceof
?的判斷。
另外一個細節是,函數具有?length
?屬性,表示形參的個數。上述實現方式形參的個數顯然會失真。我們的實現就需要對?length
?屬性進行還原。可是難點在于:函數的?length
?屬性值是不可重寫的。
總結
通過本課的學習,我們看到?this
?紛繁多象,確實不容易徹底掌握。本節盡可能系統地進行講解、說明,例題盡可能地覆蓋更多 case。與此同時,需要讀者在閱讀之外繼續進行消化與吸收。只有“記死”,才能“用活”。