【JS】你不知道的JavaScript笔记(二)- this - 四种绑定规则 - 绑定优先级 - 绑定例外 - 箭头函数

668 阅读13分钟

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

今天继续总结《你不知道的JavaScript》,来探索探索JavaScript中的this关键字

我们之前学习作用域的时候提到过this

this是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

其实不止JavaScript中有this关键字,像java等很多语言也都有this这个关键字。 this是一个很特别的关键字,被自动定义在所有函数的作用域中。 this的不明确性让他成为了一个很令人头疼的东西,我们先来了解为什么要用this

1. 为什么要用 this

首先我们假设有两个对象分别代表两个人

let me = {
  name: "yk",
};

let you = {
  name: "YK菌",
};

我们要定义一个函数,函数是用来自我介绍的,没有 this 的话,我们通过传入不同的参数,来实现不同的自我介绍

function speak(context) {
  console.log(`你好,我是${context.name}`);
}

我们这样调用函数,来传入参数

speak(me); // 你好,我是yk
speak(you); // 你好,我是YK菌

而如果我们使用this 的话,函数就可以这样来定义

function speak() {
  console.log(`你好,我是${this.name}`);
}

这样来调用函数

speak.call(me); // 你好,我是yk
speak.call(you); // 你好,我是YK菌

所以说,this提供了一种更优雅的方式来隐式传递一个对象引用,因此可以将API设计得更加==简洁==并且易于==复用==。

2. 关于this的误解

2.1 this不是指向函数自身

在函数中用this,在英语的语法中,this总是说的是自己,然而在函数中的this不是指向的函数自身,这一定要注意区别!!!

如果要从函数对象内部引用它自身,那只使用this是不够的。一般来说你需要通过一个指向函数对象的词法标识符(变量)来引用它。

function foo() {
	foo.count = 1;
}

2.2 this不指向函数的词法作用域

另外在上一篇博文中说到,JavaScript代码执行遵守的是词法作用域,但是this在任何情况下都不指向函数的词法作用域

当一个函数被调用时,会创建一个==执行上下文==。 这个执行上下文会包含函数在哪里被调用(调用栈)、函数的调用方式、传入的参数等信息。 this就是这个上下文的一个属性,会在函数执行的过程中用到。

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

3. 什么是调用栈与调用位置

==调用位置==就是函数在代码中被调用的位置(而不是声明的位置)

这句话看上去很简单,甚至让人觉得是一句废话。但事实上,在有些编程模式下隐藏了真正的调用位置,让你不容易判断调用位置真的在哪里

最重要的是要分析==调用栈==(就是为了到达当前执行位置所调用的所有函数)。 我们关心的调用位置就在当前正在执行的函数的前一个调用中。

function fun1() {
  // 当前调用栈:fun1
  // 当前调用位置:全局作用域
  console.log("fun1");
  fun2(); // fun2的调用位置:fun1
}

function fun2() {
  // 当前调用栈是 fun1 -> fun2
  // 当前调用位置:fun1
  console.log("fun2");
  fun3();
}

function fun3() {
  // 当前调用栈是 fun1 -> fun2 -> fun3
  // 当前调用位置:fun2
  console.log("fun3");
}

fun1(); // fun1的调用位置:全局作用域

在fun3的第一行打一个断点,通过调试工具可以看到当前的调用堆栈和 this 的值 在这里插入图片描述

在这里插入图片描述

4. this的绑定规则

知道了调用栈之后,我们就需要知道在函数的执行过程中调用位置如何决定this的绑定对象

找到调用位置之后,根据下面四种绑定的规则来确定this的绑定

4.1 默认绑定 fun()

函数调用类型:独立函数调用 默认绑定:无法应用其他规则时的默认规则

函数调用时应用了this的==默认绑定==,因此this指向全局对象 (node中是global对象,浏览器中是window对象)

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

foo(); 
// Window {window: Window, self: Window, document: document, name: "", location: Location, …}
// 2

如果使用严格模式(strict mode),则不能将全局对象用于默认绑定,因此 this 会绑定到 undefined :

function foo() {
  'use strict'
  console.log(this);
}

foo(); // undefined

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

4.2 隐式绑定 obj.fun()

