前言
小伙伴们,大家好,今天我们来说一下Javascript中创建对象的几种方式,请看文章。
使用Object构造函数&使用对象字面量
首先,我们来看第一种创建单个对象的方法:使用new操作符后跟Object构造函数,请看演示代码:
/*使用Object构造函数*/ let obj = new Object(); obj.name = "shipudong"; obj.age = 22; obj.sayHello = function () { console.log(`Hello,sir,my name is ${this.name},i am ${this.age} years old`); } console.log(obj)
接下来我们再来看第二种方法:使用对象字面量,对象字面量是对象定义的一种简写方式,目的在于简化创建包含大量属性的对象的过程。我们来看上述代码的等价写法,请看演示代码:
/*使用对象字面量*/ let obj = { "name":"shipudong", "age":22, sayHello:function () { console.log(`Hello,sir,my name is ${this.name},i am ${this.age} years old`) } }
我们在平时的学习或开发中,一般会选择通过字面量来定义一个对象,但是这两种方法并非没有不足之处,虽然Object构造函数或对象字面量都可以用来创建单个的对象,但是这些方式有个明显的缺点:使用同一个接口创建很多对象,会产生大量的重复代码,因此为了解决这个问题,我们衍生出了下一部分将要介绍的工厂模式。
工厂模式
学过设计模式的小伙伴肯定对这个不陌生吧,该种模式将具体创建对象的过程抽象起来,请看演示代码:
/*工厂模式*/ function childInfo(name,sex,age) { var obj = newObject(); obj.name = name; obj.sex = sex; obj.age = age; obj.sayHello = function () { console.log(`This is my child ${this.name},${this.age} years old,a very handsome ${this.sex}.`) } return obj; } let child = childInfo("hahaCoder","boy",3) console.log(child)
上述代码中,ChildInfo()函数能够根据接受的三个参数来构建一个包含所有必要信息(姓名、性别、年龄)的child对象,我们可以无数次的调用这个函数,而它每次都会返回一个包含三个属性和一个方法的对象。
工厂模式虽然解决了创建多个相似对象的问题,但是对于对象识别的问题,却无能为力,我们在上述代码中添加如下代码:
/*工厂模式---补充代码*/ console.log(child.constructor) console.log(child instanceof childInfo) console.log(child instanceof Object)
对象的constructor属性最初是用来标识对象类型的,但是提到检测对象类型,还是instanceof操作符更可靠一些。
工厂模式下,childInfo()被当作成一个普通函数来调用,其返回的实例均是Object的实例,因此无法确定每一个对象的具体类型,故当通过工厂模式生成多个实例时,这种方法的缺点就被暴露出来了,为了解决这种对象识别的问题,新的模式又出现了,请看下文。
构造函数
我们都知道ES中的构造函数可以用来创建特定类型的对象,像是Object和Array这样的原生构造函数,当代码运行时,均会自动出现在执行环境中,除了这些原生的构造函数,我们当然也可以创建自定义的构造函数,我们来将工厂模式中的代码重写一下,请看演示代码:
/*构造函数模式*/ function ChildInfo(name,sex,age) { this.name = name; this.sex = sex; this.age = age; this.sayHello = function () { console.log(`This is my child ${this.name},${this.age} years old,a very clever ${this.sex}.`) } } let child1 = new ChildInfo("shipudong","boy",22) let child2 = new ChildInfo("wangluyao","girl",18)
对比构造函数模式的代码和工厂模式的代码,我们发现以下几点不同之处:
- 没有显示地创建对象;
- 直接将属性和方法赋给了this对象;
- 没有return语句;
还有一个特别小的点,不知道小伙伴发现没有,工厂模式中函数名为childInfo,构造函数模式中函数名为ChildInfo,即首字母大写。
我们来说一下使用new操作符创建ChildInfo新实例要经过的几个步骤:
- 创建一个新对象;
- 将构造函数的作用域赋给新的对象(因此this就指向了这个新对象);
- 执行构造函数中的代码(为新对象添加属性);
- 返回一个新对象
接下来,回到我们最为关心的问题,小伙伴还记得工厂模式的缺点吗?对,就是无法识别对象,我们来看看构造函数模式是如何解决这种bug的,请看代码:
/*构造函数模式---补充代码*/ console.log(child1.constructor) console.log(child1.constructor == child2.constructor) console.log(child1 instanceof ChildInfo) console.log(child1 instanceofObject)
但是构造函数并非没有自己的缺点,我们从代码中可以观察到,虽然构造函数模式完美解决了对象识别的问题,但是每个方法都要在每个实例上重新创建一遍,请看以下代码:
/*构造函数模式---补充代码*/ console.log(child1.sayHello == child2.sayHello) // false
我们发现,child1和child2都有一个名为sayHello()的方法,但是这两个方法不是同一个Function的实例。当我们通过构造函数模式去创建对象时,会导致不同的作用域链和标识符解析,但创建Function新实例的机制是相同的,故不同实例上的同名函数是不相等的。
然而,为了解决这个问题,我们大可不必创建两个完成同样任务的Function的实例,这样会显的代码很冗余,请看解决方法:
/*构造函数模式plus*/ function ChildInfo(name,sex,age) { this.name = name; this.sex = sex; this.age = age; this.sayHello = sayHello; } function sayHello(){ console.log(this.name) } let child1 = new ChildInfo("shipudong","boy",22) let child2 = new ChildInfo("wangluyao","girl",18)
我们将sayHello()函数的定义转移到构造函数外部,在构造函数内部,我们将sayHello属性设置成等于全局的sayHello函数,这样一来,由于sayHello包含的是一个指向函数的指针,因此child1和child2对象就共享了全局作用域中的定义的同一个sayHello函数。
但是新的问题又出现了,正所谓一波刚平一波又起:我们在全局作用域中定义的函数实际上只能被某个对象调用,这有点委屈全局作用域了,更令人恶心的是,如果需要定义很多个方法,我们就必须定义很多个全局函数,那我们自定义的引用类型就毫无封装性可言了,因此为了解决这个问题,新的模式又出现了,请看下文。
原型模式
我们知道,每个函数都有一个prototype属性,它指向函数的原型对象,原型对象的用途是包含可以由特定类型的所有实例共享的属性和方法。所以我们可以得出结论:使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法,请看演示代码:
/*原型模式*/ function ChildInfo(name,sex,age) { } ChildInfo.prototype.name = "shipudong"; ChildInfo.prototype.sex = "boy"; ChildInfo.prototype.age = 22; ChildInfo.prototype.sayHello = function () { console.log(`This is my child ${this.name},${this.age} years old,a very handsome ${this.sex}.`) } let child1 = new ChildInfo() child1.sayHello() let child2 = new ChildInfo() console.log(child1.sayHello == child2.sayHello)
请看关系图:
小伙伴有没有发现,上述例子中的代码,我们每添加一个属性或者方法就要敲一遍ChildInfo.prototype,这也太麻烦了,小手都敲疼了,所以我们一般在开发中会用一个包含所有属性和方法的对象字面量来重写整个原型对象,请看修改之后的代码:
/*原型模式plus*/ ChildInfo.prototype = { name : "shipudong", sex : "boy", age : 22, sayHello : function () { console.log(`This is my child ${this.name},${this.age} years old,a very clever ${this.sex}.`) } }
上述代码中,我们实际上是重写了默认的prototype对象,故当我们想要获取原型对象的constructor属性时,其指向早已不是ChildInfo,请看演示代码:
/*原型模式plus*/ console.log(ChildInfo.prototype.constructor == ChildInfo) //false
因此,如果constructor的值真的很重要,我们可以像下面这样特意将它设置回合适的值,请看演示代码:
ChildInfo.prototype = { constructor:ChildInfo, name : "shipudong", sex : "boy", age : 22, sayHello : function () { console.log(`This is my child ${this.name},${this.age} years old,a very clever ${this.sex}.`) } }
原型模式中我们将方法定义在原型对象上,非常有效的解决了上文中构造函数所衍生出的问题,然而原型模式也并非没有缺点:首先,它省略了为构造函数传递初始化参数这一环节,结果所有实例默认情况下都将取得相同的属性值;其次,由于原型中所有属性是被很多实例共享的,这种共享对于函数非常合适,对于那些包含基本值的属性也说得过去,毕竟通过在实例上添加一个属性,可以覆盖掉原型中对应的属性,然而,对于包含引用类型的值来说,问题就比较突出了,请看演示代码:
function ChildInfo() { } ChildInfo.prototype = { constructor:ChildInfo, name : "shipudong", sex : "boy", age : 22, hobby:["wangluyao","coding","fitness"], sayHello : function () { console.log(`This is my child ${this.name},${this.age} years old,a very clever ${this.sex}.`) } } let child1 = new ChildInfo() let child2 = new ChildInfo() child1.hobby.push("basketball") console.log(child1.hobby) console.log(child2.hobby) console.log(child1.hobby == child2.hobby)
上述代码中,ChildInfo.prototype对象有一个名为hobby的属性,该属性包含一个字符串数组,然后,我们创建了ChildInfo的两个实例child1和child2,接着,我们修改了child1.hobby引用的数组,向数组中添加了一个字符串;由于hobby数组存在于ChildInfo.prototype而非child1中,所以所做的修改也会通过child2.hobby(与child1.hobby指向同一个数组)反映出来。
假如我们的初衷就是像这样在所有实例中共享一个数组,那么对这个结果我没有话可说,然而,实例一般都是要有属于自己的全部属性的。
为了解决原型模式中的两个问题(无法传递参数、对于引用类型的值不太友好),新的模式又出现了,请看下文。
组合使用构造函数模式和原型模式
创建自定义类型的最常见方式,就是组合使用构造函数模式与原型模式。
构造函数用于定义实例属性,而原型模式用于定义方法和共享的属性。结果,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方法的引用,最大限度地节省了内存。另外,这种混成模式还支持向构造函数传递参数,可谓是集两种模式之长,请看演示代码:
function ChildInfo(name,sex,age) { this.name = name; this.sex = sex; this.age = age; this.hobby = ["wangluyao","coding","fitness"]; } ChildInfo.prototype = { constructor:ChildInfo, sayHello : function () { console.log(`This is my child ${this.name},${this.age} years old,a very clever ${this.sex}.`) } } let child1 = new ChildInfo("shipudong","boy",22) let child2 = new ChildInfo("wangluyao","girl",23) child1.hobby.push("basketball") console.log(child1.hobby) console.log(child2.hobby) console.log(child1.hobby == child2.hobby) console.log(child1.sayHello == child2.sayHello)
动态原型
有其他面向对象语言开发经验的小伙伴可能在看到独立的构造函数和原型时,会有一丝丝诧异。动态原型模式正是致力于解决这个问题的一个方案,它把所有信息都封装在了一个构造函数中,而通过在构造函数中初始化原型(仅在必要情况下),又保持了同时使用构造函数和原型的优点,换句话说,可以通过检查某个应该存在的方法是否有效,来决定是否需要初始化原型,请看演示代码:
function ChildInfo(name,sex,age) { this.name = name; this.sex = sex; this.age = age; if (typeofthis.sayHello != "function"){ ChildInfo.prototype.sayHello = function () { console.log(this.name); } } } let child = new ChildInfo("shipudong","boy",22) child.sayHello()
上述代码中,只有在sayHello()方法不存在的情况下,才会将它添加到原型中,这段代码只会在初次调用构造函数时才会执行。此后,原型已经完成初始化,不需要在做什么修改了。值得提醒的一点,这里对原型所做的修改,能够立即在所有实例中得到反映,因此这种方法,可谓十分完美。
寄生构造函数模式
如果给出的业务场景以上几种模式都不适用,我们可以使用寄生构造函数模式,这种模式的基本思想是创建一个函数,该函数的作用仅仅是封装创建对象的代码,然后在返回新创建的对象,请看代码:
function ChildInfo(name,sex,age) { var obj = newObject(); obj.name = name; obj.sex = sex; obj.age = age; obj.sayName = function () { console.log(this.name); } return obj; } let child = new ChildInfo("shipudong","boy",22) console.log(child) child.sayName()
上述代码中,ChildInfo函数创建了一个新对象,并以相应的属性和方法初始化该对象,然后返回了这个对象。除了使用new操作符并把使用的包装函数叫做构造函数之外,这个模式跟工厂模式是一摸一样的。
有的小伙伴可能会问了,这种模式有应用场景吗?当然有,要相信,鸟大了什么林子都会有,比如给出如下业务:我们想要创建一个具有额外方法的数组,即数组元素之间通过 --@hahaCoder@-- 进行连接,请看演示代码:
function SpecialArray() { //创建数组 let arr_values = newArray(); //向数组中添加值 arr_values.push.apply(arr_values,arguments) //添加方法 arr_values.SpecialMethod = function () { returnthis.join("--@hahaCoder@--"); } return arr_values; } let names = new SpecialArray("shipudong","wangluyao","zhenghehuizi","computer vision","natural language process"); console.log(names.SpecialMethod());
关于寄生构造函数模式,有一点要说明:首先,返回的对象与构造函数或者与构造函数的原型属性之间没有关系;也就是说,构造函数返回的对象与在构造函数内部创建的对象没有什么不同。因此,不能依赖instanceof操作符来确定对象类型。由于存在上述问题,我们建议在可以使用其他模式的情况下,不要使用这种模式。
稳妥构造函数模式
哇,终于到最后一种模式了,好开心呀~
所谓稳妥对象,指的是没有公共属性,而且其方法也不引用this的对象。稳妥对象最适合在一些安全的环境中(这些环境中会禁止使用this和new),或者防止数据被其他应用程序改动时使用。
稳妥构造函数遵循与寄生构造函数类似的模式,但有两点不同:一是新创建对象的实例方法不引用this;二是不使用new操作符调用构造函数。
请看演示代码:
function ChildInfo(name,sex,age) { var obj = newObject(); //可以在这里定义私有变量和函数 //添加方法 obj.sayName = function () { console.log(name); } return obj; } let child = ChildInfo("shipudong","boy",22) child.sayName()
上述代码中,除了使用sayName()方法外,没有其他办法访问name的值,即在child中保存了一个最为稳妥的对象。
值得注意的一点,稳妥构造函数模式与寄生构造函数模式类似,使用稳妥构造函数模式创建的对象与构造函数之间也没有什么关系,因此instanceof操作符对这种对象没有什么意义。
写在文末
本文详细介绍了JavaScript中的8种创建对象的方式,并通过demo分析了各种模式的优缺点,小伙伴们学会了吗?快去实践一下吧!