JS基础——作用域、原型、闭包初探(下1——原型、原型链)

本文深入探讨JavaScript中对象创建及继承的各种模式,包括构造函数、原型链、组合继承等,并对比分析各种模式的优缺点。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

(文本源自“JavaScript高级程序设计”第六章的读后梳理,文末会有一些对这板块内容更深层次一点的思考)

第六章、面向对象的程序设计

(其实就是在将JavaScript中的对象创建以及继承,这里会涉及我们常说的原型以及原型链的概念)

一、理解对象 

对象是一组无序属性的集合,是一个key-value 键值对散列表。

这些属性可以是基本值、对象或函数。

对象的属性名会自动转换为字符串。

1、属性类型 

对象中有两种属性类型:数据属性、访问器属性。

每一个属性又有相应的特性,这些特性就是这一节要讨论的主要内容。

数据属性:

拥有四个特性:

  1. [[Configurable]]:属性是否可配置,即属性是否可以通过delete删除、是否能够修改特性、是否能够把数据属性设置为访问其属性;默认true。
  2. [[Enumable]]:属性是否可枚举,即是否可以通过for-in返回这个属性;默认true。
  3. [[Writable]]:属性值是否可以修改;默认true。
  4. [[Value]]:属性值;默认为undefined。 

这四个属性默认的值为:true、true、true、undefined。

想要修改属性的特性,就可以通过object.defineProperty(objectName,propertyName,{...})对相应的属性的特性进行修改设置。如:

objecct.defineProperty(book,"name",{
    configurable:false;
    enumerable:true;
    writable:false;
    value:"JavaScript Profession";
});

设定了book对象中,“year”属性的特性。

注意上面,设定了configurable为false之后,就不能再设置回true了,即再次调用object.defineProperty()只能修改writable特性,修改其他特性的值都会导致错误。

访问器属性:

拥有四个特性;

  1. [[Confugurable]]:同上。
  2. [[Enumerable]]:同上。
  3. [[Get]]:读取属性时调用的函数;默认为undefined。
  4. [[Set]]:写入属性时调用的函数;默认为undefined。

基本和前面将的数据属性差不多,不同的是这里的[[Get]]、[[Set]]分别是读取与写入属性时调用的函数,如:

//这里_year是一个访问器属性
var book = {
    _year:2004,
    edition;1
};

object.defineProperty(book,"year",{
    get:function(){
        return this._year;
    },
    set:function(newYear){
        if(newYear > 2004){
            this._year = newYear;
            this.edition +=newYear - 2004;
        }
    }
});

book.year = 2005;
alert(book.edition); //2

2、定义多个属性 

object.defineProperties()。。。直接上代码吧

object.defineProperties(book,{
    _year:{
        writable:true,
        value:2004
    },
    
    edition:{
        writable:true,
        value;1
    },

    year:{
        get:function(){
            return this._year;
        },
        
        set:function(newYear){
            if(newYear>2004){
                this._year = newYear;
                this.edition += newYear-2004;
            }
        }
    }
});

 3、读取属性的特性

object.getOwnPropertyDescriptor(),返回一个对象,对象包含着传入对象以及对象属性的特性。

object.getOwnPropertyDescriptor(book,"_year");

二、创建对象 

创建对象的方式——工厂模式,构造函数模式,原型模式,组合使用构造函数和原型模式,动态原型模式,寄生构造函数模式,稳妥构造函数模式。

1、工厂模式

function createPerson(name,age,job){
    var o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function(){
        alert(this.name);
    };
    
    return o;
}

person1 = createPerson("Nicholas",27,"software engineer");
person2 = createPerson("dingding",25,"software engineer");

这种方式将创建出一系列相同类型的对象,

缺点:无法确定创建的对象类型。

在实际中使用的情况较少。

2、构造函数模式 

function Person(name,age,job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = function(){
        alert(this.name);
    };
}

var person1 = new Person("Nicholas",27,"software engineer");
var person2 = new Person("dingding",25,"software engineer");

 一般函数名第一个字母大写,约定俗成的认为这是一个构造函数;

优点:这种方式解决了工厂模式的无法确定创建的对象是何种类型的弊端;

缺点:sayName()是一个方法,而方法在JavaScript中其实是对象,也就是每当我们创建一个Person类的实例时,就会创建一个sayName对象,这样对于内存来说是一个很大的浪费。为什么这样说,是因为sayName()对于所有的Person 类型的实例来说,都是一样的,但这里在用new操作符调用构造函数的时候,会在每一个实例中都重复的创建这个sayName()方法,这里做了重复的操作,而重复的操作导致了内存占用变多

person1.sayName == person2.sayName;    //false

实际中单独使用构造函数模式创建对象的方式是很少的。

3、原型模式

function Person(){}

Person.prototype.name ="Nicholas";
Person.prototype.age = 27;
Person.prototype.job = "software engineer";
Person.prototype.sayName = function(){
    alert(this.name);
}; 

person1 = new Person();
person2 = new Person();

person1.sayName == person2.sayName;    //true

 原型模式中的原型prototype与构造函数实例之间的关系可以用下面这个图表示:

每一个构造函数都有一个原型,原型中保存着构造函数的所有实例所共享的属性和方法。

这里有这样一些要点需要记住:

  1. 在使用过程中,对实例属性和方法的使用,其实是在引用原型中的属性和方法,这个引用过程其实是一个搜索的过程。它会先搜索实例中的属性和方法,如果没有再去搜索原型中的属性和方法
  2. 实例中的属性和方法,对原型中的有“屏蔽”效应。也就是,在创建了person1之后,我通过person1修改name、age、job的值,是不会影响到原型中的值。
    person1.name = "dingding";
    
    alert(person1.name);    //"dingding"
    alert(person1.name);    //"Nicholas"
    alert(Person.prototype.name);    //"Nicholas"
  3. 这个时候我们可能就要确定实例属性到底是存在于原型中,还是仅存在于实例中:

    1. hasOwnProperty(propertyName),这是前面讲到的每个Object类型都会有的实例方法(所有的类型的“父类”都是Object)。所以通过这个方法,可以检查到实例是否具有properName属性,并且这个只检测实例,不会检测原型

    2. if(prepertyName in person1){..},这个in,检测的是实例是否具有属性。如果实例中没有,就会去检索原型中是否有这个属性。原型和实例全方位检测

      (想一想,通过1、2方法的结合,是不是可以确定属性只存在于实例中呀)
    3. 三种循环遍历属性的方法

      1. for-in:所有实例中和原型中可枚举的属性;

      2. object.keys:实例中所有可枚举的属性;

      3. object.getOwnPropertyNames():所有实例中的属性(包括不可枚举的属性)

  4. 原型的动态性:

    var friend = new Person();
    
    Person.prototype.sayHi = function(){
        alert("hi");
    };
    
    friend.sayHi();    //"hi",没有问题

    因为在原型中查找值的过程是一次搜索。

  5. 原型的简写方式:

    function Person(){}
    
    Person.prototype = {
        constructor:Person,
        name : "Nicholas",
        age : 27,
        job : "software engineer",
        sayName : function(){
            alert(this.name);
        }
    };
    
    person1 = new Person();

    这里使用对象字面量的方式来定义的Person.prototype。我们知道对象字面量创建的是Object类型的实例,这个时候,Person.prototype的constructor指向的其实是Object,通过上面关于构造函数、原型、实例之间的关系图,我们知道原型的constructor是要指向构造函数的,所以在使用对象字面量的时候,记得将constuctor属性指向响应的构造函数(这里是Person)。

  6. 原生类型的原型:Object类型、Array类型、Date类型、RegExp类型、Function类型、基本包装类型(Boolean、Number、String)都具有相应的原型,但是不推荐去修改它们的原型属性

原型模式创建对象的优缺点:

优点:解决了在由于通过构造函数重复定义同一个方法的情况;

缺点:如果,原型中出现了引用类型的属性——var group = [“mike”,“Tom”,“jack”];。我们知道实例对于原型中的属性是具有屏蔽效应的,但是如果原型中出现了应用类型的属性,我们在实例中对该引用类型属性的修改会影响到原型中的数据,从而影响到其他实例。。

(所以在实际中很少单独使用原型模式)

function Person(){}

Person.prototype = {
    constructor:Person,
    name : "Nicholas",
    age : 27,
    job : "software engineer",
    group : ["Mike","Tom","Jack"],
    
    sayName:function(){
        alert(this.name);
    }
};

var person1 = new Person();
var person2 = new Person();

peron1.group.push("dingding");

Person.prototype.group.join();    //"Mike,Tom,Jack,dingding"
person2.group.join();    //"Mike,Tom,Jack,dingding"

 4、组合使用构造函数模式和原型模式

function Person(name,age,job){
    this.name = name;
    this.age =age;
    this.job = job;
    this.group = ["Mike","Tom","Jack"];
}

