继承进阶:如何实现 new、apply、call、bind 的底层逻辑?

373 阅读10分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动

  JavaScript 中的 applycallbind 方法是前端代码开发中相当重要的概念,并且与 this 的指向密切相关。很多人对它们的理解还比较浅显,如果想拥有扎实的 JavaScript 编程基础,那么必须要了解这些基础常用的方法。

为了方便更好地理解本讲的内容,在课程开始前请先思考几个问题:

  1. 用什么样的思路可以 new 关键词?
  2. applycallbind这三个方法之间有什么区别?
  3. 怎样实现一个 apply 或者 call 的方法?

方法的基本介绍

new 原理介绍

  new 关键词的主要作用就是执行一个构造函数、返回一个实例对象,在 new 的过程中,根据构造函数的情况,来确定是否可以接受参数的传递。下面通过一段代码来看一个简单的 new 的例子。

function Person(){

   this.name = 'Jack';

}

var p = new Person(); 

console.log(p.name)  // Jack

  这段代码比较容易理解,从输出结果可以看出,p 是一个通过 person 这个构造函数生成的一个实例对象,这个应该很容易理解。那么 new 在这个生成实例的过程中到底进行了哪些步骤来实现呢?总结下来大致分为以下几个步骤。

  1. 创建一个新对象;
  2. 将构造函数的作用域赋给新对象(this 指向新对象);
  3. 执行构造函数中的代码(为这个新对象添加属性);
  4. 返回新对象。

  那么问题来了,如果不用new这个关键词,结合上面的代码改造一下,去掉 new,会发生什么样的变化呢?再来看下面这段代码。

function Person(){

  this.name = 'Jack';

}

var p = Person();

console.log(p) // undefined

console.log(name) // Jack

console.log(p.name) // 'name' of undefined

从上面的代码中可以看到,我们没有使用 new 这个关键词,返回的结果就是 undefined。其中由于 JavaScript 代码在默认情况下 this 的指向是 window,那么 name 的输出结果就为 Jack,这是一种不存在 new关键词的情况。

  那么当构造函数中有 return 一个对象的操作,结果又会是什么样子呢?我们再来看一段在上面的基础上改造过的代码。

function Person(){

   this.name = 'Jack'; 

   return {age: 18}

}

var p = new Person(); 

console.log(p)  // {age: 18}

console.log(p.name) // undefined

console.log(p.age) // 18

通过这段代码又可以看出,当构造函数最后 return 出来的是一个和 this 无关的对象时,new 命令会直接返回这个新对象,而不是通过 new 执行步骤生成的this 对象。

  但是这里要求构造函数必须是返回一个对象,如果返回的不是对象,那么还是会按照 new 的实现步骤,返回新生成的对象。接下来还是在上面这段代码的基础之上稍微改动一下。

function Person(){

   this.name = 'Jack'; 

   return 'tom';

}

var p = new Person(); 

console.log(p)  // {name: 'Jack'}

console.log(p.name) // Jack

  可以看出,当构造函数中 return 的不是一个对象时,那么它还是会根据 new 关键词的执行逻辑,生成一个新的对象(绑定了最新 this),最后返回出来。

  前美团技术专家若离总结:new 关键词执行之后总是会返回一个对象,要么是实例对象,要么是 return 语句指定的对象。

在这里插入图片描述

apply & call & bind原理介绍

  先来了解一下这三个方法的基本情况,callapplybind 是挂在 Function 对象上的三个方法,调用这三个方法的必须是一个函数。

请看这三个函数的基本语法。

func.call(thisArg, param1, param2, ...)

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

func.bind(thisArg, param1, param2, ...)

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

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

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

let a = {

  name: 'jack',

  getName: function(msg) {

    return msg + this.name;

  } 

}

let 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 来判断类型是最合适的,借用它我们几乎可以判断所有类型的数据,在 JS 的数据类型你了解多少数据类型的判断中有介绍过,将当时总结的用来判断数据类型的那部分代码粘贴在下面了,可以回忆一下。

function getType(obj){

  let type  = typeof obj;

  if (type !== "object") {

    return type;

  }

  return Object.prototype.toString.call(obj).replace(/^$/, '$1');

}

  结合上面这段代码,以及在前面讲的 call 的方法的 “借用” 思路,那么判断数据类型就是借用了 Object 的原型链上的 toString 方法,最后返回用来判断传入的obj 的字符串,来确定最后的数据类型,这里就不再多做讲解了。

类数组借用方法

  类数组因为不是真正的数组,所有没有数组类型上自带的种种方法,所以就可以利用一些方法去借用数组的方法,比如借用数组的 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}

  从上面的代码可以看到,arrayLike 是一个对象,模拟数组的一个类数组。从数据类型上看,它是一个对象。从上面的代码中可以看出,用 typeof 来判断输出的是 'object',它自身是不会有数组的 push 方法的,这里就用 call 的方法来借用 Array 原型链上的 push 方法,可以实现一个类数组的 push 方法,给 arrayLike 添加新的元素。

获取数组的最大 / 最小值

  可以用 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

继承

  在探究 JS 常见的 6 种继承方式说到了继承,它与 newcall 共同实现了各种各样的继承方式。那么下面我们结合着这一讲的内容再来回顾一下组合继承方式,代码如下。

  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'

如何自己实现这些方法

  在面试中,手写实现 newcall、applybind 一直是比较高频的题目,结合本讲的内容,一起来手工实现一下这几个方法。

new 的实现

  刚才在讲 new 的原理时,介绍了执行 new 的过程。那么来看下在这过程中,new 被调用后大致做了哪几件事情。

  1. 让实例可以访问到私有属性;
  2. 让实例可以访问构造函数原型(constructor.prototype)所在原型链上的属性
  3. 构造函数返回的最后结果是引用数据类型
function _new(ctor, ...args) {

    if(typeof ctor !== 'function') {

      throw 'ctor must be a function';

    }

    let obj = new Object();

    obj.__proto__ = Object.create(ctor.prototype);

    let res = ctor.apply(obj,  [...args]);



    let isObject = typeof res === 'object' && res !== null;

    let isFunction = typeof res === 'function';

    return isObject || isFunction ? res : obj;

};

apply 和 call 的实现

  由于 applycall 基本原理是差不多的,只是参数存在区别,因此我们将这两个的实现方法放在一起讲。

  依然是结合方法“借用”的原理,我们一起来思考一下这两个方法如何实现,请看下面实现的代码。

Function.prototype.call = function (context, ...args) {

  var context = context || window;

  context.fn = this;

  var result = eval('context.fn(...args)');

  delete context.fn

  return result;

}

Function.prototype.apply = function (context, args) {

  let context = context || window;

  context.fn = this;

  let result = eval('context.fn(...args)');

  delete context.fn

  return result;

}

  从上面的代码可以看出,实现 callapply 的关键就在 eval 这行代码。其中显示了用 context 这个临时变量来指定上下文,然后还是通过执行 eval 来执行 context.fn 这个函数,最后返回 result

  要注意这两个方法和 bind 的区别就在于,这两个方法是直接返回执行结果,而 bind 方法是返回一个函数,因此这里直接用 eval 执行得到结果。

bind 的实现

  结合上面两个方法的实现,bind 的实现思路基本和 apply 一样,但是在最后实现返回结果这里,bindapply 有着比较大的差异,bind不需要直接执行,因此不再需要用 eval ,而是需要通过返回一个函数的方式将结果返回,之后再通过执行这个结果,得到想要的执行效果。

那么,结合这个思路,看下 bind 这个方法的底层逻辑实现的代码是什么样的,如下所示。

Function.prototype.bind = function (context, ...args) {

    if (typeof this !== "function") {

      throw new Error("this must be a function");

    }

    var self = this;

    var fbound = function () {

        self.apply(this instanceof self ? this : context, args.concat(Array.prototype.slice.call(arguments)));

    }

    if(this.prototype) {

      fbound.prototype = Object.create(this.prototype);

    }

    return fbound;

}

  从上面的代码中可以看到,实现 bind 的核心在于返回的时候需要返回一个函数,故这里的 fbound 需要返回,但是在返回的过程中原型链对象上的属性不能丢失。因此这里需要用Object.create 方法,将 this.prototype 上面的属性挂到 fbound 的原型上面,最后再返回 fbound。这样调用 bind 方法接收到函数的对象,再通过执行接收的函数,即可得到想要的结果。

总结

  通过原理以及对底层逻辑的剖析,介绍了日常开发中经常用的 newapplycallbind 这几种方法,最后动手进行了实践。

  综上,可以看到这几个方法是有区别和联系的,通过下面的表格再来梳理一下这些方法的异同点,可以更好地理解。

方法/特征callapplybind
方法参数多个单个数组多个
方法功能函数调用改变this函数调用改变this函数调用改变this
返回结果直接执行直接执行返回待执行函数
底层实现通过eval通过eval间接调用apply

  在日常的前端开发工作中,大家往往会忽视对这些方法的系统性学习,其实这些方法在高级 JavaScript 编程中经常出现,尤其是看一些比较好的开源项目,经常会通过“借用”的方式去复用已有的方法,来节约内存、优化代码。

  而且这些方法的底层逻辑的实现,在前端面试中出现的频率也比较高,每个实现的方法细节也比较零散,很多开发者很难有一个系统的、整体的学习,造成了在面试过程中遇到此类手写底层 API 等问题时,容易临场发怵。