当面试被问到JavaScript面向对象,需要掌握哪些知识点?

180 阅读14分钟

前言

面向对象编程的思想是非常重要的,面向对象表示的知识更接近客观世界,表示方案更加自然,易于理解。学习面向对象的思想有利于我们开发的功能具有以下优点:

  • 良好的模块性
  • 良好的可维护性
  • 可扩充性
  • 可重用性

有那么多好处?那到底是一个什么样的思想

面对对象的思想

把构成问题的各个事务分解成各个对象,建立对象的目的不是为了完成一个步骤,而是为了描述一个事物在整个解决问题的步骤中的行为。

image.png

不是很好理解,举个简单的🌰:

假设现在你想吃煎饼果子,如果基于面向过程,你可能会这么写代码:

/**
 * 面向过程实现
 * 1. 学习摊煎饼的技术
 * 2. 买材料鸡蛋,油,葱等等
 * 3. 开始摊
 * 4. 吃
 * 5. 收拾
 * @param {Pancake} material 制作煎饼需要的材料
 */
//  1. 学习摊煎饼的技术
function makePancake(...material) {
  // make
  return 'Pancake'
}

// 2. 买材料鸡蛋,油,葱等等
// 鸡蛋
const egg = 1
// 油
const oil = 10
// 葱
const onion = 1

// 3. 开始摊
const pancake = makePancake(egg, oil, onion)

// 4. 吃
function eat(pancake) {
  pancake = null
}
eat(pancake)

// 5. 收拾干净,洗碗,洗锅...
function reset() {
  // clean
}
reset()

那基于面向对象会怎么写?

function SomeoneSellPancake() {
  // 会做煎饼的大妈
  // 怎么做别管
  this.price = 5
}

SomeoneSellPancake.prototype.sell = function (money) {
  if (!money) return null // 没给钱,不卖
  else if (this.price > money) return money // 钱不够,钱还给人家
  else return ['pancake', money - this.price] // 钱够了,找钱/不用找钱
}
// 找到卖煎饼的大妈
const someoneSellPancake = new SomeoneSellPancake()
// 买
const pancake = someoneSellPancake.sell(5)
// 吃
function eat(pancake) {
  pancake = null
}
eat(pancake)

这样感觉更符合我们平时的做法。

  • 对象是单个实物的抽象。
  • 对象是一个容器,封装了属性(property)和方法(method)。

在上面的例子中,封装的属性有:price(煎饼的价格),封装的方法有:sell 卖煎饼

前置知识

构造函数

构造函数就是专门用来生成实例对象的函数。也可以说是对象的模板。 特点:

  • 函数体内的this代表了所要生成的对象实例。
  • 生成对象的时候,必须使用new命令。忘记使用new命令,导致d1变成了undefined,而name属性变成了全局变量。为了避免这个问题,可以让构造函数在严格模式下执行。
  • 构造函数第一个字母大写。
function Dog (name){
  this.name = name; // this指向d1
}
var d1 = new Dog('阿黄');
console.log(d1.name);//阿黄

new一个构造函数的执行过程

  • 创建一个空对象,作为将要返回的对象实例。
  • 将这个空对象的原型,指向了构造函数的prototype属性。
  • 将这个空对象赋值给函数内部的this关键字。
  • 开始执行构造函数内部的代码。

new 操作符的模拟实现

/**
 * @param {*} ctor 构造函数
 * @param  {...any} argu 参数
 * @returns 
 */
function myNew(ctor, ...argu) {
  if (!ctor || typeof ctor !== 'function') {
    throw ('ctor must be a function')
  }
  const instance = Object.create(ctor.prototype)
  const ctorRes = ctor.apply(instance, argu)
  if (typeof ctorRes === 'object' && ctorRes !== null) {
    return ctorRes
  }
  return instance
}

constructor

每个对象在创建时都自动拥有一个构造函数属性contructor,其中包含了一个指向其构造函数的引用。而这个constructor属性实际上继承自原型对象,而constructor也是原型对象唯一的自有属性。

console.log(d1.constructor === Dog);// true
console.log(d1.__proto__.constructor === Dog);// true

优缺点:

  1. 使用构造函数的好处在于所有用同一个构造函数创建的对象都具有同样的属性和方法。
  2. 但是构造函数并没有消除代码冗余。使用构造函数生成对象,构造函数中的每个方法都要在每个实例上重新创建一遍,造成系统资源的浪费。
  3. constructor属性的作用是,可以得知某个实例对象,到底是哪一个构造函数产生的。

__proto__ 属性

实例对象内部包含一个 __proto__ 属性,指向该实例对象对应的原型对象。

function Foo(){};
var f1 = new Foo;
console.log(f1.__proto__ === Foo.prototype); //true

原型对象和prototype(原型)

prototype(原型)

