为了账号安全,请及时绑定邮箱和手机立即绑定

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

标签:
JavaScript

前端进击的巨人七带你走进面向对象原型继承

“面向对象” 是以 “对象” 为中心的编程思想它的思维方式是构造。

“面向对象” 编程的三大特点"封装、继承、多态”

  1. 封装属性方法的抽象
  2. 继承一个类继承复制另一个类的属性/方法
  3. 多态方法接口重写

“面向对象” 编程的核心离不开 “类” 的概念。简单地理解下 “类”它是一种抽象方法。通过 “类” 的方式可以创建出多个具有相同属性和方法的对象。

但是但是但是JavaScript中并没有 “类” 的概念对的没有。

ES6 新增的 class 语法只是一种模拟 “类” 的语法糖底层机制依旧不能算是标准 “类” 的实现方式。

在理解JavaScript中如何实现 “面向对象” 编程之前有必要对JavaScript中的对象先作进一步地了解。

什么是对象

对象是**“无序属性"的集合表现为"键/值对”**的形式。属性值可包含任何类型值基本类型、引用类型对象/函数/数组。

有些文章指出**“JS中一切都是对象”略有偏颇修正为“JS中一切引用类型都是对象”**更为稳妥些。

函数 / 数组都属于对象数组就是对象的一种子类型不过函数稍微复杂点它跟对象的关系有点"鸡生蛋蛋生鸡"的关系可先记住“对象由函数创建”

简单对象的创建

  1. 字面量声明常用
  2. new 操作符调用 Object 函数
// 字面量
let person = {
  name: '以乐之名'
};

// new Object()
let person = new Object();
person.name = '以乐之名';

以上两种创建对象的方式并不具备创建多个具有相同属性的对象。

TIPSnew 操作符会对所有函数进行劫持将函数变成构造函数对函数的构造调用。

对象属性的访问方式

  1. . 操作符访问 (也称 “键访问”
  2. [] 操作符访问也称 “属性访问”

. 操作符 VS [] 操作符

  1. . 访问属性时属性名需遵循标识符规范兼容性比 [] 略差
  2. [] 接受任意UTF-8/Unicode字符串作为属性名
  3. [] 支持动态属性名变量
  4. [] 支持表达式计算字符串连接 / 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. 数据属性
  1. Configurable 可配置可删除[true|false]
  2. Enumerable 可枚举 [true|false]
  3. Writable 可写 [true|false]
  4. Value 值默认undefined
2. 访问器属性
  1. Get [[Getter]] 读取方法
  2. Set [[Setter]] 设置方法

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

  1. 访问器属性会优于 writeable/value
    • 获取属性值时如果对象属性存在 get()会忽略其 value 值直接调用 get()
    • 设置属性值时如果对象属性存在 set()会忽略 writable 的设置直接调用 set();
  2. 访问器属性日常应用
    • 属性值联动修改一个属性值修改会触发另外属性值修改
    • 属性值保护只能通过 set() 制定逻辑修改属性值

定义属性特性

  1. Object.defineProperty() 定义单个属性
  2. Object.defineProperties() 定义多个属性
let Person = {};
Object.defineProperty(Person, 'name', {
  writable: true,
  enumerable: true,
  configurable: true,
  value: '以乐之名'
});
Person.name;   // 以乐之名

TIPS使用 Object.defineProperty/defineProperties 定义属性时属性特性 configurable/enumerable/writable 值默认为 falsevalue 默认为 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的执行过程

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

原型链

"对象由函数创建"既然 prototype 也是对象那么它的 __proto__ 原型链上应该还有属性。Person.prototype.__proto__ 指向 Function.prototypeFunction.prototype.__proto__ 最终指向 Object.prototype

TIPSObject.prototype.__proto__ 指向 null特例。

日常调用对象的 toString()/valueOf() 方法虽然没有去定义它们但却能正常使用。实际上这些方法来自 Object.prototype所有普通对象的原型链最终都会指向 Object.prototype而对象通过原型链关联继承的方式使得实例对象可以调用 Object.prototype 上的属性 / 方法。

访问一个对象的属性时会先在其基础属性上查找找到则返回值如果没有会沿着其原型链上进行查找整条原型链查找不到则返回 undefined。这就是原型链查找。

基础属性与原型属性

hasOwnProperty()

判断对象基础属性中是否有该属性基础属性返回 true

涉及 in 操作都是所有属性基础 + 原型

  1. for...in... 遍历对象所有可枚举属性
  2. in 判断对象是否拥有该属性

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

  1. Object.keys(...) 返回所有可枚举属性
  2. Object.getOwnPropertyNames(...) 返回所有属性

屏蔽属性

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

创建对象属性时属性特性对屏蔽属性的影响

  1. 对象原型链上有同名属性且可写在对象上创建新属性屏蔽原型属性
  2. 对象原型链上有同名属性且只读忽略
  3. 对象原型链上有同名属性存在访问器属性 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'

缺点

  1. 无法解决对象识别问题
  2. 属性值为函数时无法共用不同实例对象的 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中常用的继承方式预先定义两个类

  1. "Person" 父类超类
  2. "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);
原理

子类的原型对象为父类的实例对象因此子类原型对象中拥有父类的所有属性

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

构造函数继承

// 构造函数继承
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 语法糖不必写冗余的类继承代码代码写少了眼镜片都亮堂了。

老话说的好“会者不难”。深入理解面向对象原型继承对日后代码能力的提升及编码方式优化都有益处。好的方案不只有一种明白个中缘由带你走进新世界大门。


参考文档

本文首发Github期待Star
https://github.com/ZengLingYong/blog

作者以乐之名
本文原创有不当的地方欢迎指出。转载请指明出处。

点击查看更多内容
29人点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消