JavaScript

JavaScript中this的原理

2020-01-08  本文已影响0人  ERICOOLU

this是 JavaScript 语言的一个关键字。

它是函数运行时,在函数体内部自动生成的一个对象,只能在函数体内部使用。

var obj = {
  foo: function () { console.log(this.bar) },
  bar: 1
};

var foo = obj.foo;
var bar = 2;

obj.foo() // 1
foo() // 2

上述调用foo()函数,输出不同的结果,这种差异的原因,就在于函数体内部使用了this关键字。this指的是函数运行时所在的环境。对于obj.foo()来说,foo运行在obj环境,所以this指向obj;对于foo()来说,foo运行在全局环境,所以this指向全局环境。所以,两者的运行结果不一样。

内存的数据结构

JavaScript 语言之所以有this的设计,跟内存里面的数据结构有关系。

var obj = { foo:  5 };

上面的代码将一个对象赋值给变量obj。JavaScript 引擎会先在内存里面,生成一个对象{ foo: 5 },然后把这个对象的内存地址赋值给变量obj


1.png

也就是说,变量obj是一个地址(reference)。后面如果要读取obj.foo,引擎先从obj拿到内存地址,然后再从该地址读出原始的对象,返回它的foo属性。

原始的对象以字典结构保存,每一个属性名都对应一个属性描述对象。举例来说,上面例子的foo属性,实际上是以下面的形式保存的。

2.png
{
  foo: {
    [[value]]: 5
    [[writable]]: true
    [[enumerable]]: true
    [[configurable]]: true
  }
}

注意,foo属性的值保存在属性描述对象的value属性里面。

函数

这样的结构是很清晰的,问题在于属性的值可能是一个函数。

var obj = { foo: function () {} };

这时,引擎会将函数单独保存在内存中,然后再将函数的地址赋值给foo属性的value属性。

3.png
{
  foo: {
    [[value]]: 函数的地址
    ...
  }
}

由于函数是一个单独的值,所以它可以在不同的环境(上下文)执行。

var f = function () {};
var obj = { f: f };

// 单独执行
f()

// obj 环境执行
obj.f()

环境变量

JavaScript 允许在函数体内部,引用当前环境的其他变量。

var f = function () {
  console.log(x);
};

上面代码中,函数体里面使用了变量x。该变量由运行环境提供。

现在问题就来了,由于函数可以在不同的运行环境执行,所以需要有一种机制,能够在函数体内部获得当前的运行环境(context)。所以,this就出现了,它的设计目的就是在函数体内部,指代函数当前的运行环境。

var f = function () {
  console.log(this.x);
}

上面代码中,函数体里面的this.x就是指当前运行环境的x。

var f = function () {
  console.log(this.x);
}

var x = 1;
var obj = {
  f: f,
  x: 2,
};

// 单独执行
f() // 1

// obj 环境执行
obj.f() // 2

上面代码中,函数f在全局环境执行,this.x指向全局环境的x。


4.png

在obj环境执行,this.x指向obj.x。

5.png

this的绑定方式

默认绑定

默认绑定是在不使用其他绑定规则时的规则,通常是独立函数的调用。

function greeting() {
  console.log(`Hello, ${this.name}`);
}

var name = 'Eric';

greeting();

// Hello, Eric

隐式绑定

隐式绑定指的是在一个对象上调用函数。

通过 obj 调用 greeting 方法,this 就指向了 obj

将 obj.greeting 赋给了一个全局的变量 otherGreeting,所以在执行 otherGreeting 时,this 会指向 window.

function  greeting() {
  console.log(`Hello,${this.name}`);
};

var name = 'Eric';

var obj = {
  name: 'World',
  greeting,
};

var otherGreeting = obj.greeting;

greeting(); // Hello,Eric
obj.greeting(); // Hello,World
otherGreeting().greeting(); // Hello,Eric

异步操作时候隐式绑定丢失问题

如果涉及到回调函数(异步操作),就要小心隐式绑定的丢失问题。

function  greeting() {
  console.log(`Hello,${this.name}`);
};

var name = 'Eric';

