JavaScript 里的 'this' 的一般解释

本文旨在帮助自己和大家理解 JS 里的 this, 翻译、整理并改写自本人关注的一个博主 Dmitri Pavlutin,原文链接如下:

https://dmitripavlutin.com/gentle-explanation-of-this-in-javascript/

欢迎大家批评指正、共同进步。

1. this 的神秘

长久以来,this 关键字对我来说都是神秘。

在 Java, PHP 或者其他标准语言里,this 是 class 方法里当前对象的实例。this 不能在方法外调用,这样一种简单的规则不会造成困惑。

在 JavaScript 里情况有所不同,this 是函数调用时候的上下文,JS 存在着4种函数调用的方式:

  • 常规调用(function invocation): alert('Hello World!')
  • 作为方法调用(method invocation):console.log('Hello World!')
  • 作为构造函数调用(constructor invocation):new RegExp('\\d')
  • 间接调用(indirect invocation):alert.call(undefined, 'Hello World!')

每一种调用方式都会对 this 有影响,因此 this 表现得和开发者的预期有所不同。

另外,严格模式(strict mode)也会对执行上下文有所影响。

理解 this 的关键在于拥有一幅关于函数调用和它是如何影响上下文的清晰图景。

本文着眼于函数调用方式的解释,函数的调用方式是如何影响 this 的,并且演示了一些在判断 this 时常见的陷阱。

在开始之前,让我们先来熟悉一些概念:

  • 函数的调用(Invocation)指的是执行组成函数体的代码,例如,对于 parseInt 函数的调用是 parseInt('15')
  • 调用时的上下文(Context)指的是函数体内 this 的值。
  • 函数的作用域(Scope)指的是函数体内可访问的变量和函数的集合。

2~5是对4种调用方式的详细介绍

2. 常规调用

一个常规调用的简单例子:

function hello(name) {
  return 'Hello ' + name + '!';
}
// 常规调用
const message = hello('World');

常规调用不能是属性访问,例如 obj.myFunc(), 这是一个方法调用,再比如 [1,5].join(',') 是一个方法调用而不是常规调用,请记住它们之间的区别

一个更高级的例子是立即执行函数(IIFE), 这也是常规调用

// IIFE
const message = (function(name) {
  return 'Hello ' + name + '!';
})('World');

2.1 常规调用时的 this

在常规调用里,this 是全局对象。

全局对象取决于执行环境,在浏览器,全局对象就是 window.

让我们通过以下的例子来检验:

function sum(a, b) {
  console.log(this === window); // => true
  this.myNumber = 20; // 给全局对象添加了 'myNumber' 属性
  return a + b;
}
// sum() 以常规的方式调用
// sum 里的 this 是全局对象(window)
sum(15, 16);     // => 31
window.myNumber; // => 20

this 在任何函数作用域之外使用时(最外层的域:全局执行上下文),它也等于全局对象

console.log(this === window); // => true
this.myString = 'Hello World!';
console.log(window.myString); // => 'Hello World!'
<!-- 在一个 html 文件里 -->
<script type="text/javascript">
 console.log(this === window); // => true
</script>

2.2 严格模式下常规调用里的 this

严格模式下,常规调用里的 thisundefined.

一个采用严格模式的常规调用的例子:

function multiply(a, b) {
  'use strict'; // 开启严格模式
  console.log(this === undefined); // => true
  return a * b;
}
// multiply() 开启了严格模式的常规调用
// multiply() 里的 this 是 undefined
multiply(2, 5); // => 10

值得注意的是,严格模式不仅会影响当前的函数作用域,也会影响嵌套定义的函数的作用域

function execute() {
  'use strict'; // 此处开启了严格模式
  function concat(str1, str2) {
    // 此处也自动开启了严格模式
    console.log(this === undefined); // => true
    return str1 + str2;
  }
  concat('Hello', ' World!'); // => "Hello World!"
}
execute();

2.3 陷阱:在嵌套定义的内层函数里的 this

一个常见的错误是认为内层函数的 this 和外层函数的 this 一样,事实上,内层函数(箭头函数除外)的 this 只取决于它的调用方式,而不是外层函数的 this.

为了让 this 变为我们期待的值,可以通过将内层函数的调用方式改为间接调用(使用 .call().apply(), 见第5节),或者创建一个预先绑定了 this 的函数(使用 .bind(), 详见第6节)。

下面的例子用以计算两个数的和:

const numbers = {
  numberA: 5,
  numberB: 10,
  sum: function() {
    console.log(this === numbers); // => true
    function calculate() {
      // this 是 window 或 undefined(严格模式下)
      console.log(this === numbers); // => false
      return this.numberA + this.numberB;
    }
    return calculate();
  }
};
numbers.sum(); // => NaN 或 throws TypeError (严格模式下)

