阅读 90

剖析JS中"this"的前世今生

这是我参与更文挑战的第26天,活动详情查看: 更文挑战

今天用一篇总结彻底搞懂JavaScript中的 this 问题,剖析 this 的前世今生。

前言

首先需要明确的是 JS 中的 this 既不指向函数自身,也不指函数的词法作用域。它实际是在函数被调用时才发生的绑定,也就是说this具体指向什么,取决于你是怎么调用的函数。

this this 是很多人会混淆的概念,只要理清this的工作原理便不难理解,先看个demo。

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

var a = 2
foo()
var obj = {
  a: 2,
  foo: foo
}
obj.foo()

// 以上两者情况 `this` 只依赖于调用函数前的对象,优先级是第二个情况大于第一个情况
// 以下情况是优先级最高的,`this` 只会绑定在 `c` 上,不会被任何方式修改 `this` 指向

var c = new foo()
c.a = 3
console.log(c.a)
// 还有种就是利用 call,apply,bind 改变 this,这个优先级仅次于 new
以上几种情况明白了,很多代码中的 this 应该就没什么问题了,下面让我们看看箭头函数中的 thisfunction a() {
  return () => {
     return () => {
       console.log(this)
     }
  }
}
console.log(a()()())
复制代码

箭头函数其实是没有 this 的,这个函数中的 this 只取决于他外面的第一个不是箭头函数的函数的 this。在这个例子中,因为调用 a 符合前面代码中的第一个情况,所以 this 是 window。并且 this 一旦绑定了上下文,就不会被任何代码改变。

This中的对象

This 对象是在运行时基于函数的执行环境绑定的:在全局函数中,this 等于 window,而当函数被作为某个对象的方法调用时,this 等于那个对象。不过,匿名函数的执行环境具有全局性,因此其 this 对象通常指向 window。但有时候由于编写闭包的方式不同,这一点可能不会那么明显。

var name = "The Window";
var object = {
  name : "My Object",
  getNameFunc : function(){
    return function(){
      return this.name;
    };
  }
};

alert(object.getNameFunc()()); 
// "The Window"(在非严格模式下)
复制代码

以上代码先创建了一个全局变量 name,又创建了一个包含 name 属性的对象。这个对象还包含一个方法 —— getNameFunc(),它返回一个匿名函数,而匿名函数又返回 this.name。由于 getNameFunc() 返回一个函数,因此调用 object.getNameFunc()() 就会立即调用它返回的函数,结果就是返回一个字符串。然而,这个例子返回的字符串是"The Window",即全局 name 变量的值。

每个函数在被调用时都会自动取得两个特殊变量:this 和 arguments。内部函数在搜索这两个变量时,只会搜索到其活动对象为止,因此永远不可能直接访问外部函数中的这两个变量。不过,把外部作用域中的 this 对象保存在一个闭包能够访问到的变量里,就可以让闭包访问该对象了,如下所示。

var name = "The Window";
var object = {
  name : "My Object",
  getNameFunc : function(){ 
    var that = this;
    return function(){
      return that.name;
    };
  }
};

alert(object.getNameFunc()()); 
// "My Object" 
复制代码

在定义匿名函数之前,我们把 this 对象赋值给了一个名叫 that 的变量。而在定义了闭包之后,闭包也可以访问这个变量,因为它是我们在包含函数中特意声名的一个变量。即使在函数返回之后,that 也仍然引用着 object,所以调用 object.getNameFunc()() 就返回了 "My Object" 。this 和 arguments 也存在同样的问题。如果想访问作用域中的 arguments 对象,必须将对该对象的引用保存到另一个闭包能够访问的变量中。在几种特殊情况下,this 的值可能会意外地改变。比如,下面的代码是修改前面例子的结果。

var name = "The Window";
var object = {
  name : "My Object",
  getName: function(){
    return this.name;
  }
}; 
复制代码

这里的 getName() 方法只简单地返回 this.name 的值。以下是几种调用 object.getName() 的方式以及各自的结果。

object.getName(); //"My Object"
(object.getName)(); //"My Object"
(object.getName = object.getName)(); 
// "The Window",在非严格模式下
复制代码

第一行代码跟平常一样调用了 object.getName() ,返回的是 "My Object" ,因为 this.name 就是 object.name。第二行代码在调用这个方法前先给它加上了括号。虽然加上括号之后,就好像只是在引用一个函数,但 this 的值得到了维持,因为 object.getName (object.getName) 的定义是相同的。第三行代码先执行了一条赋值语句,然后再调用赋值后的结果。因为这个赋值表达式的值是函数本身,所以 this 的值不能得到维持,结果就返回了 "The Window"。

执行上下文

当执行 JS 代码时,会产生三种执行上下文

全局执行上下文
函数执行上下文
eval 执行上下文

每个执行上下文中都有三个重要的属性变量对象(VO),包含变量、函数声明和函数的形参,该属性只能在全局上下文中访问作用域链(JS 采用词法作用域,也就是说变量的作用域是在定义时就决定了)。

// this
var a = 10;
function foo(i) {
  var b = 20;
}
foo()
复制代码

对于上述代码,执行栈中有两个上下文:全局上下文和函数 foo 上下文。

stack = [
  globalContext,
  fooContext
]
复制代码

对于全局上下文来说,VO 大概是这样的。

globalContext.VO === globe;
globalContext.VO = {
  a: undefined,
  foo: <Function>,
}
复制代码

对于函数 foo 来说,VO 不能访问,只能访问到活动对象(AO)

fooContext.VO === foo.AO
  fooContext.AO {
    i: undefined,
    b: undefined,
  arguments: <>
}
// arguments 是函数独有的对象(箭头函数没有)
// 该对象是一个伪数组,有 `length` 属性且可以通过下标访问元素
// 该对象中的 `callee` 属性代表函数本身
// `caller` 属性代表函数的调用者
复制代码

对于作用域链,可以把它理解成包含自身变量对象和上级变量对象的列表,通过 [[Scope]] 属性查找上级变量。

fooContext.[[Scope]] = [
  globalContext.VO
]
fooContext.Scope = fooContext.[[Scope]] + fooContext.VO
fooContext.Scope = [
  fooContext.VO,
  globalContext.VO
]
接下来让我们看一个老生常谈的例子,var

b();            // call b
console.log(a); // undefined

var a = 'Hello world';
function b() {
  console.log('call b')
}
复制代码

想必以上的输出大家肯定都已经明白了,这是因为函数和变量提升的原因。通常提升的解释是说将声明的代码移动到了顶部,这其实没有什么错误,便于大家理解。但是更准确的解释应该是:在生成执行上下文时,会有两个阶段。第一个阶段是创建的阶段(具体步骤是创建 VO),JS 解释器会找出需要提升的变量和函数,并且给他们提前在内存中开辟好空间,函数的话会将整个函数存入内存中,变量只声明并且赋值为 undefined ,所以在第二个阶段,也就是代码执行阶段,我们可以直接提前使用。在提升的过程中,相同的函数会覆盖上一个函数,并且函数优先于变量提升。

b() // call b second

function b() {
  console.log('call b fist')
}
function b() {
  console.log('call b second')
}
var b = 'Hello world'
复制代码

var 会产生很多错误,所以在 ES6 中引入了 let。let 不能在声明前使用,但是这并不是常说的 let 不会提升,let 提升了声明但没有赋值,因为临时死区导致了并不能在声明前使用。对于非匿名的立即执行函数需要注意以下一点

var foo = 1
(function foo() {
 foo = 10
 console.log(foo)
}()) // -> ƒ foo() { foo = 10 ; console.log(foo) }
复制代码

因为当 JS 解释器在遇到非匿名的立即执行函数时,会创建一个辅助的特定对象,然后将函数名称作为这个对象的属性,因此函数内部才可以访问到 foo,但是这又个值是只读的,所以对它的赋值并不生效,所以打印的结果还是这个函数,并且外部的值也没有发生更改。

// 
specialObject = {};
  Scope = specialObject + Scope;
  foo = new FunctionExpression;
  foo.[[Scope]] = Scope;
  specialObject.foo = foo; // {DontDelete}, {ReadOnly}
  delete Scope[0]; // remove specialObject from the front of scope chain
复制代码

this的原理

JavaScript 里的 this 就是被调用对象的引用。形象的说,就是那"."之前的那个对象的引用。 JavaScript 里虽然有函数(类型),也有函数(对象),但在调用的时候总是跟某个对象绑定在一起来调用的。直接调用一个看似没有跟什么对象绑定的函数,实际上是跟"全局"对象绑定在一起了。在浏览器DOM里这个全局对象就是 window 。 当使用new运算符来构造新对象时,new 之后跟着的那个构造器里的 "this" 指向的就是由 new 而构造出来的一个空的新对象。这个对象里暂时什么都还没有(有是有,不过 DontEnum 看不到罢了),而构造器里的 this.xxx 形式的赋值就能够创建给那个对象新的属性。说到底,JavaScript里的“对象”不过就是关联数组罢了。按 Concepts of Programming Languages 一 书里的讲法,JavaScript 里的对象与 perl 里的 hash 是很像的。现下流行的 JSON 也正是利用了这个特性而发展出来的。

 function fooConstructor() {  
        this.variable = 1;  
    }  

    function makeAnonymousFunction() {  
        return function() {  
            this.gooValue = 2;  
        };  
    }  

    fooConstructor();          // invoke a function that looks like a constructor  
    makeAnonymousFunction()(); // invoke an anonymous function  
    document.write(variable + "<br />");  
    document.write(window.gooValue + "<br />");  

    var obj = new fooConstructor(); // invoke a constructor with "new"  
    document.write(obj.variable + "<br />");  

// 运行结果
1  
2  
1  
复制代码

虽然我们在第一次调用 fooConstructor() 时并没有以 "object.method()" 的形式来调用,它实际上等价于 window.fooConstructor() 。于是我们把 window 对象(浏览器 DOM里的"全局"对象)隐式传给了所调用的函数,在 fooConstructor 里 this 就指向了 window,并为window对象创建了 variable 属性,赋值为1。 makeAnonymousFunction()() 的调用是为了演示这个this的指向与嵌套层次的无关性。makeAnonymousFunction() 返回了一个函数对象,不过我们没有为这个对象给予一个名字,而是直接调用了它。与前一例一样,这个调用为 window 对象创建了一个名为 gooValue 的属性,并赋值为2。 然后我们演示了以new 运算符来创建新对象的状况。这个很普通没什么需要解释的了。

总结

简单来说,JavaScript 里的 this 就是被调用对象的引用。形象的说,就是那"."之前的那个对象的引用。

参考文献

JavaScript 高级程序设计

文章分类
前端
文章标签