javascript中的this

133 阅读13分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

前言

大家好,我是小草。本菜鸟是来写基础文章的。仅供大家参考,欢迎指正。

科技发展或改变世界或毁灭世界

关于this

this是javascript中的一个关键字,它是函数体内自动生成的一个对象,只能在函数内部使用,关于this指向一直是一个热门话题,对于有经验的开发者来说,this也是一种复杂的机制。

当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包 含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的 其中一个属性,会在函数执行的过程中用到

this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。

找到this的具体指向一般通过如下步骤:

找到调用栈和调用位置 => 四条规则 => 优先级 => 确定this对象

接下来具体说一下:

调用栈和调用位置

调用栈的第二项就是调用位置

调用栈:函数在哪里被调用

调用栈:函数在哪里被调用

最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的 调用位置就在当前正在执行的函数的前一个调用中。

下面代码理解一下调用栈和调用位置

function baz(){ 
    // baz的调用栈: baz 
    console.log("baz") bar(); // bar的调用位置 
} 
function bar(){ 
    // bar的调用栈:baz==> bar 
    console.log("bar") foo(); // foo的调用位置 
} 
function foo(){ 
    // foo的调用栈: baz==>bar==>foo 
    console.log("foo")
} 
baz() // baz的调用位置-全局

注意:我们是如何(从调用栈中)分析出真正的调用位置的,因为它决定了 this 的绑定。

就本例来说,你可以在工具中给 foo() 函数的 第一行代码设置一个断点,或者直接在第一行代码之前插入一条 debugger; 语句。运行代码时,调试器会在那个位置暂停,同时会展示当前位置的函数 调用列表,这就是你的调用栈。因此,如果你想要分析 this 的绑定,使用开 发者工具得到调用栈,然后找到栈中第二个元素,这就是真正的调用位置。

Untitled.png

四条规则

来看看在函数的执行过程中调用位置如何决定 this 的绑定对象。

必须找到调用位置,然后判断需要应用下面四条规则中的哪一条。我们首先会分别解释 这四条规则,然后解释多条规则都可用时它们的优先级如何排列

1、默认绑定

首先要介绍的是最常用的函数调用类型:独立函数调用。可以把这条规则看作是无法应用 其他规则时的默认规则。

function foo(){ 
    console.log(this.a) 
} 
var a = 2 foo() // 2

可以看到当调用 foo() 时,this.a 被解析成了全局变量 a。为什么?因为在本 例中,函数调用时应用了 this 的默认绑定,因此 this 指向全局对象。

需要注意的是,虽然 this 的绑定规则完全取决于调用位置,但是只有foo()运行在非 strict mode 下时,默认绑定才能绑定到全局对象;严格模式下与 foo() 的调用位置无关;

2、隐式绑定

第二条需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含

function foo(){
  console.log(this.a)
}
var obj = {
  a:2,
  foo:foo
}
obj.foo() // 2

当 foo() 被调用时,它的落脚点确实指向 obj 对象。当函数引 用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。因为调 用 foo() 时 this 被绑定到 obj,因此 this.a 和 obj.a 是一样的。

对象属性引用链中只有最顶层会影响调用位置。举例来说

function foo(){
  console.log(this.a)
}
var obj2 = {
  a: 42,
  foo:foo // 只有最顶层会影响调用位置
}
var obj1 = {
  a:2,
  obj2:obj2
}
obj1.obj2.foo() // 42

隐式丢失: 个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把 this 绑定到全局对象或者 undefined 上,取决于是否是严格模式。

function foo(){
  console.log(this.a)
}
var obj = {
  a: 2,
  foo:foo
}
var a = 'global'
var bar = obj.foo;
bar(); // global

虽然 bar 是 obj.foo 的一个引用,但是实际上,它引用的是 foo 函数本身,实际,因此此时的 bar() 其实是一个不带任何修饰的函数调用,因此应用了默认绑定。

更常见并且更出乎意料的隐式丢失发生在传入回调函数时:

function foo(){
  console.log(this.a)
}
function doFoo(fn){
  fn()
}

var obj = {
  a: 2,
  foo: foo
}
var a = 'global'
doFoo(obj.foo) // global

参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一个例子一样

