从ECMAScript规范深度分析JavaScript(四):This

本文深入探讨了JavaScript中this的指向,从全局代码、函数代码中的this值、Reference类型、函数调用的不同形式等方面详细分析。理解这些内容有助于解决关于this值的常见问题和疑惑,确保在编程中正确使用this。
摘要由CSDN通过智能技术生成

本文译自Dmitry Soshnikov的《ECMA-262-3 in detail》系列教程。其中会加入一些个人见解以及配图举例等等,来帮助读者更好的理解JavaScript。

前言

一句强调的话:我们不仅要知其然,还要知其所以然。
如果有人问我们JavaScript中不同情况下的this值是什么,我们可能会很容易说出来,但是有没有想过为什么this的值是那样?并且其中有哪些我们不知道的细节转换?本文带你一起深度分析this指向,搞懂本文,再也不会搞错关于this值的问题。

在本章中我们讨论关于执行期上下文相关的更多细节,这章的主题是this关键字。这个主题很难,并且经常在不同的执行期上下文中导致确定this值的问题。许多程序员习惯于认为编程语言中的this关键字与面向对象密切相关,其准确指向构造函数新创建的对象。在ECMAScript规范中,这个概念也是这样实现的,但是正如我们看到的那样,它不仅仅限于创建对象的定义。

让我们来详细了解一下,在ECMAScript中,this值到底是什么。

阅读本文前需要对执行期上下文有所了解,可以看我的前两篇文章《从ECMAScript规范深度分析JavaScript(一):执行期上下文》《从ECMAScript规范深度分析JavaScript(二):变量对象(上)》来进行深入理解

定义

this是执行期上下文的属性,他是代码执行时上下文的特殊对象。

    activeExecutionContext = {
      VO: {...},
      this: thisValue
    };

VO(variable object)是我们在上两章中讨论的变量对象。

this和上下文的可执行代码的类型直接相关,值在进入上下文期间确定,并且在上下文运行代码期间不可改变。接下来让我们来详细讨论这些细节

全局代码中的this值

在这种情况下是非常简单的。在全局代码中,this值总是全局对象本身,因此可以间接引用它:

    // 全局对象的显示属性定义
    this.a = 10; // global.a = 10
    console.log(a); // 10
     
    // 通过赋值给非限定标志符隐式定义
    b = 20;
    console.log(this.b); // 20
     
    // 通过变量声明隐式定义
    // 因为全局上下文的变量对象就是全局对象本身
    var c = 30;
    console.log(this.c); // 30

函数代码中的this值

当在函数代码中使用this值的时候,就会变得有趣起来,这种中情况是最困难的,并且会导致许多问题。

在这种类型的代码中,this值的第一个特性就是它不是静态绑定到函数的。

正如上面提到的那样,this值在进入上下文时确定的,也就是说,对于函数代码,this值每次都可能完全不同。然而,在代码运行时this值是不可改变的,也就是说,不能给他赋一个新值,因为他不是一个变量(相反,对于python编程语言,它显示定义的self对象可以在运行时被反复修改):

    var foo = {x: 10};
     
    var bar = {
      x: 20,
      test: function () {
     
        console.log(this === bar); // true
        console.log(this.x); // 20
         
        this = foo; // 错误,没法改变this的值
      
        console.log(this.x); // 如果没有报错,这里将会是10,而不是20
     
      }
     
    };
     
    // 在进入上下文时,this值被定义为bar; 我们下面会详细讨论为什么
     
    bar.test(); // true, 20
     
    foo.test = bar.test;
     
    // 但是这里的this值指向foo,即使我们调用了相同的函数
     
    foo.test(); // false, 10

那么是什么影响了函数代码中this值的变化呢?有几个因素。

首先,在通常的函数调用中,它是由激活上下文的代码的调用者提供的,即调用函数的父上下文。并且this值由调用表达式的形式决定(换句话说,由调用函数的语法形式决定)。

为了能够在任何上下文中毫无问题地确定this值,理解并记住这一点是非常重要的。确切地说:

调用表达式的形式,即调用函数的形式,影响了调用上下文的this值,没有其他什么了。

