JavaScript对象需要模拟类吗?
早期的JavaScript程序员一般都有过使用JavaScript"模拟面向对象"的经历,因为JavaScript实现面向对象的方式和主流派不太一样,所以才让人产生误会,JavaScript本身就是面向对象,所以它并不需要模拟.
随着我们理解的思路继续深入,这些"模拟面向对象",实际上做的事情就是"模拟基于类的面向对象".因为一些公司政治原因,JavaScript推出时,管理层要求模仿java,所以创始人Brendan Eich 在原型运行时的基础上引入了new/this等语言特性,使之看起来更像java,而java正是基于类的面向对象的代表语言之一. 但是JavaScript这样的半吊子模拟,缺少了继承等关键字,导致大家试图对它进行修补,进而产生了种种互不相容的解决方案.
从ES6开始,JavaScript提供了class关键字来定义类,尽管这样的方案仍然是基于原型运动的模拟,但是它修正了之前的一些常见的坑,统一了社区的方案,这对语言的发展有着非常大的好处.
"基于类"并非面向对象的唯一形态,如果我们把视线从"类"移开,Brendan当年选择的原型系统,就是一个非常优秀的抽象对象的形式.
什么是原型?
原型是顺应人类自然思维的产物,中文中有个成语叫"照猫画虎",这里的猫看起来就是虎的原型.所以由此我们可以看出,用原型来描述对象的方法可以说是古已有之.
"基于类"的编程提倡使用一个关注分类和类之间的关系来发模型,在这类语言中,总是先有类,再从类去实例化一个对象,类与类之间又可能会形成继承/组合等关系,类又往往与语言的类型系统整合,形成一定编译时的能力.
与此相对,"基于原型"的编程看起来更为提倡程序员去关注一系列对象实例的行为,而后才去关心如何将这些对象划分到最近的使用方式相似的原型对象,而不是将它们分成类.
基于原型的面向对象系统通过"复制"的方式来创建新对象.一些语言的实现中,还允许复制一个空对象,这实际上就是创建一个全新的对象.基于原型和基于类都能够满足基本的复用和抽象需求,但是适用的场景不太相同.
这就像专业人士可能喜欢在看到老虎的时候,喜欢用猫科豹属豹亚种来描述它,但是对一些不那么正式的场合,“大猫”可能更为接近直观的感受一些(插播一个冷知识:比起老虎来,美洲狮在历史上相当长时间都被划分为猫科猫属,所以性格也跟猫更相似,比较亲人)。
我们的JavaScript并非第一个使用原型的语言,在它之前,self/kevo等语言已经开始使用原型来描述对象了.在JavaScript之前,原型系统就更多与高动态语言配合,并且多数基于原型的语言提倡运行时的运行修改,
原型系统的"复制操作" 有两种实现思路:
- 一个是并不真的去复制一个原型对象,而是使得新对象持有一个原型的引用; 2.切实地复制对象,从此两个对象再无关联;
历史上基于原型语言因此产生两个流派,显然,JavaScript选择了前一种.
JavaScript的原型
如果抛开JavaScript用于模拟Java类复杂语法设施(如newfunction/Object函数等),原型系统可以说相当简单,可以用两条概括: 1.如果所有对象都有私有字段[[prototype]],就是对象原型; 2.读一个属性,如果对象本身没有,则会继续访问对象的原型,直到原型为空或者找到为止;
这个模型在ES的各个历史版本中并没有很大的改变,但从ES6以来,JavaScript提供了一系列内置函数,以便更为直接地访问操作原型,三个方法分别为: 1.Object.create根据指定的原型创建新对象,原型可以是null; 2.Object.getPrototypeOf 获得一个对象的原型; 3.Object.setPrototypeOf 设置一个对象原型;
利用这三个方法,我们可以完全抛开类的思维,利用原型来实现抽象和复用,代码如下:
var cat = {
say(){
console.log("meow~");
},
jump(){
console.log("jump");
}
}
var tiger = Object.create(cat, {
say:{
writable:true,
configurable:true,
enumerable:true,
value:function(){
console.log("roar!");
}
}
})
var anotherCat = Object.create(cat);
anotherCat.say();
var anotherTiger = Object.create(tiger);
anotherTiger.say();
这里代码创建了一个"猫"对象,又根据猫做了一些修改创建了虎,之后我们完全可以用Object.create来创建另外的猫和虎对象,我们可以通过"原始猫对象"和"原始虎对象"来控制所有猫和虎的行为.但是早期版本中,程序员只能通过Java风格的类接口来操作运行时.
早期版本中的类与原型
在早期版本的JavaScript中,"类"的定义是一个私有属性[[class]],语言标准为内置类型诸如Number/String/Date等指定了[[class]]属性,以表示它们的类,语言使用者唯一可以访问[[class]]属性的方式是Object.prototype.toString.代码如下:
var o = new Object;
var n = new Number;
var s = new String;
var b = new Boolean;
var d = new Date;
var arg = function(){ return arguments }();
var r = new RegExp;
var f = new Function;
var arr = new Array;
var e = new Error;
console.log([o, n, s, b, d, arg, r, f, arr, e].map(v => Object.prototype.toString.call(v)));
因此,在ES3和之前的版本,js中类的概念是相当弱的,它仅仅是运行时的一个字符串属性. 在ES5开始,[[class]]私有属性被Symbol.toStringTag代替,Object.prototype.toString的意义从命名上不再跟class相关,我们甚至可以自定义Object.prototype.toString的行为,以下代码展示了使用Symbol.toStringTag来自定义Object.prototype.toString的行为:
var o = { [Symbol.toStringTag]: "MyObject" }
console.log(o + "");
这里创建了一个新对象,并且给他唯一的一个属性Symbol.toStringTag,我们用字符串加法触发了Obkect.prototype.toString的调用,发下这个属性最终对Object.prototype.toString的结果产生了影响.
考虑到JavaScript语法中跟Java相似的部分,我们对类的讨论不能用"new 运算是针对构造器对象,而不是类"来试图回避.所以我们仍然要把new理解成JavaScript面向对象的一部分.
new运算接受一个构造器和一组调用参数,实际上做了几件事: 1.以构造器的prototype属性(注意与私有字段[[prototype]]的区分)为原型,创建新对象; 2.将this和调用参数传给构造器执行; 3.如果构造器返回的是对象,则返回,否则返回第一步创建的对象;
new 这样的行为,试图让函数在语法上跟类变得相似,但是,它客观上提供了两种方式,一是在构造器中添加属性,而是在构造器的prototype属性上添加属性,代码如下:
function c1(){
this.p1 = 1;
this.p2 = function(){
console.log(this.p1);
}
}
var o1 = new c1;
o1.p2();
function c2(){
}
c2.prototype.p1 = 1;
c2.prototype.p2 = function(){
console.log(this.p1);
}
var o2 = new c2;
o2.p2();
第一种方法是直接在构造器中修改this,给this添加属性. 第二种方法是修改构造器的prototype属性指向对象,它是从这个构造器造出来的所有对象原型 ;
ES6中的类
在ES6中引入了新特性,new 跟function搭配的怪异行为终于可以退休了(虽然运行时没有改变),ES6中引入了class关键字,并且在标准中删除了所有[[class]]相关私有属性描述,类的概念正式从属性升级成为语言的基础设施,一下代码是ES6中类的基本写法:
class Rectangle {
constructor(height, width) {
this.height = height;
this.width = width;
}
// Getter
get area() {
return this.calcArea();
}
// Method
calcArea() {
return this.height * this.width;
}
}
在现有的类语法中,getter/setter和method是兼容性最好的.数据成员最好写在构造器里面.
类的写法实际上也是有原型运行时来承载的,逻辑上JavaScript认为每个类是有共同原型的一组对象,类中定义的方法和属性则会被写在原型对象之上.此外,最重要的是,类提供了继承能力,代码如下:
class Rectangle {
constructor(height, width) {
this.height = height;
this.width = width;
}
// Getter
get area() {
return this.calcArea();
}
// Method
calcArea() {
return this.height * this.width;
}
}
比起早期的原型模拟方式,使用extends关键字自动设置了constructor,并且会自动调用父类的构造器函数,这是一种更少坑的设计.
总结:在新ES版本中,我们 不再需要模拟类了,我们有光明正大的新语法,而原型体系同时作为一种编程范式和运行时机制存在.我们可以自由选择原型或者类作为代码的抽象风格.
转载自:https://juejin.cn/post/7159529731245735944