你不知道的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 ); // READER speak( 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; }; }