如果把函数传入语言内置的函数而不是传入你自己声明的函数,会发生什么呢?结果是一 样的,没有区别:

function foo(){
  console.log(this.a)
}
var obj = {
  a: 2,
  foo: foo
}
var a = 'global'
setTimeout(obj.foo,100) // global
3、显示绑定

如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么 做呢?

call()和apply()方法改变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(..))。这通常被称为“装箱”。

显式绑定仍然无法解决我们之前提出的丢失绑定问题。

硬绑定

function foo(){
  console.log(this.a)
}
var obj = {
  a: 2
}
var bar = function(){
  foo.call(obj)
}
bar() // 2
setTimeout(bar,1000) // 2
// 硬绑定的 bar 不可能再修改它的 this 
bar.call( window ); // 2

我们来看看这个变种到底是怎样工作的。我们创建了函数 bar(),并在它的内部手动调用 了 foo.call(obj),因此强制把 foo 的 this 绑定到了 obj。无论之后如何调用函数 bar,它 总会手动在 obj 上调用 foo。这种绑定是一种显式的强制绑定,因此我们称之为硬绑定

由于硬绑定是一种非常常用的模式,所以在 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

第三方库的许多函数,以及 JavaScript 语言和宿主环境中许多新的内置函数,都提供了一 个可选的参数,通常被称为“上下文”(context),其作用和 bind(..) 一样,确保你的回调 函数使用指定的 this

function foo(el){
  console.log(el,this.id)
}
var obj = {
  id: 123
}
var a = [1,2,3]
a.forEach(foo,obj) // 调用 foo(..) 时把 this 绑定到 obj
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 来调用 foo(..) 时,我们会构造一个新对象并把它绑定到 foo(..) 调用中的 this 上。我们称之为 new 绑定

优先级

现在我们已经了解了函数调用中 this 绑定的四条规则,你需要做的就是找到函数的调用位 置并判断应当应用哪条规则。但是,如果某个调用位置可以应用多条规则该怎么办?为了 解决这个问题就必须给这些规则设定优先级,这就是我们接下来要介绍的内容。

毫无疑问,默认绑定的优先级是四条规则中最低的,所以我们可以先不考虑它。

隐式绑定和显式绑定哪个优先级更高?我们来测试一下:

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 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对象

现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的 顺序来进行判断:

  • 函数是否在new中调用(绑定),如果是的话,this绑定的是新创建的对象var bar = new foo()
  • 函数是否通过call,apply(显示绑定)或硬绑定调用,如果是的话,this绑定的是指定的对象;var bar = foo.call(obj2)
  • 函数是否在某个上下文对象中调用(隐式绑定),如果是的话,this绑定的是指定的对象 var bar = obj1.foo()
  • 如果都不是的话,使用默认绑定。如果在严格模式下就绑定到undefined,否则绑定到window等全局对象

就是这样。对于正常的函数调用来说,理解了这些知识你就可以明白 this 的绑定原理了。 不过……凡事总有例外。

绑定例外

在某些场景下this的绑定行为会出乎意料,你认为应当应用其他绑定规则时,实际上应用的可能是默认绑定规则

如果把null或undefined作为this的绑定对象传入call,apply,bind,这些值在调用的时候会被忽略,实际应用的默认绑定规则

function foo(){
    console.log(this.a)
}
var a = 2
foo.call(null) // 2

一种常见的应用场景就是:1使用apply(...) 来展开一个数组,并当作参数传入一个函数。2类似的bind可以对参数进行柯里化,这种方法有时很有用

function foo(a,b){
    console.log(a+','+b)
}
foo.apply(null,[2,3]) // 2,3
var bar = foo.bind(null,2)
bar(3) // 2,3

这两种方法都需要传入一个参数当作 this 的绑定对象。如果函数并不关心 this 的话,你 仍然需要传入一个占位值,这时 null 可能是一个不错的选择,就像代码所示的那样。

尽管可以用 ... 操作符代替 apply(..) 来“展 开”数组,foo(...[1,2]) 和 foo(1,2) 是一样的,这样可以避免不必要的 this 绑定。可惜,在 ES6 中没有柯里化的相关语法,因此还是需要使用 bind(..)。

总是使用 null 来忽略 this 绑定可能产生一些副作用:那默认绑定规则会把 this 绑定到全局对象,这将导致不可预计的后果

