new、call、apply、bind、及JS常见的6种继承

93 阅读11分钟

new、call、apply、bind、及JS常见的6种继承

1. new

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

function Person(){
   this.name = 'Jack';
}
var p = new Person(); 
console.log(p.name)  // Jack

1.1 new 在生成实例的过程中进行了以下几个步骤:

  1. 在内存种创建一个新对象
  2. 这个新对象内部的[[Prototype]]特性(proto)被赋值为构造函数的 prototype 属性
  3. 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)
  4. 执行构造函数内部的代码(给新对象添加属性)
  5. 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象

2.apply & call & bind 原理介绍

call、apply 和 bind 是挂在 Function 对象上的三个方法,调用这三个方法的必须是一个函数。

基本用法如下:

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

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

2.1 相同点&区别:

都可以改变函数 func 的 this 指向

**call 和 apply 的区别在于传参的写法不同:**apply 的第 2 个参数为数组; call 则是从第 2 个至第 N 个都是给 func 的传参;

bind 和这两个(call、apply)又不同,bind 虽然改变了 func 的 this 指向,但不是马上执行,而这两个(call、apply)是在改变了函数的 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

2.2 方法的应用场景

2.2.3 判断数据类型

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

function getType(obj) {
  let type = typeof obj;
  if (type !== "object") {    // 先进行typeof判断,如果是基础数据类型,直接返回
    return type;
  }
  // 对于typeof返回结果是object的,再进行如下的判断,正则返回结果
  return Object.prototype.toString.call(obj).replace(/^\[object (\S+)\]$/, '$1');  // 注意正则中间有个空格
}

/* 代码验证,需要注意大小写,哪些是typeof判断,哪些是toString判断?思考下 */
console.log(getType([]))     // "Array" typeof []是object,因此toString返回
console.log(getType('123'))  // "string" typeof 直接返回
console.log(getType(window)) // "Window" toString返回
console.log(getType(null))  // "Null"首字母大写,typeof null是object,需toString来判断
console.log(getType(undefined))   // "undefined" typeof 直接返回
console.log(getType())           // "undefined" typeof 直接返回
console.log(getType(function () { })) // "function" typeof能判断,因此首字母小写
console.log(getType(/123/g))      //"RegExp" toString返回
console.log(getType(Symbol(1)))

2.2.4 类数组借用方法

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

2.2.5 获取数组的最大 / 最小值

用 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

2.2.5 继承(如下)

3. 手写实现 new、call、apply、bind

3.1 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;
};

3.2 apply 和 call 的实现

由于 apply 和 call 基本原理是差不多的,只是参数存在区别

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;
}

3.3 bind 的实现

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

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)));
        // 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 方法接收到函数的对象,再通过执行接收的函数,即可得到想要的结果。

图片5.png

JS常见的6种继承

1. 原型链继承

原型链继承是比较常见的继承方式之一,其中涉及的构造函数、原型和实例,三者之间存在着一定的关系,即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针。

function Parent1() {
    this.name = 'parent1';
    this.play = [1, 2, 3]
  }
  function Child1() {
    this.type = 'child2';
  }
  Child1.prototype = new Parent1();
  console.log(new Child1());

上面的代码看似没有问题,虽然父类的方法和属性都能够访问,但其实有一个潜在的问题

var s1 = new Child2();
var s2 = new Child2();
s1.play.push(4);
console.log(s1.play, s2.play);

Drawing 0.png

明明我只改变了 s1 的 play 属性,为什么 s2 也跟着变了呢?原因很简单,因为两个实例使用的是同一个原型对象。它们的内存空间是共享的,当一个发生变化的时候,另外一个也随之进行了变化,这就是使用原型链继承方式的一个缺点。

那么要解决这个问题的话,我们就得再看看其他的继承方式,下面我们看看能解决原型属性共享问题的第二种方法。

2. 构造函数继承(借助 call)

function Parent1(){
    this.name = 'parent1';
  }

  Parent1.prototype.getName = function () {
    return this.name;
  }

  function Child1(){
    Parent1.call(this);
    this.type = 'child1'
  }

  let child = new Child1();
  console.log(child);  // 没问题
  console.log(child.getName());  // 会报错

执行上面的这段代码,可以得到这样的结果。

Drawing 2.png

因此,从上面的结果就可以看到构造函数实现继承的优缺点,它使父类的引用属性不会被共享,优化了第一种继承方式的弊端;但是随之而来的缺点也比较明显——只能继承父类的实例属性和方法,不能继承原型属性或者方法。

上面的两种继承方式各有优缺点,那么结合二者的优点,于是就产生了下面这种组合的继承方式。

3. 组合继承(前两种组合)

function Parent3 () {
 this.name = 'parent3';
 this.play = [1, 2, 3];
}

Parent3.prototype.getName = function () {
 return this.name;
}
function Child3() {
// 第二次调用 Parent3()
 Parent3.call(this);
this.type = 'child3';
}

// 第一次调用 Parent3()
Child3.prototype = new Parent3();
// 手动挂上构造器,指向自己的构造函数
Child3.prototype.constructor = Child3;
var s3 = new Child3();
var s4 = new Child3();
s3.play.push(4);
console.log(s3.play, s4.play);  // 不互相影响
console.log(s3.getName()); // 正常输出'parent3'
console.log(s4.getName()); // 正常输出'parent3'

执行上面的代码,可以看到控制台的输出结果,之前方法一和方法二的问题都得以解决。

Drawing 3.png

但是这里又增加了一个新问题:通过注释我们可以看到 Parent3 执行了两次,第一次是改变Child3 的 prototype 的时候,第二次是通过 call 方法调用 Parent3 的时候,那么 Parent3 多构造一次就多进行了一次性能开销,这是我们不愿看到的。

4.原型式继承

