路客的文档

技术总结和生活记录

前端面试之路——JS基础(二)this/call/apply/bind

前言

本篇文章,我们来总结归纳下万恶的this以及衍生出来的call/apply/bind对this进行绑定,想了很久,决定用实例演示的方式来讲解this,这样才能够理解this,因为this确实变化莫测,只靠概念,是不能够理解的;之后如果有看到更好的案例,也会同步更新到文章中。

总目录单击链接进行跳转:👉前端面试之路(目录)

this的绑定方式

首先我们要牢记于心this的指向,是在函数被调用的时候确定的,因此,this的指向便非常灵活,情况多样,常见的this一共有5种绑定方式:

  • 默认绑定(非严格模式下this指向全局对象, 严格模式下this会绑定到undefined)
  • 隐式绑定(当函数(fn)引用有上下文对象的时候, 如 obj.fn()的调用方式, fn内部的this是指向obj)
  • 显式绑定(通过call()或者apply()方法直接指定this的绑定对象)
  • new操作符(执行过程中会将新生成的对象绑定到函数调用的this)
  • 箭头函数绑定(this的指向由外层作用域环境决定的)

那让我们开始找到相应的题目,进行分析。

1.默认绑定

我们先来看默认绑定: 非严格模式下,this指向window,严格模式下this指向undefined,这句话其实有些歧义和令人不解的地方,我们通过几个题目来分析一下。

题目一:

var a = 1;
function fn () {
  console.log(this.a)
}
fn();
复制代码

我们知道,如果用var来声明变量(不在函数内)的话,会自动挂载到全局,全局调用函数,相当于是window调用了这个函数,所以上边这段代码相当于:

window.a = 1
function fn () {
  console.log(this.a)
}
window.fn()
复制代码

答案显而易见,输出为:

1
复制代码

而如果我们把var声明改为letconst,那么结果是什么呢?可以试一下,没错,是undefined,因为letconst声明的变量,不会被挂在到window对象中。

题目二:

"use strict";
var a = 1;
function fn () {
  console.log('inner-this', this)
  console.log(window.a)
  console.log(this.a)
}
console.log(window.fn)
console.log('outer-this', this)
fn();
复制代码

当我们在最上边写上use strict的时候,相当于开启了严格模式,但所谓的严格模式,只是将fn函数中的this指向了undefined,二并不会影响到全局的this指向,所以inner-this打印的是undefinedouter-this打印的是window对象,全局下使用var声明的变量a,依旧会被挂在到全局。

所以输出结果为:

image.png

2.隐式绑定

这种就是面试中经常出现的类型,那么对于判断这种this的指向,我们只需要记住哪个对象最后调用函数,函数中的this就指向那个对象(箭头函数除外)。 我们来看题目:

题目一:

function fn () {
  console.log(this.a)
}
var obj = { a: 1, fn }
var a = 2
obj.fn()
复制代码

我们利用上边的那句口诀,经过分析,发现最后调用fn的,是obj对象,所以,fn函数内部的this,便指向obj对象,答案显而易见输出:

1
复制代码

还有两种隐式绑定的情况,非常具有迷惑性,很容易绕晕,那就是将函数赋给一个新的变量,或者将函数作为参数,进行传递,我们还是通过几道题目来分析。

题目一:

function fn () {
  console.log(this.a)
};
var obj = { a: 1, fn };
var a = 2;
var fn2 = obj.fn;
var obj2 = { a: 3, fn2 }

obj.fn();
fn2();
obj2.fn2();
复制代码

先来判断obj.fn()的输出,没错,和上一题一样,很容易就能分析出此时this指向obj,输出1;而fn2被赋值成了obj.fn,在调用的时候,出现了隐式丢失,依旧是window来调用的,所以此时this指向window,输出2;再来分析obj2.fn2(),此时fn2是被obj2所调用了,又出现了隐式丢失,所以this指向obj2,输出3,最终结果为:

1
2
3
复制代码

题目二:

function foo () {
  console.log(this.a)
}
function fooWrapper (fn) {
  console.log(this)
  fn()
}
var obj = { a: 1, foo }
var a = 2
var obj2 = { a: 3, fooWrapper }

obj2.fooWrapper(obj.foo)
复制代码

我们来分析一下,这道题中,fooWrapper函数是被obj2对象所调用的,所以我们可以先得出fooWrapper中的this是指向obj2对象的,所以先打印出{ a: 3, fooWrapper: f }其实就是obj2这个对象;而obj.foo被当做参数传递到了fooWrapper中,此时是window调用的fn,所以,此时会输出2,最终结果为:

{ a: 3, fooWrapper: f } // 其实就是obj2这个对象
2
复制代码

所以我们可以得出结论在函数被当成参数传递进另一个函数时,会发生隐式丢失的问题,其this并不会受外层包裹它的函数所影响,非严格模式下指向window,严格模式下指向undefined,现在再看这句话,是不是比最开始看到,明白了很多了呢?同样的代码,可以在顶部加上use strict查看结果,来验证这句话。

3.显示绑定

就是使用一些方法,强行绑定函数内部this的指向,比如call apply bind,需要先注意下他们之间的区别:

  • 经由call()apply()绑定的函数,会直接调用执行;
  • call()apply()用法几乎相同,第一个参数都为绑定的对象,如果第一个参数为null或者undefined的话,会自动忽略这个参数;他们之间的区别就在于之后的传参方式:call接收多个参数,apply接收一个数组;
  • bind()绑定会生成一个新的函数,需要手动再次调用,才会执行;

我们先通过一个比较简单的题目来看下他们之间的区别,顺便温习一下call apply bind的基本用法:

题目一:

function fn (x, y) {
  console.log(this.a)
  console.log(x + y)
}
var obj = { a: 1 }
var a = 2

fn(5, 6)
fn.call(obj, 5, 6)
fn.apply(obj, [5, 6])
fn.bind(obj)(5, 6)
复制代码

首先fn被调用的时候,因为是window调用的fn,所以此时fn中的this指向window,从而打印1, 11;接下来的3种显示绑定,fn中的this便指向了obj,然后立即调用执行(bind因为返回的是一个函数,所以还需要手动加括号进行调用),结果都输出1,11;我们可以发现区别如上所述,就是传参方式不同。

题目二:

var obj1 = {
  a: 1
}
var obj2 = {
  a: 2,
  fn1: function () {
    console.log(this.a)
  },
  fn2: function () {
    setTimeout(function () {
      console.log(this)
      console.log(this.a)
    }.call(obj1), 0)
  }
}
var a = 3
obj2.fn1()
obj2.fn2()
复制代码

首先可以分析出obj2.fn1中,因为是ob2调用的fn1,所以fn1中的this指向obj2,首先输出2;再来看obj2.fn2,因为函数作为setTimeout的参数传入发生了隐式丢失,所以函数内部的this按理来说应该是指向window的,但是我们使用了call方法,改变了this指向为obj1,所以输出{ a: 1 } 1,最后输出结果为:

2
{ a: 1 } // 就是obj1对象自己
1
复制代码

题目三:

再来看一道返回匿名函数发生隐式丢失,与显示绑定结合的题目吧。

var obj = {
  a: 'obj',
  fn: function () {
    console.log('fn:', this.a)
    return function () {
      console.log('inner-a:', this.a)
    }
  }
}
var a = 'outer-a'
var obj2 = { a: 'obj2' }

obj.fn()()
obj.fn.call(obj2)()
obj.fn().call(obj2)
复制代码