更安全的this 一种“更安全”的做法是传入一个特殊的对象,把 this 绑定到这个对象不会对你的程序 产生任何副作用。我们可以创建一个“DMZ”(demilitarized zone,非军事区)对象——它就是一个空的非委托的对象

如果我们在忽略 this 绑定时总是传入一个 DMZ 对象,那就什么都不用担心了,因为任何 对于 this 的使用都会被限制在这个空对象中,不会对全局对象产生任何影响

如果我们在忽略 this 绑定时总是传入一个 DMZ 对象,那就什么都不用担心了,因为任何 对于 this 的使用都会被限制在这个空对象中,不会对全局对象产生任何影响

function foo(a,b){
  console.log("a:"+a+",b:"+b)
}
// 我们的 DMZ 空对象
var ø = Object.create(null)
foo.apply(ø,[2,3]) // 2,3
var bar = foo.bind(ø,2)
bar(3)// 2,3

使用变量名 ø 不仅让函数变得更加“安全”,而且可以提高代码的可读性,因为 ø 表示 “我希望 this 是空”,这比 null 的含义更清楚。

软绑定

之前我们已经看到过,硬绑定这种方式可以把 this 强制绑定到指定的对象(除了使用 new 时),防止函数调用应用默认绑定规则。问题在于,硬绑定会大大降低函数的灵活性,使 用硬绑定之后就无法使用隐式绑定或者显式绑定来修改 this。

如果可以给默认绑定指定一个全局对象和 undefined 以外的值,那就可以实现和硬绑定相 同的效果,同时保留隐式绑定或者显式绑定修改 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; 
  }; 
}
// 应用
function foo() { 
  console.log("name: " + this.name); 
}
var obj = { name: "obj" }, 
    obj2 = { name: "obj2" }, 
    obj3 = { name: "obj3" };
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 <---- 应用了软绑定

除了软绑定之外,softBind(..) 的其他原理和 ES5 内置的 bind(..) 类似。它会对指定的函 数进行封装,首先检查调用时的 this,如果 this 绑定到全局对象或者 undefined,那就把 指定的默认对象 obj 绑定到 this,否则不会修改 this。此外,这段代码还支持可选的柯里化

以下代码ES5的bind方法原理

if(!Function.prototype.bind){
  Function.prototype.bind = function(oThis){
    if(typeof this !== '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 && oThis ? this : oThis
            ),
            aArgs.concat(Array.prototype.slice.call(arguments))
          )
        }
    FNOP.prototype = this.prototype
    FBound.prototype = new FNOP()
    return fBound
  }
}

箭头函数

之前介绍的四条规则已经可以包含所有正常的函数。但是 ES6 中介绍了一种无法使用 这些规则的特殊函数类型:箭头函数

箭头函数不使用 this 的四种标准规则,而是根据外层(函数或者全局)作用域来决 定 this。

function foo(){
  return (a) => {
    console.log(this.a)
  }
}
var obj1 = {a:2}
var obj2 = {a:3}
var bar = foo.call(obj2)
bar.call(obj2) // 2

箭头函数的绑定无法被修改。(new 也不 行!)

箭头函数可以像 bind(..) 一样确保函数的 this 被绑定到指定对象,此外,其重要性还体 现在它用更常见的词法作用域取代了传统的 this 机制。

最后

如果要判断一个运行中函数的 this 绑定,就需要找到这个函数的直接调用位置。找到之后 就可以顺序应用下面这四条规则来判断 this 的绑定对象。

  • 由 new 调用?绑定到新创建的对象。
  • 由 call 或者 apply(或者 bind)调用?绑定到指定的对象。
  • 由上下文对象调用?绑定到那个上下文对象。
  • 默认:在严格模式下绑定到 undefined,否则绑定到全局对象。

一定要注意,有些调用可能在无意中使用默认绑定规则。如果想“更安全”地忽略 this 绑 定,你可以使用一个 DMZ 对象,比如 ø = Object.create(null),以保护全局对象。

ES6 中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定 this,具体来说,箭头函数会继承外层函数调用的 this 绑定(无论 this 绑定到什么)。这 其实和 ES6 之前代码中的 self = this 机制一样。