var obj1 = {
  name: 'Obj1',
  greeting() {
    setTimeout(function() {
      console.log(`Hello,${this.name}`);
    })
  }
};

var obj2 = {
  name: 'Obj2',
  greeting,
};

obj1.greeting(); //Hello,Eric

obj2.greeting(); //Hello,Obj2

setTimeout(obj2.greeting, 100); //Hello,Eric

setTimeout(function() {
  obj2.greeting();
}, 200); //Hello,Obj2

因为涉及到异步操作setTimeout。在JavaScript中,一段代码执行时,会先执行宏任务中的同步代码,当遇到setTimeout之类的宏任务,那么就把这个 setTimeout内部的函数推入「宏任务的队列」中,下一轮宏任务执行时调用。当本轮宏任务调用结束后,下一轮宏任务执行时,此时函数位于内存中,this指向全局环境。此时 this.name 就是 Eric

greeting函数位于内存中,通过obj2来调用,那么this的指向环境变成了obj2。如前面讲述的图所示:


5.png

可以理解为将 obj2.greeting 赋值给一个新的变量(此时与obj1.greeting类似),所以此时 this 也是指向了 window。

此时setTimeout的function参数里面包含了obj2.greeting()方法,调用则是隐式绑定,此时 this 指向 obj2。我们可以做个实验来验证

setTimeout(function() {
  console.log(this);
  obj2.greeting();
}, 200); 

其中,打印出来的this指向window,所以在200毫秒后,function回调函数里面的this环境指向window,这个与前面的实验obj1.greeting()调用是一致的,此时相当于在全局环境中,我们来调用了obj2.greeting(),这个与前面的实验obj2.greeting()调用是一致的,所以打印的结果是Hello,Obj2

显式绑定

显示绑定就是通过 call, apply, bind 来显式地指定 this 的绑定对象。三者的第一个参数都是传递 this 指向的对象,call 与 apply 的区别是前者从第二个参数起传递一个参数序列,后者传递一个数组,call, apply 和 bind 的区别是前两个都会立即执行对应的函数,而 bind 方法不会。

我们通过 call 显式绑定 this 指向的对象来解决隐式绑定丢失的问题。

function  greeting() {
  console.log(`Hello,${this.name}`);
};

var name = 'Eric';

var obj = {
  name: 'Obj',
  greeting,
};

var otherGreeting = obj.greeting;

// 强制将 this 绑定到 obj
otherGreeting.call(obj); // Hello,Obj
setTimeout(obj.greeting.call(obj), 100); // Hello,Obj

在使用显式绑定时,如果将 null, undefined 作为第一个参数传入 call, apply 或者 bind,实际应用的是默认绑定。

function greeting() {
  console.log(`Hello,${this.name}`);
};

var name = 'Eric';

var obj = {
  name: 'Obj',
  greeting,
};

var otherGreeting = obj.greeting;
// this 仍然指向 window
otherGreeting.call(null); //Hello,Eric

箭头函数

var obj = {
  hi: function() {
    console.log(this);
    return () => {
      console.log(this);
    };
  },

  sayHi: function() {
    return function() {
      console.log(this);
      return () => {
        console.log(this);
      };
    };
  },

  say: () => {
    console.log(this);
  }
};

let hi = obj.hi(); // 输出 obj 对象
hi(); // 输出 obj 对象
let sayHi = obj.sayHi();  // 输出 window
fun1(); // 输出 window
obj.say(); // 输出 window
  1. 第一步是隐式绑定,此时 this 指向 obj,所以打印出 obj 对象
  2. 第二步执行 hi() 方法,虽然看着像闭包,但这是一个箭头函数,它会继承上一层的 this,也就是 obj,所以打印出 obj 对象
  3. 因为 obj.sayHi() 返回一个闭包,所以 this 指向 window,因此打印出 window 对象
  4. 同样箭头函数继承上一层的 this,所以 this 指向 window,因此打印出 window 对象
  5. 最后一次输出,因为 obj 中不存在 this,因此按作用域链找到全局的 this,也就是 window,所以打印出 window 对象
var obj = {
  name: 'Eric',
  greeting() {
    setTimeout(() => {
      console.log(`Hello, ${this.name}`);
    })
  },
  greeting2() {
    console.log(`Hello, ${this.name}`);
  },

  greeting3() {
    setTimeout(function() {
      console.log(`Hello, ${this.name}`);
    });
  }
};

var name = 'Global';
obj.greeting();   //Hello, Eric
obj.greeting2();  //Hello, Eric
obj.greeting3();  //Hello, Global
  1. obj.greeting(),虽然 setTimeout 会将 this 指向全局,但箭头函数继承上一层的 this,也就是 obj.greeting() 的 this,因为这是一个隐式绑定,所以 this 指向 obj,所以箭头函数的 this 也会指向 obj.
  2. obj.greeting2(),这里是一个隐式绑定,所以 this 指向 obj
  3. greeting3(),setTimeout 会将 this 指向全局

解析一

var number = 5;
var obj = {
  number: 3,
  fn: (function() {
    var number;
    this.number *= 2;
    number = number * 2;
    number = 3;
    return function() {
      var num = this.number;
      this.number *= 2;
      console.log(num);
      number *= 3;
      console.log(number);
    };
  })(),
};
var fn = obj.fn;
fn.call(null);
obj.fn();
console.log(window.number);

因为 obj.fn 是一个立即执行函数(this 会指向 window),所以在 obj 创建时就会执行一次,并返回闭包函数。

var number; // 创建了一个私有变量 number 但未赋初值
this.number *= 2; // this.number 指向的是全局那个 number,所以 window.number = 10
number = number * 2; // 因为私有变量 number 未赋初值,所以乘以 2 会变为 NaN
number = 3; // 此时私有变量 number 变为 3

接着执行下面两句:

var fn = obj.fn;
fn.call(null);

因为将 obj.fn 赋值给一个全局变量 fn,所以此时 this 指向 window。接着,当 call 的第一个参数是 null 或者 undefined 时,调用的是默认绑定,因此 this 仍然指向 window.

var num = this.number; // 因为 window.number = 10,所以 num 也就是 10
this.number *= 2; // window.number 变成了 20
console.log(num); // 打印出 10
number *= 3; // 因为是闭包函数,有权访问父函数的私有变量,所以此时 number 为 9
console.log(number); // 打印出 9

当执行 obj.fn(); 时,此时的 this 指向的是 obj:

var num = this.number; // 因为 obj.number = 3,所以 num 也就为 3
this.number *= 2; // obj.number 变为 6
console.log(num); // 打印出 3
number *= 3; // 上一轮私有变量为变成了 9,所以这里变成 27
console.log(number); // 打印出 27

最后打印出 window.number 就是 20

最终结果:
10
9
3
27
20

解析二

var length = 10;

function fn() {
  console.log(this.length);
}

var obj = {
  length: 5,
  method: function(fn) {
    fn();
    arguments[0]();
  },
};

obj.method(fn, 1);

最终结果:

10
2

传入了 fn 而非 fn(),相当于把 fn 函数赋值给 method 里的 fn 执行,所以这里是默认绑定,此时 this 指向 window,所以执行 fn() 时会打印出 10

arguments0,就相当于执行 fn(),所以是隐式绑定,此时 this 指向 arguments,所以 this.length 就相当于 arguments.length,因为我们传递了两个参数,因此返回 2

window.val = 1;

var obj = {
  val: 2,
  dbl: function() {
    this.val *= 2;
    val *= 2;
    console.log('val:', val);
    console.log('this.val:', this.val);
  },
};

obj.dbl();
var func = obj.dbl;
func();

最终结果:

2, 4
8, 8

第一次调用是隐式调用,因此 this 指向 obj,所以 this.val 也就是 obj.val 变成了 4,但是 dbl 方法中没有定义 val,所以会沿着作用域链找到 window.val,所以会依次打印出 2,4

第二次是默认调用,this 指向 window,window.val 会经历两次乘 2 变成 8,所以会依次打印出 8,8

总结

Notes from
http://www.ruanyifeng.com/blog/2018/06/javascript-this.html

上一篇下一篇

猜你喜欢

热点阅读