JS:面向对象(基础篇)

时间:2021-04-29 11:33:52

面向对象(Object-Oriented,OO)的语言有一个标志,那就是它们都有类的概念。long long ago,js是没有类的概念(ES6推出了class,但其原理还是基于原型),但是它是基于原型的语言,可以通过一些技巧来让他属于类的用法。

我们先建个最简单的对象:

var person = new Object();
person.name = "cjh";
person.age = 22;
person.sayName = function () {
//this.name => person.name
console.log(this.name);
}
//输出:cjh
person.sayName(); var person = {
name: "cjh",
age:22,
sayName:function () {
//this.name => person.name
console.log(this.name);
}
}
//输出:cjh
person.sayName();

这是JS定义对象的常用两种方式,当然两种方式的最终的结果都是一样的,在JS中有两种属性,数据属性和访问器属性:

数据属性

数据属性包含一个数据值的位置,在这个位置可以读取和写入值,数据属性有四个描述其行为的特性。

Configurable:表示能否通过delete删除属性从而重新定义属性,默认值是true,注意:一旦把属性定义为不可配置时(false),就不能再把它变回可以配置(true)。

Enumerable:表示能否通过for-in循环返回属性。默认值为true。(当我们for-in一个对象时,属性默认会一个个遍历出来)。

var person = {
name: "cjh",
age:22,
sayName:function () {
//this.name => person.name
console.log(this.name);
}
} for (one in person) {
console.log(one);
}
// 输出:
// name
// age
// sayName

Writeable:表示能否修改属性的值,默认为true,设成false也就是只读。

Value:顾名思义就是定义属性名是附带的值,默认是undefined,所以基本都会重写这个,不然定义属性毫无意义。

var animal = {};
Object.defineProperty(animal, "name", {
configurable: false,
value: "monkey"
});
// 输出:monkey
console.log(animal.name); //下面的语句报错,不能将false改为true
Object.defineProperty(animal, "name", {
configurable: true,
value: "monkey"
});

访问器属性

访问器属性不包含数据值:它们包含一对getter和setter函数,这和java里面的java bean一摸一样。当然他也有4个特性:

Configurable:表示能否通过delete删除属性从而重新定义属性,默认值是true

Enumerable:表示能否通过for-in循环返回属性。默认值为true。(当我们for-in一个对象时,属性默认会一个个遍历出来)。

Get:在读取属性时调用的函数,默认值为undefined

Set:在写入属性时调用的函数,默认值为undefined

注意:访问起属性不能直接定义,必须使用Object.defineProperty()定义:

var book = {
_year:2017,
now:408
};
Object.defineProperty(book, "year", {
//默认数据属性是可以直接获取,访问器属性可以在获取前加个逻辑操作
get:function () {
return this._year;
},
//默认数据属性是可以直接赋值,访问器属性可以在赋值前加个判断并且可以定义赋值操作
set:function (newValue) {
if (newValue > 2017){
this._year = newValue;
this.now++;
}
}
})
console.log('这是book定义是给的属性_year:' + book._year);
console.log('now:' + book.now);
console.log('这是用defineProperty定义的访问器属性year:' + book.year);
book.year = 2018;
console.log('year:' + book.year);
console.log('now:' + book.now);
//这时的2000没有大于先前的year:2018所以没有执行赋值
book.year = 2000;
console.log('year:' + book.year);

运行结果:

这是book定义是给的属性_year:2017
now:408
这是用defineProperty定义的访问器属性year:2017
year:2018
now:409
year:2018

注意:不一定要同时指定getter和setter,只指定getter意味着属性是只读的,尝试写入会被忽略,在严格模式下会报错。

上面的Object.defineProperty()只能一次定义一个属性,所以还有个方法定一个多个属性,

'use strict'
var book = {
};
Object.defineProperties(book, {
_year:{
value:2005,
//没有写这个,在严格模式还是会报错,虽然默认是true,
writable:true
},
now:{
value:1,
writable:true
},
  //_year是数据属性,year是访问器属性,两者可以相辅相成
year:{
get: function () {
return this._year;
}, set: function (newValue) {
if (newValue > 2017){
this._year = newValue;
this.now++;
}
}
}
});
console.log('这是book定义是给的属性_year:' + book._year);
console.log('now:' + book.now);
console.log('这是用defineProperty定义的访问器属性year:' + book.year);
book.year = 2018;
console.log('year:' + book.year);
console.log('now:' + book.now);
//这时的2000没有大于先前的year:2018所以没有执行赋值
book.year = 2000;
console.log('year:' + book.year);