当函数引用有上下文对象(函数是否被某个对象拥有或者包含)时,隐式绑定规则会把函数调用中的this绑定到这个上下文对象

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

var obj = {
  a: 2,
  foo: foo,
};

obj.foo(); 

无论是直接在obj中定义foo 还是 先定义foo再添加为引用属性,这个函数严格来说都不属于obj对象

调用位置会使用obj上下文来引用函数,因此你可以说函数被调用时obj对象“拥有”或者“包含”它 在这里插入图片描述

对象属性引用链中只有上一层或者说最后一层在调用位置中起作用。

function foo() {
  console.log(this); // {a:2, foo:f}
  console.log(this.a); // 2
}

var obj = {
  a: 2,
  foo: foo,
};

var yk = {
  a: 222,
  obj: obj,
};

yk.obj.foo();

在这里插入图片描述

隐式绑定丢失的情况

这种隐式绑定有时候是会丢失的,我们来看下面这种情况

① 函数别名

function fun1() {
  console.log(this);
  console.log(this.a);
}

var obj = {
  a: "局部参数",
  fun1: fun1,
};

var fun2 = obj.fun1; // 函数别名

var a = "全局参数";

obj.fun1(); // 局部参数
fun2(); // 全局参数 【所以说this是在调用时绑定的,不是在定义的时候绑定的】

在这里插入图片描述

虽然fun2obj.fun1的一个引用,但是实际上,它引用的是fun1函数==本身==,因此下面调用的fun2()其实是一个不带任何修饰的函数调用,因此应用了默认绑定

② 参数传递函数

function fun1() {
  console.log(this);
  console.log(this.a);
}

function fun2(fn) {
  // obj.fun1传进来的是引用值,实际上就是fun1
  fn(); // 直接调用,指向window
}

var obj = {
  a: "局部参数",
  fun1: fun1,
};

var a = "全局参数";

fun2(obj.fun1); // 全局参数

在这里插入图片描述

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

这也就解释了为什么使用JavaScript环境中内置的setTimeout()函数中回调的this指向的是全局对象了 在回调函数中丢失this是非常常见的现象

setTimeout(obj.fun1, 100) // '全局对象'

在这里插入图片描述 在setTimeout内部是这样调用 回调函数 的

function setTimeout(fn, delay) {
	// 等待dealy毫秒
	fn(); // 调用位置 【obj.fun1是引用值,传进来的就是fun1,直接调用,指向window】
}

③ 事件处理器

还有一种情况this的行为会出乎我们意料:调用回调函数的函数可能会修改this。 在一些流行的JavaScript库中事件处理器常会把回调函数的this强制绑定到触发事件的DOM元素上。

无论是哪种情况,this的改变都是意想不到的,实际上你无法控制回调函数的执行方式,因此就没有办法控制调用位置以得到期望的绑定。

4. 3 显式绑定 fun.call(obj)

在Function的原型对象上有三个方法apply、call、bind可以显式的改变this的指向

【JS】函数定义与调用方式-函数this指向问题-call-apply-bind方法使用与自定义

在这里插入图片描述 先看看 applycall 显式绑定

function fun1() {
  console.log(this);
  console.log(this.a);
}

var obj = {
  a: "局部对象",
};

fun1.call(obj); // 局部对象

在这里插入图片描述

显式绑定仍然无法解决我们之前提出的丢失绑定问题

这是因为显式绑定,会立即执行这个函数,回调函数中函数的执行时间是不确定的,所有我们需要提前将this绑定到指定的对象上,在需要的时候调用回调函数时,this是明确的。

显式强制绑定(硬绑定)就是解决这个问题的

① 显式强制绑定 —— 硬绑定 bind

创建函数fun2(),并在它的内部手动调用了fun1.call(obj),因此==强制==把fun1this绑定到了obj。 无论之后如何调用函数fun2,它总会手动在obj上调用fun1

这种绑定是一种显式的强制绑定,因此我们称之为硬绑定

function fun1() {
  console.log(this);
  console.log(this.a);
}

var obj = {
  a: "局部对象",
};

function fun2() {
  fun1.call(obj); // 显式绑定
}

fun2(); // 局部对象 【内部进行了绑定】

setTimeout(fun2, 100); // 局部对象

