Javascript基础:你真的理解new、call、apply、bind吗?

吐槽君 分类:javascript

New

在JS基础面试题中,我们经常都能遇到new的相关问题,但是较多从事前端行业不久的同学们却总是希望通过背题这种方式来逃过面试官的法眼,回答问题的时候也胆战心惊生怕面试官逮住问题继续深究。

好,正题。众所周知,

new关键字后面接的是函数(除了箭头函数外其他函数皆可),会执行函数内的代码,返回值是一个对象,如果没有return或者return值并不是一个对象的话,则会返回一个实例,该实例由函数内部this属性值和_proto_构成。_proto_本身内容不再赘述,具体参照原型链相关文章。

function A () {
    this.name = 'Li Lei'
}
const aObj = new A()
console.log(aObj) // { a: 'Li Lei', _proto_: xxx }

function B (sound) {
	this.sound = sound
}
const bObj = new B('wangwang')
console.log(bObj) // { sound: 'wangwang', _proto_: xxx }
 

上面两段代码都不难理解,bObj是通过B这个构造函数生成的一个实例,B上面有一个参数,通过向构造函数传参的方式可以更加自由地创建实例,那么new在这期间执行了一个什么样的过程呢?

  1. 创建一个空对象
  2. 将函数内部的this指向上一步新建的空对象
  3. 执行函数内部的代码
  4. 将新建的对象的_proto_属性指向函数的prototype
  5. 返回新对象或者用户自定义的对象

由此可以进一步总结:

  1. 让实例可以访问到私有属性
  2. 让实例可以访问构造函数原型所在原型链上的属性
  3. 构造函数返回的最后结果是引用数据类型

下面我们来模仿一下new关键字的实现代码

function _new(ctor, ...rest) {
	if (typeof ctor !== 'function') { // 首先得是函数才能new,如果不是函数,则报错
    throw 'Your first params is not a function'
  }
  const newObj = Object.create(ctor.prototype) // 创建一个新的空对象并将新对象的原型链指向构造函数的显式原型,同时箭头函数没有prototype,所以这一步可以排除掉箭头函数的情况
  const res = ctor.apply(newObj, rest) // 将this指向新对象都是执行构造函数并获取返回值
  if (typeof res === 'function') { 
    return res
  }
  if (typeof res === 'object' && res !== null) {
    return res
  }
  return newObj
}
 

apply、call And bind介绍

三者都是挂载到Function对象上的方法,故调用这三个方法的必须是一个函数。在使用区别上,三者的方法是两两相同又两两不同,除了bind是返回一个函数,其他都是立即调用。

下面是三个方法的使用示例:

func.call(thisArg, param1, param2, ...)
func.apply(thisArg, [param1, param2, ...])
func.bind(thisArg, param1, param2, ...)
 

其中 func 是要调用的函数,thisArg 一般为 this 所指向的对象,后面的 param1、2 为函数 func 的多个参数,如果 func 不需要参数,则后面的 param1、2 可以不写。

这三个方法共有的、比较明显的作用就是,都可以改变函数 func 的 this 指向。call 和 apply 的区别在于,传参的写法不同:apply 的第 2 个参数为数组; call 则是从第 2 个至第 N 个都是给 func 的传参;而 bind 和这两个(call、apply)又不同,bind 虽然改变了 func 的 this 指向,但不是马上执行,而这两个(call、apply)是在改变了函数的 this 指向之后立马执行。

这几个方法的区别和原理基本讲清楚了,但是理解起来是不是很抽象呢?那么我举个形象的例子再配合着代码一起看下。

例如,生活中我不经常做饭,家里没有锅,周末突然想给自己做个饭尝尝。但是家里没有锅,而我又不想出去买,所以就问隔壁邻居借了一个锅来用,这样做了饭,又节省了开销,一举两得。

对应在程序中:A 对象有个 getName 的方法,B 对象也需要临时使用同样的方法,那么这时候我们是单独为 B 对象扩展一个方法,还是借用一下 A 对象的方法呢?当然是可以借用 A 对象的 getName 方法,既达到了目的,又节省重复定义,节约内存空间。

为了更好地掌握这部分概念,我们结合一段代码再深入理解一下这几个方法。

const a = {
  name: 'jack',
  getName: function(msg) {
    return msg + this.name;
  } 
}
const b = { name: 'lily' }
console.log(a.getName('hello~'));  // hello~jack
console.log(a.getName.call(b, 'hi~'));  // hi~lily
console.log(a.getName.apply(b, ['hi~']))  // hi~lily
let name = a.getName.bind(b, 'hello~');
console.log(name());  // hello~lily
 

从上面的代码执行的结果中可以发现,使用这三种方式都可以达成我们想要的目标,即通过改变 this 的指向,让 b 对象可以直接使用 a 对象中的 getName 方法。从结果中可以看到,最后三个方法输出的都是和 lily 相关的打印结果,满足了我们的预期。

我们再看看这几个方法的使用场景。

  • 判断数据类型

    用 Object.prototype.toString 来判断类型是最合适的,借用它我们几乎可以判断所有类型的数据。

    function getType(obj){
      let type  = typeof obj;
      if (type !== "object") {
        return type;
      }
      return Object.prototype.toString.call(obj).replace(/^$/, '$1');
    }
     
  • 类数组借用方法

    类数组因为不是真正的数组,所有没有数组类型上自带的种种方法,所以我们就可以利用一些方法去借用数组的方法,比如借用数组的 push 方法,看下面的一段代码。

    var arrayLike = { 
      0: 'java',
      1: 'script',
      length: 2
    } 
    Array.prototype.push.call(arrayLike, 'jack', 'lily'); 
    console.log(typeof arrayLike); // 'object'
    console.log(arrayLike);
    // {0: "java", 1: "script", 2: "jack", 3: "lily", length: 4}
     
  • 获取数组的最大 / 最小值

    我们可以用 apply 来实现数组中判断最大 / 最小值,apply 直接传递数组作为调用方法的参数,也可以减少一步展开数组,可以直接使用 Math.max、Math.min 来获取数组的最大值 / 最小值,请看下面这段代码。

    let arr = [13, 6, 10, 11, 16];
    const max = Math.max.apply(Math, arr); 
    const min = Math.min.apply(Math, arr);
     
    console.log(max);  // 16
    console.log(min);  // 6
     
  • 继承

      function Parent3 () {
        this.name = 'parent3';
        this.play = [1, 2, 3];
      }
    
      Parent3.prototype.getName = function () {
        return this.name;
      }
      function Child3() {
        Parent3.call(this);
        this.type = 'child3';
      }
    
      Child3.prototype = new Parent3();
      Child3.prototype.constructor = Child3;
      var s3 = new Child3();
      console.log(s3.getName());  // 'parent3'
     

call代码实现

Function.prototype._call = function (context, ...rest) {
  if (typeof this !== 'function') throw 'this is not a function' // 调用call的必须是一个函数
  if (typeof context === 'number') {
    context = new Number(context)
  }
  if (typeof context === 'string') {
    context = new String(context)
  }
  if (typeof context === 'boolean') {
    context = new Boolean(context)
  }
  if (typeof context === 'Symbol') {
    context = new Symbol(context)
  }
  // 目前已知借用的函数中只有toString会返回null和undefined的结果,欢迎补充
  if ((context === null || context === undefined) && this === Object.prototype.toString) {
    return context === null ? '[object Null]' : this(undefined)
  }
  const objName = Symbol('fn') // 通过唯一属性来实现赋值,确保不会覆盖原有属性
  context[objName] = this
  const results = context[objName](...rest)
  Reflect.deleteProperty(context, objName) // 借用完后进行删除
  return results // 返回调用结果
}
 

apply代码实现

apply与call大同小异,唯一的区别就是后面的参数传递形式不同

// 除了rest这里不需要用拓展运算符外,其他与call均一致
Function.prototype._apply = function (context, rest) {...}
 

bind代码实现

bind与call也基本一致,不一样的是bind返回一个函数,并不立即执行

Function.prototype._bind = function (context, ...rest) {
	if (typeof this !== 'function') throw 'this is not a function'
        // 保存被借用的函数,因为在下面fn内部的this指向跟外部可能不一致
        const self = this
        // 注意这里不能使用箭头函数,因为返回函数可能被用作构造函数
        const bound = function () { 
    // 如果是以普通函数调用的方式使用fn,则self依然是之前的被借用者,apply第一个参数依然是bind的第一个参数
    // 使用apply而不是call是因为第二个参数需要将两次传参合并到一起,整个bind是一个函数柯里化的过程
    // 网上有self.apply(this instanceof self ? this : context, rest.concat(Array.prototype.slice.call(arguments)))这种写法,但是没有必要,因为bind返回的函数没有prototype,不能用作构造函数
    self.apply(context, rest.concat(Array.prototype.slice.call(arguments)))
  }
  // 下面这段也不加,按原本的语法不能用作构造函数
  // if (this.prototype) fn.prototype = Object.create(this.prototype)
  return bound
}
 

总结

image.png

回复

我来回复
  • 暂无回复内容