JavaScript对象机制
JavaScript对象机制
ECMAScript规范明确定义了这样的概念:对象是零到多个的属性的集合。JavaScript是基于原型编程,ES5中使用了很多特性来模拟类的行为,虽然ES6新引入了class
关键字正式定义类,但本质仍是原型和构造函数。
一、创建对象的方式
JavaScript提供了多种创建对象的方式:
1.工厂模式
function createPerson(first, last, age, gender, interests) {
let o = new Object();
o.name = {
first,
last
};
o.age = age;
o.gender = gender;
o.interests = interests;
o.greeting = function () {
alert('Hi I\'m' + o.name.first + '.');
}
}
但是工厂模式存在一个问题,即无法表示新创建的对象是什么类型(所有创建出的对象都是Object,不具有唯一性)。
2.构造函数模式
如果自定义构造函数,就能够以函数的形式为自己的对象类型定义属性和方法。
function Person(first, last, age, gender, interests) {
this.name = {
first,
last
};
this.age = age;
this.gender = gender;
this.interests = interests;
this.greeting = function () {
alert('Hi, I\'m' + o.name.first + '.');
}
}
let person1 = new Person('Tammi', 'Smith', 17, 'female', ['music', 'skiing', 'kickboxing']);
由于构造函数就是能创建对象的函数,因此为了区分,构造函数首字母为大写,非构造函数首字母为小写。事实上该函数是以类的身份来声明了一系列的属性。创建Person的实例,需要使用new操作符。使用构造函数模式会带来如下操作:
- 内存中创建新对象
- 新对象[[Prototype]]特性被赋值为构造函数的Prototype特性
- this指向这个新对象
- 函数可以返回非空对象。如果没有则返回创建的新对象
构造函数也是普通的函数,因此可以将其作为普通函数调用。没有使用new
操作符时,结果会将属性和方法添加到Global对象上。而在浏览器中Global对象为window对象。
// 作为函数调用
Person('Tammi', 'Smith', 17, 'female', ['music', 'skiing', 'kickboxing']);
console.log(window.age); // 17
// 在另一个对象的作用域中调用
// 通过Call()调用时,将对象o指定为Person()内部的this值,因此执行完函数后所有属性和方法将添加到对象o上
let o = new Object();
Person.call(o, 'Tammi', 'Smith', 17, 'female', ['music', 'skiing', 'kickboxing']);
console.log(o.first.name); // Tammi
使用构造函数模式时存在的问题是会定义两个不同的Function
实例。不同实例上的函数同名却不等价。解决这个问题需要将函数定义转到构造函数外部:
function Person(first, last, age, gender, interests) {
this.name = {
first,
last
};
this.age = age;
this.gender = gender;
this.interests = interests;
this.greeting = greeting;
}
function greeting () {
alert('Hi, I\'m' + this.name.first + '.');
}
在构造函数内greeting
属性指向了全局的greeting()
函数。如此以来person1
和person2
共享了全局作用域上的greeting()
函数。但这样带来的问题也显而易见,如果该对象需要多个方法就要在全局作用域中定义多个函数。从面向对象编程的观点来看,这样的做法破坏掉了“封装性”。原型模式可以解决这一问题。
3.原型模式
使用原型对象的好处是,定义的所有属性和方法都可以被所有的对象实例共享。
function Person() {}
Person.prototype.name = ['Tammi', 'Smith'];
Person.prototype.age = 17;
Person.prototype.gender = 'female';
Person.prototype.interests = ['music', 'skiing', 'kickboxing'];
Person.prototype.greeting = function () {
alert('Hi, I\'m' + this.name.first + '.');
}
let person1 = new Person();
console.log(person1.age); // 17
let person2 = new Person();
console.log(person2.age); // 17
因此,person1和person2访问的都是相同属性和相同的greeting()
函数。
二、类
1.类的定义及构造函数
ES6引入了class
关键字来具备正式的定义类的能力。表面上看可以正式支持面向对象编程,但实际上仍是使用原型和构造函数的概念。constructor
关键字用于在类定义块内部创建类的构造函数,并告诉解释器在使用new
操作符创建类的新的实例时应调用这个函数。
class Person {
constructor(first, last, age, gender, interests) {
this.name = {
first,
last
};
this.age = age;
this.gender = gender;
this.interests = interests;
}
greeting() {
console.log(`Hi! I'm ${this.name.first}`);
};
farewell() {
console.log(`${this.name.first} has left the building. Bye for now!`);
};
}
2.实例化
实例化过程与构造函数模式相似:
let han = new Person('Han', 'Solo', 25, 'male', ['Smuggling']);
han.greeting();
// Hi! I'm Han
let leia = new Person('Leia', 'Organa', 19, 'female', ['Government']);
leia.farewell();
// Leia has left the building. Bye for now
事实上在后台这些类也将被转为原型模式。但ES6提供了更为便利的语法糖,降低了编写的难度。
class Person {
constructor() {
this.name = new String('Hellcat');
this.greeting = () => console.log(`Hi! I'm ${this.name}`);
this.interests = ['music', 'programming'];
}
}
而每一个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享:
let p1 = new Person(), p2 = new Person();
p1.name === p2.name; // false
p1.greeting(); // Hi! I'm Hellcat
p2.greeting(); // Hi! I'm Hellcat
三、继承
面向对象的语言(如Java)提供两种继承:接口继承和实现继承。前者只继承方法的函数签名,后者继承实际的方法。但在ECMAScript中没有函数签名这一概念,因此无法实现接口继承。ECMAScript唯一支持的继承方式是实现继承,主要通过原型链来实现。
1. 原型链
JavaScript是基于原型的语言,每个对象都拥有一个原型对象,对象以原型为模板,从原型继承方法和属性。原型对象可能也拥有原型,并从中继承方法和属性。以这样“套娃”的方式层层递进的关系常被称为原型链(Prototype chain)。
在JavaScript中,每个函数都有一个特殊的属性,为__proto___
:
如果添加一些属性到doSomething
的原型上,则会有如下结果:
通过new
标识符实例化对象后查看在对象上添加的属性,可以看到doSomeInstancing
的__proto__
属性即doSomething.prototype
:
当访问doSomeInstancing
中的某一属性时,浏览器会先查找doSomeInstancing
中是否有该属性,如果没有则在doSomeInstancing
的__proto__
中查找(也就是doSomething.prototype
)。若仍未找到,则在doSomeInstancing
的__proto__
的__proto__
中查找(也就是Object.prototype
)。而doSomeInstancing
的__proto__
的__proto__
的__proto__
并不存在,此时原型链上的所有__proto__
均层层迭代找了一遍,因此再未找到则该属性为undefined
。这就是原型链的工作机制。原型链中的方法和属性并没有被复制到其他对象,需要通过“原型链”的方式来迭代访问。
考虑如下代码片段:
function Person(first, last, age, gender, interests) {
// 属性与方法定义
};
let person1 = new Person('Tammi', 'Smith', 32, 'neutral', ['music', 'skiing', 'kickboxing']);
Person.prototype.farewell = function() {
console.log(this.name.first + ' has left the building. Bye for now!');
}
prototype
中的farewell()
方法是之后才添加的,但是仍可以用旧有的对象实例,旧有对象实例的功能被自动更新了。这种继承模型下,上游对象的方法不会复制到下游的对象实例中;下游对象本身虽然没有定义这些方法,但浏览器会通过上溯原型链、从上游对象中找到它们。这种继承模型提供了一个强大而可扩展的功能系统。
2.盗用构造函数
在子类构造函数中调用父类构造函数。而函数是特定上下文中执行代码的对象,因此可以用call()
方法以新创建的对象为上下文执行构造函数。
盗用构造函数的优点是可以在子类构造函数中向父类构造函数传参。
function Person(first, last, age, gender, interests) {
this.name = {
first,
last
};
this.age = age;
this.gender = gender;
this.interests = interests;
}
Person.prototype.greeting = function() {
alert('Hi! I\'m ' + this.name.first + '.');
};
function Teacher(first, last, age, gender, interests, subject) {
Person.call(this, first, last, age, gender, interests); // 继承Person
this.subject = subject;
}
然而,盗用构造函数存在一个问题,即子类不能访问父类原型上定义的方法。因此需要增加如下代码:
Teacher.prototype = Object.create(Person.prototype);
但这样会带来一个问题,即将Teacher.prototype.constructor
指向Person
构造函数,因此还需要进行如下修改:
Object.defineProperty(Teacher.prototype, 'constructor', {
value: Teacher,
enumerable: false,
writable: true
});
3.基于类的继承
ES6类支持单继承。使用extends
关键字就可以继承任何拥有[[Construct]]
和原型的对象。继承不仅可以继承类,也可以继承构造函数。
class Vehicle {}
class Bus extends Vehicle {} // 继承类
function Person() {}
class Engineer extends Person {} // 继承普通构造函数
我们看一下class写法的Person类:
class Person {
constructor(first, last, age, gender, interests) {
this.name = {
first,
last
};
this.age = age;
this.gender = gender;
this.interests = interests;
}
greeting() {
console.log(`Hi! I'm ${this.name.first}`);
}
farewell() {
console.log(`${this.name.first} has left the building. Bye for now!`);
}
}
在上面的代码段中,constructor()
方法定义了Person
类的构造函数,greeting()
和farewll()
均为类方法。
let han = new Person('Han', 'Solo', 25, 'male', ['Smuggling']);
han.greeting();
// Hi! I'm Han
let leia = new Person('Leia', 'Organa', 19, 'female', ['Government']);
leia.farewell();
// Leia has left the building. Bye for now
派生类方法可以通过super
关键字引用它们的原型。但要注意的是,不要在调用super()
之前引用this,否则会导致异常:
super()
实际上是调用了基类的构造函数,因此也需要传递相应的参数。子类Teacher
的完整代码如下:
class Teacher extends Person {
constructor(first, last, age, gender, interests, subject, grade) {
super(first, last, age, gender, interests);
this.subject = subject;
this.grade = grade;
}
}
测试结果: