全面解析JavaScript的this指向

208 阅读9分钟

1. 前言

  1. 执行上下文分类: 全局执行上下文;函数执行上下文; eval执行上下文
  2. 执行上下文的创建阶段包括哪几步:1.binding this; 2. 创建词法环境 3. 创建变量环境
  3. ES6中,词法环境和变量环境的区别在于前者用来保存函数声明和let、const声明的变量;后者用来保存var声明的变量。
  4. JavaScript中,基本数据类型存放在栈中,引用类型存放在堆中。
  5. 内存回收:全局变量很难判断什么时候回收,局部变量用完就回收,因此尽量避免使用全局变量。
  6. 内存回收算法: 引用计数(现代浏览器已经不再使用);标记清除法(现代浏览器基本上在使用)。
  7. 内存泄漏:不用的内存不释放就会产生内存溢出--内存释放:赋值为null,几种常见的内存泄漏。

this 关键字是 JavaScript 中最复杂的机制之一。它是一个很特别的关键字,被自动定义在所有函数的作用域中。但是即使是非常有经验的 JavaScript 开发者也很难说清它到底指向什么。 在掌握this之前,我们首先要掌握以下两个概念

2. 为什么要使用this

所以this这么麻烦,我们为什么还要使用this呢?

请看下面这段代码,我的目标是需要分别打印出:Hello, I'm KYLE 和 Hello, I'm TOMMY。

function identify(context) {
 return context.name.toUpperCase();
}

function speak(context) {
 var greeting = "Hello, I'm " + identify( context );
 console.log( greeting );
}

var me = {
 name: "Kyle"
};

var you = {
 name: "Tommy"
};

identify( me ); // KYLE
identify( you ); // TOMMY
speak( me ); // Hello, I'm KYLE
speak( you ); // Hello, I'm TOMMY

上述代码实现的是两个不同的上下文对象重复调用两个函数identity、speak。很显然,采用了显示传参数的方法。随着代码量的增加,使用模式将会越来越复杂。显式传递上下文对象会让代码变得越来越混乱。有没有一种更好的方法呢?

function identify() {
 return this.name.toUpperCase();
}

function speak() {
 var greeting = "Hello, I'm " + identify.call( this );
 console.log( greeting );
}

var me = {
 name: "Kyle"
};

var you = {
 name: "Reader"
};

identify.call( me ); // KYLE
identify.call( you ); // READER
speak.call( me ); // Hello, I'm KYLE
speak.call( you ); // Hello, I'm READER

那么this到底指向什么呢?

3.对于this错误的认识

3.1 误解一:this指向自身

第一种常见的误解就是this指向函数自身。

function foo(num) {
 console.log( "foo: " + num );
 // 记录 foo 被调用的次数
 this.count++;
}
foo.count = 0;
var i;
for (i=0; i<10; i++) {
 if (i > 5) {
 foo( i );
 }
}

console.log(foo.count);

分析:按照我们正常的逻辑进行分析,for循环执行了4次,按理来说,foo.count应该输出4.但是实际上,foo.count输出的是0.为什么?因为在上面那段代码中实际上创建了一个全局的count,这个全局的count和foo.count不一样。如果想要让foo.count输出的内容为我们预期中的4,应该进行怎样的修改?

3.2 误解二:this指向函数的作用域

第二种常见的误解就是this指向函数的作用域。 虽然这段代码看起来好像是我们故意写出来的例子,但是实际上它出自一个公共社区中互助论坛的精华代码。这段代码非常完美(同时也令人伤感)地展示了this 多么容易误导人。

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

一起来挑刺:上面这段代码有哪些地方写的不好? 首先,这段代码试图通过 this.bar() 来引用 bar() 函数。如果要引用bar函数,直接引用就可以。 此外,编写这段代码的开发者还试图使用 this 联通 foo() 和 bar() 的词法作用域,从而让bar() 可以访问 foo() 作用域里的变量 a。这是不可能实现的,你不能使用 this 来引用一个词法作用域内部的东西。

3.3 this到底是什么?

词法作用域的执行环境是在编写代码时就指定了的,而this是在运行时进行绑定的,this的绑定与位置没有任何关系,只取决于函数的调用方式。

上一节说的函数在被调用的时候,会创建执行上下文,这个执行上下文里包含了this的绑定、词法环境和变量环境,其中,词法环境有两个部分:环境记录和对外部环境的引用。 环境记录包括了函数声明和变量存储的实际位置,而对外部环境的引用则显示其可以引用的外部词法环境。

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

4. 深入理解this

4.1 调用位置

在理解 this 的绑定过程之前,首先要理解调用位置:调用位置就是函数在代码中被调用的位置(而不是声明的位置)。只有仔细分析调用位置才能回答这个问题:这个 this 到底引用的是什么?通常来说,寻找调用位置就是寻找“函数被调用的位置”,但是做起来并没有这么简单,因为某些编程模式可能会隐藏真正的调用位置。
最重要的是要分析执行栈(我们在上一节也讲过,也叫调用栈,具有 LIFO(后进先出)结构,用于存储在代码执行期间创建的所有执行上下文。 首次运行JS代码时,会创建一个全局执行上下文并Push到当前的执行栈中。每当发生函数调用,引擎都会为该函数创建一个新的函数执行上下文并Push到当前执行栈的栈顶。根据执行栈LIFO规则,当栈顶函数运行完成后,其对应的函数执行上下文将会从执行栈中Pop出,上下文控制权将移到当前执行栈的下一个执行上下文)。我们关心的调用位置就在当前正在执行的函数的前一个调用中。

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 的绑定。

4.2 绑定规则

4.2.1 默认绑定

function foo() {
    console.log( this.a );
}
var a = 2;
foo(); //  <-- foo()函数是在这里被调用的,调用是不带任何修饰符.
function foo() {
   "use strict" // 函数运行在严格模式时,this会绑定到undefined.
    console.log( this.a );
}
var a = 2;
foo(); //undefined
  • 没有应用其他任何规则的默认规则,是独立的函数调用(不带任何修饰符的函数引用进行调用)。
  • 当函数运行在严格模式下时,全局对象无法使用默认绑定,this会绑定到undefined,只用当函数运行在非严格模式下时,默认绑定才能绑定到全局对象。

4.2.2 隐式绑定

隐式绑定:另一条需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含,不过这种说法可能会造成一些误导。

function foo() {
 console.log( this.a );
}
var obj = {
 a: 2,
 foo: foo
};
obj.foo(); // <--函数在这里被调用,调用位置有obj上下文

在上述代码中,调用位置使用obj上下文来引用函数,我们可以认为函数被调用时obj对象“拥有”或者“包含”它,但是严格来说这个函数不属于obj对象。

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

4.2.2.1隐式丢失

某些情况下,隐式绑定的函数会丢失绑定对象,此时将应用默认规则,也就是将this绑定到全局对象(非严格模式)或者undefined(严格模式)上。

function foo() {
 console.log( this.a );
}
var obj = {
 a: 2,
 foo: foo
};
var bar = obj.foo; // 函数别名!
var a = "oops, global"; // a 是全局对象的属性
bar();

我们对上段代码进行分析,虽然 barobj.foo 的一个引用,但是实际上,它引用的是 foo 函数本身,因此此时的bar() 其实是一个不带任何修饰的函数调用,因此应用了默认绑定。下面这张图有助于我们理解。
_20190417112206.png

还有一种情况,关于传参:

