JS中面向对象与其他高级语言面向对象的区别
其他高级语言面向对象
像Java、C#、C++这些高级程序语言的面向对象具有以下特征:
- 以类(class)为核心构建面向对象机制
- 类是更高层的抽象,对象是类的实例,可以把一切事物抽象为对象
- 具有继承、封装、多态的三个基本特征
JavaScript的面向对象
JavaScript语言中并没有像上述那些高级语言具有一套完整的面向对象机制。在ES6之前,JS还没有真正的class关键字(目前有了仍旧是语法糖),所以在ES6之前的那段时期,js严格的说是类(类似)面向对象或者是基于面向对象的或者说是基于原型的语言。虽然说不是传统的面向对象,但是我们仍然可以使用一些方法去实现类似的传统面向对象中”继承“、”封装“、”多态“的特征。
引入MDN中的一段描述:
“经典”的面向对象的语言并非好事,就像上面提到的,OOP 可能很快就变得非常复杂,JavaScript 找到了在不变的特别复杂的情况下利用面向对象的优点的方法。
后文我们以ES5标准书写JavaScript代码来阐述“JavaScript中面向对象与继承”的问题,因为使用ES5才能让这个问题的探讨过程变得有意义。
后续将由浅入深一步步引出相关的知识,形成清晰地知识结构体系,从面向对象引出构造函数,从构造函数引出原型,从原型引出原型链,从原型链引出继承。
JavaScript中创建对象的几种方法
js中的对象实际上非常“轻量”,简单来说就是一组无序的键值对集合,属性值可以是值类型、引用类型(包含方法)。
1.使用New Object()构造函数创建对象
var person = new Object();
person.age = 18;
person.name = 'Bob';
// 不要使用箭头函数 会导致this指向Window
person.sayHello = function() {
console.log(this.name + ' ' + this.age);
}
console.log(person); // {age: 18, name: "Bob", sayHello: ƒ}
console.log(person.name); // Bob
person.sayHello();// Bob 18
最原始的创建对象的方法,使用new Object()创建Object实例,然后分别添加对应的属性和方法。
2.使用字面量写法快速创建对象
var person = {
age: 18,
name: 'Bob',
sayHello: function () {
console.log(this.name + ' ' + this.age);
}
};
console.log(person); // {age: 18, name: "Bob", sayHello: ƒ}
console.log(person.name); // Bob
person.sayHello(); // Bob 18
日常开发中创建对象最常用的方法,使用字面量方式可以帮助我们快速创建临时对象。
此时有这样一个问题,假设说我们需要定义多个person对象,但是每个person有其独有的属性,比如自己的姓名、自己的年龄,那么如果使用上述两种方法去实现,就需要不断地去编写重复的代码,就会出现大量的代码冗余。于是,我们可以使用工厂函数把功能做封装,该工厂的单一职责就是帮助我们创建对象。
3.使用工厂函数创建对象
function createObjectFactory(name, age) {
var o = new Object();
o.name = name;
o.age = age;
o.sayHello = function () {
console.log(this.name + ' ' + this.age);
}
return o;
}
var p1 = createObjectFactory('Bob', 19);
var p2 = createObjectFactory('Tom', 20);
var p3 = createObjectFactory('Jane', 21);
console.log(p1);// {name: "Bob", age: 19, sayHello: ƒ}
console.log(p2);// {name: "Tom", age: 20, sayHello: ƒ}
console.log(p3);// {name: "Jane", age: 21, sayHello: ƒ}
p1.sayHello();// Bob 19
这样我们就可以使用createObjectFactory()去创建对象,既实现了封装又支持很好的扩展,假设说我们的person对象未来要添加gender属性,那么只需要在工厂函数的实现上做处理即可。目前看起来是一种比较好的创建对象方式。我们思考这样一个问题:p1、p2、p3是真正的person对象吗?
- console.log(p1 instanceof Object); // true
显然,p1还是Object类的实例,只是我们把它当做了person对象而已。所以说工厂模式创建对象最大的问题就是“无法明确具体抽象的类(如Student或者Teacher),通过Factory创建的对象都属于Object基类的对象”。
那么我们就需要使用其他方法解决此问题,那就是ES6之前标准的创建对象的方式-使用构造函数。
4.使用构造函数创建对象
function Person(name, age) {
this.name = name;
this.age = age;
this.sayHello = function () {
console.log(this.name + ' ' + this.age);
};
};
console.log(Person instanceof Function); // true
var p1 = new Person('Bob', 19);
var p2 = new Person('Tom', 20);
p1.sayHello(); // Bob 19
p2.sayHello(); // Tom 20
console.log(p1 instanceof Person); // true
console.log(p2 instanceof Object); // true
关于构造函数应该并不陌生,在Java、Python、C#、C++中都存在。在JS中使用构造函数定义类,使我们的代码编写看起来更加规范,同时也更加的接近传统面向对象语言的书写方式。
需要注意以下几点:
- 构造函数归根到底扔是一个Function类的实例
- 构造函数无需手动return
- 构造函数定义时建议使用Pascal命名规则以区分普通函数
- 创建对象时使用new关键字
- 使用类的构造函数创建的对象属于该类的实例
- 每个对象都有一个constructor属性(具体来自哪里我们后文会进行说明)
JavaScript中的原型
首先,我们思考这样几个问题:
- 在上述构造函数创建对象的代码块中创建的p1和p2,两个对象的sayHello方法是否指向同一地址?
- 如果两个function为不同的地址,那么是否有必要这样做?
- 如果我们要实现一个通用的日期格式化方法dateFormat,那么这个方法定义在哪里最合适?
console.log(p1.sayHello === p2.sayHello) // false
显然,p1的sayHello和p2的sayHello并不是同一个function,但是两个不同的sayHello方法要实现的功能是完全一样的:输出对应实例的姓名和年龄。
当我们实例化了多个person对象时,就会出现以下类似情况:
可以看出每一个person对象都会重新创建一个新的sayHello方法,但是这些方法实现的功能是完全一样的。所以说当我们创建了大量的对象时,在Heap上分配了大量的空间存储了同样的东西,这显然不是特别友好。那么我们只需一个通用的sayHello方法即可,但是此时这个Person类所有的实例共享的function应该放在哪里合适?
我们改写代码片变成如下形式:
var sayHello = function () {
console.log(this.name + ' ' + this.age);
};
function Person(name, age) {
this.name = name;
this.age = age;
this.sayHello = sayHello;
}
var p1 = new Person('Bob', 19);
var p2 = new Person('Tom', 20);
p1.sayHello(); // Bob 19
p2.sayHello(); // Tom 20
console.log(p1 instanceof Person); // true
console.log(p2 instanceof Object); // true
console.log(p1.sayHello === p2.sayHello) // true
上述代码,我们把sayHello提前定义好,在创建对象时直接将sayHello的地址赋值到每个Person对象上,此时每个Person对象中的sayHello使用的是同一个function。
看起来问题暂时得到了解决,但是要注意的是此时sayHello并不是仅由Person类独享而是全局共享(window对象的一个属性),我们无法区分sayHello是普通函数还是某个类的实例方法。
于是我们引出解决此问题的最佳方案-使用原型(prototype)。
原型(prototype)
原型的作用就是存储了某个类中所有实例共享的属性和方法,这些属性和方法所属于这个类而不是类的实例,每个类的对象都可以直接访问到所属类的原型上的属性和方法。如果对Java、C#语言较为熟悉,原型上的属性和方法功能跟一些传统面向对象对象语言中的静态属性和静态方法类似,仅仅是类似,都是归属于类上共有的属性和方法。
在JavaScript中,每一个“类”都有自己的原型prototype;更为严格的说,每一个function都有自己的原型prototype,prototype中默认的有constructor属性指向对应的类的构造函数/对应的function本身。
我们将上述代码片改写成原型模式:
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.sayHello = function () {
console.log(this.name + ' ' + this.age);
}
var p1 = new Person('Bob', 19);
var p2 = new Person('Tom', 20);
p1.sayHello(); // Bob 19 调用原型上的sayHello
p2.sayHello(); // Tom 20 调用原型上的sayHello
console.log(p1 instanceof Person); // true
console.log(p2 instanceof Object); // true
console.log(p1.sayHello === p2.sayHello) // true
console.log(Person.prototype); // {sayHello: ƒ, constructor: ƒ}
使用原型模式将所有实例共享的属性和方法挂载到原型上我们就可以解决同一类中不同对象重复创建function的问题,那么这里也可以很清楚的知道前文说的每个对象中的constructor属性实际上就是所属类prototype中的constructor属性。
原型补充
一、对象的“__proto__”属性
我们知道每一个function都有对应的原型prototype属性,原型上挂载着所有实例共享的属性和方法。同时,每一个实例对象中存在一个"__proto__"指回对应类的prototype。
console.log(p1.__proto__);// {sayHello: ƒ, constructor: ƒ} 指回Person类的prototype
console.log(p1.__proto__ === Person.prototype);// true
通过上述的分析,我们明白每一个function都有自己的prototype,每一个对象实例都有自己的__proto__指回对应类/function的prototype。
下面我们来说几个比较容易混淆的点:
- 对于我们已经定义的Person类,为什么Person类也是有__proto__属性的?
乍一看,这跟我们上面总结的是相悖的,因为我们说只有实例对象才会有__proto__属性啊,Person是个function怎么也会有__proto__属性呢?
仔细想一想,在面向对象中我们是如何定义一个类的?显然使用construct function去充当一个class,那么function也一定是Function的一个实例对象,所以Person也有__proto__因为它是Function的一个实例,总得来说Person既有自己的prototype原型又有作为Function实例指向Function's prototype的__proto__。
console.log(Person instanceof Function);// true
console.log(Person.__proto__);// ƒ () { [native code] }
console.log(Person.__proto__ === Function.prototype); // true
- Person.prototype.__proto__是什么?
首先Person.prototype是Person类的原型对象,本质还是一个对象。那么上述表达式就简化成了类似{}.__proto__,很显然指向{}所属类的prototype,即Object.Prototype。
console.log(Person.prototype instanceof Object); // true
// {constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ, hasOwnProperty: ƒ, …} ƒ Object() { [native code] }
console.log(Person.prototype.__proto__, Person.prototype.__proto__.constructor);
console.log(Person.prototype.__proto__ === Object.prototype); //true
- Person.prototype.prototype又是什么?
上述表达式类似等价于{}.prototype,但是{}并不是一个function,所以默认是没有prototype属性的,故表达式结果为undefined。
console.log(Person.prototype.prototype); // undefined
二、对象属性的搜索机制
但了解完原型相关的知识后,我们认识到实例的属性一般定义在构造函数中,方法一般挂载到原型上。但是原型prototype上也可定义类中所有实例共享的属性,当实例和原型中同时拥有某个同名的属性时JavaScript是如何进行搜索的呢?
如以下代码:
function Person(name, age, desc) {
this.name = name;
this.age = age;
this.desc = desc;
}
Person.prototype.sayHello = function () {
console.log(this.name + ' ' + this.age);
}
Person.prototype.desc = 'Prototype\'s desc';
Person.prototype.counter = 0;
var p1 = new Person('Bob', 19, 'p1-desc');
console.log(p1.desc);// ?
console.log(p1.counter); // ?
console.log(p1.abc); // ?
属性的搜索机制:
所以以上三个log分别输出:
console.log(p1.desc); // p1-desc
console.log(p1.counter); // 0
console.log(p1.abc); // undefined
以上是在属性取值时的搜索机制,当对属性赋值时则不按照上述机制进行。
如,将p1的counter赋值为999,那么很容易想当然的认为直接修改了p1.__proto__.counter即类原型上的counter,实则不然。
p1.counter = 999;
此时分别打印p1以及Person.prototype查看输出结果。
注意:在对属性赋值时,若实例中不存在该属性则会直接在实例中添加该属性并为其赋值,而不是直接修改原型中的此属性。这是取值与赋值之间不同的地方。
若此时再访问p1.counter应该输出999,因为根据属性搜索机制优先取实例中的属性。
三、判断实例属性与原型上的属性
p1打“."后既可以取到实例属性也可以取到原型上的属性,可以通过hasOwnProperty判断某个属性属于实例还是原型。
如:
console.log(p1.hasOwnProperty("name"));// true
console.log(p1.hasOwnProperty("counter"));// false 没有赋值999时
console.log(p1.hasOwnProperty("constructor"));// false
注意:即使某个实例属性为null或者undefined,hasOwnProperty仍然可以检测到。
p1.testProp1 = null;
p1.testProp2 = undefined;
console.log(p1.hasOwnProperty("testProp1"));// true
console.log(p1.hasOwnProperty("testProp2"));// true
关于原型的案例-日期格式化方法
基本上我们从js中的面向对象一步步引申到原型的相关知识,那么此时我们可以解决上个第三个章节中的第三个小问题
“如果我们要实现一个通用的日期格式化方法dateFormat,那么这个方法定义在哪里最合适?”
显然我们应该把此方法定义在Date类的prototype中以达到通用。
Date.prototype.dateFormat = function (fmt) {
if (!(this instanceof Date) || !fmt || fmt.length === 0) {
throw new Error('format error!');
}
var fmtOpt = {
"y+": this.getFullYear().toString(), // 年
"M+": (this.getMonth() + 1).toString(), // 月
"d+": this.getDate().toString(), // 日
"H+": this.getHours().toString(), // 时
"m+": this.getMinutes().toString(), // 分
"s+": this.getSeconds().toString() // 秒
};
for (var k in fmtOpt) {
var regResult = fmt.match(new RegExp(k));
if (regResult && regResult.length > 0) {
fmt = fmt.replace(regResult[0], (regResult[0].length === 1) ? fmtOpt[k] : fmtOpt[k].padStart(regResult[0].length, "0"));
};
};
return fmt;
}
console.log(new Date().dateFormat('yyyy-MM-dd'));
console.log(new Date().dateFormat('yyyy-MM-dd HH:mm:ss'));
console.log(new Date(1629829278000).dateFormat('yyyy-MM-dd HH:mm'));
Date.prototype.dateFormat.call({});
上篇我们分析了JavaScript中面向对象与传统面向对象语言之间的不同,从面向对象一步步延伸出一个重要的概念-原型,进一步说明了为何要使用原型,以及使用原型需要注意的地方。实际上在关于原型的讨论中,“原型链”已经若隐若现,下篇我们将会继续向后分析JavaScript中面向对象中的重要部分-”JavaScript中的原型链及继承“。