就像我们甚至可以看到一些文章和书籍的JavaScript,声称“这个值取决于函数的定义:如果是全局函数那么this值被设置为全局对象,如果函数是对象的一个方法this值总是被设置为这个对象”——这是错误的描述!!!

接下来,我们看到即使是正常的全局函数也可以被不同形式的调用表达式激活,这些调用表达式会产生不同的this值:

    function foo() {
      console.log(this);
    }
     
    foo(); // global
     
    console.log(foo === foo.prototype.constructor); // true
     
    // 但是通过这个函数的另外一种调用表达式的形式,this值是不同的
    
    foo.prototype.constructor(); // foo.prototype

类似地,也可以调用定义为某个对象的方法的函数,但是this值不会被设置为这个对象:

    var foo = {
      bar: function () {
        console.log(this);
        console.log(this === foo);
      }
    };
     
    foo.bar(); // foo, true
     
    var exampleFunc = foo.bar;
     
    console.log(exampleFunc === foo.bar); // true
     
    // 再次通过这个函数的另外一种调用表达式的形式,this值是不同的
     
    exampleFunc(); // global, false

所以,调用表达式的形式是如何影响this值的呢?为了完全理解this值的定义,有必要详细考虑一种内部类型——Reference类型。

Reference类型

使用伪代码可以将Reference类型表示为一个拥有两个属性的对象:base(拥有属性的那个对象)和这个base中propertyName:

    var valueOfReferenceType = {
      base: <base object>,
      propertyName: <property name>
    };

注意:从ES5,Reference也包含一个名为strict的属性——这是一个reference是否在strict模式中处理的标志:

    'use strict';
     
    // Access foo.
    foo;
     
    // Reference for `foo`.
    const fooReference = {
      base: global,
      propertyName: 'foo',
      strict: true,
    };

Reference类型的值只有两种情况:

  • 当我们处理一个标志符时
  • 使用属性访问器时

标志符是由标志符解析过程来处理的,之后会写一篇作用域链中详细介绍。这里我们只关注这个算法总是返回一个Reference类型的值(这对于this值很重要)。

标志符是变量名,函数名,函数参数名,和全局对象的非限定属性名。例如,对于以下标志符的值:

    var foo = 10;
    function bar() {}

在操作的中间结果中,对应的引用类型值如下:

    var fooReference = {
      base: global,
      propertyName: 'foo'
    };
     
    var barReference = {
      base: global,
      propertyName: 'bar'
    };

对于从Reference类型的值中获取对象的真实值,有GetValue方法,其伪代码描述如下:

    function GetValue(value) {
     
      if (Type(value) != Reference) {
        return value;
      }
      var base = GetBase(value);
      
      if (base === null) {
        throw new ReferenceError;
      }
      return base.[[Get]](GetPropertyName(value));
    }

为了从Reference类型中得到一个对象真正的值,在伪代码中可以使用GetValue方法来表示,如下:

    function GetValue(value) {
     
      if (Type(value) != Reference) {
        return value;
      }
      var base = GetBase(value);
     
      if (base === null) {
        throw new ReferenceError;
      }
      return base.[[Get]](GetPropertyName(value));
     
    }

内部[[Get]]方法返回对象属性的真实值,包括从原型链继承来的属性的分析:

    GetValue(fooReference); // 10
    GetValue(barReference); // function object "bar"

属性访问器都应该熟悉。它有两种变体:点(.)语法(此时属性名是正确的标示符,且事先知道),或括号语法([])

    foo.bar();
    foo['bar']();

在中间计算的返回中,我们获取到了Reference类型的值:

    var fooBarReference = {
      base: foo,
      propertyName: 'bar'
    };
     
    GetValue(fooBarReference); // function object "bar"

那么,从最重要的意义上讲,Reference类型的值如何与函数上下文的this值相关的呢?接下来是本文的主要内容!!!核心内容。在函数上下文中确定this值的通用规则如下:

  1. 函数上下文中的this值由调用者提供,并由调用表达式(函数调用的语法编写方式)的当前形式决定。
  2. 如果在调用括号(…)的左边,有一个Reference类型的值,然后将该值设置为该Reference类型值的base对象。
  3. 在所有其他情况下(即不同于Reference类型的任何其他值类型),该值总是设置为null。但是由于null对于this值没有任何意义,所以它被隐式地转换为全局对象。

