前言
在上一篇博客里我总结了下辨认this指向的四种方式,但是有师兄抛出一个问题来,我发现那些this的指向并不能说明更复杂的情况,先看下这段代码
var a = { name: 'a', getName: function(){ console.log(this.name) } } var c = a.getName.bind(a) var b={ } b.getName=a.getName; c();//a
那么为什么最后执行c会得到a呢?this在其中的指向到底是啥呢?我觉得利用上篇的博文并不能很好的解释。所以,这里查找了很多资料,然后我觉得还要再做一个二次总结。也希望各位看官在看完博文之后能够思考出这个例子中的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 的绑定对象。
你必须找到调用位置,然后判断需要应用下面四条规则中的哪一条。我们首先会分别解释这四条规则,然后解释多条规则都可用时它们的优先级如何排列。
a.默认绑定
function foo() { console.log( this.a ); } var a = 2; foo(); // 2
function foo() { "use strict"; console.log( this.a ); } var a = 2; foo(); // TypeError: this is undefined
这里有一个微妙但是非常重要的细节,虽然 this 的绑定规则完全取决于调用位置,但是只有 foo() 运行在非 strict mode 下时,默认绑定才能绑定到全局对象;严格模式下与 foo()的调用位置无关:
function foo() { console.log( this.a ); } var a = 2; (function(){ "use strict"; foo(); // 2 })();
b.隐式绑定
首先,我们来看一段代码:
function foo() { console.log( this.a ); } var obj = { a: 2, foo: foo }; obj.foo(); // 2
我们可以看出,foo()函数的声明方式和最后的obj中作为引用属性添加到obj里,但即使是这样,严格意义上来说,foo()也不属于obj对象,就算我们把声明放入obj中,亦是如此。然而,调用位置会使用 obj 上下文来引用函数,因此你可以说函数被调用时 obj 对象“拥
有”或者“包含”它。
但是无论如何去称呼这个模式,当foo()被调用的时候,指向确实指到了obj对象。当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。因为调用 foo() 时 this 被绑定到 obj ,因此 this.a 和 obj.a 是一样的。
这里还会出现一个问题,隐式丢失。 这个怎么来理解呢?其实就是说在隐式绑定的函数中,可能会出现绑定对象丢失,然后就会应用默认规则,从而把this绑定到全局对象或者undefined上。这种情况的出现取决于是否使用严格模式。
function foo() { console.log( this.a ); } var obj = { a: 2, foo: foo }; var bar = obj.foo; // 函数别名! var a = "Kevin"; // a 是全局对象的属性 bar(); // "Kevin"
来看这个例子,虽然bar是obj.foo的一个引用,但是实际上引用的却是foo这个函数本身,所以此时,bar()是一个没有任何修饰的函数调用,自然就应用了默认规则进行绑定。
还有一种情况,十分常见,也会出现隐式丢失,那就是在回调函数中。来看下例子:
function foo() { console.log( this.a ); } var obj = { a: 2, foo: foo }; var a = "Kevin"; // a 是全局对象的属性 setTimeout( obj.foo, 100 ); // "Kevin"
参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一个例子一样。就像我们看到的那样,回调函数丢失 this 绑定是非常常见的。除此之外,还有一种情况 this 的行为会出乎我们意料:调用回调函数的函数可能会修改 this 。无论是哪种情况, this 的改变都是意想不到的,实际上你无法控制回调函数的执行方式,因此就没有办法控制会影响绑定的调用位置。
c.显式绑定
在分析隐式绑定时,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把 this 间接(隐式)绑定到这个对象上。那么如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么做呢?这个时候,我们就需要使用call或者apply两个方法来调用了。严格来说,JavaScript 的宿主环境有时会提供一些非常特殊的函数,它们并没有这两个方法。但是这样的函数非常罕见,JavaScript 提供的绝大多数函数以及你自己创建的所有函数都可以使用 call(..) 和 apply(..) 方法。这两个方法是如何工作的呢?它们的第一个参数是一个对象,它们会把这个对象绑定到this ,接着在调用函数时指定这个 this 。因为你可以直接指定 this 的绑定对象,因此我们称之为显式绑定。
来看下这个例子:
function foo() { console.log( this.a ) } var obj = { a:2 }; foo.call( obj ); // 2
通过 foo.call(..) ,我们可以在调用 foo 时强制把它的 this 绑定到 obj 上。如果你传入了一个原始值(字符串类型、布尔类型或者数字类型)来当作 this 的绑定对象,这个原始值会被转换成它的对象形式(也就是 new String(..) 、 new Boolean(..) 或者new Number(..) )。这通常被称为“装箱”。
但是即使这样,也无法完全解决之前出现的绑定丢失的情况
1. 硬绑定
但是显式绑定的一个变种可以解决这个问题。
我们来看这样一个例子
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
我们来看看这个变种到底是怎样工作的。我们创建了函数 bar() ,并在它的内部手动调用了 foo.call(obj) ,因此强制把 foo 的 this 绑定到了 obj 。无论之后如何调用函数 bar ,它总会手动在 obj 上调用 foo 。这种绑定是一种显式的强制绑定,因此我们称之为硬绑定。硬绑定的典型应用场景就是创建一个包裹函数,传入所有的参数并返回接收到的所有值:
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
另一种使用方法是创建一个 i 可以重复使用的辅助函数:
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 ,它的用法如下:
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 的上下文并调用原始函数。
2. API调用的“上下文”
第三方库的许多函数,以及 JavaScript 语言和宿主环境中许多新的内置函数,都提供了一个可选的参数,通常被称为“上下文”(context),其作用和 bind(..) 一样,确保你的回调函数使用指定的 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(..) 实现了显式绑定,这样你可以少些一些代码。
d.new绑定
这是第四条也是最后一条 this 的绑定规则,在讲解它之前我们首先需要澄清一个非常常见的关于 JavaScript 中函数和对象的误解。在传统的面向类的语言中,“构造函数”是类中的一些特殊方法,使用 new 初始化类时会调用类中的构造函数。通常的形式是这样的:
something = new MyClass(..);
JavaScript 也有一个 new 操作符,使用方法看起来也和那些面向类的语言一样,绝大多数开发者都认为 JavaScript 中 new 的机制也和那些语言一样。然而,JavaScript 中 new 的机制实际上和面向类的语言完全不同。首先我们重新定义一下 JavaScript 中的“构造函数”。在 JavaScript 中,构造函数只是一些使用 new 操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上,它们甚至都不能说是一种特殊的函数类型,它们只是被 new 操作符调用的普通函数而已。
举例来说,思考一下 Number(..) 作为构造函数时的行为,ES5.1 中这样描述它:
15.7.2 Number 构造函数当 Number 在 new 表达式中被调用时,它是一个构造函数:它会初始化新创建的对象。所以,包括内置对象函数(比如 Number(..) ,详情请查看第 3 章)在内的所有函数都可以用 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 来调用 foo(..) 时,我们会构造一个新对象并把它绑定到 foo(..) 调用中的 this上。 new 是最后一种可以影响函数调用时 this 绑定行为的方法,我们称之为 new 绑定。
优先级
有种情况我们需要进行考虑,就是在函数调用过程中,某个位置如果出现应用了多条绑定规则怎么办?那么要解决这种问题,我们就需要知道规则的优先级。
毫无疑问,默认规则是四条规则中最低的,所以暂时不做考虑。所以首先,我们需要比对一下显式绑定与隐式绑定的优先级,看看哪一个更高一些。
来看如下的例子:
function foo() { console.log( this.a ); } var obj1 = { a: 2, foo: foo }; var obj2 = { a: 3, foo: foo }; obj1.foo(); // 2 obj2.foo(); // 3 obj1.foo.call( obj2 ); // 3 obj2.foo.call( obj1 ); // 2
可以看到,显式绑定优先级更高,也就是说在判断时应当先考虑是否可以应用显式绑定。
接下来,我们需要比对下隐式和new的优先级,看看谁高谁低。来看如下例子:
function foo(something) { this.a = something; } var obj1 = { foo: foo }; var obj2 = {}; obj1.foo( 2 ); console.log( obj1.a ); // 2 obj1.foo.call( obj2, 3 ); console.log( obj2.a ); // 3 var bar = new obj1.foo( 4 ); console.log( obj1.a ); // 2 console.log( bar.a ); // 4
可以看到 new 绑定比隐式绑定优先级高。但是 new 绑定和显式绑定谁的优先级更高呢?
但这里我们需要注意一个问题:new 和 call / apply 无法一起使用,因此无法通过 new foo.call(obj1) 来直接进行测试。但是我们可以使用硬绑定来测试它俩的优先级。
然后回忆下硬绑定,Function.prototype.bind(...)创建了一个新的包装函数,这个函数会忽略当前this的绑定,并且强制把我们提供的对象绑定到this上。那么由此看来,new绑定的优先级似乎比硬绑定(显式绑定)要底,但是真的是这样的么?
这里有个例子:
function foo(something) { this.a = something; } var obj1 = {}; var bar = foo.bind( obj1 ); bar( 2 ); console.log( obj1.a ); // 2 var baz = new bar(3); console.log( obj1.a ); // 2 console.log( baz.a ); // 3
这个结果似乎有点出乎意料,bar被硬绑定到了obj1上,但是new bar(3)并不是按照之前所想的那样把obj1.a修改为3。恰恰相反的是,new修改了硬绑定(到 obj1 的)并且调用 bar(..) 中的 this 。因为使用了
new 绑定,我们得到了一个名字为 baz 的新对象,并且 baz.a 的值是 3。
判断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();
就是这样。对于正常的函数调用来说,理解了这些知识你就可以明白 this 的绑定原理了。
this词法
关于this的指向,以及优先级,我们上面已经总结了很多,但是这个时候就不得不提ES6了,因为在其中有一个无法使用上述规则的特殊函数类型:箭头函数
箭头函数并不是使用 function 关键字定义的,而是使用被称为“胖箭头”的操作符 => 定义的。箭头函数不使用 this 的四种标准规则,而是根据外层(函数或者全局)作用域来决定 this 。
我们来看看箭头函数的词法作用域:
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 !
foo()内部创建的箭头函数会捕获调用时 foo() 的 this 。由于 foo() 的 this 绑定到 obj1 ,bar (引用箭头函数)的 this 也会绑定到 obj1 ,箭头函数的绑定无法被修改。 ( new 也不行!)
箭头函数最常用于回调函数中,例如事件处理器或者定时器:
function foo() { setTimeout(() => { // 这里的 this 在此法上继承自 foo() console.log( this.a ); },100); } var obj = { a:2 }; foo.call( obj ); // 2
箭头函数可以像 bind(..) 一样确保函数的 this 被绑定到指定对象,此外,其重要性还体现在它用更常见的词法作用域取代了传统的 this 机制。实际上,在 ES6 之前我们就已经在使用一种几乎和箭头函数完全一样的模式。
function foo() { var self = this; // lexical capture of this setTimeout( function(){ console.log( self.a ); }, 100 ); } var obj = { a: 2 }; foo.call( obj ); // 2
虽然 self = this 和箭头函数看起来都可以取代 bind(..) ,但是从本质上来说,它们想替代的是 this 机制。如果你经常编写 this 风格的代码,但是绝大部分时候都会使用 self = this 或者箭头函数来否定 this 机制,那你或许应当:
1. 只使用词法作用域并完全抛弃错误 this 风格的代码;
2. 完全采用 this 风格,在必要时使用 bind(..) ,尽量避免使用 self = this 和箭头函数。
当然,包含这两种代码风格的程序可以正常运行,但是在同一个函数或者同一个程序中混合使用这两种风格通常会使代码更难维护,并且可能也会更难编写。
总结
如果要判断一个运行中函数的 this 绑定,就需要找到这个函数的直接调用位置。找到之后就可以顺序应用下面这四条规则来判断 this 的绑定对象。
1. 由 new 调用?绑定到新创建的对象。
2. 由 call 或者 apply (或者 bind )调用?绑定到指定的对象。
3. 由上下文对象调用?绑定到那个上下文对象。
4. 默认:在严格模式下绑定到 undefined ,否则绑定到全局对象。
ES6 中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定this ,具体来说,箭头函数会继承外层函数调用的 this 绑定(无论 this 绑定到什么) 。这d其实和 ES6 之前代码中的 self = this 机制一样。
ps.其实关于这篇,例如对于this指向的显式绑定与后续的优先级,我都属于能理解是什么意思,但是却没法用我自己的语言去归纳与总结...所以那部分内容还是已整理为主。希望在后续的工作中能够有更多的几乎去实践博客中所提及的语法糖,同时也希望这篇博文能给大家带来一点点帮助。如果博文中有错误或者不详之处,请各位批评指正!
完结撒花~