Object.create 方法,这个方法接收两个参数:一是用作新对象原型的对象、二是为新对象定义额外属性的对象(可选参数)。

let parent4 = {
 name: "parent4",
 friends: ["p1", "p2", "p3"],
 getName: function() {
   return this.name;
}
};

let person4 = Object.create(parent4);
person4.name = "tom";
person4.friends.push("jerry");

let person5 = Object.create(parent4);
person5.friends.push("lucy");

console.log(person4.name);
console.log(person4.name === person4.getName());
console.log(person5.name);
console.log(person4.friends);
console.log(person5.friends);

从上面的代码中可以看到,通过 Object.create 这个方法可以实现普通对象的继承,不仅仅能继承属性,同样也可以继承 getName 的方法,请看这段代码的执行结果。

Drawing 4.png

第一个结果“tom”,比较容易理解,person4 继承了 parent4 的 name 属性,但是在这个基础上又进行了自定义。

第二个是继承过来的 getName 方法检查自己的 name 是否和属性里面的值一样,答案是 true。

第三个结果“parent4”也比较容易理解,person5 继承了 parent4 的 name 属性,没有进行覆盖,因此输出父对象的属性。

最后两个输出结果是一样的,讲到这里你应该可以联想到 02 讲中浅拷贝的知识点,关于引用数据类型“共享”的问题,其实 Object.create 方法是可以为一些对象实现浅拷贝的。

那么关于这种继承方式的缺点也很明显,多个实例的引用类型属性指向相同的内存,存在篡改的可能,接下来我们看一下在这个继承基础上进行优化之后的另一种继承方式——寄生式继承。

5. 寄生式继承

使用原型式继承可以获得一份目标对象的浅拷贝,然后利用这个浅拷贝的能力再进行增强,添加一些方法,这样的继承方式就叫作寄生式继承。

let parent5 = {
name: "parent5",
 friends: ["p1", "p2", "p3"],
getName: function() {
   return this.name;
}
};

function clone(original) {
let clone = Object.create(original);
 clone.getFriends = function() {
  return this.friends;
 };
return clone;
}

let person5 = clone(parent5);

console.log(person5.getName());
console.log(person5.getFriends());

通过上面这段代码,我们可以看到 person5 是通过寄生式继承生成的实例,它不仅仅有 getName 的方法,而且可以看到它最后也拥有了 getFriends 的方法,结果如下图所示。

Drawing 5.png

从最后的输出结果中可以看到,person5 通过 clone 的方法,增加了 getFriends 的方法,从而使 person5 这个普通对象在继承过程中又增加了一个方法,这样的继承方式就是寄生式继承。

我在上面第三种组合继承方式中提到了一些弊端,即两次调用父类的构造函数造成浪费,下面要介绍的寄生组合继承就可以解决这个问题。

6. 寄生组合式继承

function clone (parent, child) {
 // 这里改用 Object.create 就可以减少组合继承中多进行一次构造的过程
 child.prototype = Object.create(parent.prototype);
 child.prototype.constructor = child;
}

function Parent6() {
 this.name = 'parent6';
 this.play = [1, 2, 3];
}
Parent6.prototype.getName = function () {
return this.name;
}
function Child6() {
 Parent6.call(this);
this.friends = 'child5';
}

clone(Parent6, Child6);

Child6.prototype.getFriends = function () {
return this.friends;
}

let person6 = new Child6();
console.log(person6);
console.log(person6.getName());
console.log(person6.getFriends());

通过这段代码可以看出来,这种寄生组合式继承方式,基本可以解决前几种继承方式的缺点,较好地实现了继承想要的结果,同时也减少了构造次数,减少了性能的开销,我们来看一下上面这一段代码的执行结果。

Drawing 6.png

可以看到 person6 打印出来的结果,属性都得到了继承,方法也没问题,可以输出预期的结果。

整体看下来,这六种继承方式中,寄生组合式继承是这六种里面最优的继承方式。另外,ES6 还提供了继承的关键字 extends,我们再看下 extends 的底层实现继承的逻辑。

7.ES6 的 extends 关键字实现逻辑

可以利用 ES6 里的 extends 的语法糖,使用关键词很容易直接实现 JavaScript 的继承,但是如果想深入了解 extends 语法糖是怎么实现的,就得深入研究 extends 的底层逻辑。

我们先看下用利用 extends 如何直接实现继承,代码如下。

class Person {
  constructor(name) {
    this.name = name
  }
  getName = function () {
    console.log('Person:', this.name)
  }
}
class Gamer extends Person {
  constructor(name, age) {
    super(name)
    this.age = age
  }
}
const asuna = new Gamer('Asuna', 20)
asuna.getName() // 成功访问到父类的方法

最后 extends 编译成了如下代码

function _possibleConstructorReturn (self, call) { 
		// ...
		return call && (typeof call === 'object' || typeof call === 'function') ? call : self; 
}
function _inherits (subClass, superClass) { 
 // 这里可以看到
	subClass.prototype = Object.create(superClass && superClass.prototype, { 
		constructor: { 
			value: subClass, 
			enumerable: false, 
			writable: true, 
			configurable: true 
		} 
	}); 
	if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; 
}

var Parent = function Parent () {
	// 验证是否是 Parent 构造出来的 this
	_classCallCheck(this, Parent);
 };
var Child = (function (_Parent) {
 	_inherits(Child, _Parent);
function Child () {
 		_classCallCheck(this, Child);
 		return _possibleConstructorReturn(this, (Child.__proto__ || Object.getPrototypeOf(Child)).apply(this, arguments));
 }
 	return Child;
 }(Parent));

从上面编译完成的源码中可以看到,它采用的也是寄生组合继承方式,因此也证明了这种方式是较优的解决继承的方式。

参考文献