举个例子:

    function foo() {
      return this;
    }
     
    foo(); // global

我们看到在括号的左边是一个Reference类型值(因为foo是一个标志符)

    var fooReference = {
      base: global,
      propertyName: 'foo'
    };

相应的,this值被设置为这个Reference类型值的base对象,即全局对象。
类似的,使用属性访问器:

    var foo = {
      bar: function () {
        return this;
      }
    };
     
    foo.bar(); // foo

同样,我们拥有一个Reference类型的值,其base是foo对象,在函数bar激活时将base设置给this:

    var fooBarReference = {
      base: foo,
      propertyName: 'bar'
    };

但是,使用另外一种调用表达式激活相同的函数,我们会得到其他的this值:

    var test = foo.bar;
    test(); // global

因为test称为了标志符,产生了Reference类型的其他值,这时base(此时是全局对象)被作为this值使用

    var testReference = {
      base: global,
      propertyName: 'test'
    };

注意:在ES5的严格模式中,this值不强制赋值为全局对象,而是被设置为undefined。(我们会在严格模式中讨论这种情况)

现在我们可以准确地说,为什么同一个函数被不同形式的调用表达式激活,它的this值也不同——答案就在不同Reference类型的中间值中:

    function foo() {
      console.log(this);
    }
     
    foo(); // global, because
     
    var fooReference = {
      base: global,
      propertyName: 'foo'
    };
     
    console.log(foo === foo.prototype.constructor); // true
     
    // another form of the call expression
     
    foo.prototype.constructor(); // foo.prototype, because
     
    var fooPrototypeConstructorReference = {
      base: foo.prototype,
      propertyName: 'constructor'
    };

另外一个动态决定通过调用表达式形式的经典例子:

    function foo() {
      console.log(this.bar);
    }
     
    var x = {bar: 10};
    var y = {bar: 20};
     
    x.test = foo;
    y.test = foo;
     
    x.test(); // 10
    y.test(); // 20
函数调用和非Reference类型

那么,像我们所说,当调用括号的左边不是Reference类型值而是其他类型的时候,this值将自动设置为null,最终被转化为全局对象。
来看一下这个表达式例子:

    (function () {
      console.log(this); // null => global
    })();

在这种情况下,我们有一个函数对象,但不是Reference对象(不是标志符,也不是属性访问器),相应的,this值最终被设为全局对象。

更加复杂的一个例子:

    var foo = {
      bar: function () {
        console.log(this);
      }
    };
     
    foo.bar(); // Reference, OK => foo
    (foo.bar)(); // Reference, OK => foo
     
    (foo.bar = foo.bar)(); // global?
    (false || foo.bar)(); // global?
    (foo.bar, foo.bar)(); // global?

那么,为什么有了一个属性存取器,中间结果应该是Reference类型的值,而事实上调用我们么获取到的this值不是base对象(foo),而是全局对象?是我们的规则出问题了吗?显然不是,是因为后三个调用,在使用一些操作后,调用括号左边的值不再是引用类型了。

第一个例子很明显——明显的引用类型,结果是,this为base对象,即foo。

在第二个例子中,分组操作符(这里的分组操作符就是指foo.bar外面的括号"()")没有调用从引用类型中获得一个对象真正的值的方法,即GetValue,回顾一下。相应的,在分组操作的返回值中———我们得到的仍是一个引用类型。这就是this的值为什么再次被设为base对象,即 foo。

第三个例子中,与分组操作符不同,赋值操作符调用了GetValue方法。返回的结果已经是函数对象(不是Reference类型),这意味着this的值被设为null,实际最终结果是被设置为全局对象。

第四个和第五个也是一样——逗号操作符和逻辑操作符(OR)调用了GetValue 方法,相应地,我们失去了Reference类型的值而得到了函数类型的值,所以this的值再次被设为global对象。

引用类型与this值为null

有一种调用括号左边的调用表达式确定了是Reference类型的值,然而this值被设置为null,并且最终被转换为全局对象,当Reference类型值的base对象是激活对象时会出现这种情况。