function foo() {
 console.log( this.a );
}
function doFoo(fn) {
 // fn 其实引用的是 foo
 fn(); // <-- 调用位置!
}
var obj = {
 a: 2,
 foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
doFoo( obj.foo ); // oops, global

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

4.2.3 显示绑定

就像我们刚才看到的那样,在分析隐式绑定时,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把 this 间接(隐式)绑定到这个对象上。那么如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么做呢?
在这里我们要介绍两个js中常用的方法: apply(...)call(...).

applycall的简单介绍

  • apply 语法:func.apply(thisArg, [argsArray])
    thisArg--可选的。在 func 函数运行时使用的 this 值。请注意,this可能不是该方法看到的实际值:如果这个函数处于非严格模式下,则指定为 nullundefined 时会自动替换为指向全局对象,原始值会被包装
    argsArray--可选的。一个数组或者类数组对象,其中的数组元素将作为单独的参数传给 func 函数。如果该参数的值为 nullundefined,则表示不需要传入任何参数。从ECMAScript 5 开始可以使用类数组对象。

  • call 语法:fun.call(thisArg, arg1, arg2, ...)
    thisArg--在 fun 函数运行时指定的 this 值。需要注意的是,指定的 this 值并不一定是该函数执行时真正的 this 值,如果这个函数在非严格模式下运行,则指定为 nullundefined 的 this 值会自动指向全局对象(浏览器中就是 window 对象),同时值为原始值(数字,字符串,布尔值)的 this 会指向该原始值的自动包装对象。
    arg1, arg2, ...--指定的参数列表。

这里解释一下对象的封装和拆封的概念:

  • 封装:基本类型值没有.length和.toString()这样的属性和方法,需要通过封装对象才能访问;此时JavaScript 会自动为基本类型值包装(box 或者wrap)一个封装对象。
var a = "abc";
a.length; // 3
a.toUpperCase(); // "ABC"
  • 拆封:如果想要得到封装对象中的基本类型值,可以使用valueOf() 函数:
var a = new String( "abc" );
var b = new Number( 42 );
var c = new Boolean( true );
a.valueOf(); // "abc"
b.valueOf(); // 42
c.valueOf(); // true

在需要用到封装对象中的基本类型值的地方会发生隐式拆封。

function foo (a, b) {
console.log(this.a);
console.log(a)
console.log(b)
}

applycall的区别:apply接收的是一个包含多个参数的列表,而call方法接收若干个参数的列表。

let obj = {
    a: 'Hello'
};
foo.call(obj, 'Tom', 'Jerry');
foo.apply(obj, ['Tom', 'Jerry']);

使用callapply可以强制把this绑定到你想要绑定的对象上。但是仍然无法解决上述提到的this丢失的问题。

以下两个方法可以解决此问题。

4.2.3.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),因此强制把 foothis 绑定到了 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

或者:

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

