深入理解JS面向对象的new、原型、原型链

412 阅读9分钟

面向对象编程

编程思想

常见的编程思想有面向过程面向对象,面向过程思想是将问题拆分成一个个过程,这种模式下,小型问题能够以更少的代码实现,但是在遇到大型复杂问题时会出现逻辑繁琐、代码量过大的问题。而面向对象编程思想则是将问题所需属性、方法描述成一个个对象。然后在根据业务逻辑调用对象的属性和方法,这么做弥补了面向过程的缺点,但是在小型问题上就没有面向过程灵活。

面向对象

OOP(面向对象编程),主要思想是将构成问题所需属性、方法描述成一个个对象,然后在问题解决的过程中找到对象的属性或方法,来解决问题。面向对象编程有3大特点(继承、多态、封装)和5大原则(单一职责、开放封闭、里氏替换、依赖导致、接口分离),感兴趣的小伙伴可以自行了解,这里就不过多赘述了。

JS中的面向对象

JavaScript是高度面向对象的,它遵循了基于原型的模型

new关键字

OOP 的本质就是创建对象用并对象来解决问题,而通过new关键字执行函数,就可以通过该函数创建一个实例化的对象

function sum(a,b){
    this.a = a;	// 通过this给实例对象添加属性
    this.b = b;
    this.total = a + b
    return a + b;
}

sum(1,1);		// 2
new sum(1,1);	// {a:1,b:1,total:2}

new关键字在修饰函数执行时,返回的不一定是函数体的返回值,而是返回的一个对象,如果不熟悉this和函数运行机制的可以参考我之前的文章《this--函数的执行主体》《JavaScript函数的存储机制和运行原理

对比普通函数执行和new执行,可以总结出new修饰的函数执行时做了什么

  1. 和普通函数执行一样,形成执行上下文EC、AO、作用域链等
  2. new操作符会在函数执行前创建一个对象
  3. 在执行上下文初始化环境时将this指针指向刚刚通过new创建的对象
  4. 执行上下文进栈执行时,通过this指针给new创建的实例对象设置‘"私有"属性、方法
  5. return 时,检测返回值,如果没有返回值或者返回值是一个原始数据类型则返回通过new创建的那个实例对象;如果有返回值并且返回值是引用数据类型,则返回该引用数据

模拟实现new方法

了解new在函数执行时做了什么,就可以通过new关键字的特点实现一个new函数

/*
 *  Func[function]:类
 *  params[array]:基于剩余运算符获取传递的实参,都是给类传递的实参
 */
function _new(Func,...params){
    // 1. 创建实例对象
    let obj = {};
    obj.__proto__ = Func.prototype;
    // 2. 将类作为普通函数执行,基于call方法强制改变this的指向,将this指向新创建的实例对象obj
    let result = Func.call(obj,...params);
    // 3. 当返回的结果为引用数据类型时,则直接返回该值,否则返回obj
    if(result !== null && /^(object|function)$/i.test(typeof result)) return result;
    return obj;
}

在IE中__proto__是被保护起来的,所以这里可以使用Object.create来优化

function _new(Func,...params){
    let obj = Object.create(Func.prototype);
    let result = Func.call(obj,...params);
    if(result !== null && /^(object|function)$/i.test(typeof result)) return result;
    return obj;
}

实现Object.create

Object.create = function create(obj){
    if(obj === null || typeof obj !== 'object'){
        throw new TypeError('Object prototype may only be an Object');
    }
    function fn(){};
    fn.prototype = obj;
    return new fn();
}

原型及原型链

类和原型

所有类都是function数据类型(包含内置类)

  • 所有函数都会天生自带一个属性:prototype原型(显示原型)
  • prototype属性默认指向一个对象数据类型
  • 在对象中存储的是供实例调用的所有公共属性和方法
  • 类的原型对象上默认有个属性:constructor构造函数,属性值指向当前类本身

实例和原型链

所有对象数据类型都会天生自带一个属性:__proto__ 原型链(隐式原型)

  • __proto__ 属性的属性值:当前实例对象所属类的原型(prototype),及 实例.__proto__ === 类.prototype

类、原型、实例之间的关系

通过一道面试题,分析一下类、原型、实例之间的关系

function Fn(){
    this.x = 100;
    this.y = 200;
    this.getX = function(){
        console.log(this.x);
    }
}
Fn.prototype.getX = function(){
    console.log(this.x);
}
Fn.prototype.getY = function(){
    console.log(this.y);
}
let f1 = new Fn;
let f2 = new Fn;

console.log(f1.getX === f2.getX);		// false
console.log(f1.getY === f2.getY);		//true
console.log(f1.__proto__.getY === Fn.prototype.getY);	// true
console.log(f1.__proto__.getX === f2.getX);				// false
console.log(f1.getX === Fn.prototype.getX);				// false
console.log(f1.constructor);							// Fn
console.log(Fn.prototype.__proto__.constructor);		// Object
f1.getX();					// 100
f1.__proto__.getX();		// undefined
f2.getY();					// 200
Fn.prototype.getY();		// undefined

通过上述例子,我们可以总结原型链的特点

  • 实例上的函数执行时,通过this用到某个属性,首先看是否是自己实例的私有属性,如果是自己的私有属性,那么就直接操作自己的私有属性
  • 如果不是自己的私有属性,会默认基于__proto__找所属类的原型上的公共属性和方法
  • 如果所属类的原型上也没有,则会继续基于原型链__proto__继续向上查找,直到找到Object.prototype上为止

原型重定向