我们通过一个从父函数来调用内部函数的例子来看一下这种情况,我们在变量对象一节中知道,本地变量,内部函数和形参都是存储在给定函数的激活对象中:

    function foo() {
      function bar() {
        console.log(this); // global
      }
      bar(); // the same as AO.bar()
    }

激活对象总是返回this值为bull(用伪代码表示AO.bar() 等同于null.bar())。然后就是如上所讲的那样,this值再一次被设置为全局对象。

当是with语句块且with对象包含函数名属性中的函数调用时情况会有所不同。with语句将其对象添加到作用域链(后续会有作用域链的篇章来讲解作用域链)的前面,即在激活对象之前。因此,如果有类型Reference的值(通过标识符或属性访问器),那么base对象就不是一个激活对象,而是with语句的对象。顺便说一下,它不仅在内部函数是这样,在全局函数中也是如此,因为with对象会覆盖(指在作用域的前面)作用域链中更高的对象(全局对象或激活对象):

    var x = 10;
     
    with ({
     
      foo: function () {
        console.log(this.x);
      },
      x: 20
     
    }) {
     
      foo(); // 20
     
    }
     
    // because
     
    var  fooReference = {
      base: __withObject,
      propertyName: 'foo'
    };

类似的情况应该是调用catch子句的实际参数的函数调用:在这种情况下,catch对象也被添加到作用域链的前面,即在激活或全局对象之前。然而,给定的行为被认为是ECMA-262-3的一个bug,并在新版本的ECMA-262-5标准中得到了修复,即给定激活中的这个值应该设置为全局对象,而不是catch对象:

    try {
      throw function () {
        console.log(this);
      };
    } catch (e) {
      e(); // __catchObject - in ES3, global - fixed in ES5
    }
     
    // on idea
     
    var eReference = {
      base: __catchObject,
      propertyName: 'e'
    };
     
    // but, as this is a bug
    // then this value is forced to global
    // null => global
     
    var eReference = {
      base: global,
      propertyName: 'e'
    };

下面这种情况Dmitry Soshnikov将其单独列出来,我个人认为这种方式和内部函数的调用方式是相同的(即父激活对象),而Dmitry Soshnikov认为这时Reference类型的base对象是有区别的,有不同理解的朋友可以留言进行讨论:

递归调用命名函数表达式的情况也是如此(后面会有专门出讲述关于函数的篇章)。在函数的第一次调用时,base对象是父激活对象(或全局对象),在递归调用时base对象应该是存储函数表达式的可选名称的特殊对象。然而,在这种情况下,this值也总是设置为全局的:

    (function foo(bar) {
     
      console.log(this);
     
      !bar && foo(1); // "should" be special object, but always (correct) global
     
    })(); // global
作为构造器调用的函数中的this值

在函数上下文中还有一种与this值相关的情况——它是函数作为构造函数的调用:

    function A() {
      console.log(this); // newly created object, below - "a" object
      this.x = 10;
    }
     
    var a = new A();
    console.log(a.x); // 10

在这种情况下,new操作符调用函数A的内部[[Construct]]方法,而函数A在创建对象之后,又调用内部[[Call]]方法,将新创建的对象作为this值提供。

手动设置一个函数调用的this值

在Function.prototype上定义了两种方法(因此这是所有函数都具有的),允许手动定义函数调用的this值,就是apply和call方法。
它们都接受第一个参数作为调用上下文中this值的使用。这些方法之间的区别是不重要的:对于apply,第二个参数必须是一个数组(或者类数组的对象,例如arguments),反过来,call方法可以接受任何参数;两个方法的必传的参数只是第一个参数——this值,比如:

    var b = 10;
     
    function a(c) {
      console.log(this.b);
      console.log(c);
    }
     
    a(20); // this === global, this.b == 10, c == 20

总结

在本文中我们详细讨论了不同情况下的this值的指向问题,理解本篇对于我们后面学习以及进行JavaScript编程具有很重要的意义。

希望此文能够解决大家工作和学习中的一些疑问,避免不必要的时间浪费,有不严谨的地方,也请大家批评指正,共同进步!
转载请注明出处,谢谢!

交流方式:QQ1670765991

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值