【進階3-1期】JavaScript 5 種 this 繫結全面解析
(關注福利,關注本公眾號回覆[資料]領取優質前端視訊,包括Vue、React、Node原始碼和實戰、面試指導)
本週正式開始前端進階的第三期,本週的主題是this全面解析,今天是第9天。
本計劃一共28期,每期重點攻克一個面試重難點,如果你還不瞭解本進階計劃,點選檢視前端進階的破冰之旅
如果覺得本系列不錯,歡迎轉發,您的支援就是我堅持的最大動力。
本期推薦文章
你不知道的JavaScript上卷—筆記,由於微信不能訪問外鏈,點選閱讀原文就可以啦。
推薦理由
這篇文章是我的讀書筆記,非常詳細的記錄了this繫結的5種規則,有程式碼,有解釋,看完絕對攻克this盲區,加油。
閱讀筆記
this
的繫結規則總共有下面5種。
- 1、預設繫結(嚴格/非嚴格模式)
- 2、隱式繫結
- 3、顯式繫結
- 4、new繫結
- 5、箭頭函式繫結
現在開始一個一個介紹,內容來自《你不知道的JS》筆記整理。
1 呼叫位置
呼叫位置就是函式在程式碼中被呼叫的位置(而不是宣告的位置)。
查詢方法:
-
分析呼叫棧:呼叫位置就是當前正在執行的函式的前一個呼叫中
function baz() { // 當前呼叫棧是:baz // 因此,當前呼叫位置是全域性作用域 console.log( "baz" ); bar(); // <-- bar的呼叫位置
-
使用開發者工具得到呼叫棧:
設定斷點或者插入
debugger;
2 繫結規則
2.1 預設繫結
- 獨立函式呼叫,可以把預設繫結看作是無法應用其他規則時的預設規則,this指向全域性物件。
- 嚴格模式下,不能將全域性物件用於預設繫結,this會繫結到
undefined
。只有函式執行在非嚴格模式下,預設繫結才能繫結到全域性物件。在嚴格模式下呼叫函式則不影響預設繫結。
function foo() { // 執行在嚴格模式下,this會繫結到undefined
"use strict";
console.log( this.a );
}
var a = 2;
// 呼叫
foo(); // TypeError: Cannot read property 'a' of undefined
// --------------------------------------
function foo() { // 執行
console.log( this.a );
}
var a = 2;
(function() { // 嚴格模式下呼叫函式則不影響預設繫結
"use strict";
foo(); // 2
})();
複製程式碼
2.2 隱式繫結
當函式引用有上下文物件時,隱式繫結規則會把函式中的this繫結到這個上下文物件。物件屬性引用鏈中只有上一層或者說最後一層在呼叫中起作用。
function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2
複製程式碼
隱式丟失
被隱式繫結的函式特定情況下會丟失繫結物件,應用預設繫結,把this繫結到全域性物件或者undefined上。
// 雖然bar是obj.foo的一個引用,但是實際上,它引用的是foo函式本身。
// bar()是一個不帶任何修飾的函式呼叫,應用預設繫結。
function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var bar = obj.foo; // 函式別名
var a = "oops, global"; // a是全域性物件的屬性
bar(); // "oops, global"
複製程式碼
引數傳遞就是一種隱式賦值,傳入函式時也會被隱式賦值。回撥函式丟失this繫結是非常常見的。
function foo() {
console.log( this.a );
}
function doFoo(fn) {
// fn其實引用的是foo
fn(); // <-- 呼叫位置!
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // a是全域性物件的屬性
doFoo( obj.foo ); // "oops, global"
// ----------------------------------------
// JS環境中內建的setTimeout()函式實現和下面的虛擬碼類似:
function setTimeout(fn, delay) {
// 等待delay毫秒
fn(); // <-- 呼叫位置!
}
複製程式碼
2.3 顯式繫結
通過call(..)
或者 apply(..)
方法。第一個引數是一個物件,在呼叫函式時將這個物件繫結到this。因為直接指定this的繫結物件,稱之為顯示繫結。
function foo() {
console.log( this.a );
}
var obj = {
a: 2
};
foo.call( obj ); // 2 呼叫foo時強制把foo的this繫結到obj上
複製程式碼
顯示繫結無法解決丟失繫結問題。
解決方案:
- 1、硬繫結
建立函式bar(),並在它的內部手動呼叫foo.call(obj),強制把foo的this繫結到了obj。這種方式讓我想起了借用建構函式繼承,沒看過的可以點選檢視 JavaScript常用八種繼承方案
function foo() {
console.log( this.a );
}
var obj = {
a: 2
};
var bar = function() {
foo.call( obj );
};
bar(); // 2
setTimeout( bar, 100 ); // 2
// 硬繫結的bar不可能再修改它的this
bar.call( window ); // 2
複製程式碼
典型應用場景是建立一個包裹函式,負責接收引數並返回值。
function foo(something) {
console.log( this.a, something );
return this.a + something;
}
var obj = {
a: 2
};
var bar = function() {
return foo.apply( obj, arguments );
};
var b = bar( 3 ); // 2 3
console.log( b ); // 5
複製程式碼
建立一個可以重複使用的輔助函式。
function foo(something) {
console.log( this.a, something );
return this.a + something;
}
// 簡單的輔助繫結函式
function bind(fn, obj) {
return function() {
return fn.apply( obj, arguments );
}
}
var obj = {
a: 2
};
var bar = bind( foo, obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5
複製程式碼
ES5內建了Function.prototype.bind
,bind會返回一個硬繫結的新函式,用法如下。
function foo(something) {
console.log( this.a, something );
return this.a + something;
}
var obj = {
a: 2
};
var bar = foo.bind( obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5
複製程式碼
- 2、API呼叫的“上下文”
JS許多內建函式提供了一個可選引數,被稱之為“上下文”(context),其作用和bind(..)
一樣,確保回撥函式使用指定的this。這些函式實際上通過call(..)
和apply(..)
實現了顯式繫結。
function foo(el) {
console.log( el, this.id );
}
var obj = {
id: "awesome"
}
var myArray = [1, 2, 3]
// 呼叫foo(..)時把this繫結到obj
myArray.forEach( foo, obj );
// 1 awesome 2 awesome 3 awesome
複製程式碼
2.4 new繫結
- 在JS中,
建構函式
只是使用new
操作符時被呼叫的普通
函式,他們不屬於某個類,也不會例項化一個類。 - 包括內建物件函式(比如
Number(..)
)在內的所有函式都可以用new
來呼叫,這種函式呼叫被稱為建構函式呼叫。 - 實際上並不存在所謂的“建構函式”,只有對於函式的“構造呼叫”。
使用new
來呼叫函式,或者說發生建構函式呼叫時,會自動執行下面的操作。
- 1、建立(或者說構造)一個新物件。
- 2、這個新物件會被執行
[[Prototype]]
連線。 - 3、這個新物件會繫結到函式呼叫的
this
。 - 4、如果函式沒有返回其他物件,那麼
new
表示式中的函式呼叫會自動返回這個新物件。
使用new
來呼叫foo(..)
時,會構造一個新物件並把它(bar
)繫結到foo(..)
呼叫中的this。
function foo(a) {
this.a = a;
}
var bar = new foo(2); // bar和foo(..)呼叫中的this進行繫結
console.log( bar.a ); // 2
複製程式碼
手寫一個new實現
function create() {
// 建立一個空的物件
let obj = new Object()
// 獲得建構函式
let Con = [].shift.call(arguments)
// 連結到原型
obj.__proto__ = Con.prototype
// 繫結 this,執行建構函式
let result = Con.apply(obj, arguments)
// 確保 new 出來的是個物件
return typeof result === 'object' ? result : obj
}
複製程式碼
使用這個手寫的new
function Person() {...}
// 使用內建函式new
var person = new Person(...)
// 使用手寫的new,即create
var person = create(Person, ...)
複製程式碼
程式碼原理解析:
-
1、用
new Object()
的方式新建了一個物件obj
-
2、取出第一個引數,就是我們要傳入的建構函式。此外因為 shift 會修改原陣列,所以
arguments
會被去除第一個引數 -
3、將
obj
的原型指向建構函式,這樣obj
就可以訪問到建構函式原型中的屬性 -
4、使用
apply
,改變建構函式this
的指向到新建的物件,這樣obj
就可以訪問到建構函式中的屬性 -
5、返回
obj
3 優先順序
st=>start: Start
e=>end: End
cond1=>condition: new繫結
op1=>operation: this繫結新建立的物件,
var bar = new foo()
cond2=>condition: 顯示繫結
op2=>operation: this繫結指定的物件,
var bar = foo.call(obj2)
cond3=>condition: 隱式繫結
op3=>operation: this繫結上下文物件,
var bar = obj1.foo()
op4=>operation: 預設繫結
op5=>operation: 函式體嚴格模式下繫結到undefined,
否則繫結到全域性物件,
var bar = foo()
st->cond1
cond1(yes)->op1->e
cond1(no)->cond2
cond2(yes)->op2->e
cond2(no)->cond3
cond3(yes)->op3->e
cond3(no)->op4->op5->e
複製程式碼
在new
中使用硬繫結函式的目的是預先設定函式的一些引數,這樣在使用new進行初始化時就可以只傳入其餘的引數(柯里化)。
function foo(p1, p2) {
this.val = p1 + p2;
}
// 之所以使用null是因為在本例中我們並不關心硬繫結的this是什麼
// 反正使用new時this會被修改
var bar = foo.bind( null, "p1" );
var baz = new bar( "p2" );
baz.val; // p1p2
複製程式碼
4 繫結例外
4.1 被忽略的this
把null
或者undefined
作為this
的繫結物件傳入call
、apply
或者bind
,這些值在呼叫時會被忽略,實際應用的是預設規則。
下面兩種情況下會傳入null
- 使用
apply(..)
來“展開”一個數組,並當作引數傳入一個函式 bind(..)
可以對引數進行柯里化(預先設定一些引數)
function foo(a, b) {
console.log( "a:" + a + ",b:" + b );
}
// 把陣列”展開“成引數
foo.apply( null, [2, 3] ); // a:2,b:3
// 使用bind(..)進行柯里化
var bar = foo.bind( null, 2 );
bar( 3 ); // a:2,b:3
複製程式碼
總是傳入null
來忽略this繫結可能產生一些副作用。如果某個函式確實使用了this,那預設繫結規則會把this繫結到全域性物件中。
更安全的this
安全的做法就是傳入一個特殊的物件(空物件),把this繫結到這個物件不會對你的程式產生任何副作用。
JS中建立一個空物件最簡單的方法是**Object.create(null)
**,這個和{}
很像,但是並不會建立Object.prototype
這個委託,所以比{}
更空。
function foo(a, b) {
console.log( "a:" + a + ",b:" + b );
}
// 我們的空物件
var ø = Object.create( null );
// 把陣列”展開“成引數
foo.apply( ø, [2, 3] ); // a:2,b:3
// 使用bind(..)進行柯里化
var bar = foo.bind( ø, 2 );
bar( 3 ); // a:2,b:3
複製程式碼
4.2 間接引用
間接引用下,呼叫這個函式會應用預設繫結規則。間接引用最容易在賦值時發生。
// p.foo = o.foo的返回值是目標函式的引用,所以呼叫位置是foo()而不是p.foo()或者o.foo()
function foo() {
console.log( this.a );
}
var a = 2;
var o = { a: 3, foo: foo };
var p = { a: 4};
o.foo(); // 3
(p.foo = o.foo)(); // 2
複製程式碼
4.3 軟繫結
- 硬繫結可以把this強制繫結到指定的物件(
new
除外),防止函式呼叫應用預設繫結規則。但是會降低函式的靈活性,使用硬繫結之後就無法使用隱式繫結或者顯式繫結來修改this。 - 如果給預設繫結指定一個全域性物件和undefined以外的值,那就可以實現和硬繫結相同的效果,同時保留隱式繫結或者顯示繫結修改this的能力。
// 預設繫結規則,優先順序排最後
// 如果this繫結到全域性物件或者undefined,那就把指定的預設物件obj繫結到this,否則不會修改this
if(!Function.prototype.softBind) {
Function.prototype.softBind = function(obj) {
var fn = this;
// 捕獲所有curried引數
var curried = [].slice.call( arguments, 1 );
var bound = function() {
return fn.apply(
(!this || this === (window || global)) ?
obj : this,
curried.concat.apply( curried, arguments )
);
};
bound.prototype = Object.create( fn.prototype );
return bound;
};
}
複製程式碼
使用:軟繫結版本的foo()可以手動將this繫結到obj2或者obj3上,但如果應用預設繫結,則會將this繫結到obj。
function foo() {
console.log("name:" + this.name);
}
var obj = { name: "obj" },
obj2 = { name: "obj2" },
obj3 = { name: "obj3" };
// 預設繫結,應用軟繫結,軟繫結把this繫結到預設物件obj
var fooOBJ = foo.softBind( obj );
fooOBJ(); // name: obj
// 隱式繫結規則
obj2.foo = foo.softBind( obj );
obj2.foo(); // name: obj2 <---- 看!!!
// 顯式繫結規則
fooOBJ.call( obj3 ); // name: obj3 <---- 看!!!
// 繫結丟失,應用軟繫結
setTimeout( obj2.foo, 10 ); // name: obj
複製程式碼
5 this詞法
ES6新增一種特殊函式型別:箭頭函式,箭頭函式無法使用上述四條規則,而是根據外層(函式或者全域性)作用域(詞法作用域)來決定this。
foo()
內部建立的箭頭函式會捕獲呼叫時foo()
的this。由於foo()
的this繫結到obj1
,bar
(引用箭頭函式)的this也會繫結到obj1
,箭頭函式的繫結無法被修改(new
也不行)。
function foo() {
// 返回一個箭頭函式
return (a) => {
// this繼承自foo()
console.log( this.a );
};
}
var obj1 = {
a: 2
};
var obj2 = {
a: 3
}
var bar = foo.call( obj1 );
bar.call( obj2 ); // 2,不是3!
複製程式碼
ES6之前和箭頭函式類似的模式,採用的是詞法作用域取代了傳統的this機制。
function foo() {
var self = this; // lexical capture of this
setTimeout( function() {
console.log( self.a ); // self只是繼承了foo()函式的this繫結
}, 100 );
}
var obj = {
a: 2
};
foo.call(obj); // 2
複製程式碼
程式碼風格統一問題:如果既有this風格的程式碼,還會使用 seft = this
或者箭頭函式來否定this機制。
- 只使用詞法作用域並完全拋棄錯誤this風格的程式碼;
- 完全採用this風格,在必要時使用
bind(..)
,儘量避免使用self = this
和箭頭函式。
上期思考題解
程式碼1:
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
checkscope();
複製程式碼
程式碼2:
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
var foo = checkscope();
foo();
複製程式碼
上面的兩個程式碼中,checkscope()
執行完成後,閉包f
所引用的自由變數scope
會被垃圾回收嗎?為什麼?
解答:
checkscope()
執行完成後,程式碼1中自由變數特定時間之後回收,程式碼2中自由變數不回收。
首先要說明的是,現在主流瀏覽器的垃圾回收演算法是標記清除,標記清除並非是標記執行棧的進出,而是從根開始遍歷,也是一個找引用關係的過程,但是因為從根開始,相互引用的情況不會被計入。所以當垃圾回收開始時,從Root(全域性物件)開始尋找這個物件的引用是否可達,如果引用鏈斷裂,那麼這個物件就會回收。
閉包中的作用域鏈中 parentContext.vo 是物件,被放在堆中,棧中的變數會隨著執行環境進出而銷燬,堆中需要垃圾回收,閉包內的自由變數會被分配到堆上,所以當外部方法執行完畢後,對其的引用並沒有丟。
每次進入函式執行時,會重新建立可執行環境和活動物件,但函式的[[Scope]]
是函式定義時就已經定義好的(詞法作用域規則),不可更改。
- 對於程式碼1:
checkscope()
執行時,將checkscope
物件指標壓入棧中,其執行環境變數如下
checkscopeContext:{
AO:{
arguments:
scope:
f:
},
this,
[[Scope]]:[AO, globalContext.VO]
}
複製程式碼
執行完畢後出棧,該物件沒有繫結給誰,從Root開始查詢無法可達,此活動物件一段時間後會被回收
- 對於程式碼2:
checkscope()
執行後,返回的是f
物件,其執行環境變數如下
fContext:{
AO:{
arguments:
},
this,
[[Scope]]:[AO, checkscopeContext.AO, globalContext.VO]
}
複製程式碼
此物件賦值給var foo = checkscope();
,將foo
壓入棧中,foo
指向堆中的f
活動物件,對於Root
來說可達,不會被回收。
如果一定要自由變數scope
回收,那麼該怎麼辦???
很簡單,foo = null;
,把引用斷開就可以了。
本期思考題
依次給出console.log輸出的數值。
var num = 1;
var myObject = {
num: 2,
add: function() {
this.num = 3;
(function() {
console.log(this.num);
this.num = 4;
})();
console.log(this.num);
},
sub: function() {
console.log(this.num)
}
}
myObject.add();
console.log(myObject.num);
console.log(num);
var sub = myObject.sub;
sub();
複製程式碼
參考
往期文章檢視
- 【進階1-1期】理解JavaScript 中的執行上下文和執行棧
- 【進階1-2期】JavaScript深入之執行上下文棧和變數物件
- 【進階1-3期】JavaScript深入之記憶體空間詳細圖解
- 【進階1-4期】JavaScript深入之帶你走進記憶體機制
- 【進階1-5期】JavaScript深入之4類常見記憶體洩漏及如何避免
- 【進階2-1期】深入淺出圖解作用域鏈和閉包
- 【進階2-2期】JavaScript深入之從作用域鏈理解閉包
每週計劃安排
每週面試重難點計劃如下,如有修改會通知大家。每週一期,為期半年,準備明年跳槽的小夥伴們可以把本公眾號置頂了。
- 【進階1期】 呼叫堆疊
- 【進階2期】 作用域閉包
- 【進階3期】 this全面解析
- 【進階4期】 深淺拷貝原理
- 【進階5期】 原型Prototype
- 【進階6期】 高階函式
- 【進階7期】 事件機制
- 【進階8期】 Event Loop原理
- 【進階9期】 Promise原理
- 【進階10期】Async/Await原理
- 【進階11期】防抖/節流原理
- 【進階12期】模組化詳解
- 【進階13期】ES6重難點
- 【進階14期】計算機網路概述
- 【進階15期】瀏覽器渲染原理
- 【進階16期】webpack配置
- 【進階17期】webpack原理
- 【進階18期】前端監控
- 【進階19期】跨域和安全
- 【進階20期】效能優化
- 【進階21期】VirtualDom原理
- 【進階22期】Diff演算法
- 【進階23期】MVVM雙向繫結
- 【進階24期】Vuex原理
- 【進階25期】Redux原理
- 【進階26期】路由原理
- 【進階27期】VueRouter原始碼解析
- 【進階28期】ReactRouter原始碼解析
交流
本人Github連結如下,歡迎各位Star
我是木易楊,網易高階前端工程師,跟著我每週重點攻克一個前端面試重難點。接下來讓我帶你走進高階前端的世界,在進階的路上,共勉!
如果你想加群討論每期面試知識點,公眾號回覆[加群]即可