numbers.sum(); 是一个方法调用(详见第3节),因此,this 等于 numbers. calculate() 函数定义在 sum() 内,你可能会认为调用 calculate() 时,this 也是 numbers.

calculate() 是一个常规调用而不是方法调用,因此其 thiswindowundefined(严格模式下),即便外层函数的 this 是 numbers 对象,对 calculate 也没影响。

为了解决这个问题,一个方法是手动地改变 calculate() 的执行上下文,例如 calculate.call(this)(一种间接调用的方式,详见第5节)。

const numbers = {
  numberA: 5,
  numberB: 10,
  sum: function() {
    console.log(this === numbers); // => true
    function calculate() {
      console.log(this === numbers); // => true
      return this.numberA + this.numberB;
    }
    // 使用 .call() 以修改上下文
    return calculate.call(this);
  }
};
numbers.sum(); // => 15

calculate.call(this) 和常规调用一样调用 calculate 函数,但是采用传入的第一个参数作为 calculatethis

另外一种稍好的方式,是使用箭头函数:

const numbers = {
  numberA: 5,
  numberB: 10,
  sum: function() {
    console.log(this === numbers); // => true
    const calculate = () => {
      console.log(this === numbers); // => true
      return this.numberA + this.numberB;
    }
    return calculate();
  }
};
numbers.sum(); // => 15

箭头函数的 this 是词法绑定的,换句话说,使用 numbers.sum()this 值。

3. 方法调用

方法 是存储于一个对象属性里的函数,例如:

const myObject = {
  // helloMethod 是一个方法
  helloMethod: function() {
    return 'Hello World!';
  }
};
const message = myObject.helloMethod();

helloMethodmyObject 的一个方法。使用属性访问器访问该方法 myObject.helloMethod.

方法调用依赖属性访问的方式调用函数(obj.myFunc()或者obj['myFunc']()),而常规的函数调用不需要(myFunc()),牢记这个区别很重要。

const words = ['Hello', 'World'];
words.join(', ');   // 方法调用
const obj = {
  myMethod() {
    return new Date().toString();
  }
};
obj.myMethod();     // 方法调用
const func = obj.myMethod;
func();             // 常规函数调用
parseFloat('16.6'); // 常规函数调用
isNaN(0);           // 常规函数调用

3.1 方法调用里的 this

在方法调用里,this 是拥有该方法的对象。

让我们创建一个拥有增加数值的方法的对象:

const calc = {
  num: 0,
  increment() {
    console.log(this === calc); // => true
    this.num += 1;
    return this.num;
  }
};
// 方法调用,this 指代 calc
calc.increment(); // => 1
calc.increment(); // => 2

让我们来考察另外一个 case, 一个对象从它的原型继承了一个方法,当这个被继承的方法在对象上调用时,this 仍是对象本身(而非原型)。

const myDog = Object.create({
  sayName() {
    console.log(this === myDog); // => true
    return this.name;
  }
});
myDog.name = 'Milo';
// 方法调用 this 是 myDog
myDog.sayName(); // => 'Milo'

Object.create() 创建了一个新对象 myDog, 并且将自己的第一个参数设为 myDog 的原型,myDog 继承了 sayName 方法。
myDog.sayName() 被调用时,myDog 是执行上下文(亦即 this)。

在 ES2015 的 class 语法里,方法调用的上下文依然是实例本身:

class Planet {
  constructor(name) {
    this.name = name;
  }
  getName() {
    console.log(this === earth); // => true
    return this.name;
  }
}
const earth = new Planet('Earth');
// 方法调用。上下文是 earth
earth.getName(); // => 'Earth'

3.2 陷阱:把方法从对象里分离出来

一个方法可以从对象里剥离出来置于一个独立的变量,例如 const alone = myObj.myMethod. 当该方法被单独调用时,alone(), 脱离了原有的对象,你可能会认为 this 是原有的对象。

事实上,当方法从对象里剥离出来并且单独调用时,这就是一个常规的函数调用,thiswindow 或者 undefined(严格模式下)。

下面的例子,定义了一个名为 Pet 的构造函数,并且实例化了一个对象:myCat, 然后 setTimout() 在1秒后打印出 myCat 对象的信息:

function Pet(type, legs) {
  this.type = type;
  this.legs = legs;
  this.logInfo = function() {
    console.log(this === myCat); // => false
    console.log(`The ${this.type} has ${this.legs} legs`);
  }
}
const myCat = new Pet('Cat', 4);
// 打印出 "The undefined has undefined legs"
// 或者严格模式下报 TypeError
setTimeout(myCat.logInfo, 1000);

