【2023面试系列—JS篇】一文掌握并手写apply,call和bind

152 阅读7分钟

前言

前端技术更新本来就很快,最近又有 chatGPT 火遍全球,更有“前端已死”的话语出圈。我们切勿焦虑,还是要调整好心态,深入学习前端技术的基础和技术主干,把基础原理吃透,技术深度比技术广度重要。

后续会持续更新JS基础或面试相关知识,感兴趣的同学可以支持点赞关注哟。 image.png

你有没有思考这样一个问题:我们明明从大量书籍,名牌讲师或优秀文章中,输入了很多JavaScript系统知识,自我感觉我们都掌握了。但是每当别人问的时候,问题就来了,往往只有只言片语,或者东拼西凑,最后甚至连一个简单的知识点都说不明白。这无疑对我们来说是很大的一个打击,也不是我们想要的。

所以这个面试系列就是要解决这个问题,以对话的形式,边看文章边思考,加深对知识点的理解,最后可以尽可能流畅,清晰的表达给他人。

模拟场景

小白和小高是合租的室友,小白是刚毕业一年的前端开发人员,小高则是前端大牛。小白觉得当前工资低,最近又到了金三银四的好时节,以自己目前的水平可以换一个高工资的工作。这天就来找小高,想让小高给点建议,小高说那我给你出几道高频面试题,看下你对知识点掌握的情况。

作用和来源

小高:apply,call,bind这3个方法你肯定用过,那你知道他们的作用和来源吗?

小白:这个简单,我肯定用过呀,他们都是修改this指向的,嗯,嗯...来源是啥呀,我没考虑过。

小高:首先它们的作用你回答的对,他们确实都是修改this指向的,但是说的完整一点,他们都是用来改变函数的执行时this的方法。至于来源,首先我们来看下代码:

function isFun(type) {
   return Function.prototype.hasOwnProperty(type);
}
console.log(isFun('call'));   // true
console.log(isFun('apply'));  // true
console.log(isFun('bind'));   // true

  上面代码中,都返回了 true,表明这三个方法都是继承自 Function.prototype 中的,属于实例方法。

小白:喔喔,我明白了,知道这个来源,就对这3个方法更了解了

用法和区别

小高:那你知道他们的用法和区别吗?

小白:我知道啊,他们接收参数的方式不同,call是数组传参,喔,不对,是参数列表,哎我有点弄不清,反正有的数组,有的参数列表,具体我忘记了。

小高:哈哈,没事,不要紧张,确实是接收参数方式不同。我们可以写个例子,加深印象。

var name="小明";
function getFoods(fruit1,fruit2) {
  return `${this.name}喜欢吃${fruit1}${fruit2}`
}
let obj={name:'小宇'}
console.log(getFoods.call(null,'苹果','香蕉')); //小明喜欢吃苹果和香蕉
console.log(getFoods.call(obj,'苹果','香蕉'));  //小宇喜欢吃苹果和香蕉
console.log(getFoods.apply(obj,['苹果','香蕉'])); //小宇喜欢吃苹果和香蕉
console.log(getFoods.bind(obj)('苹果','香蕉')); //小宇喜欢吃苹果和香蕉

从上述可以看出,apply,call,bind 改变了this指向,指向obj。

call,apply 基本类似,除接收的参数不同,call 接收的是参数列表,而 apply 是包含多个参数的数组

bind 创建了一个新函数,并且在调用该函数时将其 this 对象绑定到指定值,该函数可以用于以后调用

image.png

总的来说,如果你想立即调用一个函数并改变它的 this 对象,那么你可以使用 call 或者 apply;如果你想创建一个新函数,而在以后调用它,并且在调用时始终改变其 this 对象,那么你可以使用 bind。

小白:喔喔,通过你这样一讲,我就很清晰了,有种豁然开朗的感觉。

2. 实现原理

小高:好了,它们的用法和区别你已经掌握了,那你会手写实现吗?

小白:手写?嗯。。。嗯。。。不会

小高:没事,接下来我们一起来看下,首先我们手写下call的实现原理,call你掌握了,apply,bind就很容易了。

call

小高:call咱们刚才用了,那你现在总结下这个方法的关键点是啥?

小白:修改this指向;参数列表形式传参。

小高:是这样的,但是你遗漏了还有重要的一点【执行函数】。总结一下实现的 call 方法的关键在于:

  • 给函数绑定新的 this

  • 执行函数

