前端进击的巨人(七):走进面向对象,原型与原型链,继承方式

39次阅读

共计 8469 个字符,预计需要花费 22 分钟才能阅读完成。

“ 面向对象 ” 是以 “ 对象 ” 为中心的编程思想,它的思维方式是构造。
“ 面向对象 ” 编程的三大特点:” 封装、继承、多态”:

封装:属性方法的抽象
继承:一个类继承(复制)另一个类的属性 / 方法
多态:方法(接口)重写

“ 面向对象 ” 编程的核心,离不开 “ 类 ” 的概念。简单地理解下 “ 类 ”,它是一种抽象方法。通过 “ 类 ” 的方式,可以创建出多个具有相同属性和方法的对象。
但是!但是!但是 JavaScript 中并没有 “ 类 ” 的概念,对的,没有。
ES6 新增的 class 语法,只是一种模拟 “ 类 ” 的语法糖,底层机制依旧不能算是标准 “ 类 ” 的实现方式。
在理解 JavaScript 中如何实现 “ 面向对象 ” 编程之前,有必要对 JavaScript 中的对象先作进一步地了解。
什么是对象
对象是 ” 无序属性 ” 的集合,表现为 ” 键 / 值对 ” 的形式。属性值可包含任何类型值(基本类型、引用类型:对象 / 函数 / 数组)。
有些文章指出 ”JS 中一切都是对象 ”,略有偏颇,修正为:”JS 中一切引用类型都是对象 ” 更为稳妥些。
函数 / 数组都属于对象,数组就是对象的一种子类型,不过函数稍微复杂点,它跟对象的关系,有点 ” 鸡生蛋,蛋生鸡 ” 的关系,可先记住:” 对象由函数创建 ”。
简单对象的创建

字面量声明(常用)

new 操作符调用 Object 函数

// 字面量
let person = {
name: ‘ 以乐之名 ’
};

// new Object()
let person = new Object();
person.name = ‘ 以乐之名 ’;
以上两种创建对象的方式,并不具备创建多个具有相同属性的对象。
TIPS:new 操作符会对所有函数进行劫持,将函数变成构造函数(对函数的构造调用)。
对象属性的访问方式

. 操作符访问 (也称 “ 键访问 ”)

[] 操作符访问(也称 “ 属性访问 ”)

. 操作符 VS [] 操作符:

. 访问属性时,属性名需遵循标识符规范,兼容性比 [] 略差;

[] 接受任意 UTF-8/Unicode 字符串作为属性名;

[] 支持动态属性名(变量);

[] 支持表达式计算(字符串连接 / ES6 的 Symbol)

TIPS: 标识符命名规范 —— 数字 / 英文字母 / 下划线组成,开头不能是数字。
// 任意 UTF-8/Unicode 字符串作为属性名
person[‘$my-name’];

// 动态属性名(变量)
let attrName = ‘name’;
person[attrName];

// 表达式计算
let attrPrefix = ‘my_’;
person[attrPrefix + ‘name’]; // person[‘my_name’]
person[Symbol.name]; // Symbol 在属性名的应用
属性描述符
ES5 新增 “ 属性描述符 ”,可针对对象属性的特性进行配置。
属性特性的类型
1. 数据属性

Configurable 可配置(可删除)?[true|false]

Enumerable 可枚举 [true|false]

Writable 可写?[true|false]

Value 值?默认 undefined

2. 访问器属性

Get [[Getter]] 读取方法

Set [[Setter]] 设置方法

访问器属性优先级高于数据属性

访问器属性会优于 writeable/value

获取属性值时,如果对象属性存在 get(),会忽略其 value 值,直接调用 get();
设置属性值时,如果对象属性存在 set(),会忽略 writable 的设置,直接调用 set();

访问器属性日常应用:

属性值联动修改(一个属性值修改,会触发另外属性值修改);
属性值保护(只能通过 set() 制定逻辑修改属性值)

定义属性特性

Object.defineProperty() 定义单个属性

Object.defineProperties() 定义多个属性

let Person = {};
Object.defineProperty(‘Person’, ‘name’, {
writable: true,
enumerable: true,
configurable: true,
value: ‘ 以乐之名 ’
});
Person.name; // 以乐之名
TIPS:使用 Object.defineProperty/defineProperties 定义属性时,属性特性 configurable/enumerable/writable 值默认为 false,value 默认为 undefined。其它方式创建对象属性时,前三者值都为 true。
可使用 Object.getOwnPropertyDescriptor() 来获取对象属性的特性描述。
原型
JavaScript 中模拟 “ 面向对象 ” 中 “ 类 ” 的实现方式,是利用了 JavaScript 中函数的一个特性(属性)——prototype(本身是一个对象)。
每个函数默认都有一个 prototype 属性,它就是我们所说的 “ 原型 ”,或称 “ 原型对象 ”。每个实例化创建的对象都有一个 __proto__ 属性(隐式原型),它指向创建它的构造函数的 prototype 属性。
new + 函数(实现 ” 原型关联 ”)
let Person = function(name, age) {
this.name = name;
this.age = age;
};
Person.prototype.say = function() {};

let father = new Person(‘David’, 48);
let mother = new Person(‘Kelly’, 46);

new 操作符的执行过程,会对实例对象进行 “ 原型关联 ”,或称 “ 原型链接 ”。
new 的执行过程

创建(构造)一个全新的空对象
“这个新对象会被执行 ” 原型 ” 链接(新对象的__proto__会指向函数的 prototype)”
构造函数的 this 会指向这个新对象,并对 this 属性进行赋值
如果函数没有返回其他对象,则返回这个新对象(注意构造函数的 return,一般不会有 return)

原型链
“ 对象由函数创建 ”,既然 prototype 也是对象,那么它的 __proto__ 原型链上应该还有属性。Person.prototype.__proto__ 指向 Function.prototype,而 Function.prototype.__proto__ 最终指向 Object.prototype.__proto__。
TIPS:Object.prototype.__proto__ 指向 null(特例)。
日常调用对象的 toString()/valueOf() 方法,虽然没有去定义它们,但却能正常使用。实际上这些方法来自 Object.prototype,所有普通对象的原型链最终都会指向 Object.prototype,而对象通过原型链关联(继承)的方式,使得实例对象可以调用 Object.prototype 上的属性 / 方法。
访问一个对象的属性时,会先在其基础属性上查找,找到则返回值;如果没有,会沿着其原型链上进行查找,整条原型链查找不到则返回 undefined。这就是原型链查找。
基础属性与原型属性
hasOwnProperty()
判断对象基础属性中是否有该属性,基础属性返回 true。
涉及 in 操作都是所有属性(基础 + 原型)

for…in… 遍历对象所有可枚举属性

in 判断对象是否拥有该属性

Object.keys(…) 与 Object.getOwnPropertyNames(…)

Object.keys(…) 返回所有可枚举属性

Object.getOwnPropertyNames(…) 返回所有属性

屏蔽属性
修改对象属性时,如果属性名与原型链上属性重名,则在实例对象上创建新的属性,屏蔽对象对原型属性的使用(发生屏蔽属性)。屏蔽属性的前提是,对象基础属性名与原型链上属性名存在重名。
创建对象属性时,属性特性对屏蔽属性的影响

对象原型链上有同名属性,且可写,在对象上创建新属性(屏蔽原型属性);
对象原型链上有同名属性,且只读,忽略;
对象原型链上有同名属性,存在访问器属性 set(),调用 set()

批量创建对象的方式
创建多个具有相同属性的对象
1. 工厂模式
function createPersonFactory(name, age) {
var obj = new Object();
obj.name = name;
obj.age = age;
obj.say = function() {
console.log(`My name is ${this.name}, i am ${this.age}`);
}
}

var father = createPersonFactory(‘David’, 48);
var mother = createPersonFactory(‘Kelly’, 46);
father.say(); // ‘My name is David, i am 48’
mother.say(); // ‘My name is Kelly, i am 46’
缺点:

无法解决对象识别问题
属性值为函数时无法共用,不同实例对象的 say 方法没有共用内存空间

obj.say = function(){…} 实例化一个对象时都会开辟新的内存空间,去存储 function(){…},造成不必要的内存开销。
father.say == mother.say; // false
2. 构造函数(new)
function Person(name, age) {
this.name = name;
this.age = age;
this.say = function() {
console.log(`My name is ${this.name}, i am ${this.age}`);
}
}

let father = new Person(‘David’, 48);
缺点:属性值为引用类型(say 方法)时无法共用,不同实例对象的 say 方法没有共用内存空间(与工厂模式一样)。
3. 原型模式
function Person() {}
Person.prototype.name = ‘David’;
Person.prototype.age = 48;
Person.prototype.say = function() {
console.log(`My name is ${this.name}, i am ${this.age}`);
};