Object.getOwnPropertyDescriptor()方法可以取得给定属性的描述符,这个方法接收两个参数:属性所在的对象和要读取其描述符的属性名称。返回值是一个对象,两种情况:如果是访问器属性对应的属性就有其访问器相应的属性,如果是数据属性也是一样。

工厂模式&&构造函数模式

工厂模式是软件工程领域一种广为人知的设计模式,解决创建多个相似对象的问题,考虑到在ES5不能创建类,所以有人就发明了一种方法,用函数来封装以特定接口创建对象的细节。

function person (name, age) {
var o = new Object();
o.name = name;
o.age = age;
o.sayName = function(){
console.log(this.name);
}
return o;
}
var person1 = person('cjh',22);
var person2 = person('csb',24);
//true
console.log(person1 instanceof Object);
//false
console.log(person1 instanceof person);
console.log('用工厂模式的对象类型')
//[Function: Object]
console.log(person1.constructor)
person1.sayName();
person2.sayName(); function Person (name, age) {
this.name = name;
this.age = age;
this.sayName = function(){
console.log(this.name);
}
}
var person3 = new Person('ccc',22);
var person4 = new Person('bbb',24);
//true
console.log(person3 instanceof Object);
//true
console.log(person3 instanceof Person);
console.log('用构造函数模式的对象类型');
//[Function: Person]
console.log(person4.constructor)
person1.sayName();
person2.sayName();

看上面的运行结果:用工厂模式(小写的person)创建出来的对象的类型是[Function: Object],而用构造函数(大写的Person)创建出来的对象的类型是[Function: Person],这就其中区别之一,有时候我们需要知道这个对象是哪个对象实例化出来的。还注意到Person()中的代码和person()有一些不同之处:

  • 没有显式地创建对象
  • 直接将属性和方法赋给了this对象
  • 没有return语句

还应该注意到函数名Person使用的是大写字母P。按照惯例,构造函数始终都应该以一个大写字母开头,这个做法借鉴与其他OO语言(面向对象语言:C++,JAVA。。),主要是为了区别于JS中其他的函数,因为构造函数也是函数本身,只是作用不同罢了。

要创建Person新实例,必须用new操作符,以这种方式调用构造函数会经历以下4个步骤:

  1. 创建一个对象
  2. 将构造函数的作用域赋给新对象(因此this就指向了这个新对象)
  3. 执行构造函数中的代码(为这个新对象添加新属性)
  4. 返回新对象
//new的时候做了什么
// var o1 = new Object();
// o1.[[Prototype]] = Base.prototype;
// Base.call(o1);

因为构造函数也是函数,所以调用的方式可以有很多种:

//创建出来的对象跟上面new出来的对象一样
let o = new Object();
Person.call(o, 'ddd',35);
o.sayName(); //在非严格模式下,Person里面的this会指向window,在严格模式下this没有指向,所以会报错。
Person('eee',36);
window.sayName();

总结:构造函数模式虽然比工厂模式好用,但也并非没有缺点:

function Person (name, age) {
this.name = name;
this.age = age;
// this.sayName = function(){
// console.log(this.name);
// }
this.sayName = new Function(console.log(this.name));
}

我们每次创建一个对象时,都创建了一个新的函数作用域sayName,两个不同的函数做的同一件事,这着实有点浪费资源,但有个解决方法:

function Person (name, age) {
this.name = name;
this.age = age;
this.sayName = sayName;
}
function sayName () {
console.log(this.name);
}

这样确实解决了浪费资源的问题,但是这是个全局函数,任何人都可以调用它,而且当对象定义很多方法时,就要定义很多全局函数,这样毫无封装性可言,那我们前面做的都白费了,所以接下来通过原型模式来解决这个问题。

原型模式

我们创建的每个函数都有一个prototype(翻译过来:原型)属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。如果按照字面意思来理解,prototype就是通过调用构造函数而创建的那个对象实例的原型对象。还记得我开头说的JS是基于原型继承的语言。简单来说:使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法。

function Person () {
}
Person.prototype.name = "cjh";
Person.prototype.age = 23;
Person.prototype.sayName = function () {
console.log(this.name);
}
var person1 = new Person();
//重写了Person默认的name值
person1.name = 'csb';
person1.sayName();
var person2 = new Person();
person2.sayName();
//true person1.sayName和person2.sayName是同一个sayName()函数
console.log(person1.sayName == person2.sayName);

学过OO语言的人都知道继承的概念,里面包括重写,这里JS继承其实原理差不多,当执行到person1.name时:

解析器问:person1有name属性么,答:有,于是就读取当前属性的值,当执行到person2.name时:

解析器问:person2有name属性么,答:没有,于是向上搜索,解析器再问:person2的原型有name属性么,答:有,于是读取原型的name值。

JS:面向对象(基础篇)

上面这张图是来自高级JS程序设计。

这边我来解释下:

在JS中无论什么时候,只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个prototype属性,这个属性指向函数的原型对象。默认情况下,所以原型对象都会自动获得一个constructor(构造函数)属性,这个属性就是用来new出实例时候调用的。然而这个constructor包含一个指向prototype属性所在函数的指针,相当于绕了一大圈,形成一个闭合的链。

function Person () {
}
//[Function: Person]
console.log(Person.prototype.constructor);
Person.prototype.name = "cjh";
Person.prototype.age = 23;
Person.prototype.sayName = function () {
console.log(this.name);
}
var person1 = new Person();
//重写了Person默认的name值
person1.name = 'csb';
person1.sayName();
//[Function: Person]
console.log(person1.constructor);

//删了person1.name后,返回的就是原型中的name值了,所以这边有个函数hasOwnProperty("name")可以查看返回的是否是自己的属性,如果是原型的话就会false
delete person1.name;
person1.sayName();

看上面的运行结果可以看出来person1.constructor == Person.prototype.constructor。现在再去看上面那张图比较好理解了,这边说下,constructor是每个函数都有会属性,起初是用来标识对象类型的,但是提到检测对象类型,还是instanceof比较可靠点。

做个小扩展,JS中提供了object.hasOwnProperty("name");用来检测是否是自身的属性,我们可以建个函数用来来检测是否是原型的属性:

function hasPrototypeProperty (object, name) {
return !object.hasOwnProperty(name) && (name in object);
}

更简单的原型语法

function Person () {

}
Person.prototype = {
name: "cjh",
age:22,
sayName:function () {
console.log(this.name);
}
}
var person1 = new Person();
//重写了Person默认的name值
person1.name = 'csb';
person1.sayName();
//[Function: Object]
console.log(person1.constructor);
//true 虽然上面的构造器是Object,但是它确实是Person的实例,所以为true
console.log(person1 instanceof Person);

但是我们用第一次原型定义写法:

function Person () {
}
Person.prototype.name = "cjh";
Person.prototype.age = 23;
Person.prototype.sayName = function () {
console.log(this.name);
}
var person1 = new Person(); //[Function: Person]
console.log(person1.constructor);
//true
console.log(person1 instanceof Person);

其实结果都没差,如果你真的觉得constructor的值很重要的话,你可以重写它:

function Person () {

}
Person.prototype = {
constructor:Person,
name: "cjh",
age:22,
sayName:function () {
console.log(this.name);
}
}
var person1 = new Person();
//重写了Person默认的name值
person1.name = 'csb';
person1.sayName();
//[Function: Person]
console.log(person1.constructor);
//true
console.log(person1 instanceof Person);

还有个问题,默认原生的constructor属性是不可枚举的,也就是不可遍历的,估计会发生什么严重错误,所以不然我们枚举,但是我们重写后Enumberable就改成true,就像刚开始讲过,只要把Enumberable特性改成false就可以了,所以可以用defineProperty();

function Person () {

}
Person.prototype = {
name: "cjh",
age:22,
sayName:function () {
console.log(this.name);
}
}
var person1 = new Person();
//重写了Person默认的name值
person1.name = 'csb';
person1.sayName();
//[Function: Person]
console.log(person1.constructor);
//true 虽然上面的构造器是Object,但是它确实是Person的实例,所以为true
console.log(person1 instanceof Person); Object.defineProperty(Person.prototype, "constructor",{
enumberable:false,
value:Person
})
//遍历可枚举的属性,其中没有constructor
for (va in Person.prototype) {
console.log(va);
}

