你不知道的JS(3)來聊聊this
為什麽要使用this?什麽是this?
來看一段代碼
function identify() { return this.name.toUpperCase(); } function speak() { var greeting = "Hello, I‘m " + identify.call( this ); console.log( greeting ); } var me = { name: "Kyle" }; var you = { name: "Reader" }; identify.call( me ); // KYLE identify.call( you ); //READER speak.call( me ); // Hello, 我是KYLE speak.call( you ); // Hello, 我是 READER
如果不用this的話,我們就需要顯式地傳入一個上下文對象
function identify(context) { return context.name.toUpperCase(); } function speak(context) { var greeting = "Hello, I‘m " + identify( context ); console.log( greeting ); } identify( you ); // READERspeak( me ); //hello, 我是KYLE
通過這個我們就可以了解到this的作用:隱式地傳遞上下文對象,避免代碼耦合
說完這個後,我們可以來描述下this:
this 是在運行時進行綁定的,並不是在編寫時綁定,它的上下文取決於函數調用時的各種條件。this 的綁定和函數聲明的位置沒有任何關系,只取決於函數的調用方式。
當一個函數被調用時,會創建一個活動記錄(有時候也稱為執行上下文)。這個記錄會包含函數在哪裏被調用(調用棧)、函數的調用方法、傳入的參數等信息。
this 就是記錄的其中一個屬性,會在函數執行的過程中用到。
this的指向
要了解this的指向也就是this的綁定,需要知道它的上下文環境,也就是調用位置,或者說如何被調用的。
通常來說,尋找調用位置就是尋找“函數被調用的位置”,但是做起來並沒有這麽簡單,因為某些編程模式可能會隱藏真正的調用位置。
最重要的是要分析調用棧(就是為了到達當前執行位置所調用的所有函數)我們關心的調用位置就在當前正在執行的函數的前一個調用中。
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 的調用位置
this的綁定規則
現在你知道了如何找到調用位置,這時候你還需要了解關於this綁定的四條規則
1.默認綁定
function foo() { console.log( this.a ); } var a = 2; foo(); // 2
聲明在全局作用域中的變量(比如var a = 2)就是全局對象的一個同名屬性。它們本質上就是同一個東西,並不是通過復制得到的.。
接下來我們可以看到當調用foo() 時,this.a 被解析成了全局變量a。為什麽?因為在本例中,函數調用時應用了this 的默認綁定,因此this 指向全局對象。
在代碼中,foo() 是直接使用不帶任何修飾的函數引用進行調用的,因此只能使用默認綁定,無法應用其他規則。
或者我們可以這麽理解,foo()是被全局函數調用的,如window.foo()
當函數的執行上下文環境是全局環境,那麽就會使用默認綁定,即綁定到全局對象上
不過,在嚴格模式下,就沒有默認綁定了,this此時為undefined
function foo() { "use strict"; console.log( this.a ); } var a = 2; foo(); // TypeError: this is undefined
2.隱式綁定
function foo() { console.log( this.a ); } var obj = { a: 2, foo: foo }; obj.foo(); // 2
首先需要註意的是foo() 的聲明方式,及其之後是如何被當作引用屬性添加到obj 中的。但是無論是直接在obj 中定義還是先定義再添加為引用屬性,這個函數嚴格來說都不屬於obj 對象。然而,調用位置會使用obj 上下文來引用函數,因此你可以說函數被調用時obj 對象“擁有”或者“包含”它。
當foo() 被調用時,它的落腳點確實指向obj 對象。當函數引用有上下文對象時,隱式綁定規則會把函數調用中的this 綁定到這個上下文對象。因為調用foo() 時this 被綁定到obj,因此this.a 和obj.a 是一樣的。也就是這裏會查找foo時,會經過obj這個上下文對象,會把obj上下文對象保存下來,因此,這裏的this指向的就是obj上下文對象。
對象屬性引用鏈中只有最頂層或者說最後一層會影響調用位置。舉例來說:
function foo() { console.log( this.a ); } var obj2 = { a: 42, foo: foo }; var obj1 = { a: 2, obj2: obj2 }; obj1.obj2.foo(); // 42
你可以這麽理解:obj1=>obj2=>foo()。因此this找到了上下文對象後(obj2),就沒必要繼續去查找了,類似作用域鏈中查找變量。
隱式丟失:
一個最常見的this 綁定問題就是被隱式綁定的函數會丟失綁定對象,也就是說它會應用默認綁定,從而把this 綁定到全局對象或者undefined 上,取決於是否是嚴格模式。
function foo() { console.log( this.a ); } var obj = { a: 2, foo: foo }; var bar = obj.foo; // 函數別名! var a = "oops, global"; // a 是全局對象的屬性 bar(); // "oops, global"
在這裏,bar 是obj.foo 的一個引用,但是實際上,它引用的是foo 函數本身。因此此時的bar() 其實是一個不帶任何修飾的函數調用,因此應用了默認綁定。
或者可以這麽理解,bar()函數指向的是一個匿名函數的引用,這時候已經和obj沒有任何關系了,也就不存在obj上下文對象的引用了。
var bar = function() { console.log( this.a ); };
一種更微妙、更常見並且更出乎意料的情況發生在傳入回調函數時:
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"
我們知道,參數傳遞其實是一種隱式賦值,也就是fn = obj.foo,所以結果和之前的例子一樣。
如果把函數傳入語言內置的函數而不是傳入你自己聲明的函數,會發生什麽呢?結果是一樣的,沒有區別:
function foo() { console.log( this.a ); } var obj = { a: 2, foo: foo }; var a = "oops, global"; // a 是全局對象的屬性 setTimeout( obj.foo, 100 ); // "oops, global"
你可以這麽理解:setTimeOut()把你的回調函數丟進去了任務隊列中,然後JS引擎拿出來執行,這個執行環境的上下文其實就是全局上下文環境,因此也是使用默認綁定。
就像我們看到的那樣,回調函數丟失this 綁定是非常常見的。除此之外,還有一種情況this 的行為會出乎我們意料:調用回調函數的函數可能會修改this。
在一些流行的JavaScript 庫中事件處理器常會把回調函數的this 強制綁定到觸發事件的DOM 元素上。這在一些情況下可能很有用,但是有時它可能會讓你感到非常郁悶。遺憾的是,這些工具通常無法選擇是否啟用這個行為。
無論是哪種情況,this 的改變都是意想不到的,實際上你無法控制回調函數的執行方式,因此就沒有辦法控制會影響綁定的調用位置。之後我們會介紹如何通過固定this 來修復/固定這個問題。
3.顯式綁定
這個比較簡單,就是使用call()和apply()函數。
function foo() { console.log( this.a ); } var obj = { a:2 }; foo.call( obj ); // 2
如果你傳入了一個原始值(字符串類型、布爾類型或者數字類型)來當作this 的綁定對象,這個原始值會被轉換成它的對象形式(也就是new String(..)、new Boolean(..) 或者new Number(..))。這通常被稱為“裝箱”。
(1)硬綁定:
顯示綁定仍然可能存在著丟失this綁定的問題,因此我們需要采用硬綁定,也就是:創建要給函數,在函數內部再顯示綁定,如這裏的bar()
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
或者說創建一個綁定的輔助函數,也就是bind
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
當然這個bind函數比起正式的bind()有很多不足,正是因為硬綁定很常用,所以才有了ES5的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
bind(..) 會返回一個硬編碼的新函數,它會把參數設置為this 的上下文並調用原始函數。
我們可以看下MDN是怎麽實現的,當然這這只是一個polyfill版本的,因此還是會有.prototype,而ES5的bind()是沒有.prototype的
if (!Function.prototype.bind) { Function.prototype.bind = function(oThis) { if (typeof this !== ‘function‘) { // closest thing possible to the ECMAScript 5 // internal IsCallable function throw new TypeError(‘Function.prototype.bind - what is trying to be bound is not callable‘); } var aArgs = Array.prototype.slice.call(arguments, 1), fToBind = this, fNOP = function() {}, fBound = function() { return fToBind.apply(this instanceof fNOP ? this : oThis, aArgs.concat(Array.prototype.slice.call(arguments))); }; if (this.prototype) { // Function.prototype doesn‘t have a prototype property fNOP.prototype = this.prototype; } fBound.prototype = new fNOP(); return fBound; }; }
(2)API中的上下文
很多函數比如叠代函數,都提供了一個參數用於傳入函數上下文來綁定this
function foo(el) { console.log( el, this.id ); } var obj = { id: "awesome" }; // 調用foo(..) 時把this 綁定到obj [1, 2, 3].forEach( foo, obj ); // 1 awesome 2 awesome 3 awesome
這些函數在內部其實用到了call或者apply();
4.new綁定
使用new 來調用函數,或者說發生構造函數調用時,會自動執行下面的操作。
1). 創建(或者說構造)一個全新的對象。
2). 這個新對象會被執行[[ 原型]] 連接。
3). 這個新對象會綁定到函數調用的this。
4). 如果函數沒有返回其他對象,那麽new 表達式中的函數調用會自動返回這個新對象。
function foo(a) { this.a = a; } var bar = new foo(2); console.log( bar.a ); // 2
當然你也可以這麽理解,其實new內部機制也是使用了call或者apply函數,我們可以嘗試實現New方法
//實現一個new方法 function New() { let obj = new Object(), Constructor = [].shift.call(arguments); obj.__proto__ = Constructor.prototype; let ret = Constructor.apply(obj, arguments); return typeof ret === ‘object‘ ? ret : obj; }; function foo(a) { this.a = a; } var bar = New(foo,2); console.log( bar); //foo { a: 2 } console.log( bar.a ); // 2
綁定規則的優先級
優先級:new綁定>顯式綁定>隱式綁定>默認綁定
註:ES6的箭頭函數在四個規則以外,箭頭函數的this值為詞法作用域中的this值。
判斷this
1. 函數是否在new 中調用(new 綁定)?如果是的話this 綁定的是新創建的對象。
var bar = new foo()
2. 函數是否通過call、apply(顯式綁定)或者硬綁定調用?如果是的話,this 綁定的是
指定的對象。
var bar = foo.call(obj2)
3. 函數是否在某個上下文對象中調用(隱式綁定)?如果是的話,this 綁定的是那個上
下文對象。
var bar = obj1.foo()
4. 如果都不是的話,使用默認綁定。如果在嚴格模式下,就綁定到undefined,否則綁定到
全局對象。
var bar = foo()
一些插曲:
如果你把null 或者undefined 作為this 的綁定對象傳入call、apply 或者bind,這些值在調用時會被忽略,實際應用的是默認綁定規則:
function foo() { console.log( this.a ); } var a = 2; foo.call( null ); // 2
一種非常常見的做法是使用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 綁定到這個對象不會對你的程序產生任何副作用。就像網絡(以及軍隊)一樣,我們可以創建一個“DMZ”(demilitarized
zone,非軍事區)對象——它就是一個空的非委托的對象,比如我們可以? = Object.create(null)創建一個空對象,以保護全局對象。
//Object.create(null) 和{} 很像, 但是並不會創建Object.prototype 這個委托,所以它比{}“更空”
function foo(a,b) { console.log( "a:" + a + ", b:" + b ); } // 我們的DMZ 空對象 var ? = Object.create( null ); // 把數組展開成參數 foo.apply( ?, [2, 3] ); // a:2, b:3 // 使用bind(..) 進行柯裏化 var bar = foo.bind( ?, 2 ); bar( 3 ); // a:2, b:3
此外介紹下軟綁定:用軟綁定之後可以使用隱式綁定或者顯式綁定來修改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; }; }
你不知道的JS(3)來聊聊this