Person.prototype = {
    constructor:Person,
    sayName:function(){
        alert(this.name);
    }
};

var person1 = new Person();
var Person2 = new Person();

 这种组合方式,是将属性(基本类型、引用类型)放在构造函数中,而方法放在原型中。

person1和person2中的group[]都是构造函数中的一个副本,那么他们各自是不同的,互不影响。 

通过这种方式,既可以保持原型模式的优点,也可以避免原型模式的缺点。

在实际使过程中,使用的这种组合模式是很广泛的。

5、动态原型模式 

在构造函数中动态的判定是否需要创建某个原型方法。

function Person(name,age,job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.group = ["mike","Tom","Jack"];

    if(typeof this.sayName != "function"){
        Person.prototype.sayName = function(){
            alert(this.name);
        };
    }
}

var person1 = new Person("Nicholas",27,"software engineer");
person1.sayName();    //"Nicholas"

 这段代码只会在初次调用构造函数时才会执行。此后,原型已经完成初始化,不需要在做什么修改

这个对原型所做的修改,能够立即在所有实例中得到反映。

6、寄生构造函数模式

function Person(name.age,job){
    var o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function(){
        alert(this.name);
    };

    return o;
}

var person1 = new Person("Nicholas",27,"software engineer");
var person2 = new Person("dingding",22,"software engineer");

 这种构造函数和工厂模式的组合的,在实际中使用很少。

当某种情况下——比如我们需要创建一个对象,它具有原生Array的所有属性,但是这个对象还需要一些我们新定义的方法、属性,就可以使用这种方式创建对象。(但工厂模式和构造函数模式的弊端它都有)

除非是这种特殊情况,其余时候都是不推荐这种使用方式的。(组合方式它不香吗?)

7、稳妥模式(durable objects)

Douglas Crockford提出来的,没有公共属性,其方法也不引用this的对象。

稳妥对象适合在一些安全环境中(禁止this和new)或者防数据被其他应用程序改动时使用。

(其实就是寄生构造函数,但是不引用this)

function Person(name,age,job){
    
    //创建要返回的对象
    var o = new Object();
   
    //定义私有变量和函数

    //添加方法
    o.sayName = function(){
        alert(name);
    };

    //返回对象
    return o;
}

三、继承

创建了对象,我们就可以通过继承的方式获得他们的行为属性。

原型链、借用构造函数、组合式继承、原型式继承、寄生式继承、寄生组合式继承。

 1、原型链

JavaScript中没有函数签名。所以就不能实现接口继承,只支持实现继承

JavaScript中没有传统面向对象的类和接口的基本结构,所以没有类似于java语言中的extends 来实现继承,但JavaScript中,可以通过原型链来使“子类”继承“父类”的行为属性。

具体的方法,就是通过将“父类”的实例 赋值给 “子类”原型。

看图:

function Suptype(){}

function Subtype(){}

Subtype.prototype = new Suptype();

通过这种原型链的方式来实现继承。

缺点:我们将Suptype的实例赋给了Subtype 的原型,那么如果Suptype实例从Suptype构造函数中得到了一个引用类型的属性,那么Subtype的原型中是不是就会有这么一个引用类型属性,,,这里问题就来了,所有的Subtype实例都会共享原型中的引用类型,,,问题也就是前面单独使用原型模式创建对象中提到的由于“共享”导致的问题

2、借用构造函数

function Suptype(){}

function Subtype(name){
    Suptype.call(this,"Nicholas");
}

 通过call方法,在Subtype构造函数中运行Suptype构造函数,那么Subtype的实例就会有Suptype类型的属性。

(秀啊)

缺点:

  1. Suptype原型中的东西,Subtype是没有得到的;
  2. 想要Subtype继承到Suptype类型的方法,那么就要在Suptype构造函数中定义方法,那么这个是不是又出现了单独使用构造函数创建对象中函数复用的问题呢?

3、组合式继承

也就是组合原型链和借用构造函数。

function Suptype(){}

function Subtype(name){
    Suptype.call(this,"Nicholas");
}

Subtype.prototype = new Suptype();
Subtype.prototype.constructor = Subtype;

这个修复了前两种继承方式带来的问题。具体怎么修复的呢?

两种情况一同发生,Subtype构造函数得到了Suptype构造函数的“东西”,Subtype.prototype是Suptype的实例。 

这个时候我们假设在Suptype构造函数中有引用类型属性A,那么Subtype原型中就会有这个引用类型属性A(因为Subtype原型其实就是Suptype的实例),这是问题最开始出现的地方。

这个时候,Subtype 的实例会不会因为Subtype原型中有这个引用类型属性A,而因为“共享”,产生问题呢?

答案是不会产生引用类型“共享”问题。

因为这个时候Subtype构造函数借用了Suptype的构造函数,所以Subtype构造函数中也有引用类型属性A,那么Subtype的实例就会通过构造函数创建这么一个引用类型属性A的副本。

这个副本就和原型以及构造函数中的引用类型属性A指向就不一样了呀,它是一个副本,修改它,就不会影响到原型,从而不会“共享”,不会引发问题。

(subtype1有自己的引用类型属性A副本,subtype2有自己的引用类型A副本)

优点:实现了原型链、借用构造函数的优点,还避免了他们各自的缺点。

缺点:有心的同学应该已经发现了——Subtype原型继承了Suptype的实例,Suptype实例里面包含Suptype构造函数中的属性以及Suptype.prototype(原型)中的方法。在这里我们的Subtype构造函数其实已经将Suptype构造函数中的属性继承了过来,也就是Subtype构造函数中定义的属性,Subtype.prototype(原型)中也定义了一次。这样造成了重复。在上面代码中我们也能看到Suptype的构造函数被引用了两次,用Suptype的构造函数创建了两次实例,这是浪费。

(怎么解决这个问题——寄生构造函数)

4、原型式继承

原型式继承和下一个点要讲到的寄生式继承都是我们最后要讲到的寄生构造函数继承的铺垫。

 原型式继承也是Douglas Crackford提出来的一种继承方式:

function object(o){
    function F(){}
    F.prototype = o;
    return new F();
}

向object()方法传入我们要操作的对象,返回的一个以我们要做的对象为原型的对象。

这里执行的其实是一个浅复制(下去搞明白浅复制)

ES5规范了这个方法,通过obejct.create(),可以是实现这个方法。object.create()接收两个参数:第一个是要操作的对象o,第二个是定义 要为对象o 添加的新属性的对象——类似于object.defineProperties()中的第二个参数。

5、寄生式继承

寄生式继承是在原型式继承的基础上来的:

function createClone(o){
    var clone = object(o);
    
    //以某种方式强化clone对象
    clone.sayName = function(){
        alert(this.name);
    };

    return clone;
}

 说简单一点就是寄生构造函数创建对象原型式继承的结合——在一个createClone()中返回一个对象,这个对象是通过object()方法返回的对象,并且在reateClone()中添加了额外的属性以增强这个对象。

6、寄生构造函数继承

正如组合式继承提到的,引用了两次Suptype的构造函数,浪费了性能。那有没有办法解决呢?

有,寄生构造函数继承。

分析一下前面组合式继承,第一次引用构造函数是在Subtype构造函数中运行Suptype构造函数,这样获得了Suptype构造函数的属性;第二次引用是将Suptype的实例赋给Subtype的原型。

这里重复的是什么呢?

 Suptype构造函数中的属性,它已经被Subtype构造函数获得,然后再一次通过Suptype的实例传递给了Subtype的原型。

在这里,其实这是原型链的副作用。我们Subtype的原型想要的其实只是Suptype原型中的方法,Subtype构造函数中的属性已经被Subtype的构造函数得到。

那么直接把Suptype的原型,给Subtype的原型不就好了!

这里通过一个方法完成这一操作,方法的思路其实就是寄生式继承的思路:

function inheritPrototype(Sup,Sub){
    var clone = object(Sub.prototype);
    
    clone.constructor = Subtype;
    Sub.prototype = clone;
}

function Suptype(){}

function Subtype(name){
    Suptype.call(this,"Nicholas");
}

//Subtype.prototype = new Suptype();
inheritPrototype(Suptype,Subtype);

这样,就只调用了一次Suptype构造函数,并且完成和组合式继承一样的效果。

(我这里有个问题就是为什么不直接使用Subtype.prototype=Suptype.prototype,而是使用inheritPrototype()方法来实现这一过程,下去还要研究一下)

附:更进一步的探索

  1. instanceof的底层实现原理,手动实现一个instanceof
  2. 至少说出一种开源项目(如Node)中应用原型继承的案例
  3. 描述new一个对象的详细过程,手动实现一个new操作符
  4. 理解es6 class构造以及继承的底层实现原理
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值