javascript 中,函数可以有属性。每个函数都有一个特殊的属性叫作原型(prototype)

原型对象

通过构造函数的 new 操作创建实例对象后,可以认为实例对象的 __proto__ 属性就指向其原型对象,也就是构造函数的 prototypeimage.png 通过同一个构造函数实例化的多个对象具有相同的原型对象。

prototype属性的作用

JavaScript继承机制的设计思想就是,原型对象的所有属性和方法,都能被实例对象共享。

function Person(name){
  this.name = name;
}
Person.prototype.age = 18;
Person.prototype.sayAge = function(){
  console.log('My age is'+ this.age);
}
var p1 = new Person('大王');
var p2 = new Person('二王');
console.log(p1.age);//18
console.log(p2.age);//18
p1.sayAge();
p2.sayAge();
  • 当实例对象本身没有某个属性或方法的时候,它会到原型对象去寻找该属性或方法。
  • 因此修改原型对象的方法或者属性,实例获取时会立刻体现变化。
  • 通过实例对象更改原型对象的方法或属性,会影响其他实例。因为原型对象只有一份。
  • 如果某个实例对象本身拥有和原型对象同名册属性和方法时,修改原型对象的属性和方法不会影响该实例。

修改原型对象

constructor属性表示原型对象与构造函数之间的关联关系,如果修改了原型对象,一般会同时修改constructor属性,防止引用的时候出错。

  function Person(name){
    this.name = name;
  }
  console.log(Person.prototype.constructor === Person);//true
  //修改原型对象
  Person.prototype = {
    constructor:Person,
    fn:function(){
      console.log(this.name);
    }
  };
  var p1 = new Person('阿黄');
  console.log(p1 instanceof Person);//true
  console.log(Person.constructor == Person);//true
  console.log(Person.constructor === Object);//false

原型链

JavaScript规定,所有对象都有自己的原型对象(prototype)。

  • 任何一个对象,都可以充当其它对象的原型;
  • 由于原型对象也是对象,所以它也有自己的原型。因此,就会形成一个原型链(prototype chain):对象的原型,再到原型的原型……。
  • 所有对象都继承了Object.prototype的属性。这就是所有对象都有valueof和toString方法的原因,因为这是从Object.prototype继承的。
Object.prototype.__proto__  //null

读取对象的某个属性时,JavaScript 引擎先寻找对象本身的属性,如果找不到,就到它的原型去找,如果还是找不到,就到原型的原型去找。如果直到最顶层的Object.prototype还是找不到,则返回undefined。如果对象自身和它的原型,都定义了一个同名属性,那么优先读取对象自身的属性,这叫做“覆盖”(overriding)一级级向上,在整个原型链上寻找某个属性,对性能是有影响的。所寻找的属性在越上层的原型对象,对性能的影响越大。如果寻找某个不存在的属性,将会遍历整个原型链。

构造函数、原型对象和实例对象之间的关系

function Foo(){};
var f1 = new Foo();
// Foo:构造函数;
// f1:通过new一个Foo构造出来的实例对象。
f1.__proto__ = Foo.prototype
Foo.prototype.constructor === Foo
f1.constructor === Foo

面向对象的三个特性

封装、继承、多态。 🔎深入理解三个特性

创建对象的几种方式

对象的字面量

可以用来创建单个对象,但如果要创建多个对象,会产生大量的重复代码

new Object()

// 使用new操作符后跟Object构造函数用以初始化一个新创建的对象。
var person = new Object();
person.name = 'mjj';
person.age = 28;

对象字面量语法糖

var person = {
  name:'mjj';
  age:28
}

Object.create()

// 从一个实例对象,生成另一个实例对象。
// 原型对象
var A = {
  getX:function(){
    console.log('hello');
  }
};
//实例对象
var B = Object.create(A);
console.log(B.getX);//"hello"

工厂模式

该模式抽象了创建具体对象的过程,用函数来封装以特地接口创建对象的细节。

function createPerson(name,age){
var p = new Object();
p.name = name;
p.age = age;
p.sayName = function(){
  alert(this.name);
}
return p;
}
var p1 = createPerson('mjj',28);
var p2 = createPerson('alex',28);
var p3 = createPerson('阿黄',8);

解决:

  • 创建多个相似对象的问题

问题:

  • 对象识别的问题,因为使用该模式并没有给出对象的类型。

构造函数模式

function Person(name,age){
  this.name = name;
  this.age = age;
  this.sayName = function(){
    alert(this.name);
  };
}
var person1 = new Person("mjj",28);
var person2 = new Person("alex",25);

问题:

  • 每个方法都要在每个实例上重新创建一遍,创建多个完成相同任务的方法完全没有必要,浪费内存空间。

构造函数拓展模式

基于构造函数模式的问题,将方法定义到全局,这样就不需要重新创建:

function Person(name,age){
  this.name = name;
  this.age = age;
  this.sayName = sayName;
}
function sayName(){
  alert(this.name);
}
var p1 = new Person("mjj",28);
var p2 = new Person("alex",25);
console.log(person1.sayName === person2.sayName);//true

问题:

  • 在全局作用域中定义的函数实际上只能被某个对象调用,这让全局作用域有点名不副实。
  • 如果对象需要定义很多方法,就要定义很多全局函数,严重污染全局空间,这个自定义的引用类型没有封装性可言了

寄生构造函数模式

  • 创建一个函数,该函数的作用仅仅是封装创建对象的代码
  • 再返回新创建的对象。
  • 该模式是工厂模式和构造函数模式的结合。
function Person(name,age){
  var p = new Object();
  p.name = name;
  p.age = age;
  p.sayName = function(){
    alert(this.name);
  }
  return p;
}
var p1 = new Person('mjj',28);
var p2 = new Person('alex',28);
//具有相同作用的sayName()方法在person1和person2这两个实例中却占用了不同的内存空间
console.log(p1.sayName === p2.sayName);//false

问题:

  • 每个方法都要在每个实例上重新创建一遍
  • 使用该模式返回的对象与构造函数之间没有关系

稳妥构造函数模式

特点:

  • 所谓稳妥对象指没有公共属性
  • 方法也不引用this对象
  • 不适用new操作符调用构造函数
  • 稳妥对象最适合在一些安全环境中(这些环境会禁止使用this和new)或者在防止数据被其他应用程序改动时使用
function Person(name,age){
  //创建要返回的对象
  var p = new Object();
  //可以在这里定义私有变量和函数
  //添加方法
  p.sayName = function (){
    console.log(name);
  }
  //返回对象
  return p;
}
//在稳妥模式创建的对象中,除了使用sayName()方法之外,没有其他方法访问name的值
var p1 = Person('mjj',28);
p1.sayName();//"mjj"

问题:

  • 每个方法都要在每个实例上重新创建一遍
  • 创建的对象与构造函数之间也没有什么关系

原型模式

特点:

  • 让所有实例共享它的属性和方法。不必在构造函数中定义对象实例的信息,而是可以将这些信息直接添加到原型对象中。
function Person(){
  Person.prototype.name = "mjj";
  Person.prototype.age = 29;
  Person.prototype.sayName = function(){
    console.log(this.name);
  }
}
var p1 = new Person();
p1.sayName();//"mjj"
var p2 = new Person();
p2.sayName();//"mjj"
alert(p1.sayName === p2.sayName);//true

更简单的原型模式

为了减少不必要的输入,也为了从视觉上更好地封装原型的功能,用一个包含所有属性的方法的对象字面量来重写整个原型对象

function Person(){};
Person.prototype = {
  constructor:Person,
  name:'mjj',
  age:28,
  sayName:function(){
    console.log(this.name);
  }
}
var p1 = new Person();
p1.sayName();//"mjj"
console.log(p1.constructor === Person);//true
console.log(p1.constructor === Object);//false

问题:

  • 引用类型值属性会被所有的实例对象共享并修改,这也是很少有人单独使用原型模式的原因。

组合模式

特点:

  1. 组合使用构造函数模式和原型模式是创建自定义类型的最常见方式。
  2. 构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性
  3. 这种组合模式还支持向构造函数传递参数。实例对象都有自己的一份实例属性的副本,同时又共享对方法的引用,最大限度地节省了内存。
  4. 该模式是目前使用最广泛、认同度最高的一种创建自定义对象的模式。
function Person(name,age){
  this.name = name;
  this.age = age;
  this.friends = ['alex','阿黄'];
}
Person.prototype = {
  constructor:Person,
  sayName:function(){
    console.log(this.name);
  }
}
var p1 = new Person('mjj',28);
var p2 = new Person('jjm',30);
p1.friends.push('wusir');
alert(p1.friends);//['alex','阿黄','wusir']
alert(p2.friends);//['alex','阿黄']
alert(p1.friends === p2.friends);//false
alert(p1.sayName === p2.sayName);//true

动态原型模式

动态原型模式将组合模式中分开使用的构造函数和原型对象都封装到构造函数中,然后通过检查方法是否被创建,来决定是否初始化原型对象,更具有封装性。

function Person(name,age){
  //属性
  this.name = name;
  this.age = age;
  //方法
  if(typeof this.sayName != "function"){ // 每创建一个对象,就会执行这里,因此需要判断只是在第一次调用时添加原型的属性,方法
    Person.prototype.sayName = function(){
      console.log(this.name);
    }
  }
}
var p1 = new Person('mjj',28);
p1.sayName();//"mjj"

实现继承的几种方式

继承是指在原型对象的所有属性和方法,都能被实例对象共享。

原型链继承

