详细剖析JavaScript原型和原型链机制并重写new与Object.create

吐槽君 分类:javascript

承接上一篇面向对象基础知识——原型与原型链基础知识

下面从两个角度分析解释原型是什么,原型链机制又是如何运作的

从两个角度先说说概念

函数数据类型角度分析

大部分函数数据类型的值都具备prototype(原型/显式原型)属性,属性值本身是一个对象。浏览器会默认为其开辟一个堆内存,用来存储当前类所属实例可以调用的公共的属性和方法。在浏览器默认开辟的这个堆内存(原型对象)中,有一个默认的属性 constructor(构造函数/构造器),属性值是当前函数/类本身。

函数数据类型分类

  • 普通函数(实名或者匿名函数)
  • 箭头函数
  • 构造函数/类「内置类/自定义类」
  • 生成器函数 Generator
  • ...

不具备prototype的函数

  • 箭头函数 const fn=()=>{}

  • 基于ES6给对象某个成员赋值函数值的快捷操作

        let obj = {
         fn1: function () {
            // 常规写法  具备prototype属性
         },
         fn2() {
            // 快捷写法  不具备prototype属性
         }
      };
      class Fn {
         fn() {} //这样的也不具备prototype属性
      };
     

对象数据类型值角度分析

每一对象数据类型的值都具备一个属性__proto__(原型链/隐式原型),属性值指向自己所属类的原型
prototype

ps:这里先不考虑函数类型的对象

对象数据类型值分类

  • 普通对象
  • 特殊对象:数组、正则、日期、MathError...
  • 函数对象
  • 实例对象
  • 构造函数。prototype

面向对象的底层都是基于这两个角度:类(构造函数)和实例对象

总结

记住这两个最重要的概念:

  1. 大部分函数数据类型的值都具备prototype(原型/显式原型)属性,属性值本身是一个对象。浏览器会默认为其开辟一个堆内存,用来存储当前类所属实例可以调用的公共的属性和方法。在浏览器默认开辟的这个堆内存(原型对象)中,有一个默认的属性 constructor(构造函数/构造器),属性值是当前函数/类本身
  2. 每一对象数据类型的值都具备一个属性__proto__(原型链/隐式原型),属性值指向自己所属类的原型 prototype

举例理解概念

举例一个内置类(数组类Array和其实例)来理解以上概念

函数数据类型角度理解

我们知道,任何函数在内存中会存在:作用域,代码字符串,属性键值对,那我们来看看Array

  1. 作用域
    image。png

    因为是内置的,作用域不清楚,但是是有作用域的

  2. 代码字符串
    image。png

    [native code] 为代码字符串,可能为C++写的,浏览器内置代码不让我们不看

  3. 属性键值对中的proptotype属性
    每一个函数都天生具备一个proptotype,所以Array中也是有的

    image。png

    并且proptotype对象中有一个constructor属性指向函数本身,另外包含很多Array的公共属性(concat,forEach等),如图:

    image。png

    总结:

    image。png

同样,Object函数也是一样

image。png

对象数据类型角度理解

ps:函数也是对对象,关于函数多种角色的问题这里暂时先不讨论,后面写文章进行补充

实例对象,prototype(原型对象),函数对象,都是对象,所有的对象都有__proto__属性,这个属性指向所属类的原型对象。

指向关系如下

image.png

注意容易混淆的点:

原型对象并不是他所属的构造函数的一个实例,比如Array.prototype不是Array的一个实例。因为既然作为原型,他是所有实例用来共享属性和方法的对象,所以不会是其中的一个实例。只有new出来的才算是某个构造函数的实例。Array的原型对象是浏览器默认开辟出来的,这里不是Array构造函数的实例。
image。png

通过浏览器我们发现Array.prototype.__proto__指向Object.proptotype,所以Array.prototypeObject的实例

image。png

注意更加特殊的Object.prototype.__proto__,指向null。因为Object是所有对象的基类,Object.prototype本身就是一个对象,Object.prototype.__proto__最后只能指向自己,即Object.prototype,这是没有意义的,所以最后浏览器让其指向null,这样也更合理一点。

image。png

image。png
image。png

那么以上原型链机制的定义明确了以后,平常是怎么运作的呢?举个例子理解:

执行arr.length,查看arr的属性length,或者执行arr.push的过程:

首先访问自己的私有属性,如果私有属性中是存在的,则直接使用。如果访问的成员在私有属性中没有,默认会基于__proto__找到所属类的prototype上的属性/方法。

所以Array.prototype上的方法,例如push等,相对于Array的实例来说,算是共有属性

执行或使用arr.hasOwnProperty的过程:

私有没有,Array.prototype上也没有,所以会继续基于Array.prototype.__proto__继续往上寻找,最终在Object.prototype上找到该方法,如果再找不到就返回undefined

我们把上面这种成员访问的查找机制叫做原型链机制

如下图:

image。png

以上是最常用的成员访问机制,还有一些其他方法进行成员访问:

例如arr.forEach(),除了直接这样使用,还可以:

  1. 可以直接使用arr.__proto__.forEach()这样可以直接跳过私有属性的查找,直接使用原型对象上的方法(此方法平时一般不会自己手动操作,因为ie浏览器没法使用这个方法,ie将其保护起来,不允许我们访问)
  2. 也可以Array.prototype.forEach(),也可以直接使用原型对象上的方法

那么这几种方法的区别是什么?三者都是找到Array.prototype.forEach,并且让其执行,区别在于forEach方法中的this指向不一样。谁调用了方法,点前面是什么,this就是什么。以上三种方式的this分别是arr,arr__proto__Array。prototype

如下例子:

let arr = [10, 20, 30];
console.log(arr.hasOwnProperty('forEach')); //->false
 

简单来看,上面的代码是 验证forEach是否为arr对象的私有属性

那么整个的执行过程实际上是:

arr按照原型链查找机制,找到的是Object.prototype.hasOwnProperty方法「@A」,并且把找到的@A执行,注意:

  • @A方法中的this应该是arr「我们要操作的对象」
  • 传递的实参应该是forEach「我们要验证的属性」
    @A方法的作用是,验证“实参”是否为当前this的一个私有属性
    ,那么最终同等效果的执行方式为:===> Object.prototype.hasOwnProperty.call(arr,'forEach')

公有属性和私有属性的"相对"

另外需要注意的点:公有属性和私有属性是"相对"的。

例如相对于arr这个实例对象,Array.prototype这个对象里面的push等方法是各个arr实例的公有属性。而Array.prototype他自己本身就是一个对象,push在他这里就是他自己的私有方法,所以对象上的属性是共有还是私有,得要相对来说。当做公有属性,是相对于类的实例来说,当做私有属性是相对于自身来说

image。png

image。png

所以没有公有属性,私有属性这个严格的概念,而只是相对来说,当不同的角色有了不同的功能,最后才区分了公有,私有的概念。

这样明白以后,我们在写代码进行操作的时候,例如我们做一个字符串截取工作,那么我们可以直接查看String.prototype上有什么公有方法可以直接调用来使用,然后还可以顺着__proto__继续往上找公有方法,按照原型连一级级往上找,只要出现在原型链上的方法都可以使用。

例如我们一直往上找document.body实例的方法,发现其有事件相关的方法

image。png

这样我们就可以使用document.body.addEventListener,再例如dom操作都有的classList方法,用来操作class

image。png

都可以找到相关的方法

所以,整个JS就是基于面向对象思想构建的

加深理解的例子

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
 

image。png
注:new Fn()new Fn是一样的效果,只是前者优先级是20,可传参数,后者优先级是19,不传参数

分析:

f1和f2都如下,迷惑点在于,其私有属性和原型上都有getX函数,两个私有属性地址不一样,返回false,而原型是同一个对象,其中的getX函数是同一个地址

__proto__返回原型

image。png

image。png

重写内置new

function Dog(name) {
    this.name = name;
}
Dog.prototype.bark = function () {
    console.log('wangwang');
}
Dog.prototype.sayName = function () {
    console.log('my name is ' + this.name);
}
/*
let sanmao = new Dog('三毛');
sanmao.sayName();
sanmao.bark();
*/
function _new() {
    //=>完成你的代码   
}
let sanmao = _new(Dog, '三毛');
sanmao.bark(); //=>"wangwang"
sanmao.sayName(); //=>"my name is 三毛"
console.log(sanmao instanceof Dog); //=>true
 

