JavaScript 构造大型对象系统

51 阅读6分钟

JavaScript 中构造大型对象系统有多种方法,包括原型继承、类继承和直接创建对象等方法,当你面对需要管理大量数据和逻辑的应用程序时,一个良好的对象系统实现方案可以使你的代码更加易于维护和扩展。

类复制

在早期构建 JavaScript 系统的时候,用的是类复制的方法。类复制是指在生成子类实例时,子类实例的构造逻辑会传入this引用,从而用以复制父类中的方法。

在子类内部通过再次引用父类中的方法,后者覆盖子类中的同名成员,整个构造过程都是在不断地从类构造器向this引用复制成员,因此被称为类复制。

通过类复制的方式,我们不仅可以修改这些复制的成员,也可以通过复制具有所有父类的方法来构建复杂的对象系统。

// 父类
function Animal(name) {
  this.name = name;
}
Animal.prototype.sleep = function() {
  console.log("动物正在睡觉");
};
// 子类
function Cat(name, color) {
  Animal.call(this, name); // 调用父类构造函数复制属性
  this.color = color;
}

// 使用 Object.assign() 方法复制父类的方法
Object.assign(Cat.prototype, Animal.prototype);

// 子类中可以覆盖父类的方法
Cat.prototype.sleep = function() {
  console.log("猫正在打盹");
};

如果我们现在多个子类中增加一个功能,则应该修改父类方法,以使得子类具有该功能的行为。这样一来,根据类的定义,由于父类有了该功能,所有的子类实例都具有了这种行为。

但当我们在给父类构造器增加「方法」时,也就意味着每次创建这个构造器的实例,都需要为实例初始化这个新方法,在这种实现方式下,会构造多个实例,并且它们的方法并不是同一个函数。

因为子类复制会构造多个实例,所以访问任何成员都不必回溯原型链,这样做的效率较高。

原型继承

原型继承是构建对象系统中最常用也是最经典的方法,它是 JavaScript 中最传统的继承方式之一,通过使用原型链来创建对象之间的继承关系。通过在原型链中使用委托来确保继承链的构造过程是唯一的途径,这是一种基于原型继承的基本方法。

// 父类
function Animal(name) {
  this.name = name;
}
Animal.prototype.sleep = function() {
  console.log("动物正在睡觉");
};
// 子类
function Cat(name, color) {
  this.name = name;
  this.color = color;
}

// 子类的原型对象指向父类的实例
Cat.prototype = new Animal();

Cat.prototype.catchMouse = function() {
  console.log("猫正在抓老鼠");
};

// 子类中可以覆盖父类的方法
Cat.prototype.sleep = function() {
  console.log("猫正在打盹");
};

然而,原型继承也存在一些缺陷,例如在维护构造函数应用和外部原型链之间平衡的难度,以及缺乏直接调用父类方法的机制。这种方法可能会导致时间和空间的权衡,例如在访问邻近成员时速度较快,而访问不存在的成员时较慢。

在大型对象系统中,实现功能时总是倾向于向「基类」靠拢,需求也促使基类的逻辑变得更重。这正是原型继承在处理复杂需求时可能不擅长的地方。

类继承

类继承既是对原型继承的增强,也是一种再实现。

在 JavaScript 种的类,本质上是用于描述对象,其 extends 声明,则是在描述继承关系。

// 父类
class Animal {
  constructor(name) {
    this.name = name;
  }

  eat() {
    console.log("动物正在吃食物");
  }

  sleep() {
    console.log("动物正在睡觉");
  }
}

// 子类
class Cat extends Animal {
  constructor(name, color) {
    super(name); // 调用父类构造函数抄写属性
    this.color = color;
  }

  catchMouse() {
    console.log("猫正在抓老鼠");
  }

  // 子类中可以覆盖父类的方法
  sleep() {
    console.log("猫正在打盹");
  }
}

类继承更加强调类的设计过程,类似于对象的描述者,并且这种描述是强加在对象之上的。这种强制性导致在基于类继承来设计系统时,必须预先考虑某个类是否具有某种属性、方法或特性。如果某个类的成员设计不正确,那么在其子类的接口和实例使用过程中可能会遇到问题,而重构这样的系统的代价是高昂的。

直接创建对象

无论是经典的原型继承还是通过class来声明的类继承,都会通过应用运算调用构造函数来创建新对象,包括语言层面的维护和对新实例的修饰。

原型继承本质上是通过复制原型对象来创建新对象,并将原型作为模板进行复制。构造函数和运算等过程对于复制原型对象来说是唯一的附加效果。

// 创建对象
const animal = {
  name: "动物",
  eat() {
    console.log("动物正在吃食物");
  },
  sleep() {
    console.log("动物正在睡觉");
  }
};

const cat = Object.create(animal); // 创建 cat 对象,并将 animal 对象设置为其原型
cat.name = "猫";
cat.color = "黄色";
cat.catchMouse = function() {
  console.log("猫正在抓老鼠");
};
// 子类中可以覆盖原型对象的方法
cat.sleep = function() {
  console.log("猫正在打盹");
};

对于原型继承来说,构造新对象的过程中可以使用一种简单的方法,例如Object.create(),它将构造函数从对象创建过程中剥离出去,使得新对象变成了简单的继承自原型对象的对象,不再需要构造函数这一层语义。

结语

在选择继承方式时,需要考虑成员访问效率和内存占用两个方面。经典的原型继承方式将原型复制为新对象,构造函数和运算过程对复制原型的影响较大。

原型继承的机制决定了我们不能单纯依赖原型继承,因为引用类型和基本类型在数据上的表现有差异。因此,对于更基础、稳定、通用的对象体系,倾向于采用类继承,以减轻在业务逻辑和最终实现上的逻辑负担。这通常需要更深层次、更细粒度的继承关系,同时也需要一种标准化的扩展与原生系统的方法,以满足系统设计的需求。

在选择继承方式时,对于大型系统,应采用基类的思路,利用继承关系的确定性和支持静态语法检测等特性,从而简化开发和业务逻辑的实现,提供足够的系统稳定性。

而在小型结构和局部使用的情况下,考虑采用原型继承的思路,有优美的实现和高效的性质,同时更深入地理解 JavaScript 中混合不同语言特性的精髓。


题图生成:Midjourney
内容优化:ChatGPT
内容来源:《JavaScript 语言精髓与编程实战》

如果您对本篇文章中提到的问题有任何疑问或想法,请在评论区留言,我将尽力回复。

微信公众号「小道研究」,获取更多关于前端技术的深入分析和实践经验。