本质是重写原型对象,代之以一个新类型的实例。

function Super(){
  this.value = true;
}
Super.prototype.getValue = function(){
  return this.value
}
function Sub(){};
//Sub继承了Super
Sub.prototype = new Super();
Sub.prototype.constroctor = Sub;
var ins = new Sub();
console.log(ins.getValue());//true

问题:

  • 私有原型属性会被实例共享
  • 在创建子类型的实例时,不能向父类型的构造函数传递参数。

借用构造函数继承

借用构造函数的技术(有时候也叫做伪类继承或经典继承)。这种技术的基本思想相当简单,即在子类构造函数的内部调用父类构造函数。

function B(name){
  this.name = name;
}
B.prototype.getValue = function(){
  return this.name;
}
function A(){
  //继承了B,同时还传递了参数
  B.call(this,'MJJ');
  //实例属性
  this.age = 28;
}
var p = new A();
alert(p.name);//'MJJ'
alert(p.age);//28

相当于把构造函数B中的this替换成了p实例对象,这样在B只有定义的私有属性会被继承下来,原型属性中定义的公共方法不会被继承下来。

问题:

  • 如果仅仅是借用构造函数,那么将无法避免构造函数模式存在的问题(方法都在构造函数中定义)
  • 在父类的原型中定义的方法,对子类而言是不可见的。所以这种方式使用较少

组合继承(重要)

使用原型链实现对原型上的公共属性和方法的继承 借用构造函数继承来实现对父类私有属性的继承

function Super(name){
this.name = name;
this.colors = ['red','blue','green'];
}
Super.prototype.sayName = function(){
alert(this.name);
}
function Sub(name,age){
Super.call(this,name);
this.age = age;
}
// 继承方法
Sub.prototype = new Super();
Sub.prototype.constructor = Sub;
Sub.prototype.sayAge = function(){
alert(this.age);
}
var ins = new Sub('mjj',28);
ins.colors.push('black');
console.log(ins.colors);// ["red", "blue", "green", "black"]
ins.sayName();//'mjj'
ins.sayAge();//28
var ins2 = new Sub('alex',38);
console.log(ins2.colors);//["red", "blue", "green"]
ins2.sayName();//'alex'
ins2.sayAge();//38

问题:

  • 无论在什么情况下,都会调用两次父类的构造函数:一次是在创建子类原型的时候,另一次是在子类构造函数内部。

寄生组合式继承

不必为了指定子类型的原型而调用超类型的构造函数,我们所需要的无非就是超类型原型的一个副本而已。

function Super(name){
  this.name = name;
  this.colors = ['red','blue','green'];
}
Super.prototype.sayName = function(){
  alert(this.name);
}
function Sub(name,age){
  //继承实例属性
  Super.call(this,name);
  this.age = age;
}
// 继承公有的方法
Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;
Sub.prototype.sayAge = function(){
  alert(this.age);
}
var ins = new Sub('mjj',28);
ins.colors.push('black');
console.log(ins.colors);// ["red", "blue", "green", "black"]
ins.sayName();//'mjj'
ins.sayAge();//28
var ins2 = new Sub('alex',38);
console.log(ins2.colors);//["red", "blue", "green"]
ins2.sayName();//'alex'
ins2.sayAge();//38
  • 实现多重继承 JavaScript中不存在多重继承,那也就意味着一个对象不能同时继承多个对象,但是我们可以通过变通方法来实现。
// 多重继承:一个对象同时继承多个对象
// Person Parent Me
function Person(){
	this.name = 'Person';
}
Person.prototype.sayName = function(){
	console.log(this.name);
}
// 定制Parent
function Parent(){
	this.age = 30;
}
Parent.prototype.sayAge = function(){
	console.log(this.age);
}
function Me(){
	// 继承Person的属性
	Person.call(this);
	Parent.call(this);
}
// 继承Person的方法
Me.prototype = Object.create(Person.prototype);
// 不能重写原型对象来实现 另一个对象的继承
Object.assign(Me.prototype,Parent.prototype);
// 指定构造函数
Me.prototype.constructor = Me;
var me = new Me();

关于 es6 class

可能有些掘友会有这样的疑问——— 不是有 es6class 吗,怎么还用 es5 的对象原型写法?关于这一点,ruanyifeng 老师的 es6教程 中这么写道:

基本上,ES6 的class可以看作只是一个语法糖,它的绝大部分功能,ES5 都可以做到,新的class写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。

总结

本文主要内容如下:

  1. 面对对象的定义,以吃煎饼果子为例,简要描述了面向对象面向过程的区别
  2. 构造函数、原型、原型对象、原型链
  3. new 操作符的原理及模拟实现
  4. 面向对象的三个特性
  5. 创建对象的各种方式以及存在的问题

若有写的不对的,欢迎大佬指出。

参考

-MDN