原型的动态性

由于在原型中查找值的过程是一次搜索,因此我们对原型对象所做的任何修改都能够立即从实例上反映出来--即使是先创建了实例后修改原型也照样如此。

function Friend () {

}
let friend = new Friend();
Friend.prototype.sayHi = function () {
console.log('hi');
}
friend.sayHi();

尽管随时都可以为原型添加属性和方法,并且修改能够立即在所有对象实例中反映出来,但如果是重写整个原型对象,那么情况就不一样了。我们知道,调用构造函数时会为实例添加一个指向最初原型的prototype指针,而把原型修改为另一个对象就等于切断了构造函数与最初原型之间的联系。请记住:实例中的指针仅指向原型而不指向构造函数。

function Friend () {

}

Friend.prototype.sayHi = function (){
console.log('hi');
}
Friend.prototype = {
name:'cjh',
age:22,
sayName:function () {
console.log(this.name);
}
}
//{ name: 'cjh', age: 22, sayName: [Function: sayName] }
console.log(Friend.prototype);

上面定义的sayHi在哪呢?莫名其妙的没掉了?我们再看个例子,只是把定义sayHi的位置换到下面去:

function Friend () {

}

Friend.prototype = {
name:'cjh',
age:22,
sayName:function () {
console.log(this.name);
}
}
Friend.prototype.sayHi = function (){
console.log('hi');
}
// { name: 'cjh',
// age: 22,
// sayName: [Function: sayName],
// sayHi: [Function] }
console.log(Friend.prototype);

我们可以发现现在sayHi又出现了,这说明当我们用Friend.prototype.name = .....这种方式定义属性时,只是在原来的基础是上再次添加一个属性,而Friend.prototype = {};是重写整个原型,相当于新建了一个对象并且把把Firend指向新的原型,这样就出现了上面的结果。

JS:面向对象(基础篇)

上图来自高级JS程序设计

原型对象的问题

原型模式的问题是由其共享的本性所导致的,原型中所有属性是被很对实例共享的,这种共享对函数非常合适,对于包含引用类型值的属性来说,问题很大,看个例子:

function Person () {

}
Person.prototype = {
name:'cjh',
age:12,
friends:['aa','bb'],
sayName:function () {
console.log(this.name);
}
}; let person1 = new Person();
let person2 = new Person(); person1.friends.push('cc');
//[ 'aa', 'bb', 'cc' ]
//[ 'aa', 'bb', 'cc' ] 因为friends.push()是在原型上面添加,不是person1自己的属性,所以两个实例共享
console.log(person1.friends);
console.log(person2.friends);

这个例子说明通实例化出来的对象默认都是共享原型里面的所有值,不清楚的话,下面这个例子会更详细的介绍:

function Person () {

}
Person.prototype = {
name:'cjh',
age:12,
friends:['aa','bb'],
sayName:function () {
console.log(this.name);
}
}; let person1 = new Person();
let person2 = new Person(); //{}
//{}
console.log(person1)
console.log(person2);
person1.sayName = function () {
console.log(this.age);
}
//{ sayName: [Function] }
console.log(person1);
person1.name = 'csb';
//{ sayName: [Function], name: 'csb' }
console.log(person1);
//csb 这时name时person1自己开辟的内存,所以更改后不影响
console.log(person1.name);
//cjh 这时的name还是Person。prototypr
console.log(person2.name);
person1.friends.push('cc');
//[ 'aa', 'bb', 'cc' ]
//[ 'aa', 'bb', 'cc' ] 因为friends.push()是在原型上面添加,不是person1自己的属性,所以两个实例共享
console.log(person1.friends);
console.log(person2.friends);
person1.friends = [123,456];
// [ 123, 456 ] 现在自己新建一个属性friends就不用搜索原型的,还记得上面说的先搜索自身,没有的话再搜索原型,但person2还是用原型的friends
// [ 'aa', 'bb', 'cc' ]
console.log(person1.friends);
console.log(person2.friends);

由于原型存在属性共享的问题,也就引出了下面的解决方法。

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

构造函数模式用于定义实例属性,而原型模式用于定义方法和共享属性。结果,每个实例都会有自己的一份实例属性的副本,但同时又共享这对方法的引用,最大限度的节省了内存,何乐而不为。详情请看:JS面向对象(进阶篇