为什么要将原型重定向?为了方便批量向原型上拓展公共属性和方法

原型重定向带来的问题

  • 新定向的原型对象上,没有constructor属性,结构不完善
  • 浏览器默认生成的原型对象会因为缺少引用而被释放掉,可能会导致原始加入的属性和方法丢失掉
  • 内置对象是不允许被重定向的,防止把内置对象的方法丢失
function Fn(){
    this.x = x;
    this.y = y;
}
Fn.prototype.getX = function(){};
Fn.prototype = {        //重定向的新对象,没有constructor,也没有getX了
    getY:function(){}
};
let f1 = new Fn;

上述函数中,先在原型上添加了getX方法,然后再Fn.prototype = { },使得原型重定向了,原先给原型添加的方法会丢失。

那原型重定向给我们带来了这么多弊端,那我们为什么还要做原型重定向呢?

给原型批量添加方法

通常我们为原型添加方法会选用以下方式来添加,但是,当我们需要添加的属性和方法比较多时,这种方法会显得计较繁琐,因为每添加一个都要加上Fn.prototype

function Fn(){
    this.x = x;
    this.y = y;
}
Fn.prototype.getX = function(){};
Fn.prototype.getY = function(){};
Fn.prototype.getZ = function(){};

笔者也在很多的插件源码中看到别人用下述方法来添加,但是,这种方式始终是治标不治本,只是视觉上的代码减少

function Fn(){
    this.x = x;
    this.y = y;
}
let A = Fn.prototype
A.getX = function(){};
A.getY = function(){};
A.getZ = function(){};

在这里我推荐使用Object.assign()合并对象的方式来做原型重定向,这种方式不但解决了重定向原型上没有constructor属性的问题,同时在重定向之前给原型添加的属性和方法都会保存下来。

function Fn(){
    this.x = x;
    this.y = y;
}

Fn.prototype.z = 300;
Fn.prototype = Object.assign(Fn.prototype,{
    getX(){},
    getY(){},
    getZ(){},
})

内置类的原型是不允许操作的,所以assign函数不能操作内置类

基于内置类原型拓展方法

JS中内置类的原型上存储了大量能够供我们在实际项目中调用的属性和方法,但是这些方法和属性不一定能够满足我们在业务开发中的所有需求。例如当我们要给数组去重时,原型上是没有对应的方法来实现的,通常我们都是定义一个去重函数再将数组作为参数传入,这种方法调用起来比较麻烦。这个时候我们就可以基于内置类直接拓展来实现。

  • 写在内置类原型上的方法,实例在调用的时候实例.xxx()
    • xxx()方法执行
    • 执行主体this是要操作的实例
  • 向内置类原型上拓展方法的目的是,将一些公共的属性和方法拓展到内置类的原型上,这样调用起来就比较的方便
  • 自定义属性或方法名一定要与原型上本来就存在的属性和方法名区分开来,避免自己的方法将原型的方法覆盖
let arr = [1,1,2,3,4,2,3,1,4,4]

// 1.方法一
function unique(arr){
    return Array.from(new Set(arr));
}
// 2.方法二
Array.prototype.unique = function unique(){
    //保证this是一个数组
    if(!Array.isArray(this)) throw new TypeError('确保操作的是一个数组');
    return Array.from(new Set(this));
}

unique(arr)    //=>[1,2,3,4]
arr.unique();   //=>[1,2,3,4]

面试题

let n = 10;
let m = n.plus(10).minus(5);
console.log(m)  //=>15 (10+10+-5)

实现上述效果

Array.prototype.plus = function plus(num){
    return this + num;
}
Array.prototype.minus = function minus(num){
    return this - num;
}

函数的三种角色

Function内置类

所有函数都是Function内置类的实例,Function.prototype和其他构造函数的原型不同,它指向的不是一个对象,而是一个匿名的空函数。

虽然函数的原型指向的不是一个对象,但是这个匿名空函数的表现却和对象是一样的,没有任何区别,而且这个函数没有函数自带的属性prototype却存在对象的__proto__属性。

虽然Function.prototype指向的是一个匿名的空函数,但是我们却同样能够通过函数的实例来调用Function原型上的公共属性和方法,从这我们能够看出函数其实也是一种对象。

函数的三种角色

1.普通函数--> 函数形成、释放、闭包作用域

2.构造函数--> new执行、类和实例

3.普通对象--> 键值对

函数的三种角色之间没有必然的联系

终极原型链

从函数的第三种角色我们知道,所有的函数都是一个对象,那么函数就会带有对象自带的属性__proto__,既然每个函数都有原型链属性,那么在整个JS原型链生态中必然会有函数的参与。

  1. 所有的内置类都是Function的实例(包括Object)
  2. Function本身,既是Object的实例也是Function的实例
  3. Object本身,既是Object的实例也是Function的实例
  4. Object.__proto__.__proto__ === Object.prototype
  5. Function.__proto__ === Function.prototype
  6. 在Function.prototype上有call、apply、bind三种改变this的方法,所有函数都能调用者三个方法

阿里面试题

function Foo() {
    getName = function () {
        console.log(1);
    };
    return this;
}
Foo.getName = function () {
    console.log(2);
};
Foo.prototype.getName = function () {
    console.log(3);
};
var getName = function () {
    console.log(4);
};
function getName() {
    console.log(5);
}
Foo.getName();          // 2
getName();              // 4
Foo().getName();        // 1
getName();              // 1
new Foo.getName();      // 2
new Foo().getName();    // 3
new new Foo().getName();    //3

参考资料