你可能会认为 setTimeout(myCat.logInfo, 1000) 会调用 myCat.logInfo(), 并且打印出关于 myCat 对象的信息。

不幸的是,这个方法是从对象里抽离出来并且作为一个参数被传递,setTimout(myCat.logInfo), 上面的例子和下面的等效:

setTimout(myCat.logInfo);
// 等同于
const extractedLogInfo = myCat.logInfo;
setTimout(extractedLogInfo);

当这个独立出来的函数 logInfo 被调用时,这就只是一次常规的调用,this 是全局对象或者 undefined(严格模式下),因此 myCat 的信息没有被正确打印出来。

一个预先绑定了 this 的函数可以解决此问题(以下是简介,详见第6节):

function Pet(type, legs) {
  this.type = type;
  this.legs = legs;
  this.logInfo = function() {
    console.log(this === myCat); // => true
    console.log(`The ${this.type} has ${this.legs} legs`);
  };
}
const myCat = new Pet('Cat', 4);
// 创建一个绑定了 `this` 的函数
const boundLogInfo = myCat.logInfo.bind(myCat);
// 打印出 "The Cat has 4 legs"
setTimeout(boundLogInfo, 1000);

myCat.logInfo.bind(myCat) 返回了一个新的函数,该函数在被以常规方式调用时,this 指向 myCat.

另外,也可使用箭头函数来达到相同目的:

function Pet(type, legs) {
  this.type = type;
  this.legs = legs;
  this.logInfo = () => {
    console.log(this === myCat); // => true
    console.log(`The ${this.type} has ${this.legs} legs`);
  };
}
const myCat = new Pet('Cat', 4);
// 打印出 "The Cat has 4 legs"
setTimeout(myCat.logInfo, 1000);

4. 构造函数调用

一些构造函数调用的例子:
new Pet('cat', 4), new RegExp('\\d')

构造函数时,this 是新创建的对象。

下面的例子定义了一个函数 Country, 并以构造函数的方式调用:

function Country(name, traveled) {
  this.name = name ? name : 'United Kingdom';
  this.traveled = Boolean(traveled); // transform to a boolean
}
Country.prototype.travel = function() {
  this.traveled = true;
};
// 构造函数调用
const france = new Country('France', false);
france.travel(); // Travel to France

new Country('France', false)Country 函数的构造函数式调用,这样的调用创建了一个新对象,该对象的 name 属性为 'France'.

从 ES2015 开始,我们还可以用 class 的语法定义上述例子:

class City {
  constructor(name, traveled) {
    this.name = name;
    this.traveled = false;
  }
  travel() {
    this.traveled = true;
  }
}
// 构造函数调用
const paris = new City('Paris', false);
paris.travel();

需要注意的是,当一个属性访问器前面跟着 new 关键字时,JS 表现的是构造函数调用,而不是方法调用
例如:new myObject.myFunction(), 这个函数先是被使用属性访问器剥离出来,extractedFunction = myObject.myFunction, 然后再以构造函数的方式被调用来创建新对象

5. 间接调用

间接调用是指,当函数以如下的方式被调用:
myFunc.apply(thisArg, [arg1, arg2, ...]) 或者 myFunc.call(thisArg, arg1, arg2, ...)

间接调用的情况下,this 是传入 apply() 或者 call() 的第一个参数

6. 预先绑定了 this 的函数

形如 myFunc.bind(thisArg[, arg1, arg2, ...), 接收第一个参数作为指定的 this, 返回一个新的函数,举例如下:

function multiply(number) {
  'use strict';
  return this * number;
}
// 创建了一个预先绑定了 this 的函数 double,用数字 2 作为 this 的值
const double = multiply.bind(2);
// 调用 double 函数
double(3); // => 6
double(10); // => 20

需要注意的是,使用 .bind() 创建出来的函数无法再使用 .call() 或者 .apply() 修改上下文,即使重新调用 .bind() 也无法改变。

7. 箭头函数

箭头函数没有属于自己的执行上下文,它从外层函数那获取 this, 换句话说,箭头函数的 this 是词法绑定的

8. 总结

因为函数的执行方式对 this 有很大的影响,所以从现在开始,不要问自己“这个this”是从哪儿来的,而是问自己,“这个函数式是怎么样调用的”,对于箭头函数,问自己“在箭头函数定义的外层函数里,this 是什么”,拥有这样的 mindset 会让你工作时少很多头痛

posted @   _王宁宁  阅读(129)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
点击右上角即可分享
微信分享提示