JavaScript 的继承机制基于原型链,class(ES6)只是原型继承的语法糖。从构造函数到 class,从 __proto__prototype 再到 instanceof——这三个概念串起来,构成了 JS 继承的全部面貌。理解原型链也能帮助读懂很多库的源码设计。

ES6 之前:构造函数 + prototype

在没有 class 的年代,JavaScript 用构造函数模拟”类”:

// 构造函数 —— 首字母大写只是约定,不是语法
function Student(name, number) {
  this.name = name;
  this.number = number;
  // 不在构造函数里定义方法,否则每个实例都有一份拷贝
}
// 方法放在 prototype 上,所有实例共享
Student.prototype.sayHi = function () {
  console.log('姓名 ' + this.name + ',学号 ' + this.number);
};
 
var xiaoming = new Student('小明', 100);
xiaoming.sayHi(); // "姓名 小明,学号 100"

new 操作符做了四件事:

  1. 创建一个空对象
  2. 将空对象的 __proto__ 指向构造函数的 prototype
  3. this 绑定到空对象,执行构造函数
  4. 如果构造函数没有返回对象,则返回 this

可以手写一个 myNew 来理解这个过程:

function myNew(constructor, ...args) {
  const obj = Object.create(constructor.prototype);
  const result = constructor.apply(obj, args);
  return result instanceof Object ? result : obj;
}

原型链继承(ES5 方式)

// 父类构造函数
function People(name) {
  this.name = name;
}
People.prototype.eat = function () {
  console.log(this.name + ' eat something');
};
 
// 子类构造函数
function Student(name, number) {
  People.call(this, name); // 调用父类构造函数,继承实例属性
  this.number = number;
}
// 关键:将子类 prototype 的原型指向父类 prototype
Student.prototype = Object.create(People.prototype);
Student.prototype.constructor = Student; // 修正 constructor 指向
 
Student.prototype.sayHi = function () {
  console.log('姓名 ' + this.name + ',学号 ' + this.number);
};

这段代码是 ES5 继承的经典范式。对比 ES6 的 class extends,体会两者的关系。

ES6 class:语法糖

class Student {
  constructor(name, number) {
    this.name = name;
    this.number = number;
  }
  sayHi() {
    console.log(`姓名 ${this.name},学号 ${this.number}`);
  }
}
 
typeof Student; // 'function'  —— class 本质上就是函数

class 内部定义的方法默认是不可枚举的(enumerable: false),而 ES5 直接赋值给 prototype 是可枚举的——这是两者在行为上的细微差别。

继承:extends 和 super

class People {
  constructor(name) {
    this.name = name;
  }
  eat() {
    console.log(`${this.name} eat something`);
  }
}
 
class Student extends People {
  constructor(name, number) {
    super(name); // 必须先调用 super(),否则 this 不可用
    this.number = number;
  }
  sayHi() {
    console.log(`姓名 ${this.name},学号 ${this.number}`);
  }
}
 
const xiaoming = new Student('小明', 100);
xiaoming.sayHi(); // "姓名 小明,学号 100"
xiaoming.eat(); // "小明 eat something"

几个细节:

  1. super() 必须在 this 之前调用——子类没有自己的 this,需要通过 super() 从父类借。在 super() 之前访问 this 会报 ReferenceError
  2. 方法可以重写(override)——子类定义同名方法覆盖父类,没有定义则沿原型链向上找
  3. eat 方法只存在于 People.prototype——所有实例共享同一份函数,这就是原型继承节省内存的地方

原型链:__proto__prototype

每个函数(包括 class)都有显示原型 prototype。每个对象(实例)都有隐式原型 __proto__。现代规范推荐用 Object.getPrototypeOf()Object.setPrototypeOf(),但调试和面试中 __proto__ 更常用。

xiaoming.__proto__  →  Student.prototype
Student.prototype.__proto__  →  People.prototype
People.prototype.__proto__  →  Object.prototype
Object.prototype.__proto__  →  null
xiaoming.__proto__ === Student.prototype; // true
Student.prototype.__proto__ === People.prototype; // true
People.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__; // null —— 原型链终点

属性查找沿链向上

执行 xiaoming.sayHi() 时的查找路径:

1. xiaoming 自身有 sayHi 吗?                → 没有
2. xiaoming.__proto__(Student.prototype)有吗? → 找到了!

