首先,我肯定是需要你告诉我,什么是面向对象,面向对象有哪些特点,以及这些特点的解释。
JavaScript 如何实现这些特点,比如封装、继承、多态。如果关于上述三点,你能够解释到有多少种实现方式、优缺点是什么。以及近几年流行的解决方案是什么。这就是「加分」 ,比如对于继承吧。类式继承、构造函数继承、组合继承、原型继承、寄生组合继承等等,说出大概的实现思路和优缺点,再介绍下 extends 或者 mixin 的实现甚至你可以衍生到 JavaScript 的模块化发展甚至到为什么现在 TS 如此流行。那么可以说到这一环节解答的就非常棒了。
回答完 JavaScript 的面向对象,是不是可以从此衍生下为什么需要面向对象。以及当先对于软件设计的高内聚、低耦合的思考?来个对此题一个提纲挈领的总结?
综上所述,其实不难看出,越是这种基础且开放的题目,可以是一个陷阱,更可以是一个机会。因为一道题真的可以全方面的感受到应聘的基础是否扎实。
JavaScript 面向对象详解(一)
ES6 之前的 JavaScript 面向对象比较不好理解,涉及到很多知识和思想。
ES6 增加了 class 和 extends 来实现类的封装和继承,但是通过 babel 转换成 ES5 之后还是之前的一套逻辑。
这里,我打算用四篇文章,来讲解一下关于 ES5 中面向对象的知识体系,一起学习一下吧!
一. JavaScript 的对象
1.1. 传统对象 vs JavaScript 对象
传统的面向对象
- 面向对象语言的一个标志就是类
- 类是所有对象的统称, 是更高意义上的一种抽象. 对象是类的实例.
- 通过类我们可以创建任意多个具体的对象.
- 在学习 C++/OC/Java/Python 等编程语言的时候, 都可以按照这种方式去创建类和对象.
JavaScript 的面向对象
- JavaScript 中没有类的概念(ES6 之前),因此我们通常称为基于对象,而不是面向对象.
- 虽然 JavaScript 中的基于对象也可以实现类似于类的封装、继承、甚至是多态。但是和传统意义的面向对象还是稍微有一些差异(后面我们会讲解它是如何实现的)
- ECMA 中定义对象是这样: 无序属性的集合, 属性可以包含基本值, 对象或者函数.
- 也就是对象是一组没有顺序的值组成的集合而已.
- 对象的每个属性或者方法都有一个名字, 而名字对应一个值. 有没有觉得非常熟悉?
- 没错, 其实就是我们经常看到和使用的映射(或者有些语言称为字典, 通常会使用哈希表来实现).
1.2. 简单的方式创建对象
创建自定义对象最简单的方式就是创建一个 Object 实例, 然后添加属性和方法
// 1.创建person的对象
var person = new Object();
// 2.给person对象赋值了一些动态的属性和方法
person.name = 'LBJ辉';
person.age = 18;
person.height = 1.88;
person.sayHello = function () {
alert('Hello, My name is ' + this.name);
};
// 3.调用方法, 查看结果
person.sayHello();
代码解析:
- 步骤一: 创建一个名为 person 的对象.
- 步骤二: 给对象动态的赋值了一些属性包括一个方法
- 步骤三: 调用 sayHello()方法, 主要看一下 this.name 会获取到谁呢? LBJ 辉
插播一个信息: 函数和方法的关系
-
很多人在学习编程的时候, 会分不清楚什么是函数, 什么又是方法. 或者在什么情景下称为函数, 什么情景下称为方法.
-
首先, 如果你看的是英文文档, 会有明显的区分: Function 被称为函数, Method 被称为方法.
-
但是英文中, 为什么需要有这两个称呼呢?
-
- 在早期的编程语言中, 只有函数(类似于数学中函数的称呼)
- 后来有了面向对象语言, 面向对象语言中, 类中也可以定义函数. 但是人们为了区分在类中定义的函数, 通常称类中的函数为方法.
- 还有一个非常重要的原因是, 通常方法中会携带一个调用者的当前对象(会将调用者作为参数一起传递进去), 也就是说 this(有些语言中是 self. 比如 OC/Swift/Python 等)
- 当然, 你从这个角度来说, JavaScript 中就没有函数了, 因为函数中都有 this 这样的参数. 但是通常来说, 我们还是会将封装到类中的函数称为方法, 而全局定义的函数称为函数.
-
如果接触过 Java 的同学可能会知道 Java 中只有方法的程序, 没有函数的称呼. 学习过 C 语言的同学可能知道, C 语言中只有函数的称呼, 没有方法的称呼.
-
这就是因为 Java 中通常不定义全局函数, 但是在类中定义的. 而 C 语言不支持面向对象的编程.
OK, 我们继续 JavaScript 面向对象之旅.
- 前面创建对象的方式, 被早期的 JavaScript 程序员经常使用
后来, 对象字面量称为创建这种对象的首选方式
// 1.创建对象的字面量
var person = {
name: 'Coderwhy',
age: 18,
height: 1.88,
sayHello: function () {
alert('My name is ' + this.name);
},
};
// 2.调用对象的方法
person.sayHello();
1.3. JavaScript 中属性的特性
JavaScript 中关于属性有一个比较重要的概念: 属性描述符
- 虽然我们开发中, 大多数情况不去可以的使用这些属性描述符
- 但是某些情况下, 也确实会用到.
- 建议大家先了解一下这些属性描述符, 以及它们的作用, 在以后用到时会非常有帮助.
JavaScript 中开始拥有了一种描述属性特征的特性(即属性描述符)。
- 根据特性的不同,可以把属性分成两种类型:数据属性和访问器属性。
常见的属性特性有哪些呢?
-
[[Configurable]] // true or false
-
- 表示能否通过 delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为 true。
-
[[Writable]] // true or false
-
- 表示能否修改属性的值。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为 true。
-
[[Enumerable]] // true or false
-
- 表示能否通过 for-in 循环返回属性。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为 true。
-
[[Value]] // everty thing
-
- 包含这个属性的数据值。读取属性值的时候,从这个位置读;写入属性值的时候,把新值保存在这个位置。这个特性的默认值为 undefined。
-
[[set]] // function or undefined
-
- 在写入属性时调用的函数。默认值为 undefined。
-
[[get]] // function or undefined
-
- 在读取属性时调用的函数。默认值为 undefined。
这些属性特性是什么东西呢?
-
从上面, 我们对这些特定的解释, 你会发现, 每个特定都会有自己特定的用途.
-
比如 Configurable 当我们配置为 false 时, 就无法使用 delete 来删除该属性.
-
设置属性特定
-
- obj: 将要被添加属性或修改属性的对象
- prop: 对象的属性
- descriptor: 对象属性的特性
- 要想修改属性的特性,必须通过两个 Object 方法,即 Object.defineProperty 和 Object.defineProperties
- 正如其字面意思,这两个方法都是用来定义(修改)属性的,前者一次只能定义一个属性,后者则可以多个。
- defineProperty(obj, prop, descriptor)
案例练习:
var person = {};
Object.defineProperty(person, 'birth', {
writable: false,
value: 2000,
});
alert(person.birth); // 2000
person.birth = 1999;
alert(person.birth); // 2000
注意:在使用 defineProperty 方法定义新属性时(非修改旧属性),如果不指定,configurable, enumerable 和 writable 特性的默认值都是 false。
也就是上面的代码等同于:
var person = {};
Object.defineProperty(person, 'birth', {
configurable: false,
enumerable: false,
writable: false,
value: 2000,
});
数据属性:
- 数据属性包含一个数值的位置,在这个位置可以读取和写入值。
- 数据属性拥有 4 个特性: [[Configurable]]/[[Enumerable]]/[[Writable]]/[[Value]]
- 按照上面的方式, 我们定义的属性就是数据属性
访问器属性:
- 访问器属性不包含数据值,它们包含一对 getter 和 setter 函数。
- 访问器属性不能直接定义,需要使用后面提到的 Object.defineProperty 函数定义。
- 访问器属性也拥有 4 个特性: [[Configurable]]/[[Enumerable]]/[[Get]]/[[Set]]
定义一个访问器属性:
var person = {
birth: 2000,
age: 17,
};
Object.defineProperty(person, 'year', {
get: function () {
return this.birth + this.age;
},
set: function (newValue) {
this.age = newValue - this.birth;
},
});
person.year = 2088;
alert(person.age); // 88
person.age = 30;
alert(person.year); // 2030
注意: getter 和 setter 都是可选的,在非严格模式下,只指定了 getter 却进行了写入操作,写入的值会被忽略; 只指定了 setter 却进行了读取操作,读取到的属性值为 undefined。在严格模式下,则都会报错。
二. JavaScript 创建对象
虽然 Object 构造函数或对象字面量可以用来创建单个对象
但是这些方式有个明显的缺点: 使用同一个接口创建很多对象, 会产生大量的重复代码.
我们会有一些列的方式来解决这个问题, 最终得到我们最佳理想的方式来创建对象.
2.1. 使用工厂模式
工厂模式是一种非常常见的设计模式, 这种模式抽象了创建具体对象的过程.
因为 JavaScript 中没法创建类, 开发人员就发明了一种函数, 用函数来封装以特定接口创建对象的细节.
工厂模式创建对象:
// 创建工厂函数
function createPerson(name, age, height) {
var o = new Object();
o.name = name;
o.age = age;
o.height = height;
o.sayHello = function () {
alert('Hello, My name is ' + this.name);
};
return o;
}
// 创建两个对象
var person1 = createPerson('LBJ辉', 18, 1.88);
var person2 = createPerson('LBJ', 38, 2.03);
person1.sayHello(); // Hello, My name is LBJ辉
person2.sayHello(); // Hello, My name is LBJ
代码解析:
- 函数 createPerson()能够根据接受的参数来构建一个包含所有必要信息的 Person 对象
- 可以无数次地调用这个函数,而每次它都会返回一个包含三个属性一个方法的对象。
- 工厂模式虽然解决了创建多个相似对象的问题,但却没有解决对象识别的问题(即怎样知道一个对象的类型)。
- 随着 JavaScript 的发展,又一个新模式出现了。
2.2. 构造函数模式
JavaScript 中的构造函数可用来创建特定类型的对象。
- 像 Object 和 Array 这样的原生构造函数,在运行时会自动出现在执行环境中。
- 此外,也可以创建自定义的构造函数,从而定义自定义对象类型的属性和方法。
使用构造函数模式创建对象:
// 构造函数
function Person(name, age, height) {
this.name = name;
this.age = age;
this.height = height;
this.sayHello = function () {
alert(this.name);
};
}
// 使用构造函数创建对象
var person1 = new Person('LBJ辉', 18, 1.88);
var person2 = new Person('LBJ', 38, 2.03);
person1.sayHello(); // LBJ辉
person2.sayHello(); // LBJ
代码解析:
-
在这个例子中,Person()函数取代了 createPerson()函数。
-
我们会发现这个函数有一些不太一样的地方:
-
- 没有显式地创建对象;(比如创建一个 Object 对象)
- 直接将属性和方法赋给了 this 对象;
- 没有 return 语句
-
另外, 我们还注意到函数名 Person 使用的是大写字母 P。
-
- 按照惯例,构造函数始终都应该以一个大写字母开头,而非构造函数则应该以一个小写字母开头;
- 这个做法借鉴自其他面向对象语言,主要是为了区别于 ECMAScript 中的其他函数;
- 因为构造函数本身也是函数,只不过可以用来创建对象而已;
-
还有, 我们在调用函数时, 不再只是简单的函数+(), 而是使用了 new 关键字
-
这种方式调用构造函数实际上会经历以下 4 个步骤:
-
- 创建一个新对象, 这个新的对象类型其实就是 Person 类型.
- 将构造函数的作用域赋给新对象(因此 this 就指向了这个新对象,也就是 this 绑定);
- 执行构造函数中的代码(为这个新对象添加属性和方法);
- 返回新对象, 但是是默认返回的, 不需要使用 return 语句;
在前面例子的最后,person1 和 person2 分别保存着 Person 的一个不同的实例。
- 这两个对象都有一个 constructor(构造函数)属性,该属性指向 Person.
- 后面我们会详细说道 constructor 到底从何而来, 所以你需要特别知道一下这里有这个属性.
// constructor属性
alert(person1.constructor === Person); // true
alert(person2.constructor === Person); // true
我们也可以通过 instanceof 来查看它的类型
- 注意: 我们会发现 person1 和 person2 既是 Person 类型, 也是 Object 类型.
- 这是因为默认所有的对象都继承自 Object.(关于继承, 后续详细讨论)
// 使用instanceof查看是否是person或者Object类型
alert(person1 instanceof Object); // true
alert(person1 instanceof Person); // true
alert(person2 instanceof Object); // true
alert(person2 instanceof Person); // true
2.3. 关于构造函数
关于构造函数
- 我们知道, 构造函数也是一个函数, 只是使用的方式和别的函数不太一样.(使用 new)
- 但是, 构造函数毕竟也是函数, 因此也可以像普通的函数一样去使用.
- 而且, 其他任何的函数, 也可以通过 new 关键字来调用, 这个时候这个函数也可以被称为构造函数.
把构造函数当做普通的函数去调用
// 当做构造函数使用
var person = new Person('LBJ辉', 18, 1.88); // person对象
person.sayHello();
// 作为普通的函数调用
Person('LBJ', 38, 2.03); // window对象
window.sayHello();
// 在另外一个对象的作用域调用
var o = new Object();
Person.call(o, 'Wade', 39, 1.93); // o对象
o.sayHello();
构造函数来创建对象的缺陷:
- 构造函数模式虽然好用,但也并非没有缺点。
- 使用构造函数的主要问题,就是每个方法都要在每个实例上重新创建一遍。
- 在前面的例子中,personl 和 person2 都有一个名为 sayName()的方法,但那两个方法不是同一个 Function 的实例。
- JavaScript 中的函数也是对象,因此每定义一个函数,也就是实例化了一个对象
构造函数的换一种形式:
- 也就是上面的代码类似于下面的写法
function Person(name, age, height) {
this.name = name;
this.age = age;
this.height = height;
this.sayHello = new Function('alert(this.name)');
}
有什么问题呢?
- 从这个角度上来看构造函数,更容易明白每个 Person 实例都包含一个不同的 Function 实例.
- 但是, 有必要创建多个 Function 实例吗? 它们执行的代码完全相同.
- 你也许会考虑, 它们需要区分不同的对象, 不过, 在调用函数时, 我们传入的 this 就可以区分了. 没有必要创建出多个 Function 的实例.
我们可以验证一下这是两个不同的函数:
alert(person1.sayHello === person2.sayHello); // false
有没有办法让它们是同一个函数呢? 使用全局函数即可
// 定义全局和函数
function sayHello() {
alert(this.name);
}
// 构造函数
function Person(name, age, height) {
this.name = name;
this.age = age;
this.height = height;
this.sayHello = sayHello;
}
// 使用构造函数创建对象
var person1 = new Person('LBJ辉', 18, 1.88);
var person2 = new Person('LBJ', 38, 2.03);
alert(person1.sayHello === person2.sayHello); // true
新的问题:
- 这样做确实解决了两个函数做同一件事的问题,可是新问题又来了: 在全局作用域中定义的函数我们的目的却是只能被某个对象调用,这让全局作用域有点名不副实。
- 而且我们进一步思考: 如果对象需要定义很多方法,那么就要定义很多个全局函数,于是我们这个自定义的引用类型就丝毫没有封装性可言了。
有没有新的解决方案呢?使用原型模式.
JavaScript 面向对象详解(二)
前面, 我们讨论了很多种场景对象的方式: 从 Object 到字面量, 再到工厂模式, 再到构造函数.
最终我们发现, 构造函数是比较理想的一种方式, 但是它也存在问题.
为了最终解决这个问题, 我们需要学习一个新的知识: 原型(prototype).
一. 理解原型模式
1.1. 什么是原型呢?
你需要先知道一个事实:
- 我们创建的每个函数都有一个 prototype(原型)属性
- 这个属性是一个指针,指向一个对象
- 而这个对象的作用是存放这个类型创建的所有实例共享的属性和方法。
- 指向的这个对象, 就是我们的所谓的原型对象.
原型对象的作用:
- 使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法。
- 换句话说,不必在构造函数中定义对象实例的信息,而是可以将这些信息直接添加到原型对象中。
我们来看看原型对象的使用:
// 创建对象的构造函数
function Person() {}
// 通过原型对象来设置一些属性和值
Person.prototype.name = 'LBJ辉';
Person.prototype.age = 18;
Person.prototype.height = 1.88;
Person.prototype.sayHello = function () {
alert(this.name);
};
// 创建两个对象, 并且调用方法
var person1 = new Person();
var person2 = new Person();
person1.sayHello(); // LBJ辉
person2.sayHello(); // LBJ辉
代码解析:
- 在上面的代码中, 我们没有给实例对象单独设置属性和方法, 而是直接设置给了原型对象.
- 而原型对象的作用是可以让所有的对象来共享这些属性和方法.
- 因此, 我们调用 sayHello()方法时, 它们打印的结果是一样的, 它们是共享的.
1.2. 深入原型对象
原型对象的创建:
- 无论什么时候,只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个 prototype 属性,这个属性指向函数的原型对象。
原型上的 constructor 属性:
- 默认情况下,所有原型对象都会自动获得一个 constructor(构造函数)属性,这个属性包含一个指向 prototype 属性所在函数的指针。
- 用我们上面的例子来说, Person.prototype.constructor 指向 Person。
- 也就是原型对象自身来说, 只有一个 constructor 属性, 而其他属性可以由我们添加或者从 Object 中继承.
新的实例创建时, 原型对象在哪里呢?
- 当调用构造函数创建一个新实例后,该实例的内部将包含一个内部属性,该属性的指针, 指向构造函数的原型对象。
- 这个属性是 proto
- 简单说, 每个实例中, 其实也会有一个属性, 该属性是指向原型对象的.
// 原型对象中有一个属性: constructor属性
// 属性指向Person函数
console.log(Person.prototype.constructor); // Person函数
// 对象实例也有一个属性指向原型
console.log(person1.__proto__); // 原型对象
console.log(Person.prototype); // 原型对象
console.log(person1.__proto__ === Person.prototype); // true
我们通过一个图来解释上面的概念:
解析:
- 上面的图解析了 Person 构造函数、Person 的原型属性以及 Person 现有的两个实例之间的关系
- Person.prototype 指向原型对象, 而 Person.prototype.constructor 又指回了 Person.
- 原型对象中除了包含 constructor 属性之外,还包括后来添加的其他属性。
- Person 的每个实例——personl 和 person2 都包含一个内部属性* proto *,该属性也指向原型对象;
对象搜索属性和方法的过程:
-
每当代码读取某个对象的某个属性时,都会执行一次搜索,也就是要找到给定名称的属性。
-
搜索首先从
对象实例本身
开始- 如果在实例中找到了具有给定名字的属性,则返回该属性的值;
-
如果没有找到,则继续搜索指针指向的原型对象,在原型对象中查找具有给定名字的属性
- 如果在原型对象中找到了这个属性,则返回该属性的值。
-
也就是说,在我们调用 personl.sayHello()的时候,会先后执行两次搜索。
-
现在我们也能理解, 为什么所有的实例中都包含一个 constructor 属性, 这是因为默认所有的原型对象中都包含了该属性.
可以通过proto来修改原型的值(通常不会这样修改, 知道即可)
- 你可以理解为什么 person1 修改了 name 后, person2 也会修改吗?
- 通过上面的图, 自己再来理解一下吧.
person1.sayHello(); // LBJ辉
person2.sayHello(); // LBJ辉
person1.__proto__.name = 'LBJ';
person1.sayHello(); // LBJ
person2.sayHello(); // LBJ
但是要注意下面的情况:
- 当我们给 person1.name 进行赋值时, 其实在给 person1 实例添加一个 name 属性.
- 这个时候再次访问时, 就不会访问原型中的 name 属性了.
// 创建两个对象, 并且调用方法
var person1 = new Person();
var person2 = new Person();
person1.sayHello(); // LBJ辉
person2.sayHello(); // LBJ辉
// 给person1实例添加属性
person1.name = 'LBJ';
person1.sayHello(); // LBJ, 来自实例
person2.sayHello(); // LBJ辉, 来自原型
通过 hasOwnProperty 判断属性属于实例还是原型.
// 判断属性属于谁
alert(person1.hasOwnProperty('name')); // true
alert(person2.hasOwnProperty('name')); // false
1.3. 简洁的原型语法
简洁语法概述:
- 如果按照前面的做法, 每添加一个原型属性和方法, 都要敲一遍 Person.prototype.
- 为了减少不必要的输入, 另外也为了更好的封装性, 更常用的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象.
字面量重写原型对象:
// 定义Person构造函数
function Person() {}
// 重写Person的原型属性
Person.prototype = {
name: 'LBJ辉',
age: 18,
height: 1.88,
sayHello: function () {
alert(this.name);
},
};
注意:
- 我们将 Person.prototype 赋值了一个新的对象字面量, 最终结果和原来是一样的;
- 但是: constructor 属性不再指向 Person 了;
- 前面我们说过, 每创建一个函数, 就会同时创建它的 prototype 对象, 这个对象也会自动获取 constructor 属性;
- 而我们这里相当于给 prototype 重新赋值了一个对象, 那么这个新对象的 constructor 属性, 会指向 Object 构造函数, 而不是 Person 构造函数了;
// 创建Person对象
var person = new Person();
alert(person.constructor === Object); // true
alert(person.constructor === Person); // false
alert(person instanceof Person); // true
如果在某些情况下, 我们确实需要用到 constructor 的值, 可以手动的给 constructor 赋值即可
// 定义Person构造函数
function Person() {}
// 重写Person的原型属性
Person.prototype = {
constructor: Person,
name: 'LBJ辉',
age: 18,
height: 1.88,
sayHello: function () {
alert(this.name);
},
};
// 创建Person对象
var person = new Person();
alert(person.constructor === Object); // false
alert(person.constructor === Person); // true
alert(person instanceof Person); // true
上面的方式虽然可以, 但是也会造成 constructor 的[[Enumerable]]特性被设置了 true.
- 默认情况下, 原生的 constructor 属性是不可枚举的.
- 如果希望解决这个问题, 就可以使用我们前面介绍的 Object.defineProperty()函数了.
// 定义Person构造函数
function Person() {}
// 重写Person的原型属性
Person.prototype = {
name: 'LBJ辉',
age: 18,
height: 1.88,
sayHello: function () {
alert(this.name);
},
};
Object.defineProperty(Person.prototype, 'constructor', {
enumerable: false,
value: Person,
});
1.4. 修改原型属性
考虑下面的代码执行是否会有问题:
// 定义Person构造函数
function Person() {}
// 创建Person的对象
var person = new Person();
// 给Person的原型添加方法
Person.prototype.sayHello = function () {
alert('Hello JavaScript');
};
// 调用方法
person.sayHello();
代码解析:
- 我们发现代码的执行没有任何问题.
- 因为在创建 person 的时候, person 的proto也是指向的 Person.prototype.
- 所以, 当动态的修改了 Person.prototype 中的 sayHello 属性时, person 中也可以获取到该属性
我们再来看下面的代码会不会有问题:
// 定义Person构造函数
function Person() {}
// 创建Person的对象
var person = new Person();
// 给Person的原型添加方法
Person.prototype = {
constructor: Person,
sayHello: function () {
alert('Hello JavaScript');
},
};
// 调用方法
person.sayHello();
代码解析:
- 代码是不能正常运行的. 因为 Person 的 prototype 指向了一个新的对象.
- 而最初我们创建的 person 依然指向原来的原型对象, 原来的原型对象没有 sayHello()函数.
- 当然, 如果再次之后, 再创建的 Person 对象, 是可以调用 sayHello()的, 但是再次之前创建的, 没有该方法.
1.5. 原型对象问题
原型对象也有一些缺点:
- 首先, 它不再有为构造函数传递参数的环节, 所有实例在默认情况下都将有相同的属性值.
- 另外, 原型中所有的属性是被很多实例共享的, 这种共享对于函数来说非常适合, 对于基本属性通常情况下也不会有问题. (因为通过 person.name 直接修改时, 会在实例上重新创建该属性名, 不会在原型上修改. 除非使用 person.proto.name 修改).
- 但是, 对于引用类型的实例, 就必然会存在问题.
考虑下面代码的问题:
// 定义Person构造函数
function Person() {}
// 设置Person原型
Person.prototype = {
constructor: Person,
name: 'LBJ辉',
age: 18,
height: 1.88,
hobby: ['Basketball', 'Football'],
sayHello: function () {
alert('Hello JavaScript');
},
};
// 创建两个person对象
var person1 = new Person();
var person2 = new Person();
alert(person1.hobby); // Basketball,Football
alert(person2.hobby); // Basketball,Football
person1.hobby.push('tennis');
alert(person1.hobby); // Basketball,Football,tennis
alert(person2.hobby); // Basketball,Football,tennis
OK, 我们会发现, 我们明明给 person1 添加了一个爱好, 但是 person2 也被添加到一个爱好.
- 因为它们是共享的同一个数组.
- 但是, 我们希望每个人有属于自己的爱好, 而不是所有的 Person 爱好都相同.
二. 组合构造函数和原型模式
创建自定义类型的最常见方式,就是组合使用构造函数模式与原型模式。
构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。
结果,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方法的引用,最大限度地节省了内存。
另外,这种混成模式还支持向构造函数传递参数;可谓是集两种模式之长。
组合构造函数和原型模式的代码
// 创建Person构造函数
function Person(name, age, height) {
this.name = name;
this.age = age;
this.height = height;
this.hobby = ['Basketball', 'Football'];
}
// 重新Peron的原型对象
Person.prototype = {
constructor: Person,
sayHello: function () {
alert('Hello JavaScript');
},
};
// 创建对象
var person1 = new Person('LBJ辉', 18, 1.88);
var person2 = new Person('LBJ', 38, 2.03);
// 测试是否共享了函数
alert(person1.sayHello == person2.sayHello); // true
// 测试引用类型是否存在问题
person1.hobby.push('tennis');
alert(person1.hobby);
alert(person2.hobby);
如果理解了原型, 上面的代码非常好理解.
- person1 和 person2 各有一份自己的属性, 但是方法是共享的.
事实上, 还有一些其他的变种模式来实现基于对象的封装. 但是这种方式是最常用的, 因此我们这里不再展开讨论其他的模式. 后续需要我们再深入讨论。
JavaScript 面向对象详解(三)
继承是面向对象中非常重要的特性.
ES5 中和类的实现一样, 不能直接实现继承. 实现继承主要是依靠原型链来实现的。
一. 原型链
原型链是 ES5 中实现继承的主要手段, 因此相对比较重要, 我们需要深入理解原型链.
1.1. 深入理解原型链
先来回顾一下构造函数、原型和实例的关系:
- 每个构造函数都有一个原型对象, 通过 prototype 指针指向该原型对象.
- 原型对象都包含一个指向构造函数的指针, 通过 constructor 指针, 指向构造函数
- 而实例都包含一个指向原型对象的内部指针, 该内部指针我们通常使用proto来描述.
思考如下情况:
- 我们知道, 可以通过 Person.prototype = {}的方式来重写原型对象.
- 假如, 我们后面赋值的不是一个{}, 而是另外一个类型的实例, 结果会是怎么样呢?
- 显然,此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针。
- 假如另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实例与原型的链条。这就是所谓原型链的基本概念。
有些抽象, 我们通过代码来理解:
// 创建Person构造函数
function Person() {}
// 设置Animal的原型
Person.prototype = {};
我们将代码修改成原型链的形式:
// 1.创建Animal的构造函数
function Animal() {
this.animalProperty = 'Animal';
}
// 2.给Animal的原型中添加一个方法
Animal.prototype.animalFunction = function () {
alert(this.animalProperty);
};
// 3.创建Person的构造函数
function Person() {
this.personProperty = 'Person';
}
// 4.给Person的原型对象重新赋值
Person.prototype = new Animal();
// 5.给Person添加属于自己的方法
Person.prototype.personFunction = function () {
alert(this.personProperty);
};
// 6.创建Person的实例
var person = new Person();
person.animalFunction(); //Animal
person.personFunction(); //Person
代码解析:
-
代码有一些复杂, 但是如果你希望学习好原型链, 必须耐心去看一看上面的代码, 你会发现其实都是我们学习过的.
-
重点我们来看第 4 步代码: 给 Person.prototype 赋值了一个 Animal 的实例. 也就是 Person 的原型变成了 Animal 的实例.
-
Animal 实例本身有一个proto可以指向 Animal 的原型.
-
那么, 我们来思考一个问题: 如果现在搜索一个属性或者方法, 这个时候会按照什么顺序搜索呢?
- 第一步, 在 person 实例中搜索, 搜索到直接返回或者调用函数. 如果没有执行第二步.
- 第二步, 在 Person 的原型中搜索, Person 的原型是谁? Animal 的实例. 所以会在 Animal 的实例中搜索, 无论是属性还是方法, 如果搜索到则直接返回或者执行. 如果没有, 执行第三步.
- 第三步, 在 Animal 的原型中搜索, 搜索到返回或者执行, 如果没有, 搜索结束. (当然其实还有 Object, 但是先不考虑)
画图解析可能更加清晰:
当代码执行到第 3 步(上面代码的序号)的时候, 如图所示:
当代码执行第 4 步(上面代码的序号)时, 发生了如图所示的变化
- 注意图片中的红色线, 原来指向的是谁, 现在指向的是谁.
代码继续执行
- Person.prototype.personFunction = function (){}
- 当执行第 5 步, 也就是给 Person 的原型赋值了一个函数时, 事实上在给 new Animal(Animal 的实例)赋值了一个新的方法.
代码继续执行, 我们创建了一个 Person 对象
- 创建 Person 对象, person 对象会有自己的属性, personProperty.
- 另外, person 对象有一个prototype指向 Person 的原型.
- Person 的原型是谁呢? 就是我们之前的 new Animal(Animal 的一个实例), 所以会指向它.
原型链简单总结:
- 通过实现原型链,本质上扩展了本章前面介绍的原型搜索机制。
- 当以读取模式访问一个实例属性时,首先会在实例中搜索该属性。如果没有找到该属性,则会继续搜索实例的原型。在通过原型链实现继承的情况下,搜索过程就得以沿着原型链继续向上。
- 在找不到属性或方法的情况下,搜索过程总是要一环一环地前行到原型链末端才会停下来。
1.2. 原型和实例的关系
如果我们希望确定原型和实例之间的关系, 有两种方式:
- 第一种方式是使用 instanceof 操作符,只要用这个操作符来测试实例与原型链中出现过的构造函数,结果就会返回 true。
- 第二种方式是使用 isPrototypeOf()方法。同样,只要是原型链中出现过的原型,都可以说是该原型链所派生的实例的原型,因此 isPrototypeOf()方法也会返回 true
instanceof 操作符
// instanceof
alert(person instanceof Object); // true
alert(person instanceof Animal); // true
alert(person instanceof Person); // true
isPrototypeOf()函数
// isPrototypeOf函数
alert('isPrototypeOf函数函数');
alert(Object.prototype.isPrototypeOf(person)); // true
alert(Animal.prototype.isPrototypeOf(person)); // true
alert(Person.prototype.isPrototypeOf(person)); // true
1.3. 添加新的方法
添加新的方法
- 在第 5 步操作中, 我们为子类型添加了一个新的方法. 但是这里有一个注意点.
- 无论是子类中添加新的方法, 还是对父类中方法进行重写. 都一定要将添加方法的代码, 放在替换原型语句之后.
- 否则, 我们添加的方法将会无效.
错误代码引起的代码:
// 1.定义Animal的构造函数
function Animal() {
this.animalProperty = 'Animal';
}
// 2.给Animal添加方法
Animal.prototype.animalFunction = function () {
alert(this.animalProperty);
};
// 3.定义Person的构造函数
function Person() {
this.personProperty = 'Person';
}
// 4.给Person添加方法
Person.prototype.personFunction = function () {
alert(this.personProperty);
};
// 5.给Person赋值新的原型对象
Person.prototype = new Animal();
// 6.创建Person对象, 并且调用方法
var person = new Person();
person.personFunction(); // 不会有任何弹窗, 因为找不到该方法
代码解析:
- 执行上面的代码不会出现任何的弹窗, 因为我们添加的方法是无效的, 被赋值的新的原型覆盖了.
- 正确的办法是将第 4 步和第 5 步操作换一下位置即可.
总结
- 其实这个问题没什么好说的, 只要你理解了原型链(好好看看我上面画的图, 或者自己画一下图)
- 但是, 切记在看图的过程中一样扫过, 因为这会让你错过很多细节, 对原型链的理解就会出现问题.
1.4. 原型链的问题
原型链对于继承来说:
- 原型链似乎对初学 JavaScript 原型的人来说, 已经算是比较高明的设计技巧了, 有些人理解起来都稍微有些麻烦.
- 但是, 这种设计还存在一些缺陷, 不是最理性的解决方案. (但是后续的解决方案也是依赖原型链, 无论如何都需要先理解它)
原型链存在的问题:
- 原型链存在最大的问题是关于引用类型的属性.
- 通过上面的原型实现了继承后, 子类的 person 对象继承了(可以访问)Animal 实例中的属性(animalProperty).
- 但是如果这个属性是一个引用类型(比如数组或者其他引用类型), 就会出现问题.
引用类型的问题代码:
// 1.定义Animal的构造函数
function Animal() {
this.colors = ['red', 'green'];
}
// 2.给Animal添加方法
Animal.prototype.animalFunction = function () {
alert(this.colors);
};
// 3.定义Person的构造函数
function Person() {
this.personProperty = 'Person';
}
// 4.给Person赋值新的原型对象
Person.prototype = new Animal();
// 5.给Person添加方法
Person.prototype.personFunction = function () {
alert(this.personProperty);
};
// 6.创建Person对象, 并且调用方法
var person1 = new Person();
var person2 = new Person();
alert(person1.colors); // red,green
alert(person2.colors); // red,green
person1.colors.push('blue');
alert(person1.colors); // red,green,blue
alert(person2.colors); // red,green,blue
代码解析:
- 我们查看第 6 步的操作
- 创建了两个对象, 并且查看了它们的 colors 属性
- 修改了 person1 中的 colors 属性, 添加了一个新的颜色 blue
- 再次查看两个对象的 colors 属性, 会发现 person2 的 colors 属性也发生了变化
- 两个实例应该是相互独立的, 这样的变化如果我们不制止将会在代码中引发一些列问题.
原型链的其他问题:
- 在创建子类型的实例时,不能向父类型的构造函数中传递参数。
- 实际上,应该说是没有办法在不影响所有对象实例的情况下,给父类型的构造函数传递参数。
- 从而可以修改父类型中属性的值, 在创建构造函数的时候就确定一个值.
二. 经典继承
为了解决原型链继承中存在的问题, 开发人员提供了一种新的技术: constructor stealing(有很多名称: 借用构造函数或经典继承或伪造对象), steal 是偷窃的意思, 但是这里可以翻译成借用.
2.1. 经典继承的思想
经典继承的做法非常简单: 在子类型构造函数的内部调用父类型构造函数.
- 因为函数可以在任意的时刻被调用
- 因此通过 apply()和 call()方法也可以在新创建的对象上执行构造函数.
经典继承代码如下:
// 创建Animal的构造函数
function Animal() {
this.colors = ['red', 'green'];
}
// 创建Person的构造函数
function Person() {
// 继承Animal的属性
Animal.call(this);
// 给自己的属性赋值
this.name = 'LBJ辉';
}
// 创建Person对象
var person1 = new Person();
var person2 = new Person();
alert(person1.colors); // red,green
alert(person2.colors); // red,green
person1.colors.push('blue');
alert(person1.colors); // red,green,blue
alert(person2.colors); // red,green
代码解析:
- 我们通过在 Person 构造函数中, 使用 call 函数, 将 this 传递进去.
- 这个时候, 当 Animal 中有相关属性初始化时, 就会在 this 对象上进行初始化操作.
- 这样就实现了类似于继承 Animal 属性的效果.
这个时候, 我们也可以传递参数, 修改上面的代码:
// 创建Animal构造函数
function Animal(age) {
this.age = age;
}
// 创建Person构造函数
function Person(name, age) {
Animal.call(this, age);
this.name = name;
}
// 创建Person对象
var person = new Person('LBJ辉', 18);
alert(person.name);
alert(person.age);
2.2. 经典继承的问题
经典继承的问题:
- 对于经典继承理解比较深入, 你已经能发现: 经典继承只有属性的继承, 无法实现方法的继承.
- 因为调用 call 函数, 将 this 传递进去, 只能将父构造函数中的属性初始化到 this 中.
- 但是如果函数存在于父构造函数的原型对象中, this 中是不会有对应的方法的.
回顾原型链和经典继承:
- 原型链存在的问题是引用类型问题和无法传递参数, 但是方法可以被继承
- 经典继承是引用类型没有问题, 也可以传递参数, 但是方法无法被继承.
- 怎么办呢? 将两者结合起来怎么样?
三. 组合继承
如果你认识清楚了上面两种实现继承的方式存在的问题, 就可以很好的理解组合继承了.
组合继承(combination inheritance, 有时候也称为伪经典继承), 就是将原型链和经典继承组合在一起, 从而发挥各自的优点.
3.1. 组合继承的思想
组合继承:
- 组合继承就是发挥原型链和经典继承各自的优点来完成继承的实现.
- 使用原型链实现对原型属性和方法的继承.
- 通过经典继承实现对实例属性的继承, 以及可以在构造函数中传递参数.
组合继承的代码:
// 1.创建构造函数的阶段
// 1.1.创建Animal的构造函数
function Animal(age) {
this.age = age;
this.colors = ['red', 'green'];
}
// 1.2.给Animal添加方法
Animal.prototype.animalFunction = function () {
alert('Hello Animal');
};
// 1.3.创建Person的构造函数
function Person(name, age) {
Animal.call(this, age);
this.name = name;
}
// 1.4.给Person的原型对象重新赋值
Person.prototype = new Animal(0);
// 1.5.给Person添加方法
Person.prototype.personFunction = function () {
alert('Hello Person');
};
// 2.验证和使用的代码
// 2.1.创建Person对象
var person1 = new Person('LBJ辉', 18);
var person2 = new Person('LBJ', 38);
// 2.2.验证属性
alert(person1.name + '-' + person1.age); // LBJ辉,18
alert(person2.name + '-' + person2.age); // LBJ,38
// 2.3.验证方法的调用
person1.animalFunction(); // Hello Animal
person1.personFunction(); // Hello Person
// 2.4.验证引用属性的问题
person1.colors.push('blue');
alert(person1.colors); // red,green,blue
alert(person2.colors); // red,green
代码解析:
- 根据前面学习的知识, 结合当前的代码, 大家应该可以理解上述代码的含义.
- 但是我还是建议大家一定要多手动自己来敲代码, 来理解其中每一个步骤.
- 记住: 看懂, 听懂不一定真的懂, 自己可以写出来, 才是真的懂了.
3.2. 组合继承的分析
组合继承是 JavaScript 最常用的继承模式之一.
- 如果你理解到这里, 点到为止, 那么组合来实现继承只能说问题不大.
- 但是它依然不是很完美, 存在一些问题不大的问题.(不成问题的问题, 基本一词基本可用, 但基本不用)
组合继承存在什么问题呢?
- 组合继承最大的问题就是无论在什么情况下, 都会调用两次父类构造函数.
- 一次在创建子类原型的时候
- 另一次在子类构造函数内部(也就是每次创建子类实例的时候).
- 另外, 如果你仔细按照我的流程走了上面的每一个步骤, 你会发现: 所有的子类实例事实上会拥有两份父类的属性
- 一份在当前的实例自己里面(也就是 person 本身的), 另一份在子类对应的原型对象中(也就是 person.proto里面)
- 当然, 这两份属性我们无需担心访问出现问题, 因为默认一定是访问实例本身这一部分的.
怎么解决呢?
- 看起来组合继承也不是非常完美的解决方案, 虽然也可以应用.
- 有没有终极的解决方案呢? 预知后事如何, 且听下回分解.
JavaScript 面向对象详解(四)
在上一篇中, 我们讨论了 ES5 中, 实现继承的一些方式.
在最后, 我们说了组合继承是相对完美的解决方案, 但是它也存在一些问题.
这篇文章, 我们就通过某种新的模式, 给出一种目前使用最多, 也是我们最终的解决方案.
一. 原型式继承
1.1. 原型式继承的思想
原型式继承的渊源
- 这种模式要从道格拉斯·克罗克福德(Douglas Crockford, 著名的前端大师, JSON 的创立者)在 2006 年写的一篇文章说起: Prototypal Inheritance in JavaScript(在 JS 中使用原型式继承)
- 在这篇文章中, 它介绍了一种继承方法, 而且这种继承方法不是通过构造函数来实现的.
- 为了理解这种方式, 我们先再次回顾一下 JavaScript 想实现继承的目的: 重复利用另外一个对象的属性和方法.
原型式继承的核心函数:
// 封装object()函数
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
代码解析:
- 在 object()函数内部, 先创建一个临时的构造函数.
- 然后将传递的对象作为这个构造函数的原型
- 最后返回了这个临时类型的一个新的实例.
- 事实上, object()对传入的对象执行了一次浅复制.
1.2. 原型式继承的使用
使用原型式继承:
// 使用原生式继承
var person = {
name: 'LBJ辉',
colors: ['red', 'green'],
};
// 通过person去创建另外一个对象
var person1 = object(person);
person1.name = 'LBJ';
person1.colors.push('blue');
alert(person1.name); // LBJ
alert(person1.colors); // red,green,blue
alert(person.name); // LBJ辉
alert(person.colors); // red,green,blue
代码解析:
- 这种方式和我们传统意义上理解的继承有些不同. 它做的事情是通过一个对象去创建另外一个对象.(利用 person 去创建 person1)
- 当然, person1 中继承过来的属性是放在了自己的原型对象中的.
- 也可以给 person1 自己再次添加 name 属性, 这个时候 name 才是在实例本身中.
- 但是如果是修改或者添加引用类型的内容, 还是会引起连锁反应.
- 可能暂时你看不到这些代码的意义, 但是这些代码是我们后续终极方案的前提思想, 所以先看看和练习一下这些代码.
针对这种思想, ES5 中新增了 Object.create()方法来规范化了原型式继承.
- 也就是上面的代码可以修改成这样.(只是将 object 函数修改成了 Object.create)
// 使用原生式继承
var person = {
name: 'LBJ辉',
colors: ['red', 'green'],
};
// 通过person去创建另外一个对象
var person1 = Object.create(person);
person1.name = 'LBJ';
person1.colors.push('blue');
alert(person1.name); // LBJ
alert(person1.colors); // red,green,blue
alert(person.name); // LBJ辉
alert(person.colors); // red,green,blue
Object.create()还可以传入第二个参数:
- 第二个参数用于每个属性的自定义描述.
- 比如 person1 的 name 我们希望修改成"LBJ", 就可以这样来做
// 使用原型式继承
var person = {
name: 'LBJ辉',
colors: ['red', 'green'],
};
// 通过person去创建另外一个对象
var person1 = Object.create(person, {
name: {
value: 'LBJ',
},
});
person1.colors.push('blue');
alert(person1.name); // LBJ
alert(person1.colors); // red,green,blue
alert(person.name); // LBJ辉
alert(person.colors); // red,green,blue
1.3. 原型式继承的问题
-
原型式继承的的优点和缺点:
-
- 如果我们只是希望一个对象和另一个对象保持类似的情况下, 原型式继承完全可以胜任, 这是它的优势.
- 但是, 原型式继承依然存在属性共享的问题, 就像使用原型链一样.
二. 寄生式继承
2.1. 寄生式继承的思想
寄生式(Parasitic)继承
- 寄生式(Parasitic)继承是与原型式继承紧密相关的一种思想, 并且同样由道格拉斯·克罗克福德(Douglas Crockford)提出和推广的
- 寄生式继承的思路是结合原型类继承和工厂模式的一种方式.
- 即创建一个封装继承过程的函数, 该函数在内部以某种方式来增强对象, 最后再将这个对象返回.
寄生式函数多增加了一个核心函数:
// 封装object函数
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
// 封装创建新对象的函数
function createAnother(original) {
var clone = object(original);
clone.sayHello = function () {
alert('Hello JavaScript');
};
return clone;
}
2.2. 寄生式继承的应用
我们来使用一下寄生式继承
// person对象
var person = {
name: 'LBJ辉',
colors: ['red', 'green'],
};
// 新的对象
var person1 = createAnother(person);
person1.sayHello();
代码解读:
- 我们基于 person 对象, 创建了另外一个对象 person1.
- 在最新的 person1 对象中, 不仅会拥有 person 的属性和方法, 而且还有自己定义的方法.
2.3. 寄生式继承的问题
寄生式继承存在的问题:
- 寄生式继承和原型式继承存在一样的问题, 引用类型会共享. (因为是在原型式继承基础上的一种封装)
- 另外寄生式继承还存在函数无法复用的问题, 因为每次 createAnother 一个新的对象, 都需要重新定义新的函数.
三. 寄生组合式继承
3.1. 寄生组合式继承的思想
寄生组合式继承
-
现在我们来回顾一下之前提出的比较理想的组合继承
-
- 组合继承是比较理想的继承方式, 但是存在两个问题:
- 问题一: 构造函数会被调用两次: 一次在创建子类型原型对象的时候, 一次在创建子类型实例的时候.
- 问题二: 父类型中的属性会有两份: 一份在原型对象中, 一份在子类型实例中.
-
事实上, 我们现在可以利用寄生式继承将这两个问题给解决掉.
-
- 你需要先明确一点: 当我们在子类型的构造函数中调用父类型.call(this, 参数)这个函数的时候, 就会将父类型中的属性和方法复制一份到了子类型中. 所以父类型本身里面的内容, 我们不再需要.
- 这个时候, 我们还需要获取到一份父类型的原型对象中的属性和方法.
- 能不能直接让子类型的原型对象 = 父类型的原型对象呢?
- 不要这么做, 因为这么做意味着以后修改了子类型原型对象的某个引用类型的时候, 父类型原生对象的引用类型也会被修改.
- 我们使用前面的寄生式思想就可以了.
寄生组合式的核心代码:
// 定义object函数
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
// 定义寄生式核心函数
function inhreitPrototype(subType, superType) {
var prototype = object(superType.prototype);
prototype.constructor = subType;
subType.prototype = prototype;
}
3.2. 寄生组合式继承的应用
直接给出使用的代码, 也是我们以后使用继承的终极方式
// 定义Animal构造函数
function Animal(age) {
this.age = age;
this.colors = ['red', 'green'];
}
// 给Animal添加方法
Animal.prototype.animalFunction = function () {
alert('Hello Animal');
};
// 定义Person构造函数
function Person(name, age) {
Animal.call(this, age);
this.name = name;
}
// 使用寄生组合式核心函数
inhreitPrototype(Person, Animal);
// 给Person添加方法
Person.prototype.personFunction = function () {
alert('Hello Person');
};
代码的优点:
- 这种方式的高效体现在现在它只调用了一次 Animal 的构造函数.
- 并且也避免了在原型上面多出的多余属性, 而且原型之间不会产生任何的干扰(子类型原型和父类型原型之间).
- 在 ES5 中, 普遍认为寄生组合式继承是最理想的继承范式.