46

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

 5 years ago
source link: https://segmentfault.com/a/1190000018196125?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

am2u2m7.png!web

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

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

  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 = '以乐之名';

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

TIPS: new 操作符会对所有函数进行劫持,将函数变成构造函数(对函数的构造调用)。

对象属性的访问方式

  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. 访问器属性

Get [[Getter]]
Set [[Setter]]

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

  1. 访问器属性会优于 writeable/value

    • 获取属性值时,如果对象属性存在 get() ,会忽略其 value 值,直接调用 get()
    • 设置属性值时,如果对象属性存在 set() ,会忽略 writable 的设置,直接调用 set() ;
  2. 访问器属性日常应用:

    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 值默认为 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);

NvaUjqm.png!web

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

new的执行过程

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

屏蔽属性

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

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

  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'

缺点:

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

作者:以乐之名


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK