本文为历史博客迁移

我们先看一下MDN对于this的定义:this 值是当前执行代码的环境对象

其中有几个关键词需要捕捉:【当前】、【环境】、【对象】

如果不能彻底搞清jsthis的机制,可能js使用者只能用两种方式和它打交道:

【1】是记住this的四种场景 【2】是使用js所提供的手段来使this指向变得可控

四种场景

一、全局环境

如果在全局执行环境使用this,那它毋庸置疑是在指向全局对象,至于这个全局对象具体值是什么,要决定于环境。比如在浏览器中的全局对象是window,在nodejs环境中却又叫作global

不过现在js有了无视环境的统一调用全局this的方式,即一个叫作globalThis的对象,其在不同的环境下就是不同环境所对应的全局对象。

二、函数环境

非严格模式全局环境直接调用函数

var func = function () {
  console.log(this);
  console.log(this === globalThis);
  // true
};
func();

严格模式全局环境直接调用函数

var func = function () {
  "use strict";
  console.log(this);
  // undefined
};
func();

那么这里要提到另外两个知识点:apply 和 call,它们可以把 this 的值从一个环境传到另一个环境,为什么要有这样的东西呢,或者说怎么理解它们的存在呢。举个例子:

function say() {
  var intro = "hello world";
  console.log(this.intro);
}
say();
// undefined

var tom = {
  name: "Tom",
  intro: "Hello, I am Tom",
};
var jerry = {
  name: "jerry",
  intro: "Hello, I am Jerry",
};
say.call(tom);
// Hello, I am Tom

我们来品一品这个例子,现在我们想弄清楚这样几件事情:

  1. this 是什么来着?
  2. 为什么要用 this
  3. call 是干嘛的
  4. 不用 this 怎么办

this 是什么来着?

首先,this是什么来着?当前执行代码的环境对象,我们可以简记为当前环境(也有其他地方专业称呼其为执行上下文,但那样很明显不利于理解)

为什么要用 this

接着,我们为什么要用this。当然就是因为它的定位,它是js提供给我们在任意地方获取当前环境的一个对象。听起来它好像是一个动态的对象,在任何时候你想要访问当前环境都能通过它拿到。js不是有着复杂的各种关系链么,什么原型链啊,作用域链啊,各种嵌套关联,会让你觉得乱糟糟的,但是this永远都保持着自己明确的定位,无论你所处怎样复杂的关系链中,它都能给到你当前环境

这样看来,我们是不是应该重新认识this,抛开以前的偏见,它其实是js提供给你的一个工具,而不是为了把你弄晕的。

我们拿这个例子说明,有一个打招呼方法叫say,它输出的是当前环境的自我介绍,这是say方法的声明内容,但这里只是声明了这个函数是做什么的,具体它输出的是什么,还得看函数执行时的当前环境是什么。比如直接执行,那么当前环境是什么,是全局。全局对象没有自我介绍intro,所以输出的就是undefined

call 是干嘛的

那么,call是干嘛的?上面说了,它是用来将this的值从一个环境传到另一个环境的。听起来还是有点晦涩难懂,但感觉上好像做了偷梁换柱的事情,有点闭包里提到的打破阴阳两界的味儿了。

那么我们结合例子看看能不能探究一下call是做啥的,say.call(tom)输出了tom的自我介绍,诶?首先,我们拿到了say方法的输出,说明say方法被执行了,但是say.call(tom)没有看出say方法的执行,说明在say.call内部执行了say方法。另外,执行结果表示执行say的时候当前环境是tom,因为say的功能是输出当前环境的自我介绍嘛,Hello, I am Tom是 tom 的自我介绍,这不就等同于当前环境 = tom吗。

于是我们探究出say.call(tom)做了两件事情:

  1. 执行了say方法
  2. 执行say方法时的当前环境是tom

这就是call干的事情。

不用 this 怎么办

那么最后,如果不用this,有没有别的方法达到同样的效果。我们很自然会想到通过给函数增加参数,来获取外部信息,连通函数内外部,在函数内访问函数外的变量。如下:

function say(intro) {
  console.log(intro);
}
say();
// undefined

var handler = say;
handler();
// undefined

var tom = {
  name: "Tom",
  intro: "Hello, I am Tom",
};
var jerry = {
  name: "jerry",
  intro: "Hello, I am Jerry",
};
say(tom.intro);
// Hello, I am Tom

虽然输出结果和上面一样,但这样好像总是差点味儿。既然存在this这样的东西,那么一定有它的道理,我们看看有没有什么this不可取代的地方。

首先,函数的功能变掉了,原来的函数是“输出当前环境的自我介绍”,现在是输出“入参的自我介绍”,原来的输出决定于函数调用时的当前环境,现在变成传参就有结果,不传就没结果。

另外,对比say.call(tom)say(tom.intro),之前传给call的是一个对象,函数会自动从对象中取出intro,因为输出的是当前环境的自我介绍,即this.intro,那么传入的对象便是this。但say(tom.intro)传入的则需是自我介绍本身,因为say函数输出的正是参数本身。

