理解 JavaScript 面向对象的 封装、继承、多态 三大特性
前言
最近学了 JavaScript 相关的设计模式,书名《JavaScript设计模式与开发实践》,为加深对 OOP 和设计模式的理解,分成三个部分来理解 JavaScript 的面向对象设计。
面向对象的 JavaScript (OOJS)
面向对象三大特性:
- 封装
- 继承
- 多态
封装
「封装」 把客观事物封装成抽象的类,隐藏属性和方法,仅对外公开接口。
私有属性和方法
只能在构造函数内访问不能被外部所访问(在构造函数内使用 var 声明的属性)
公有属性和方法(或实例方法)
对象外可以访问到对象内的属性和方法(在构造函数内使用 this 设置,或者设置在构造函数原型对象上比如 Person.prototype.xxx)
静态属性和方法
定义在构造函数上的方法(比如 Person.xxx),不需要实例就可以调用;
ES6 之前的封装(非 Class)
函数(function)
首先来看零散的语句
var oDiv = document.getElementsByTagName("div")[0];
var p = document.createElement("p");
body.style.backgroundColor = "green";
p.innerText ="我是新增的标签内容";
oDiv.appendChild(p);
缺点很明显:
- 每次都会执行这段代码,造成浪费资源
- 用以被同名变量覆盖掉——因为是在全局作用域下申明的,所以容易被同名变量覆盖
接下来将零散的的语句写进函数的花括号内,成为函数体。
function createTag() {
var oDiv = document.getElementsByTagName("div")[0];
var p = document.createElement("p");
body.style.backgroundColor = "green";
p.innerText = "我是新增的标签内容";
oDiv.appendChild(p);
}
优点也很明显
提高了代码的复用性; 按需执行——解析器读到此处,函数并不会立即执行,只有当被调用的时候才会执行; 避免全局变量——因为存在函数作用的问题。
原始模式
var p1 = {};
p1.name = 'a';
p2.sex = 'male';
var p2 = {};
p2.name = 'b';
p2.sex = 'female'
优点:一把梭 缺点:生成几个实例,相似的对象,代码重复;而且实例与原型之间没有什么联系;
工厂模式
function Person(name, sex) {
// this.name = name
// this.sex = sex
return { name, sex }
}
var p1 = new Person('a', 'male')
var p2 = new Person('b', 'female')
优点:解决代码重复的问题 缺点:p1 和 p2 没有内在联系,不能反映出是同一个原型的实例
构造函数模式
构造函数其实就是一个普通函数,但是内部有 this 指向,对构造函数使用 new 运算符就可以生成构造函数的实例。并且 this 会指向新生成的实例。
比如工厂模式中的 Person 改造成构造函数
function Person(name, sex) {
this.name = name;
this.sex = sex;
this.getName = function() {
console.log(this.name)
}
}
// 通过 new 运算符
var p1 = new Person('a','male');
var p2 = new Person('b','female');
p1.getName()
console.log(p1) //{name: "a", sex: "male"}
console.log(p2) //{name: "b", sex: "female"}
优点:解决代码重复的问题,反映出 p1 和 p2 是同一个原型的实例。 缺点:多个实例有重复的属性和方法,占用内存。(所有的实例都会有 getName 方法)
Prototype 模式
每一个构造函数都有一个 prototype 属性,指向另一个对象。这个对象的所有属性和方法,都会被构造函数的实例继承。这意味着那些不变的属性和方法,可以直接定义在 prototype 对象上。
function Person(name, sex) {
this.name = name
this.sex = sex
}
Person.prototype.getName = function() {
console.log(this.name)
}
var p1 = new Person('a','male');
var p2 = new Person('b','female');
p1.getName()
console.log(p1) //{name: "a", sex: "male"}
console.log(p2) //{name: "b", sex: "female"}
优点:所有实例的 getName 方法指向都是同一个内存地址,指向 prototype 对象,减少内存占用,提高效率。
一个完整的例子
function Person(name, sex) {
// !!! 私有属性和方法
var id = +new Date()
var getID = function() {
return id
}
// !!! 公有属性和方法
this.name = name
this.sex = sex
this.description = '我是公有属性'
this.getInfo = function() {
var id = getID()
return { id, name, sex }
}
}
// !!! 静态属性和方法
Person.description = '我是静态属性'
Person.work = function() {
return 'freelancer'
}
// !!! 原型上的属性和方法
Person.prototype.description = '我是原型上的属性'
Person.prototype.hobby = ['游泳', '跑步'];
Person.prototype.getHobby = function() {
console.log(this.hobby)
}
var p1 = new Person('小明','male');
// 试着输出私有属性 id
console.log(p1.id) // undefined
// 输出公有属性或方法
console.log(p1.name, p1.sex, p1.description, p1.getInfo())
// 输出静态属性或方法
console.log(Person.description, Person.work())
// 输出原型上的属性或方法
console.log(p1.hobby, p1.getHobby()
注意 p1.description 输出的是 ”我是公有属性“
ES6 之后的封装
在 ES6 之后,新增了 class 这个关键字,代表传统面向对象语言的类的概念。但是并不是真的在 JavaScript 中实现了类的概念,还是一个构造函数的语法糖。存在只是为了让对象的原型功能更加清晰,更加符合面向对象语言的特点。
class Person {
constructor(name, sex) {
this.name = name
this.sex = sex
}
getInfo() {
console.log(this.name, this.sex)
}
}
注意 class 内部的属性和方法都是不可枚举的 类的数据类型就是函数,类本身指向构造函数即 Person.prototype.constructor === Person 成立
类的公有属性和方法
class Person {
constructor(name, sex) {
// 公有属性和方法
this.name = name
this.sex = sex
this.getInfo = function() {
return { name, sex }
}
}
}
类的私有属性和方法
class Person {
constructor(name, sex) {
this.name = name
this.sex = sex
this.getInfo = function() {
return { id, name, sex }
}
// 私有属性和方法
var id = +new Date()
var getID = function() {
return id
}
}
}
let p = new Person('小明','male')
console.log(p.getInfo())
原型上的属性和方法
class Person {
constructor(name, sex) {
this.name = name
this.sex = sex
// 原型上的属性和方法
a = 1
getInfo() {
console.log(this.name, this.sex)
}
}
}
let p = new Person('小明','male')
console.log(p.a)
静态属性和静态方法(static)
class Person {
constructor(name, age) {
this.name = name
this.age = age
}
static description = '我是一个静态属性'
static getDescription() {
console.log("我是一个静态方法")
}
}
// 或者
class Person {
constructor(name, age) {
this.name = name
this.age = age
}
}
Person.description = '我是一个静态属性'
Person.getDescription = function() {
console.log("我是一个静态方法")
}
// 我是一个静态属性
console.log(Person.description)
// 我是一个静态方法
Person.getDescription()
类的实例属性
class Person {
constructor(name, age) {
this.name = name
this.age = age
this.getName = function() {
console.log(this.name)
}
}
// !!! 实例的属性和方法
myProp = '我是实例属性'
getMyProp = function() {
console.log(this.myProp + '=')
}
// !!! 类的原型上的方法
getMyProp() {
console.log(this.myProp)
}
getInfo() {
console.log('获取信息')
}
}
let p = new Person('小明','male')
// 我是实例属性
p.getMyProp() // 我是实例属性=,实例属性优先于类的原型的上的属性和方法
console.log(p.hasOwnProperty('getName')) // true
console.log(p.hasOwnProperty('getMyProp')) // true
console.log(p.hasOwnProperty('getInfo')) // false
注意事项
- class 不会变量提升
new Foo();class Foo{}会报错; - class 中如果存在同名的属性或者方法,用 this 定义的方法会覆盖用”等号“定义的属性或方法;
继承
继承是面向对象语言中最有意思的概念。 许多面向对象语言都支持两种继承方式,继承通常包括"实现继承"和"接口继承"。
- 接口继承:继承方法签名
- 实现继承:继承实际方法
由于 JS 中没有签名,所以无法实现接口继承,只支持实现继承,依赖原型链实现。
原型和实例的关系
- 构造函数.prototype 指向原型对象
- 原型对象.constructor 指向构造函数
- 实例对象.proto 指向原型对象

看一个简单的原型链实现继承的例子
function Father(name){
this.fatherName = name
}
Father.prototype.getFatherValue = function(){
return this.fatherName
}
function Son(name){
this.sonName = name
}
// 继承 Father
Son.prototype = new Father('Dad') // Son.prototype 被重写, 导致 Son.prototype.constructor 也一同被重写
Son.prototype.getSonValue = function() {
return this.sonName
}
// 实例化 son
var son = new Son('Son')
console.log(son.getFatherValue()) // Dad
原型链实现继承存在的问题
- 当原型链中包含引用类型值的原型时,该引用类型值会被所有实例共享
- 在创建子类型时,不能向超类(例如父类)的构造函数中传递参数
实践中很少会单独使用原型链,有几种办法尝试弥补原型链的不足
借助构造函数 / 经典继承
在子类构造函数的内部调用超类构造函数
// 构造函数
function Father() {
this.colors = ['red', 'blue', 'green']
// 添加超类方法
this.test = function() {
console.log('测试添加超类方法')
}
}
function Son(name, age) {
this.name = name
this.age = age
// 相当于调用 Father 函数将父类的属性和方法添加到子类上,原型没有被修改
return Father.call(this) // 继承了 Father,且向父类型传递参数
}
var son1 = new Son('son1', 12)
son1.colors.push('black')
console.log(son1.colors) // "red,blue,green,black"
var son2 = new Son('son2', 13)
console.log(son2.colors) // "red,blue,green" 可见引用类型值是独立的
优点:
- 原型链中引用类型值不再被所有实例共享
- 子类型创建的时候也能够向父类传递参数
缺点:
- 方法都在构造函数里定义,函数无法复用
- 超类中定义的方法,对子类是不可见的,即子类无法直接调用父类的方法
组合继承 / 伪经典继承
使用原型链实现对原型属性和方法的继承,通过借用构造函数来实现对实例属性的继承
function Father(name) {
this.name = name
this.colors = ['red', 'blue', 'green']
}
Father.prototype.sayName = function() {
console.log(this.name)
}
function Son(name, age) {
Father.call(this, name) // 第二次调用 Father() 使用构造函数来继承实例属性
this.age = age
}
// 第一次调用 Father() 使用原型链实现对原型属性和方法的继承
Son.prototype = new Father()
Son.prototype.sayAge = function() {
console.log(this.age)
}
var son1 = new Son('son1', 10)
son1.colors.push('black')
son1.sayName(), son1.sayAge()
console.log('son1.colors', son1.colors)
var son2 = new Son('son2', 12)
son2.sayName(), son2.sayAge()
console.log('son2.colors', son2.colors)
组合继承避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为 JavaScript 中最常用的继承模式。而且, instanceof 和 isPrototypeOf() 也能用于识别基于组合继承创建的对象。同时我们还注意到组合继承其实调用了两次父类构造函数,造成了不必要的消耗。
规范化原型继承
在 es5 中,通过 Object.create() 方法规范化了上面的原型式继承,接收了两个参数,一个用作新对象原型的对象、一个为新对象定义额外属性的对象(可选的)。
提醒:因为对传入的对象使用的是浅拷贝,所以包含引用类型值的属性始终都会共享相应的值,就像使用原型模式一样。
var person = {
friends: ["Van","Louis","Nick"]
}
var anotherPerson = Object.create(person, {
name: {
value: "Louis"
}
})
anotherPerson.friends.push("Rob")
// 在创建一个人,修改朋友列表
var yetAnotherPerson = Object.create(person)
yetAnotherPerson.friends.push("Style")
console.log(person.friends) // "Van,Louis,Nick,Rob,Style” 不同实例对象始终共享引用类型值
寄生式继承
构造函数+工厂模式:创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再像真的是它做了所有工作一样返回对象。
function createAnother(origin) {
var clone = Object.create(origin)
clone.sayHi = function() {
console.log('hi')
}
return clone
}
var a = {
test: 1
}
var a1 = createAnother(a)
console.log(a1)
输出
a1: {
sayHi: f(),
__proto__: {
test: 1,
__proto__: {
constructor: f Object()
}
}
}
使用寄生式继承来为对象添加函数,无法做到函数复用而降低效率。
寄生组合继承
组合继承是最常用的,但是会调用两次父类构造函数。
一是在创建子类型原型的时候,另一次是在子类型的构造函数内部。
寄生组合式继承就是为了降低父类构造函数的开销而出现的。
集寄生式继承和组合式继承的优点于一身,是最有效的实现类型继承的方法。
// 寄生继承
function extend(subClass, superClass) {
//基于超类(构造函数)的原型对象创建新的原型对象
var prototype = Object.create(superClass.prototype)
// 子类的原型对象指向新创建的原型对象
subClass.prototype = prototype // 指定对象
// 原型对象的构造函数指向子类
prototype.constructor = subClass // 增强对象
}
function Father(name){
console.log('调用了 father')
this.name = name;
this.colors = ["red","blue","green"];
}
Father.prototype.sayName = function(){
console.log(this.name);
};
// 组合继承
function Son(name, age){
Father.call(this, name); //继承实例属性,第一次调用Father()
this.age = age;
}
extend(Son, Father) // 继承父类方法,此处并不会第二次调用Father()
Son.prototype.sayAge = function(){
console.log(this.age);
}
多态
看完了上面的封装和继承,到了多态这一步就没那么多复杂的代码了。
概念
同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。
在强类型语言中,如 C++、C#、Java
通常采用抽象类或者接口,进行更高一层的抽象,从而可以直接使用该抽象,即”向上转型“。本质上是为了弱化具体类型带来的限制。
在弱类型语言中,如 JavaScript
在 JavaScript 中,万物皆对象,对象的多态性是与生俱来的。多态可以把过程化的条件分支语句转化为对象的多态性,从而消除条件分支语句。
常见的两种实现方式
- 覆盖,子类重新定义父类方法,不同的子类可以自定义实现父类的方法。
- 重载,多个同名但参数不同的方法。
JavaScript 中的两种多态
通过子类重写父类方法的方式实现多态。
function Person(name, sex) {
this.name = name
this.sex = sex
}
Person.prototype.getInfo = function() {
return "I am " + this.name + " and sex is " + this.sex
}
function Employee(name, sex, age) {
this.name = name
this.sex = sex
this.age = age
}
Employee.prototype = new Person()
Employee.prototype.getInfo = function() {
return "I am " + this.name +
" and sex is " + this.sex +
" and age is " + this.age
}
var person = new Person('xiaoming', 'male')
var employee = new Employee('xiaoming', 'male', 12)
console.log(person.getInfo())
console.log(employee.getInfo())
鸭子类型
一个 JavaScript 对象,既可以表示 Duck 类型的对象,又可以表示 Chicken 类型的对象,这意味着JavaScript 对象的多态性是与生俱来的。
var makeSound = function(animal) {
animal.sound()
}
var Duck = function() {}
Duck.prototype.sound = function() {
console.log('gagaga')
}
var Chicken = function() {}
Chicken.prototype.sound = function() {
console.log('gegege')
}
makeSound(new Duck())
makeSound(new Chicken())
总结
在 JavaScript 中,会很难看到多态性的影响。因为 JavaScript 具有动态类型系统,因此在编译时没有函数重载或自动类型强制。由于语言的动态特性,我们甚至都不需要 JavaScript 中的参数多态性。 但是 JavaScript 仍具有两种多态的形式:
- 类型继承的形式,来模仿子类型多态性
- 鸭子类型(duck typing)的形式,关注的是对象的行为而不是对象本身。
总而言之,多态的设计是为了面向对象编程时共享对象的行为。
附录:
new 运算符里做了什么?
function myNew(func, ...args) {
// 创建一个空对象
let obj = {}
// 将对象与构造函数原型链接起来
Object.setPrototypeOf(obj, func.prototype)
// 将构造函数的 this 指向新生成的空对象
let result = func.apply(obj, args)
// 最后返回新对象的实例
return result
}
- 创建一个空对象
- 将空对象的 proto 指向构造函数对象的 prototype 属性(即继承构造函数的原型)
- 将构造函数内部的 this 指针替换成 obj,然后再调用构造函数
- 将构造函数上的属性和方法添加到空对象上
- 最后返回这个改造后的对象
遍历实例对象属性的三种方法
- 使用
for...in...能获取到实例对象自身的属性和原型链上的属性; - 使用
Object.keys()和Object.getOwnPropertyNames()只能获取实例对象自身的属性; - 使用
hasOwnProperty()方法传入属性名来判断一个属性是不是实例自身的属性;
属性查找
-
instance.hasOwnProperty('property')检查属性是否在指定实例对象上 -
Father.prototype.isPrototypeOf(instance)判断调用该方法的对象是不是参数实例对象的原型对象
模拟 Object.create()
先创建一个临时的构造函数,然后将传入的对象作为这个构造函数的原型,最后返回这个构造函数的一个新实例。
function objectCreate(o) {
var F = function() {}
F.prototype = o
return new F()
}
subClass.prototype = objectCreate(superClass.prototype)
参考文章
个人能力及理解有限,欢迎提问交流~