let father = new Person();
优点:解决公共方法内存占用问题(所有实例属性的 say 方法共用内存)缺点:属性值为引用类型时,因内存共用,一个对象修改属性会造成其它对象使用属性发生改变。
Person.prototype.like = [‘sing’, ‘dance’];
let father = new Person();
let mother = new Person();
father.like.push(‘travel’);

// 引用类型共用内存,一个对象修改属性,会影响其它对象
father.like; // [‘sing’, ‘dance’, ‘travel’]
mother.like; // [‘sing’, ‘dance’, ‘travel’]
4. 构造函数 + 原型(经典组合)
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.say = function() {
console.log(`My name is ${this.name}, i am ${this.age}`);
}
原理:结合构造函数和原型的优点,” 构造函数初始化属性,原型定义公共方法 ”。
5. 动态原型
构造函数 + 原型的组合方式,区别于其它 “ 面向对象 ” 语言的声明方式。属性方法的定义并没有统一在构造函数中。因此动态原型创建对象的方式,则是在 “ 构造函数 + 原型组合 ” 基础上,优化了定义方式(区域)。
function Person(name, age) {
this.name = name;
this.age = age;

// 判断原型是否有方法,没有则添加;
// 原型上的属性在构造函数内定义,仅执行一次
if (!Person.prototype.say) {
Person.prototype.say = function() {
console.log(`My name is ${this.name}, i am ${this.age}`);
}
}
}
优点:属性方法统一在构造函数中定义。
除了以上介绍的几种对象创建方式,此外还有 ” 寄生构造函数模式 ”、” 稳妥构造函数模式 ”。日常开发较少使用,感兴趣的伙伴们可自行了解。
“ 类 ” 的继承
传统的面向对象语言中,” 类 ” 继承的原理是 “ 类 ” 的复制。但 JavaScript 模拟 “ 类 ” 继承则是通过 “ 原型关联 ” 来实现,并不是 “ 类 ” 的复制。正如《你不知道的 JavaScript》中提出的观点,这种模拟 “ 类 ” 继承的方式,更像是 “ 委托 ”,而不是 “ 继承 ”。
以下列举 JavaScript 中常用的继承方式,预先定义两个类:

“Person” 父类(超类)
“Student” 子类(用来继承父类)

// 父类统一定义
function Person(name, age) {
// 构造函数定义初始化属性
this.name = name;
this.age = age;
}
// 原型定义公共方法
Person.prototype.eat = function() {};
Person.prototype.sleep = function() {};
原型继承
// 原型继承
function Student(name, age, grade) {
this.grade = grade;
};
Student.prototype = new Person(); // Student 原型指向 Person 实例对象
Student.prototype.constructor = Student; // 原型对象修改,需要修复 constructor 属性
let pupil = new Student(name, age, grade);
原理:
子类的原型对象为父类的实例对象,因此子类原型对象中拥有父类的所有属性
缺点:

无法向父类构造函数传参,初始化属性值
属性值是引用类型时,存在内存共用的情况
无法实现多继承(只能为子类指定一个原型对象)

构造函数继承
// 构造函数继承
function Student(name, age, grade) {
Person.call(this, name, age);
this.grade = grade;
}
原理:
调用父类构造函数,传入子类的上下文对象,实现子类参数初始化赋值。仅实现部分继承,无法继承父类原型上的属性。可 call 多个父类构造函数,实现多继承。
缺点:
属性值为引用类型时,需开辟多个内存空间,多个实例对象无法共享公共方法的存储,造成不必要的内存占用。
原型 + 构造函数继承(经典)
// 原型 + 构造函数继承
function Student(name, age, grade) {
Person.call(this, name, age); // 第一次调用父类构造函数
this.grade = grade;
}
Student.prototype = new Person(); // 第二次调用父类构造函数
Student.prototype.constructor = Student; // 修复 constructor 属性
原理:
结合原型继承 + 构造函数继承两者的优点,” 构造函数继承并初始化属性,原型继承公共方法 ”。
缺点:
父类构造函数被调用了两次。
待优化:父类构造函数第一次调用时,已经完成父类构造函数中 “ 属性的继承和初始化 ”,第二次调用时只需要 “ 继承父类原型属性 ” 即可,无须再执行父类构造函数。
寄生组合式继承(理想)
// 寄生组合式继承
function Student(name, age, grade) {
Person.call(this, name, age);
this.grade = grade;
}
Student.prototype = Object.create(Person.prototype);
// Object.create() 会创建一个新对象,该对象的__proto__指向 Person.prototype
Student.prototype.constructor = Student;