Function.prototype.myCall=function(context){
  // 给函数绑定新的this
  context['fn']=this;
  //绑定后执行函数
  context['fn']()
}

小高:这样我们基本就实现了call的方法,是不是很简单。

小白:实现了?这么简单,那让我写个例子测试一下;

//使用 globalThis 访问全局变量
globalThis name='小明';
let obj={
  name:'小宇'
};
function getName() {
  console.log(this.name);
}
getName(); //小明
getName.myCall(obj); //小宇

从上述可以看出改变了this指向,输出“小宇”;

小白:哇,真的呀,原理这么简单!不对,咱们没有传参,是不是还要接收参数。

小高:对,很聪明嘛,看来咱们上面说的你真的明白了,接下来然后我们将代码进行一些优化,不光是传参,还要增加代码可扩展性

Function.prototype.myCall=function(context,...args){
  // 1. 检查调用call的对象是否是函数
  if(typeof(this)!=='function'){
    throw new TypeError('not a function')
  }
  // 2. 若不传入context,this默认会指向window,这里使用glbalthis访问全局变量
  context=context||globalThis;
  // 3. 利用es6 Symbol(),给context创建一个独一无二的属性
  let fn=Symbol();
  // 4. 执行函数并赋值
  // 把当前的函数给传进来的目标对象context身上拷贝了一份
  context[fn]=this;
  // 执行的时候就执行对象上context[fn]的方法,this指向就是当前对象了
  const res=context[fn](...args);
  // 5. 删除新增的属性
  delete context[fn];
  // 6. 返回结果
  return res;
}

小白:明白了明白了,以后面试再让手写call我就不怕了。

apply

小高:手写call你已经掌握了,apply就很容易,apply 和call 原理一致,只是第二个参数传入的是数组

Function.prototype.myApply=function(context,args){
  // 1. 检查调用apply的对象是否是函数
  if(typeof(this)!=='function'){
    throw new TypeError('not a function')
  }
  // 2. 若不传入context,this默认会指向window,这里使用glbalthis访问全局变量
  context=context||globalThis;
  // 3. 利用es6 Symbol(),给context创建一个独一无二的属性
  let fn=Symbol();
  // 4. 执行函数并赋值
  context[fn]=this;
  const res=context[fn](...args);
  // 5. 删除新增的属性
  delete context[fn];
   // 6. 返回结果
  return res;
}

测试一下:

var year = 2022
function getDate(month, day) {
  const date= `${this.year}-${month}-${day}`
  console.log(date);
}

let obj = {year: 2023}
getDate.myApply(obj,[2,21]) //2023-2-21

小白:哇,谢谢谢谢,我都佩服自己了,一小会儿的功夫,已经掌握了手写call(),apply()方法,我原来都觉得那是很深奥的东西,不是我这种菜鸟能理解和掌握的,你这么一讲,我就很清楚了。

小高:别高兴的太早了,咱们还有bind呢,你不想掌握手写bind方法吗。

小白:想呀,想呀,咱们继续,我要一气呵成,以后这3个方法在哪都不怕了。

bind

小高:bind 的实现虽然要复杂一点,但是在前面 call,apply 实现的基础上,也比较容易上手了。那你再回忆一下,bind和call的区别是啥?

小白:bind和call接收的参数都是参数列表的形式,但是call可以直接调用,而bind返回的是函数。

小高:对,bind返回的是函数,在这里需要注意的是,既然是函数,那么可以普通函数调用,也可以构造函数调用,所以我们要考虑一下几个要素:

  1. bind调用的时候可以传参,调用之后生成新的函数也可以传参,所以需要将剩余的参数和传入的参数拼接,作为新的参数:
newArgs=[...args, ...innerArgs]
  1. 判断是否为构造函数,使用instanceof操作符判断
 // 判断是否为构造函数
    context = this instanceof myBound ? this : context;
  1. 实现原型继承,参考JS中的继承与原型链
// Object.create拷贝原型对象
  myBound.prototype = Object.create(this.prototype);

根据以上几个要素,接下来进行分步实现 bind() 方法:

  1. 首先在原型上定义一个函数 myBind,函数的第一个参数作为context,后面的参数作为返回新方法的参数:
Function.prototype.mybind = function (context, ...args) {
  // ...
}

2.myBind 方法会返回一个新函数,该函数将外层函数的参数,与内层函数的参数连接起来一起作为参数:

Function.prototype.mybind = function (context, ...args) {
  return function () {
  //使用es6扩展运算符合并参数
    newArgs=[...args, ...innerArgs]
  }
}

3.接下来可以使用 apply() 来完成 this 指向变更,在那之前可以使用变量 that 先保存原函数:

Function.prototype.mybind = function (context, ...args) {
  let that = this;
  return function () {
    newArgs=[...args, ...innerArgs];
    //使用apply修改this的指向
    return that.apply(context, ...newArgs);
  }
}

以上就实现了普通函数调用 bind(),是不是很容易理解。

  1. 当传入的函数是一个构造函数时,不需要更改 this 的指向。
Function.prototype.mybind = function (context, ...args) {
  let that = this;
 // 因为需要构造函数,所以不能是匿名函数了
  let myBound=function () {
    newArgs=[...args, ...innerArgs];
     // 判断是否为构造函数
    context = this instanceof myBound ? this : context;
    return that.apply(context, ...newArgs);
  }
  //返回函数
  return myBound;
}
  1. 新返回的函数与原函数的原型对象并没有建立联系,所以new出来的对象不能访问到原函数的原型对象上的方法,换句话说就是不能继承构造函数原型属性和方法。
Function.prototype.mybind = function (context, ...args) {
  let that = this;
  let myBound=function () {
    newArgs=[...args, ...innerArgs];
    context = this instanceof myBound ? this : context;
    return that.apply(context, ...newArgs);
  }
  // 实现继承1: 构造一个中间函数来实现继承 
  // let mFun = function () { } 
  // mFun.prototype = this.prototype 
  // result.prototype = new mFun() //原型式继承
  // 实现继承2: 使用Object.create拷贝原型对象
  result.prototype = Object.create(this.prototype)
  return myBound;
}

完整实现过程

Function.prototype.myBind = function (context, ...args) {
  if(typeof(this)!=='function'){
    throw new TypeError('not a function')
  }
  context=context||window;
  let fn = Symbol();
  context[fn] = this;
  // 保存外部函数的this
  const that = this;
  //返回一个新函数
  let myBound=function (...innerArgs) {
    newArgs=[...args, ...innerArgs];
    context = this instanceof myBound ? this : context;
    return that.call(context, ...newArgs);
  }
  // 实现继承1: 构造一个中间函数来实现继承 
  // let mFun = function () { } 
  // mFun.prototype = this.prototype 
  // result.prototype = new mFun() //原型式继承
  // 实现继承2: 使用Object.create拷贝原型对象
  myBound.prototype = Object.create(this.prototype)
  return myBound;
}

小高:好,实现代码我们写完了,接下来测试下:

function Product(name,count){
  console.log(`${name}${count}个`);//香蕉2个
  console.log(this); // this指向实例对象 Product {}
  console.log(`${this.name}${this.count}个`);//undefinedundefined个
}
Product.prototype.getColor=function(color){
  console.log(color);//yellow
}
let obj={
  name:'苹果',
  count:'10'
}
// 构造函数调用
let fn=Product.myBind(obj,'香蕉');
let pp=new fn('2');
pp.getColor('yellow');

小白:我有个疑问,你看上述这个代码,既然传入的obj没有生效,那我们用bind实现了个啥,总感觉怪怪的。

小高:因为new出来的构造函数,this指向创建出的对象,在构造函数中使用bind的作用就是传参。

小白:那既然没什么用,为什么还要传入obj?

小高:哈哈,你忘了我们还有普通函数调用,你别急,你看下面这个普通函数调用,是不是指向了obj:

let obj={
  name:'苹果',
  count:'10'
}
//普通函数调用
function getProduct(name,count) {
  console.log(`${name}${count}个`);//西瓜5个
  console.log(this);//this指向传进来的obj
  console.log(`${this.name}${this.count}个`);//苹果10个
}
let fnBind=getProduct.myBind(obj);
fnBind('西瓜','5');

小白:喔喔,我明白了,就是构造函数就是传参使用,不改变this指向;普通函数调用才改变this指向,你上面其实已经说过了,就是那个判断this的代码

context = this instanceof myBound ? this : context;

小高:这个我们就到这里吧,其实实现原理这里面还涉及到了原型,原型链,new操作符和ES6的相关知识点,后续我们也会一一讨论这些,让我们一起期待吧。