// 硬绑定后的fun2不能再修改this
fun2.call(window); // 局部对象

在这里插入图片描述

② 硬绑定应用场景

① 创建一个包裹函数,负责接收参数并返回值

function fun1(something) {
  console.log(this.a, something);
  return this.a + something;
}

var obj = {
  a: 2,
};

// 创建一个包裹函数,负责接收参数并返回值
function fun2() {
  return fun1.apply(obj, arguments);
}

var b = fun2(3); // 2 3

console.log(b); // 5

② 创建一个可以重复使用的 辅助绑定函数

function fun1(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 fun2 = bind(fun1, obj);

var b = fun2(3); // 2 3 
console.log(b); // 5

其实这个辅助绑定函数,JavaScript已经帮我们创建好了就是函数原型上的bind()方法

function fun1(something) {
  console.log(this.a, something);
  return this.a + something;
}

var obj = {
  a: 2,
};

// 使用 bind 方法
var fun2 = fun1.bind(obj);

var b = fun2(3); // 2 3 
console.log(b); // 5

③ API调用的上下文

第三方库的许多函数,以及JavaScript语言和宿主环境中许多新的内置函数,都提供了一个可选的参数,通常被称为“上下文”(context),其作用和bind(..)一样,确保你的回调函数使用指定的this

function fun(el) {
	console.log(el, this.id);
}
var obj = {
	id: 'yk'
}
// 第二个参数用来指定this
[1,2,3].forEach(fun, obj); // 1 yk 2 yk 3 yk

这些API在内部实现了显式绑定

4.4 new绑定

JavaScript中的new的机制和面向类的语言完全不同

在JavaScript中,构造函数只是一些使用new操作符时被调用的函数。 它们并不会属于某个类,也不会实例化一个类。 实际上,它们甚至都不能说是一种特殊的函数类型,它们只是被new操作符调用的普通函数而已

JavaScript中的所有的函数都是可以用new来调用,称为构造函数调用

使用new来调用函数,或者说发生构造函数调用时,会自动执行下面的操作:

  1. 创建(或者说构造)一个全新的对象
  2. 这个新对象会被执行[[Prototype]]连接【隐式原型 指向 构造函数的显式原型】
  3. 这个新对象会绑定到函数调用的this
  4. 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象
function fun(a){
	this.a = a;
}
// 将fun构造函数中的 this 绑定到obj
var obj = new fun(2)
console.log(obj.a) // 2

自定义new

/**
 * 自定义new
 * 创建Fn构造函数的实例对象
 * @param {Function} Fn
 * @param  {...any} args
 * @returns
 */
export default function newInstance(Fn, ...args) {
  // 1. 创建新对象
  // 创建空的object实例对象,作为Fn的实例对象
  const obj = {};
  // 修改新对象的原型对象
  // 将Fn的prototype(显式原型)属性赋值给obj的__proto__(隐式原型)属性
  obj.__proto__ = Fn.prototype;
  // 2. 修改函数内部this指向新对象,并执行
  //
  const result = Fn.call(obj, ...args);
  // 3. 返回新对象
  // return obj
  // 与new保持一直,如果构造函数有返回值,返回值是对象a就返回对象a,否则返回实例对象
  return result instanceof Object ? result : obj;
}

根据上面的四条绑定规则,只要我们找到函数的==调用位置==,判断使用哪种==规则==,就可以知道this到底绑定给谁了

如果有多条绑定规则都满足,那就要看他们之间的优先级了

4.5 绑定的优先级

new绑定 > 显式绑定 > 隐式绑定 > 默认绑定(最低)

① 显式绑定 > 隐式绑定

function fun() {
  console.log(this.a);
  console.log(this);
}

let obj1 = {
  a: "obj1里面的a",
  fun: fun,
};

let obj2 = {
  a: "obj2里面的a",
  fun: fun,
};

obj1.fun(); // 隐式绑定 obj1
fun.call(obj2); // 显式绑定 obj2
// 比较优先级
obj1.fun.call(obj2); // obj2

在这里插入图片描述

② new绑定 > 隐式绑定

function fun(a) {
  this.a = a;
}

let obj1 = {
  fun: fun,
};

obj1.fun("隐式绑定");
console.log(obj1.a); // "隐式绑定"

let obj2 = new fun("new绑定");
console.log(obj2.a); // "new绑定"

// 比较优先级
let obj3 = new obj1.fun("new绑定");
console.log(obj1.a); // "隐式绑定"
console.log(obj3.a); // "new绑定"

在这里插入图片描述

③ new绑定 > 显式绑定

function fun(a) {
  this.a = a;
}

let obj1 = {};

let fun1 = fun.bind(obj1);
fun1("硬绑定的a");
console.log(obj1.a); // 硬绑定的a

let fun2 = new fun1("new绑定的a");
console.log(obj1.a); // 硬绑定的a
console.log(fun2.a); // new绑定的a

在这里插入图片描述

4.6 规则总结

① 由new调用?绑定到新创建的对象。 ② 由call或者apply(或者bind)调用?绑定到指定的对象。 ③ 由上下文对象调用?绑定到那个上下文对象。 ④ 默认:在严格模式下绑定到undefined,否则绑定到全局对象。

5. 绑定例外

5.1 显式绑定时传入null

如果你把null或者undefined作为this的绑定对象传入call、apply或者bind,这些值在调用时会被忽略,实际应用的是默认绑定规则 用处

function fun(a, b) {
  console.log(`a:${a}, b:${b}`);
}

// 将数组展开成参数【ES6可以使用展开运算符】
fun.apply(null, [2, 3]); // a:2, b:3

// 函数柯里化
let fun1 = fun.bind(null, 2);
fun1(3); // a:2, b:3

总是传null也不太好,可以传一个空对象øø

function fun(a, b) {
  console.log(`a:${a}, b:${b}`);
}

let ø = Object.create(null);

// 将数组展开成参数【ES6可以使用展开运算符】
fun.apply(ø, [2, 3]); // a:2, b:3

// 函数柯里化
let fun1 = fun.bind(ø, 2);
fun1(3); // a:2, b:3

使用变量名ø不仅让函数变得更加“安全”,而且可以提高代码的可读性,因为ø表示“我希望this是空”,这比null的含义更清楚。

5.2 间接引用

有可能(有意或者无意地)创建一个函数的“间接引用”,在这种情况下,调用这个函数会应用默认绑定规则

function fun() {
  console.log(this.a);
}

var a = "全局的a";

let obj1 = {
  a: "obj1中的a",
  fun: fun
};
let obj2 = {
  a: "obj2中的a"
};

obj1.fun(); // obj1中的a

obj2.fun = obj1.fun;
obj2.fun(); // obj2中的a

(obj2.fun = obj1.fun)(); // 全局的a

赋值表达式返回值时目标函数的引用,因此调用位置是fun(),而不是obj2.fun()或obj1.fun()

5.3 软绑定

之前说的硬绑定会大大降低函数的灵活性,使用硬绑定之后就无法使用隐式绑定或者显式绑定来修改this

可以给默认绑定指定一个全局对象和undefined以外的值,那就可以实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定修改this的能力。

Function.prototype.softBind = function (obj) {
  let fn = this;
  // 捕获所有参数
  let curried = [].slice.call(arguments, 1);
  let bound = function () {
    return fn.apply(
      // 如果this绑定到全局对象或者undefined,那就把指定的默认对象obj绑定到this,否则不会修改this
      !this || this === (window || global) ? obj : this,
      curried.concat.apply(curried, arguments)
    );
  };
  bound.prototype = Object.create(fn.prototype);
  return bound;
};


function fun() {
  console.log(`name:${this.name}`)
}

let obj1 = {
  name: 'obj1'
}
let obj2 = {
  name: 'obj2'
}
let obj3 = {
  name: 'obj3'
}

let funObj = fun.softBind(obj1)
funObj() // name:obj1

obj2.fun = fun.softBind(obj1)
obj2.fun() // name:obj2

funObj.call(obj3) // name:obj3

setTimeout(obj2.fun, 10) // name:obj1

5.4 箭头函数

箭头函数并不是使用function关键字定义的

箭头函数不使用this的四种标准规则,而是根据外层(函数或者全局)作用域【词法作用域】来决定this【继承外层函数调用的this绑定】

箭头函数的绑定无法被修改。(new也不行!)