这样看来,使用参数的函数是不是相较于使用this的要有失灵活一些,this虽包含着不确定性,但是却能灵活“动态”地提供当前环境。相比之下,函数传参是更加充满确定性和可控性,函数输出能够一眼看出来龙去脉的。对于二者的使用当然是要“因地制宜,择优录取”。

最后我们做一个对于call的总结,我们可以巧妙地理解其为以下几点:

  1. call 是函数对象才有的方法(因为call是调用的意思,函数才能被调用)
  2. call 也是打电话、招呼的意思,所以func.call(obj)可以理解为函数func呼叫obj来应援
  3. funcobj应援,obj自然会成为“座上宾”,所以 func 被执行时的当前环境就是obj
  4. 当前环境(this)是一个对象,所以call的参数应该是一个对象,所以这里用obj来表示

三、对象方法被调用

当函数作为对象的方法被调用时,this 即是调用方法的对象。例如:

var obj = {
  name: "obj",
  sayHello: function () {
    console.log(this.name + "'s method says hello");
  },
};
function sayHello() {
  console.log("funciton declaration says hello");
}
var exp = function () {
  console.log("function expression says hello");
};
obj.sayHello();
sayHello();
exp();
// obj's method says hello
// funciton declaration says hello
// function expression says hello

这里也就区分了函数方法叫法的区别,函数就是通过函数声明或函数表达式声明的普通函数,而叫作方法是因为函数作为对象的一个属性而存在(函数在 js 被认定为一等公民,可以同变量一样作为属性、参数、函数返回值等)

四、new

使用new调用函数时,是把函数当作构造函数在调用,此时的this会指向构造出来的新对象(实例)。例如:

function classA() {
  this.from = "A";
}
var instance = new classA();
console.log(instance.from);
// A

这里函数内部在操作this并向其添加属性from,然后使用new调用,则执行函数classA时,其内部的this即是instance,于是instance便在诞生之初就有了from属性。

原生提供的可控方法

bind

既然每次函数执行时的当前环境都可能变化,充满着不确定性,那么有没有什么办法把它捆着不动,那就是bind方法,bind意为“绑定”,其效果也如其名,一旦绑定,不会再被修改。但bind方法很明确自己的任务只是绑定当前环境,至于函数何时执行、怎么执行不归其管,这区别于call和 apply 在指定了当前环境后还把函数执行了。

我们需要记住bind的特性只有两个:

  1. 一经绑定,不再修改
  2. 只作绑定,不会执行

实例如下:

function func() {
  console.log(this.intro);
}
var tom = {
  name: "Tom",
  intro: "I am Tom",
};
var jerry = {
  name: "Jerry",
  intro: "I am Jerry",
};
var tomFunc = func.bind(tom);
var jerryFunc = tomFunc.bind(jerry);
tomFunc();
// I am Tom
jerryFunc();
// I am Tom

箭头函数

先看 MDN 中关于箭头函数的“权威解释“:”在箭头函数中,this 与封闭词法环境的 this 保持一致“,虽然言简意赅,但是晦涩难懂且包含专业名词,什么是”封闭词法环境“??

我们先来捕捉一些箭头函数的特性,然后从其特性推断出其是一个怎样的东西

  1. 没有自己的 this 指针
  2. 不能用作构造器
  3. 没有 prototype 属性
  4. 不绑定 Arguments 对象
  5. 没有自己的 super

其实把”箭头函数的 this 与封闭词法环境的 this 保持一致“转而描述为”箭头函数的 this 总与其被创建时的环境一致“会更好理解一点。举例如下:

var globalObject = this;
var foo = () => this;
console.log(foo() === globalObject); // true

// 接着上面的代码
// 作为对象的一个方法调用
var obj = { foo: foo };
console.log(obj.foo() === globalObject); // true

// 尝试使用call来设定this
console.log(foo.call(obj) === globalObject); // true

// 尝试使用bind来设定this
foo = foo.bind(obj);
console.log(foo() === globalObject); // true

总结一下箭头函数,谨记一点即可“当创建一个箭头函数的时候,就确定这个箭头函数的 this 和创建时所在作用域的 this 一致了”。

附录

call 的模拟实现

call 的实现依赖如下:

  1. 通过 this 获取调用 call 方法的对象(即要修改 this 的函数)
  2. 通过 arguments 获取调用 call 方法时传递的参数
  3. 通过 eval 动态解析字符串以实现修改 this 后的函数执行时访问 call 接收到的不确定参数
var tom = {
  name: "Tom",
  func: function (type, desc) {
    if (type && desc) {
      return this.name + " is a " + desc + " " + type;
    } else {
      return "Just " + this.name;
    }
  },
};
var jerry = {
  name: "Jerry",
};
var me = {
  name: "me",
};
var print = console.log;
print(tom.func("cat", "big"));
print(tom.func.call(jerry, "mouse", "small"));
print(tom.func.call(me));
// Tom is a big cat
// Jerry is a small mouse
// Just me

Function.prototype.myCall = function (obj) {
  // 参数分别判空处理
  if (!obj) obj = globalThis;
  // this是调用本函数的函数
  obj.func = this;
  // 要换用指定的this(这里的obj)去执行要被执行的函数(func)
  // 并且传入指定参数(args)
  // 函数执行结果是返回函数的返回值
  var result;
  if (!arguments[1]) {
    result = obj.func();
  } else {
    var params = [];
    for (var i = 1; i < arguments.length; i++) {
      params.push("arguments[" + i + "]");
    }
    result = eval("obj.func(" + params + ")");
    // eval解析出的结果如:obj.func(arguments[1], arguments[2], ...)
  }
  delete obj.func;
  return result;
  // 执行结束要去掉新捆绑的属性
};

print(tom.func("cat", "big"));
print(tom.func.myCall(jerry, "mouse", "small"));
print(tom.func.myCall(me));
// Tom is a big cat
// Jerry is a small mouse
// Just me

apply 的模拟实现

apply 实现与 call 类似,但 apply 获取的参数是以一个数组整体的形式存在的,而 call 获取到的是不确定数量的参数。

var tom = {
  name: "Tom",
  func: function (type, desc) {
    if (type && desc) {
      return this.name + " is a " + desc + " " + type;
    } else {
      return "Just " + this.name;
    }
  },
};
var jerry = {
  name: "Jerry",
};
var me = {
  name: "me",
};
var print = console.log;
print(tom.func("cat", "big"));
print(tom.func.apply(jerry, ["mouse", "small"]));
print(tom.func.apply(me));
// Tom is a big cat
// Jerry is a small mouse
// Just me

// 区别于call方法,apply方法的第二个参数是一个数组
Function.prototype.myApply = function (thisObj, args) {
  if (!thisObj) thisObj = globalThis;
  thisObj.func = this;
  var result;
  if (!args) {
    result = thisObj.func();
  } else {
    // params用于组装参数字符串,以备eval解析使用
    var params = [];
    for (var i = 0; i < args.length; i++) {
      params.push("args[" + i + "]");
    }
    result = eval("thisObj.func(" + params + ")");
    // 解析结果如 thisObj.func(args[0], args[1], ...)
  }
  delete thisObj.func;
  return result;
};

print(tom.func("cat", "big"));
print(tom.func.myApply(jerry, ["mouse", "small"]));
print(tom.func.myApply(me));

// Tom is a big cat
// Jerry is a small mouse
// Just me

bind 的模拟实现

bind 的实现依赖如下:

  1. 因为最终新函数要使用新绑定的 this 进行执行,所以需要依赖于 call 或 apply
  2. 如果内部把参数处理为数组形式,则可仅依赖 apply,如果参数为零散方式则依赖 call
  3. 至于绑定时所作的参数处理也可以不依赖于 call 或 apply 操作,通过遍历方式完成
var tom = {
  name: "Tom",
  func: function (type, desc, log) {
    if (type && desc) {
      if (log) {
        console.log(log);
      }
      return this.name + " is a " + desc + " " + type;
    } else {
      return "Just " + this.name;
    }
  },
};
var jerry = {
  name: "Jerry",
};
var me = {
  name: "me",
};
var print = console.log;
print(tom.func("cat", "big"));
print(tom.func.bind(jerry, "mouse", "small")("Jerry comes at " + new Date()));
print(tom.func.bind(me)());
// Tom is a big cat
// Jerry comes at Fri Apr 24 2020 18:48:32 GMT+0800 (GMT+08:00)
// Jerry is a small mouse
// Just me

// bind函数接收第一个参数用于作为新绑定的this,后续可选参数作为绑定时传入该函数的参数
// 返回一个绑定后的函数,该函数并不立即调用,并且在以后的调用时仍可传入新的参数
Function.prototype.myBind = function () {
  // 调用bind的this即是要改变this的函数
  var func = this;
  // 拷贝一份参数,arguments是一个类数组,无法直接调用数组的方法
  var args = Array.prototype.slice.call(arguments);
  // 参数第一个是调用当前函数的this对象
  // 这里从arguments中获取调用bind函数的this,也可以直接从函数形参中取
  var object = args.shift();
  // 首选确定bind函数返回的是一个函数,而不是什么确定的值
  // 因为js中函数是一等公民,所以可以想象为同样返回了一个变量,只不过这个变量可以执行
  // 这里会产生闭包,当bind函数返回后,bind函数内的变量仍未被回收,并且在外部新函数调用时可以访问到
  return function () {
    // 这里的arguments已经是调用新函数时传入的新参数
    // 新的函数被执行时要有使用新this和新参数执行的结果返回
    // 因为最终执行时调用了apply,所以没有对新this做判空处理,由apply内部去做
    return func.apply(
      object,
      args.concat(Array.prototype.slice.call(arguments))
    );
  };
};

print(tom.func("cat", "big"));
print(tom.func.myBind(jerry, "mouse", "small")("Jerry comes at " + new Date()));
print(tom.func.myBind(me)());
print(tom.func.myBind()());

// Tom is a big cat
// Jerry comes at Fri Apr 24 2020 18:48:32 GMT+0800 (GMT+08:00)
// Jerry is a small mouse
// Just me
// Just undefined

代码仓库见: https://github.com/barnett617/codehub/blob/main/front-end/src/4-this/index.md