执行 xiaoming.eat() 时:

1. 自身 → 没有
2. Student.prototype → 没有
3. People.prototype → 找到了!
xiaoming.hasOwnProperty('name'); // true  —— 构造函数赋值在实例自身
xiaoming.hasOwnProperty('sayHi'); // false —— sayHi 在 Student.prototype 上
xiaoming.hasOwnProperty('eat'); // false —— eat 在 People.prototype 上
xiaoming.hasOwnProperty('toString'); // false —— toString 在 Object.prototype 上

hasOwnProperty 只检查自身属性,不看原型链。它来自 Object.prototype,任何对象都可以调用(除非原型链被 Object.create(null) 打断)。

实例方法 vs 原型方法:内存视角

class Bad {
  constructor() {
    // 每个实例创建一份独立的函数副本 —— 浪费内存
    this.sayHi = () => {
      console.log('hi');
    };
  }
}
 
class Good {
  // 所有实例共享 prototype 上的同一份函数
  sayHi() {
    console.log('hi');
  }
}
 
const a = new Bad();
const b = new Bad();
console.log(a.sayHi === b.sayHi); // false —— 两份不同的函数
 
const x = new Good();
const y = new Good();
console.log(x.sayHi === y.sayHi); // true —— 共享,高效

什么时候需要在 constructor 里定义方法?当你需要箭头函数的 this 绑定,或者方法需要访问构造函数闭包里的私有变量时(不过现在有私有字段 #field 了)。

instanceof 的判断逻辑

沿着变量的 __proto__ 链向上查找,看能否匹配到某个 class 的 prototype

xiaoming instanceof Student; // true —— __proto__ 匹配到 Student.prototype
xiaoming instanceof People; // true —— 继续向上匹配
xiaoming instanceof Object; // true —— 最终匹配到 Object.prototype
xiaoming instanceof Array; // false —— 遍历完整条链都没找到
 
[] instanceof Array; // true
[] instanceof Object; // true

可以手写一个 myInstanceof 来理解:

function myInstanceof(obj, constructor) {
  let proto = Object.getPrototypeOf(obj);
  while (proto) {
    if (proto === constructor.prototype) return true;
    proto = Object.getPrototypeOf(proto);
  }
  return false;
}

instanceof 可以判断数组,但跨 iframe 场景下会失效(不同 iframe 有各自的 Array 构造函数),这时用 Array.isArray() 更可靠。

Object.create 与原型式继承

除了构造函数和 class,还可以用 Object.create() 直接创建一个以指定对象为原型的实例:

const parent = {
  greet() {
    console.log('hello ' + this.name);
  },
};
 
const child = Object.create(parent);
child.name = 'xiaoming';
child.greet(); // "hello xiaoming"
 
// child.__proto__ === parent  →  true

Object.create(null) 创建一个没有原型链的纯字典对象——连 toStringhasOwnProperty 都没有,非常适合做纯粹的 key-value 存储,不用担心原型污染。

手写简易 jQuery

这个例子展示了原型在库设计中的应用——实例方法在 prototype 上,插件通过扩展 prototype 实现:

class jQuery {
  constructor(selector) {
    const result = document.querySelectorAll(selector);
    const length = result.length;
    for (let i = 0; i < length; i++) {
      this[i] = result[i];
    }
    this.length = length;
    this.selector = selector;
  }
 
  get(index) {
    return this[index];
  }
 
  each(fn) {
    for (let i = 0; i < this.length; i++) {
      fn.call(this, this[i], i);
    }
    return this; // 返回 this 支持链式调用
  }
 
  on(eventName, fn) {
    return this.each((elem) => {
      elem.addEventListener(eventName, fn, false);
    });
  }
}
 
// 插件:扩展 prototype,所有已有和未来的实例自动获得新方法
jQuery.prototype.dialog = function (info) {
  alert(info);
};
 
// 使用
const $p = new jQuery('p');
$p.get(1);
$p.each((elem) => console.log(elem.nodeName));
$p.on('click', () => alert('clicked'));
$p.dialog('hello');

这个设计的精妙之处:任何时候给 jQuery.prototype 添加方法,所有实例都能立刻使用——因为 __proto__ 指向的是同一个 prototype 对象。这也是为什么 jQuery 插件生态能如此繁荣。

相关笔记