从 class 到原型链,彻底吃透JS中类与继承的底层逻辑

112 阅读15分钟

前言:类与继承是编程语言的核心概念,JavaScript 自然也不例外。提到 JS 中的类、对象与继承,你或许会立刻想到class关键字用于声明类,extends关键字实现继承,以及constructor作为构造函数的角色 —— 但这些语法背后的关联与逻辑,你真的吃透了吗?

本文将从基础概念出发,通过「手写实现」的方式层层拆解:从类的本质到对象的创建逻辑,从继承的底层原理到不同实现方式的优劣对比,带你跳出语法糖的表象,真正理解 JavaScript 中类与继承的设计思想与运行机制。

一、引入

1.0 本篇文章的契机——一段“复杂”的代码

话不多说,先上一段代码,这也是这篇文章的由来。

function extend(sup, base) {
  var descriptor = Object.getOwnPropertyDescriptor(
    base.prototype,
    "constructor",
  );
  base.prototype = Object.create(sup.prototype);
  var handler = {
    construct: function (target, args) {
      var obj = Object.create(base.prototype);
      this.apply(target, obj, args);
      return obj;
    },
    apply: function (target, that, args) {
      sup.apply(that, args);
      base.apply(that, args);
    },
  };
  var proxy = new Proxy(base, handler);
  descriptor.value = proxy;
  Object.defineProperty(base.prototype, "constructor", descriptor);
  return proxy;
}

var Person = function (name) {
  this.name = name;
};

var Boy = extend(Person, function (name, age) {
  this.age = age;
});

Boy.prototype.sex = "M";

var Peter = new Boy("Peter", 13);
console.log(Peter.sex); // "M"
console.log(Peter.name); // "Peter"
console.log(Peter.age); // 13

这是MDN上有关Proxy对象(Proxy - JavaScript | MDN)举例的一段代码,它向我们展示了如何扩展一个类的构造函数。如果你能清晰解读这段代码的含义与运作机制,那么恭喜你,对 JavaScript 的类与继承体系已经具备相当深入的理解;但如果你像我初次见到它时那样感到困惑,也无需担心 —— 跟随本文的解析,一步步揭开这些概念的面纱(文末会再次对代码进行解释),相信你会收获清晰的认知与扎实的理解。

1.1 通过这篇文章你能学会什么?

  1. 类、对象、继承的基本概念和用法
  2. JavaScript中有关类和对象的特殊知识(原型和原型链等)
  3. 详解JavaScript中的继承机制以及手写

二、内容

2.1 前置知识

(1)对象(Object)

大家对于对象这个概念肯定不陌生,这也是我们正式开始学习类相关知识的起点。

简单来说,对象就是一系列键值对的集合,这些值可以是方法(函数),也可以是属性,简单举个例子:

const Person = { 
    name: 'Juice', //属性
    sayHi: () => {
        console.log(this.name);
    },//方法
    }

对象的创建方式也很简单,直接使用对象字面量{}并在其中编写即可,如上面代码所示。那么现在提出问题,如果我们想创建很多个都具有name属性sayHi方法的对象,应该怎么操作呢?

这个时候就有同学说,复制这份代码,在需要的地方粘贴不就好了😀 诚然,这是一个方法,但是效率却不高,如果我们用一个函数来创建一个和Person相似的对象,每次只调用这个函数,是不是会方便很多呢?如果想到了这一点,那么恭喜你成功发明了构造函数

(2)构造函数(Constructor)

经过上一段的解释,我们可以从名字很方便的理解,“构造函数”的功能就是用来“构造”一些有相同方法和属性的对象。

从专业角度解释,构造函数写法上类似普通函数,只不过在通过new关键字调用时,成为 “创建对象的模板”。简单举例:

function Person(name) {
  this.name = name; // 实例属性
  this.sayHi = () => {
      console.log(this.name);
  };//实例方法
}
// 用 new 创建实例(对象)
const Person1 = new Person('张三');
const Person2 = new Person('李四');

至此我们学会了一种创建相似的对象的方法,创建完毕之后还可以在其中扩展其他属性。比如:

//紧接上段代码
Person1.age = 18;

console.log(Person1.name, ":", Person1.age);// 张三:18
console.log(Person2.name, ":", Person2.age);//李四:undefined

可以看到Person1对象有了age这个我们加上的属性而Person2没有;也就是说,Person1Person2是独立的,互相不影响。这似乎是必然且必要的,因为每个人的名字和年龄都不尽相同,所以需要“独立”。

sayHi方法呢?

每个实例对象的 sayHi 方法功能完全相同(都是输出自身的 name 属性),但在当前写法中,每个实例都会单独存储一份该方法 —— 这无疑造成了内存的冗余浪费。

那么,有没有一种方式能让这些重复的方法只存一份,却能被所有实例共享呢?

如果你也想到了这个问题,那你其实已经触碰到了 JavaScript 中原型对象(Prototype)  的设计核心 —— 它正是为解决 “共享” 而生的公共存储空间。

(3)原型对象(Prototype)

原型是每个函数和对象都有的属性(包括构造函数),指向原型对象,其中存储了需要被所有实例共享的方法和属性。

说到原型,听说过的同学或许第一时间会想到Prototype__proto__;这俩都表示原型吗?有什么区别?下面让我们来辨析一下。同时加深对原型的理解。

首先明确,这俩指向的东西都是同一个——实例的原型对象,区别在于,Prototype是构造函数的属性,指向构造函数创造出的实例的原型对象;而__proto__是对象的属性,就指向自己的原型对象。下面举例说明:

// 构造函数
function Person(name) { this.name = name; }
// 方法定义在原型上,所有实例共享
Person.prototype.sayHi = function() {
  console.log(`我是${this.name}`);//构造函数通过Prototype访问实例
};
const Person1 = new Person('张三');
const Person2 = new Person('李四');
Person1.__proto__.sayHi === Person2.__proto__.sayHi; // true(同一函数)

既然我们使用原型对象承载了所有实例的共享属性和方法,那原型对象不也是对象吗?它有没有自己的原型呢?

——答案是肯定的,原型也有自己的原型,所有的原型都像链条一样串接起来,形成了原型链Prototype Chain

原型链上存储着该对象所有的共享属性和方法,我们在访问某个对象的某个属性时,就会自动在原型链上查找,直到找到。That's to say,我们不用知道某个属性到底存在在原型链的哪一个对象中,直接调用就好,举例如下:

// 构造函数
function Person(name) {
  this.name = name; // 实例自身的属性
}

// 原型修改:添加方法
Person.prototype.sayHi = function() {
  console.log(`你好,我是${this.name}`);
};

// 创建实例
const person1 = new Person('小明');

//原型链修改:在“遥远”的地方添加属性
person1.__proto__.__proto__.__proto__.test = 'test'; 

// 访问原型上的方法和属性(自身没有,自动去原型链找)
person1.sayHi(); // 输出:你好,我是小明
console.log(person1.test)// 输出:test

可以看到我们直接在实例中调用方法和访问属性,都成功的找到了原型和原型链中对应的方法和属性。JS中的原型链机制真是大大提高了编码便利性和性能呀!

(4)拓展:构造函数和原型的关系

我们不妨先想一个问题:明明只是调用了构造函数,为什么新创建的实例对象会自动带上原型,并且和原型对象关联起来呢?

这说明,构造函数的工作机制远比表面看起来更复杂。

下面我们就来模拟一下:当用 new 关键字调用构造函数时,除了给实例初始化属性和方法,它还悄悄做了哪些关键操作,才让实例和原型产生了关联。

//模拟new关键字调用

function myNew(constructor,...args){
    //0.参数解释
    //constructor:构造函数;args:调用构造函数的参数数组
    
    //1.原型赋值
    
    //1.1 创建空对象之后手动赋值:动态属性
    let obj={};
    Object.setPrototypeOf(obj,constructor.prototype);

    //1.2 直接使用原型创建对象:静态属性
    // let obj=Object.create(constructor.prototype);

    //2.在新对象上调用构造函数
    const result=constructor.apply(obj,args);

    //3.判断返回值类型并返回
    return typeof result === "object" ? result : obj;
}

从代码中能看到,在真正执行构造函数之前,还有一步关键的「原型赋值」操作。前文已经说过,构造函数的 prototype 属性指向的正是实例的原型对象,因此这里直接用它为新创建的对象绑定原型即可。

这一步通常会用到两个 API:Object.setPrototypeOf 和 Object.create,它们的核心作用都是关联原型,但用法不同(具体更深层次的区别这里不展开,有兴趣的同学自行查阅):

  • Object.setPrototypeOf(obj, prototype):直接修改已有对象 obj 的原型,让它指向 prototype
  • Object.create(prototype):创建一个新对象,并将这个新对象的原型直接设为 prototype

简单说,前者是 “修改已有对象的原型”,后者是 “基于指定原型创建新对象”。

(5)总结

自此我们已经完全搞定了所有的前置知识,总结如下:

  1. 对象是 JS 中最基础的数据结构,由键值对(属性和方法)组成,可通过字面量直接创建,且支持动态增删属性。

  2. 构造函数是用于批量创建相似对象的 “模板”,通过 new 关键字调用时,会初始化实例的私有属性,解决了重复创建对象的冗余问题。

  3. 原型对象(prototype  是构造函数的属性,用于存储所有实例可共享的方法和属性,避免了方法的重复存储(内存浪费);而对象的 __proto__ 属性则指向其原型对象,形成实例与原型的关联。

  4. 原型链是由 __proto__ 串联起的原型层级关系,当访问对象的属性 / 方法时,JS 会自动沿原型链向上查找,直至找到目标或抵达链的尽头(null),实现了属性和方法的层级共享。

  5. new 关键字的本质:通过创建空对象、绑定原型(Object.setPrototypeOf 或 Object.create)、执行构造函数初始化属性、返回实例等步骤,完成实例与原型的关联,让实例既能拥有私有属性,又能共享原型上的资源。

这些概念层层递进,从对象的基础创建,到构造函数的批量生产,再到原型与原型链的共享机制,共同构成了 JS 面向对象编程的核心逻辑 ——以原型为基础,实现对象的复用与关联

这个时候有同学就要问了,主包主包说了这么久为什么还没有提到这个概念呀,我知道你很急但是先别急,下面我们就来看看继承和类的相关知识。

2.2 类(class)和继承(extends)

(1)类(class)

首先明确概念,为什么我们需要

经过前面的学习,我们已经理清了 JS 中对象创建的完整逻辑:一个可用的实例对象,背后需要构造函数来初始化属性,需要原型对象来存储共享方法,还需要原型链来实现属性的层级查找。这些部分相互配合,才能让 “批量创建对象” 和 “方法共享” 高效运作。

但细心的话你会发现,这套机制在写法上其实是 “分离” 的:

  • 我们得先定义一个构造函数(比如 function Person() {}),在里面写属性初始化逻辑;
  • 然后还要单独给构造函数的 prototype 赋值(比如 Person.prototype.sayHi = ...),才能把共享方法挂到原型上;

这种 “拆分” 的写法不仅不够直观,还容易因为漏写某一步(比如忘了绑定原型)导致 bug。于是,ES6 引入了 class 关键字 —— 它就像一个 “语法糖”,把构造函数、原型方法、继承逻辑等原本分散的部分,集中到一个统一的语法结构里,让代码更贴近我们对 “类” 的直觉理解。

简单说,class 并没有改变 JS 基于原型的本质,只是用更简洁的方式,把构造函数、原型、继承这些逻辑 “打包” 在了一起,让我们写起来更顺手,看起来也更清晰。下面举例:

// 用 class 定义类(整合构造函数+原型逻辑)
class Person {
  // 1. 构造函数:对应之前的构造函数逻辑,初始化实例私有属性
  constructor(name) {
    this.name = name; // 和 function Person(name) { this.name = name } 作用完全一致
  }

  // 2. 原型方法:直接定义在类体中,自动被添加到 Person.prototype
  sayHi() {
    console.log(`你好,我是${this.name}`); 
    // 等价于 Person.prototype.sayHi = function() { ... }
  }

  // 3. 静态方法:用 static 修饰,直接挂在类上(不共享给实例)
  static createAnonymous() {
    return new Person('匿名用户'); 
    // 等价于 Person.createAnonymous = function() { ... }
  }
}

// 使用 class 创建实例(和 new 构造函数完全一样)
const person1 = new Person('张三');
person1.sayHi(); // 输出:你好,我是张三(调用原型方法)

// 调用静态方法(通过类名直接调用)
const anonymous = Person.createAnonymous();
anonymous.sayHi(); // 输出:你好,我是匿名用户

因此,在 JavaScript 中,class 关键字本质上是一种语法糖—— 它并没有引入新的语言机制,只是将我们之前学习的构造函数、原型对象、原型链等底层逻辑,用更统一、更直观的语法形式整合在了一起。

(2)继承(extends)

还是用一个问题来引入继承的概念,之前的举例中,Person类nameage属性以及原型上的sayHi方法,现在需要一个Student类,包含Person类的所有属性和方法,同时多一个ID属性,应该怎么操作呢?

这个时候就需要用到extends关键字了,话不多说,我们通过代码理解:

class Student extends Person {
  // 构造函数
  constructor(name, age, id) {
    // 调用父类的构造函数,必须在this之前调用
    super(name, age);
    // 添加Student独有的属性
    this.id = id;
  }
  
  // 可以添加Student独有的方法
  study() {
    console.log(`${this.name}(学号: ${this.id})正在学习`);
  }
}

// 使用示例
const student = new Student("小明", 18, "2023001");
student.sayHi(); // 继承自Person的方法
console.log(student.id); // 输出:2023001
student.study(); // 输出:小明(学号: 2023001)正在学习

可以看到,使用extends关键字后,在构造函数内通过super调用父类的构造函数实现继承,其后可以添加子类的属性和方法,使用和理解都很简单。

那么至此js中关于类和继承的所有知识都学完了!!

——真的吗?😀

刚刚我们已经说过,class的本质是语法糖,背后是一系列复杂的知识,那么聪明的你一定可以想到,extends会不会也是语法糖呢?答案还是肯定的。那么extends背后的原理又是什么?

2.3 继承的本质:如何不借助extends实现“完美”的继承

作为一个合格的学习者,只会用内置的关键字可远远不行,和newclass一样,现在我们需要跳出extends关键字,来探寻它更深层的原理,实现“完美”的继承。

再回到引出继承概念的那个问题:Person类nameage属性以及原型上的sayHi方法,现在需要一个Student类,包含Person类的所有属性和方法,同时多一个ID属性,应该怎么操作?

首先拆解问题:

  1. Student类需要Person类的nameage属性: 说明继承肯定和父类的构造函数有关。
  2. Student类需要Person类实例原型上的sayHi方法:说明继承肯定和父类实例的原型有关。

那么具体怎么有关系呢?下面是不断变复杂和完善的继承方法:

(1)组合继承

有同学可能会第一时间想到:和构造函数相关,那我在子类的构造函数中调用一遍父类的构造函数不就好了(●'◡'●);和父类实例的原型相关,那我把父类实例赋值成子类的原型不就好了(^_^)。于是有了以下代码:

function Child(params){
    Parent.call(this,params);
    //...子类自己的逻辑;
}
Child.prototype=new Parent();

看到这里,恭喜你学会了我们通常所说的组合继承这种继承方式:它通过原型链继承父类原型的方法和属性,通过借用构造函数继承父类的实例属性和方法;

这个方法确实解决了问题,那他是否完美呢?

分析发现,Parent.call(this,params)这句代码调用了父类的构造函数;而new Parent() new关键字中同样调用了一遍父类的构造函数。所以这个方法问题就是,调用了两遍父类的构造函数,造成了不必要的性能开销。

(2)寄生组合继承

现在有了问题就需要解决,我们如何减掉一次父类构造函数的调用呢?分析如下:

  1. 子类构造函数中需要父类构造函数来创造父类的属性和方法,所以是必须的。
  2. 子类的原型设置为父类实例本身,会导致父类的属性和方法出现在原型中,但是我们构造函数中已经有一份了,所以这里不需要设置为父类实例,而直接设置为父类实例的原型就行了。这样一来就去掉了那次多余的构造函数调用。

可得代码如下:

function Child(params){
    Parent.call(this,params);
}
Child.prototype=Object.create(Parent.prototype);

这样一来,我们便可以交出一份较为完美的“继承”答卷了。

三、复杂代码分析

还记得文章最开始那段让人有些摸不着头脑的代码吗?现在我们一起来解决它吧!(PS:proxy的知识和相关API这里只做简单阐述不做展开,感兴趣可以通过传送门去学习)

大部分解释已经通过注释的方式给出:

function extend(sup, base) {
  //sup:父类   base:子类
  //读取子类原型上的构造函数属性的相关信息
  var descriptor = Object.getOwnPropertyDescriptor(
    base.prototype,
    "constructor",
  );
  
  //原型链继承(父类原型赋值给子类原型,前文提到过)
  base.prototype = Object.create(sup.prototype);
  
  var handler = {
    //通过new调用时的拦截器
    construct: function (target, args) {
      //创建带有正确原型的新对象
      var obj = Object.create(base.prototype);
      //手动触发apply拦截器(也就是下方的函数)
      this.apply(target, obj, args);
      //返回新对象
      return obj;
    },
    //函数调用的拦截器(这里被上面的代码手动触发了)
    apply: function (target, that, args) {
      //在obj中调用父类和子类的构造函数
      sup.apply(that, args);
      base.apply(that, args);
    },
  };
  //创建代理“构造函数”
  var proxy = new Proxy(base, handler);
  //更新子类构造函数的值(改为这个代理构造函数)
  descriptor.value = proxy;
  Object.defineProperty(base.prototype, "constructor", descriptor);
  //返回新构造函数
  return proxy;
}

//使用示例
//父类构造函数
var Person = function (name) {
  this.name = name;
};

//子类构造函数
var Boy = extend(Person, function (name, age) {
  this.age = age;
});


var Peter = new Boy("Peter", 13);
console.log(Peter.name); // "Peter"
console.log(Peter.age); // 13

其实这段代码就是在用Proxy创建一个“代理构造函数”,实现了构造函数的扩展,也就是继承。与传统的继承相比,多了由Proxy对象本身带来的灵活性(比如在调用构造函数的同时添加其他信息和逻辑),但是关键步骤都是前文我们已经实现过的:比如父类构造函数的调用原型的赋值等等。

相信认真学习了前面知识的你理解这段代码一定比第一次见到它时游刃有余!

三、总结

3.1 知识性总结

本文从对象入手,总结清楚了js中面向对象的相关知识,比如构造函数原型原型链以及class关键字,最后运用以上知识实现了“继承”的概念;其中不仅仅局限于概念的阐释,更深入剖析原理(比如new操作符的原理)

3.2 学习方法总结

不知道正在看文章的你有没有发现主包的一些些小巧思:比如讲解类,先从熟悉的对象切入,再深入到构造函数、原型的概念解释,最后引出class关键字进行运用;但是在讲解extends时则相反,先通过使用理解概念,再深入探究原理。

但是无论是 “先原理后运用” 还是 “先运用后原理”,核心是找到能让自己 “主动思考” 的路径。当一个个知识点像链条一样串联起来(比如:对象的动态性催生了构造函数,构造函数的冗余问题引出了原型,原型的层级关联形成了原型链,就形成了对象→构造函数→原型→原型链→类与继承的知识链条),原本零散的概念就会形成体系,这正是深入理解的关键。

最后的最后,再次希望这篇文章能对正在学习JS的你有所帮助哦~