【進階3-1期】JavaScript深入之史上最全–5種this繫結全面解析

木易楊說發表於2019-03-04

this的繫結規則總共有下面5種。

  • 1、預設繫結(嚴格/非嚴格模式)
  • 2、隱式繫結
  • 3、顯式繫結
  • 4、new繫結
  • 5、箭頭函式繫結

1 呼叫位置

呼叫位置就是函式在程式碼中被呼叫的位置(而不是宣告的位置)。

查詢方法:

  • 分析呼叫棧:呼叫位置就是當前正在執行的函式的前一個呼叫

    function baz() {
        // 當前呼叫棧是:baz
        // 因此,當前呼叫位置是全域性作用域
        
        console.log( "baz" );
        bar(); // <-- bar的呼叫位置
    }
    
    function bar() {
        // 當前呼叫棧是:baz --> bar
        // 因此,當前呼叫位置在baz中
        
        console.log( "bar" );
        foo(); // <-- foo的呼叫位置
    }
    
    function foo() {
        // 當前呼叫棧是:baz --> bar --> foo
        // 因此,當前呼叫位置在bar中
        
        console.log( "foo" );
    }
    
    baz(); // <-- baz的呼叫位置
    複製程式碼
  • 使用開發者工具得到呼叫棧:

    設定斷點或者插入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() {
	// 建立一個空的物件
    var obj = new Object(),
	// 獲得建構函式,arguments中去除第一個引數
    Con = [].shift.call(arguments);
	// 連結到原型,obj 可以訪問到建構函式原型中的屬性
    obj.__proto__ = Con.prototype;
	// 繫結 this 實現繼承,obj 可以訪問到建構函式中的屬性
    var ret = Con.apply(obj, arguments);
	// 優先返回建構函式返回的物件
	return ret instanceof Object ? ret : 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的繫結物件傳入callapply或者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繫結到obj1bar(引用箭頭函式)的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();
複製程式碼

參考

你不知道的JavaScript上卷—筆記

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原始碼解析

交流

進階系列文章彙總如下,內有優質前端資料,覺得不錯點個star。

github.com/yygmind/blo…

我是木易楊,網易高階前端工程師,跟著我每週重點攻克一個前端面試重難點。接下來讓我帶你走進高階前端的世界,在進階的路上,共勉!

【進階3-1期】JavaScript深入之史上最全–5種this繫結全面解析

相關文章