let pupil = new Student(‘ 小明 ’, 10, ‘ 二年级 ’);
原理:
创建一个新对象,将该对象原型关联至父类的原型对象,子类 Student 已使用 call 来调用父类构造函数完成初始化,所以只需再继承父类原型属性即可,避免了经典组合继承调用两次父类构造函数。(较完美的继承方案)
ES6 的 class 语法
class Person {
constructor(name, age) {
this.name = name;
this.grade = grade;
}

eat () { //…}
sleep () { //…}
}

class Student extends Person {
constructor (name, age, grade) {
super(name, age);
this.grade = grade;
}

play () { //…}
}
优点:ES6 提供的 class 语法使得类继承代码语法更加简洁。
Object.create(…)

Object.create() 方法会创建一个新对象,使用现有对象来提供新创建的对象的__proto__

Object.create 实现的其实是 ” 对象关联 ”,直接上代码更有助于理解:
let person = {
eat: function() {};
sleep: function() {};
}

let father = Object.create(person);
// father.__proto__ -> person, 因此 father 上有 eat/sleep/talk 等属性

father.eat();
father.sleep();
上述代码中,我们并没有使用构造函数 / 类继承的方式,但 father 却可以使用来自 person 对象的属性方法,底层原理依赖于原型和原型链的魔力。
// Object.create 实现原理 / 模拟
Object.create = function(o) {
function F() {}
F.prototype = o;
return new F();
}
Object.create(…) 实现的 “ 对象关联 ” 的设计模式与 “ 面向对象 ” 模式不同,它并没有父类,子类的概念,甚至没有 “ 类 ” 的概念,只有对象。它倡导的是 “ 委托 ” 的设计模式,是基于 “ 面向委托 ” 的一种编程模式。
文章篇幅有限,仅作浅显了解,后续可另开一章讲讲 “ 面向对象 ” VS “ 面向委托 ”,孰优孰劣,说一道二。
对象识别(检查 “ 类 ” 关系)
instanceof
instanceof 只能处理对象与函数的关系判断。instanceof 左边是对象,右边是函数。判断规则:沿着对象的 __proto__ 进行查找,沿着函数的 prototype 进行查找,如果有关联引用则返回 true,否则返回 false。
let pupil = new Student();
pupil instanceof Student; // true
pupil instanceof Person; // true Student 继承了 Person
Object.prototype.isPrototypeOf(…)
Object.prototype.isPrototyepOf(…) 可以识别对象与对象,也可以是对象与函数。
let pupil = new Student();
Student.prototype.isPrototypeOf(pupil); // true
判断规则:在对象 pupil 原型链上是否出现过 Student.prototype , 如果有则返回 true,否则返回 false
ES6 新增修改对象原型的方法:Object.setPrototypeOf(obj, prototype),存在有性能问题,仅作了解,更推荐使用 Object.create(…)。
Student.prototype = Object.create(Person.prototype);
// setPrototypeOf 改写上行代码
Object.setPrototypeOf(Student.prototype, Person.prototype);
后语
“ 面向对象 ” 是程序编程的一种设计模式,具备 “ 封装,继承,多态 ” 的特点,在 ES6 的 class 语法未出来之前,原型继承确实是 JavaScript 入门的一个难点,特别是对新入门的朋友,理解起来并不友好,模拟继承的代码写的冗余又难懂。好在 ES6 有了 class 语法糖,不必写冗余的类继承代码,代码写少了,眼镜片都亮堂了。
老话说的好,“会者不难”。深入理解面向对象,原型,继承,对日后代码能力的提升及编码方式优化都有益处。好的方案不只有一种,明白个中缘由,带你走进新世界大门。

参考文档:

你不知道的 JavaScript(上卷)
《JavaScript 高级程序设计》
JavaScript 常见的六种继承方式
深入理解 javascript 原型和闭包

本文首发 Github,期待 Star!https://github.com/ZengLingYong/blog
作者:以乐之名本文原创,有不当的地方欢迎指出。转载请指明出处。

正文完
 0