new 关键字做了什么:

  1. 创建Ctor的一个实例对象(创建一个实例对象,并将实例对象的__proto__指向Ctor.prototype
  2. 把构造函数当做普通函数执行,并且让方法中的this指向实例对象
  3. 确认方法执行的返回值。如果没有返回值或者返回的是原始值,让其默认返回实例对象即可。

注:

  1. Ctor -> constructor缩写 构造函数

  2. params -> 后期给Ctor传递的所有的实参信息

function _new(Ctor, ...params) {
    // 1.创建Ctor的一个实例对象 
    // 实例.__proto__===Ctor.prototype
    let obj = {};//这里仅仅只创建了Object的一个实例对象 
    obj.__proto__ = Ctor.prototype;

    // 2.把构造函数当做普通函数执行「让方法中的THIS->实例对象」
    let result = Ctor.call(obj, ...params);

    // 3.确认方法执行的返回值「如果没有返回值或者返回的是原始值,我们让其默认返回实例对象即可」
    if (result !== null && /^(object|function)$/.test(typeof result)) return result;
    return obj;
} 
 

创建Ctor的一个实例对象 如果用

 let obj = {};//这里仅仅只创建了Object的一个实例对象 
    obj.__proto__ = Ctor.prototype;
 

上面这种方法不太好,下面说说新的方法

Object.create()

Object.create([obj]):创建一个空对象,并且让空对象.__proto__指向 obj

官方解释:把obj作为新实例对象的原型。

也这样解读:以obj为原型,创造一个新对象

注意:

  • 参数obj可以是一个对象或者是null,但是不能是其他的值
  • Object.create(null) 创建一个不具备__proto__属性的对象(不是任何类的实例)

重写Object.create()

Object._create = function create(prototype) {
    if (prototype !== null && typeof prototype !== "object") throw new TypeError('Object prototype may only be an Object or null');
    var Proxy = function Proxy() {}
    Proxy.prototype = prototype;
    return new Proxy;
};
 

效果:
image。png

需要注意的是,我们自己写的这个_create,在传入null的时候是无法消除__proto__

image。png

image。png
__proto__是没办法消除的,无法删除,并且直接指向Object

image。png

允许使用Object.create()时重写new

使用Object.create()可替代重写new的第一步

将变量前置声明,更加规范,并注意一些校验规则
image.png

function _new(Ctor, ...params) {
    let obj,
        result,
        proto = Ctor.prototype,
        ct = typeof Ctor;
    // 构造函数的校验规则
    //前提不能是Symbol或者BigInt,然后Ctor不是函数或者proto不存在(箭头函数)就抛出一个类型错误
    if (Ctor === Symbol || Ctor === BigInt || ct !== 'function' || !proto) throw new TypeError(`${Ctor} is not a constuctor!`);
    
    //符合规则后按逻辑来
    //1. 
    obj = Object.create(Ctor.prototype);
    //2. 
    result = Ctor.call(obj, ...params);
    //3. 
    if (result !== null && /^(object|function)$/.test(typeof result)) return result;
    return obj;
}
 

原型重定向

小tip:纯粹对象的概念
纯粹对象创建的是Object的实例,即纯粹对象.__proto__===Object.prototype

例如数组就不是纯粹对象 ,因为[].__proto__===Array.prototype
Array.prototype.__proto__===Object.prototype

例子理解原型重定向

function fun() {
    this.a = 0;
    this.b = function () {
        alert(this.a);
    }
}
fun.prototype = {
    b: function () {
        this.a = 20;
        alert(this.a);
    },
    c: function () {
        this.a = 30;
        alert(this.a)
    }
}
var my_fun = new fun();
my_fun.b();
my_fun.c();
 

上面比较简单的例子输出0,30

fun.prototype = {...}这个操作叫做原型重定向,重定向后,原来的带constructor的原型在空闲的时候会被浏览器释放。

应用场景:原型重定向可以批量得给构造函数的原型上扩充属性/方法

let proto = fun.prototype;
proto.b = function () {};
// ...
proto.c = function () {}; 
 

我们也可以逐一向原型对象上扩充属性和方法,但是有一些缺点:

  • 麻烦:fun.prototype操作起来一些麻烦(可以起一个小名)
  • 不聚焦:向原型上扩充方法的代码可能会分散开,这样不利于维护

重定向可以解决这些问题,有点借助了单例模式的思想

fun.prototype = {
    b: function () {},
    c: function () {}
};
 

这样也会有问题,重定向之后,原始浏览器开辟的原型对象可能会被释放掉。这样导致原始原型对象上的属性和方法可能会被清除(包含constructor)

解决方法:

  1. 如果我们知晓原始原型对象上除了constructor没有其他属性和方法,我们只要在在重定向的原型对象上自己手动设置constructor即可
  2. 如果我们确定或者不确定原始对象上是否存在其余的属性和方法,我们把原始的原型对象上的内容copy一份,重新赋值给新的原型对象(涉及到两个对象的合并)

使用Object.assign扩展原型

Object。assign([obj1],[obj2],...):两个或者多个对象进行浅合并「右侧属性替换左侧」
注意:并没有返回全新的一个合并后的对象,返回的值依然是obj1这个堆,只是把obj2中的内容都合并到obj1中

let obj1 = {
    x: 10,
    y: 20,
    getX: function () {}
};
let obj2 = {
    x: 100,
    getY: function () {}
};
console.log(Object.assign(obj1, obj2));
console.log(Object.assign(obj1, obj2)===obj1);
console.log(Object.assign({}, obj1, obj2))
console.log(Object.assign({}, obj1, obj2)===obj1)
 

image。png

function fun() {}
fun.prototype.x = 100;
fun.prototype.getX = function () {};
fun.prototype = Object.assign(fun.prototype, {
    b: function () {},
    c: function () {}
});
 

这样并没有重定向原型指向,并且可以扩展fun.prototype

image。png

内置类原型扩充

为了避免内置的属性方法被覆盖丢失,内置类的原型是不允许重定向的,Array.prototype = {}不报错,但是没有任何的效果

虽然不允许重定向,但是可以把内置的原型上的某些内置方法进行单一的重写。例如Array.prototype.push = function (){console.log('哈哈')}就会重写push

内置类的原型上,虽然提供很多供其实例调取的属性和方法,但是不一定能完全满足我们的需求,此时我们需要自己向内置类的原型上扩充方法

  • 好处:
    • 使用起来方便「实例.方法」;
    • 可以实现链式写法「核心:函数执行的返回值如果是某个类的实例,则可以直接继续调用这个类原型上的其他方法」;
  • 弊端:自己写的方法容易覆盖内置的方法「所以起名字最好设置前缀,例如:myXxx」

Array.prototype上扩充数组去重方法

Array.prototype.unique = function unique() {
    // this -> arr 一般都是当前要操作的实例「无需传递要处理的数组,因为this存储的值就是」
    return Array.from(new Set(this));
};
let arr = [1, 2, 3, 4, 2, 3, 4, 5, 6, 2, 3, 4, 5, 6, 7, 2, 2, 3, 4, 6, 7];
console.log(arr.unique());
let result = arr.unique()
console.log(result); 
console.log(arr); //原数组没变
 

也可以链式调用

let result = arr.unique().sort((a, b) => b - a);
console.log(result);
 

其中this一般都是当前要操作的实例,无需传递要处理的数组,因为this存储的值就是实例
自定义的方法就要传入arr

const unique = function unique(arr) {
    return Array.from(new Set(arr));
};
console.log(unique(arr));
 

Number.prototyp扩充方法

(function (proto) {
    const verification = function verification(num) {
        num = +num;//将其转换为数字类型
        return isNaN(num) ? 0 : num;//判断是否为NaN
    };

    const plus = function plus(num) {
        // this -> 要操作的数字{对象类型}
        let self = this;
        num = verification(num);
        return self + num;
    };

    const minus = function minus(num) {
        // this -> 要操作的数字{对象类型}
        let self = this;
        num = verification(num);
        return self - num;
    };

    proto.plus = plus;
    proto.minus = minus;
})(Number.prototype);

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

注意:运行到let m = n.plus(10).minus(5);这句话是,浏览器会把Number类型的原始值进行'装箱',将其变为对象类型的值,然后就可以调用原型上的方法了

回复

我来回复
  • 暂无回复内容