看起来花里胡哨,我们只要仔细阅读,就不难分析出结果:

  • 首先看obj.fn()(),注意了,为啥是两个括号呢?其实是执行了2个操作,首先执行了obj.fn()函数,输出结果为fn: obj,之后,obj.fn()返回了一个匿名函数后,又执行了这个匿名函数,因为是window调用的,所以此时又会输出inner-a: outer-a
  • 之后再来看obj.fn.call(obj2)(),比较长,我们慢慢来看,首先obj.fn注意此时没加括号,说明obj.fn没有被调用,可以理解为找到了这个fn,之后使用call方法,给obj.fn进行显示绑定到obj2对象,所以,此时obj.fn中的this是指向obj2的,首先输出了fn: obj2,之后,又出现了一个括号,和上边一样,也是匿名函数此刻被调用了,依旧是window对齐进行的调用,所以此时又会输出inner-a: outer-a
  • 最后再来看obj.fn().call(obj2),会发现好理解了许多,区别也很明显,call是为obj.fn()执行后,返回的匿名函数,进行了显示绑定,所以输出结果为fn: obj,之后又会输出inner-a: obj2

4.new操作符进行绑定

在普通函数被当成构造函数,执行new操作生成对象时,函数中的this会被绑定为新创建的对象。

题目一:

function Person (age) {
  this.age = age
  this.fn1 = function () {
    console.log(this.age)
  }
  this.fn2 = function () {
    return function () {
      console.log(this.age)
    }
  }
}
var person1 = new Person(20)
person1.fn1()
person1.fn2()()
复制代码

我们可以分析到person1对象创建后person1.fn1()打印出来的就是构造函数中传入的20,所以会先输出20;而person1.fn2()返回的是一个匿名函数,之后匿名函数又被调用,调用者是window,而window.age没有被定义,所以会输出undefined

其实和用字面量创建对象十分类似,使用new关键字创建对象的this指向几乎是没有区别,可以靠同一套逻辑来进行判断。

5.箭头函数绑定

之前我们说过,哪个对象最后调用函数,函数中的this就指向那个对象(箭头函数除外),为啥箭头函数除外呢?因为箭头函数中的this,是要根据作用域链进行查找,来决定的。

题目一:

var obj = {
  name: 'obj',
  fn1: () => {
    console.log(this.name)
  },
  fn2: function () {
    console.log(this.name)
    return () => {
      console.log(this.name)
    }
  }
}
var name = 'window'
obj.fn1()
obj.fn2()()
复制代码

先来分析obj.fn1(),因为fn1是一个箭头函数,所以在调用的时候,其外部作用域是window,所以先输出window;再看obj.fn2()(),因为obj.fn2是个普通函数,所以执行时,先打印的this.nameobj,之后返回了一个匿名箭头函数,它的this指向是由外部作用域确定的,所以它内部的this其实用的就是fn2中的this,所以打印的this.name依旧是obj,最终输出为:

'window'
'obj'
'obj'
复制代码

题目二:

如果将普通函数和箭头函数嵌套,那么this该如何判断呢?根据排列组合我们可以得到4种情况:普通套普通;普通套箭头;箭头套普通;箭头套箭头,你别说,写项目的时候,还真会有人这样写,然后就会遇到很奇怪的bug。

var name = 'window'
var obj1 = {
  name: 'obj1',
  fn: function () {
    console.log(this.name)
    return function () {
      console.log(this.name)
    }
  }
}
var obj2 = {
  name: 'obj2',
  fn: function () {
    console.log(this.name)
    return () => {
      console.log(this.name)
    }
  }
}
var obj3 = {
  name: 'obj3',
  fn: () => {
    console.log(this.name)
    return function () {
      console.log(this.name)
    }
  }
}
var obj4 = {
  name: 'obj4',
  fn: () => {
    console.log(this.name)
    return () => {
      console.log(this.name)
    }
  }
}

obj1.fn()()
obj2.fn()()
obj3.fn()()
obj4.fn()()
复制代码

我们一条一条来分析:

  • obj1.fn()在执行后,因为fn是一个普通函数,所以内部的this指向obj,首先输出obj1,之后返回了一个匿名的普通函数,和前文说过的一样,是window来调用的,所以又会输出window,我们已经很熟悉了;
  • obj2.fn()在执行后,结果是obj2,因为返回的匿名函数是箭头函数,所以其内部的this使用的就是fn中的this,输出obj2
  • obj3.fn()是一个箭头函数,由外部作用域决定this取值,所以先输出window,返回的匿名普通函数,调用者是window,所以输出window
  • obj4.fn()()同理,是两个箭头函数,所以输出结果为两个window

我们还可以得出一个结论,箭头函数中的this由外层作用域决定,并且指向函数定义时的this,而并非调用时。同时,我们虽然没法用call apply bind来改变箭头函数中的this指向,但是我们可以通过改变箭头函数外层作用域的this指向,间接的改变箭头函数中this的指向。

关于this的内容,我们暂时告一段落,接下来,我们写一下之前使用过new call apply bind的原理,面试中的高频考点哦~

new的实现原理

//写一个模拟new的函数
function mockNew() {
  //获取第一个参数即构造函数,因为shift的返回值就是第一个参数,同时arguments数组中第一个参数被移除掉了
  let Constructor = [].shift.call(arguments);
  //创建一个新对象
  let obj = {};
  //新对象的__proto__指向构造函数的prototype,从而obj能方位原型上的属性
  obj.__proto__ = Constructor.prototype;
  // 上边两个步骤可以合并为let obj = Object.create(Construcrot.prototype),知识为了看的更清楚才分开写了
  //执行构造函数,改变this指向,使得obj能访问到构造函数中的属性
  let result = Constructor.apply(obj, arguments);
  // 加上这一步的作用就是如果构造函数有返回值(一般我们不会这样去做),那么就返回,否则,默认返回obj
  return result instanceof Object ? result : obj;
}

// 试验一下
function Animal(type) {
  this.type = type;
}
Animal.prototype.say = function() {
  console.log('say');
}
let o = mockNew(Animal, '哺乳类');

o.say();
console.log(o.type);
复制代码

顺便提一句,既然我们用到了instanceof,那我们写一下其原理是怎么实现的吧~其实就是根据__proto__属性,在原型链上不断查找,instanceof左边是实例对象,右边是构造函数或类:

function instanceOf (A, B) {
  B = B.prototype
  A = A.__proto__
  while (true) {
    if (A === null) {
      return false
    }
    if (A === B) {
      return true
    }
    A = A.__proto__
  }
}
// 尝试一下
class A {

}
let a = new A()
let res = instanceOf(a, A)
console.log(res)

复制代码

call实现原理

Function.prototype.call = function (context, ...args) {
  // 如果context为真值,那么将其包装成一个对象
  context = context ? Object(context) : window
  // 创建一个独一无二的fn名
  let fn = Symbol()
  // 将this赋值给context.fn属性,这里的this就是指调用call的那个函数
  context[fn] = this
  // 这样,在调用这个函数的时候,因为被放置在了context里边,所以this就会指向context
  context[fn](...args)
  delete context[fn]
  return result
}
复制代码

apply实现原理

applycall是类似的,只不过传参不一样,能看懂call的话,那么apply也不在话下!

Function.prototype.apply = function(context, arr) {
  context = context ? Object(context) : window;
  let fn = Symbol();
  context[fn] = this;

  let result = arr ? context[fn](...arr) : context[fn]();
  delete context[fn];
  return result;
};
复制代码

bind实现原理

bind在内部使用了apply,返回一个新的函数,所以代码如下:

Function.prototype.bind = function (context, ...args) {
  return (...argument) => {
    return this.apply(context, [...args, ...argument])
  }
}
// 尝试一下
let obj = {
  name: 'jw'
}
function fn (a, b) {
  console.log(this.name, a + b)
}
let bindFn = fn.bind(obj, 9)
bindFn(3)
复制代码

结尾

关于this的问题,我们看完这篇文章,大致就能比较清楚的判断了,如果在项目中,因为this指向出现一些问题,也能及时的排查出来。下次看到了绕来绕去代码,this乱指,如果是在面试中,不妨耐着性子一点点梳理,如果是你同事写出来的代码,一巴掌直接呼过去了就。