看到这里,熟悉bind()方法的人应该会很开心了,这难道不就是ES5中的Function.prototype.bind方法么!
再简单复习以下bind()方法:
语法:function.bind(thisArg[, [arg1[, arg2[, ...]]])
thisArg--调用绑定函数时作为this参数传递给目标函数的值。 如果使用new运算符构造绑定函数,则忽略该值。当使用bindsetTimeout中创建一个函数(作为回调提供)时,作为thisArg传递的任何原始值都将转换为object。如果bind函数的参数列表为空,执行作用域的this将被视为新函数的thisArgarg1, arg2, ...--当目标函数被调用时,预先添加到绑定函数的参数列表中的参数。

bindapplycall的区别: 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 的上下文并调用原始函数。

4.2.3.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

4.2.4 new绑定

在《JavaScript对象与原型》中,我们讲过,通过new调用构造函数时,会发生以下几步:

  1. 创建(或者说构造)一个全新的对象。
  2. 创建他的构造函数。
  3. 这个新对象会被执行 [[ 原型 ]] 连接。
  4. 这个新对象会绑定到函数调用的 this
  5. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。

这里大家可以自行回顾如何手写一个new实现。

function create() {
   let obj = new Object();
   let Con = [].shift.call(arguments);
   obj.__proto__ = Con.protoType;
   let ret = Con.apply(obj,arguments);
   return typeof ret === 'object'? ret : obj;
}

使用这个手写的new:

function Person(name) {
   this.name = name;
   return 'new person';
}
let person = new Foo('Lucy');
person.name; // Lucy
let person = create(Person, 'Lucy');
person.name; //Lucy

思考下面的代码:

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

使用new 来调用 foo(..) 时,我们会构造一个新对象并把它绑定到 foo(..) 调用中的 this上。new 是最后一种可以影响函数调用时 this 绑定行为的方法,我们称之为 new 绑定。

5. 优先级

上述我们一共给出了4中绑定this的规则,那么需要考虑到这四种规则到底谁的优先级更高呢? 优先级比较: new绑定 > 显式绑定 > 隐式绑定 > 默认绑定

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()

6.绑定例外

6.1 被忽略的this

上面在简单介绍applycallbind的时候有说过,如果第一个参数传的是null或者undefined,那么在非严格模式下,this会绑定到全局变量window
在展开数组中的元素、函数柯里化时参数会传null。 ​```js function foo(a,b) { console.log( "a:" + a + ", b:" + b ); }

foo.apply( null, [2, 3] ); // a:2, b:3

// 使用 bind(..) 进行柯里化 var bar = foo.bind( null, 2 ); bar( 3 ); // a:2, b:3


然而,总是使用 `null` 来忽略 `this` 绑定可能产生一些副作用。如果某个函数确实使用了`this`(比如第三方库中的一个函数),那默认绑定规则会把 `this`绑定到全局对象(在浏览器中这个对象是 window),这将导致不可预计的后果(比如修改全局对象)。  
> 我们一般建议使用`Object.create(null)`来代替直接使用`null`,因为任何对于 `this` 的使用都会被限制在这个空对象中,不会对全局对象产生任何影响。

### 6.2 间接引用

```js
function foo() {
 console.log( this.a );
}
var a = 2;
var o = { a: 3, foo: foo };
var p = { a: 4 };
o.foo(); // 3
(p.foo = o.foo)(); // 2

赋值表达式p.foo = o.foo 的返回值是目标函数的引用,因此调用位置是foo() 而不是p.foo() 或者o.foo()。根据我们之前说过的,这里会应用默认绑定。

注意:对于默认绑定来说,决定 this 绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式。如果函数体处于严格模式,this 会被绑定到 undefined,否则this 会被绑定到全局对象。

6.3 软绑定

在上面我们提到了硬绑定,那为什么在这里又要提出软绑定呢? 硬绑定的缺点主要变现在一旦使用硬绑定指定了this,再无法用隐式绑定改变this的指向,大大降低了灵活性。在这种情况下,提出了软绑定。 软绑定:给默认绑定指定一个全局对象和 undefined 以外的值,那就可以实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定修改 this 的能力。

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 <---- 应用了软绑定

7.箭头函数

箭头函数不适用于以上几条绑定规则,箭头函数的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
思考题: 依次给出console.log输出的数值:

var num = 1;
var myObject = {
   num: 2,
   add: function() {
      this.num = 3;
      (function() {
         console.log(this.num);
         this.num = 4;
      })();
      console.log(this.num);
   },
   sub: function() {
      console.log(this.num);
   }
}
myObject.add();
console.log(myObject.num);
console.log(num);
var sub = myObject.sub;
sub();

看答案吧:

var num = 1;
var myObject = {
   num: 2,
   add: function() {
      this.num = 3; // myObject.add()为隐式调用,所以this指向调用的上下文对象myObject,所以myObject.num=3.
      (function() {
         console.log(this.num);  //立即执行函数,此时应用的是默认绑定规则,因此此时的this指向全局对象window.
         this.num = 4; // window.num=4
      })();
      console.log(this.num); // 此时的myObject.num 为3.
   },
   sub: function() {
      console.log(this.num); // 应用默认规则,此时window.num为4.
   }
}
myObject.add();
console.log(myObject.num);
console.log(num); // 此时的全局的num值为4.
var sub = myObject.sub;
sub(); // 这是函数的实际调用位置,没有任何修饰符,所以应用默认规则.

参考:
《你不知道的js--上卷》 --Kyle Simpson
《MDN Web Docs》 --mdn