面向对象编程

一、两种范式的分野

面向对象(OOP)函数式(FP)
抽象方式把数据与行为打包成对象,对象是一等公民把行为抽象为函数,数据与行为分离,函数是一等公民
复用手段继承(以及组合)组合(compose / pipe)
状态对象持有可变状态,方法修改它避免状态,用不可变数据和纯函数
关注点"谁"来做(职责划分)"怎么"变换(数据流)

两者不是对立的。JS 是多范式语言,实际工程里常见的组合是:用对象/模块划分职责边界,用纯函数实现内部的数据变换。

二、JS 的对象模型:原型,不是类

JS 没有真正的"类",它靠原型链实现继承。理解这三个概念的关系是理解 JS OOP 的前提:

function Person(name) { this.name = name; }
Person.prototype.sayName = function () { return this.name; };

const p = new Person('Tom');

p.__proto__ === Person.prototype;              // true,实例指向构造函数的原型对象
Person.prototype.constructor === Person;       // true,原型对象反向指回构造函数
Object.getPrototypeOf(p) === Person.prototype; // true,标准写法(__proto__ 已不推荐使用)
  • prototype 是函数才有的属性,指向"用这个函数 new 出来的实例的原型对象";
  • __proto__(内部 [[Prototype]])是每个对象都有的,指向它的原型;
  • 属性查找沿 __proto__ 逐级向上,直到 Object.prototype,再往上是 null——这条链就是原型链。
p ──__proto__──→ Person.prototype ──__proto__──→ Object.prototype ──__proto__──→ null

new 到底做了什么

function myNew(Ctor, ...args) {
    // 1. 创建一个新对象,并把它的原型指向 Ctor.prototype
    const obj = Object.create(Ctor.prototype);
    // 2. 以新对象为 this 执行构造函数
    const result = Ctor.apply(obj, args);
    // 3. 构造函数显式返回对象时以该对象为准,否则返回新创建的对象
    return (result !== null && (typeof result === 'object' || typeof result === 'function'))
        ? result
        : obj;
}

第 3 步解释了工厂模式里那个"构造函数返回对象会覆盖 this"的技巧从何而来。

instanceof 的原理

a instanceof B 判断的是 B.prototype 是否出现在 a 的原型链上:

function myInstanceof(obj, Ctor) {
    if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) return false;
    let proto = Object.getPrototypeOf(obj);
    while (proto !== null) {
        if (proto === Ctor.prototype) return true;
        proto = Object.getPrototypeOf(proto);
    }
    return false;
}

instanceof 的两个坑

  1. 跨执行环境失效:iframe 或 Node 的不同 vm context 里各有一份 Array,跨环境传来的数组 arr instanceof Array 为 false。判断类型请用 Array.isArray() 或 Object.prototype.toString.call()。
  2. 可以被伪造:Symbol.hasInstance 允许自定义 instanceof 的行为,因此它不是可靠的安全边界。

三、class 语法

class 是原型继承的语法糖,但不完全等价于函数写法:

class Person {
    // 1. 公有实例字段
    species = 'human';
    // 2. 私有字段:真正的私有,外部访问是语法错误
    #ssn;
    // 3. 静态属性与静态块
    static count = 0;
    static { /* 类初始化时执行一次 */ }

    constructor(name, ssn) {
        this.name = name;
        this.#ssn = ssn;
        Person.count++;
    }

    // 4. 原型方法:定义在 Person.prototype 上,且不可枚举
    sayName() { return this.name; }

    // 5. 存取器
    get maskedSsn() { return `***${this.#ssn.slice(-4)}`; }
    set ssn(v) { this.#ssn = v; }

    // 6. 静态方法:定义在 Person 本身上
    static create(name) { return new Person(name, '000000000'); }

    // 7. 私有方法
    #validate() { return this.#ssn.length === 9; }
}

与 function 构造函数的实际差异(面试常问):

差异点function 构造函数class
提升声明与赋值都提升,可提前调用提升但处于 TDZ,提前使用报 ReferenceError
不加 new 调用静默执行,this 为 undefined/全局直接 TypeError
内部代码沿用外部模式始终是严格模式
原型方法可枚举性可枚举,会出现在 for...in 中不可枚举
私有成员只能靠闭包/约定原生 # 私有字段

四、封装:JS 里实现私有性的四种方式

// ① 下划线约定:零成本,但只是君子协定
class A { constructor() { this._secret = 1; } }

// ② 闭包:真正私有,但每个实例都要复制一份方法,内存开销大
function B() {
    let secret = 1;                       // 外部完全访问不到
    this.get = () => secret;
}

// ③ WeakMap:私有且方法可共享,实例被回收时数据自动释放
const privates = new WeakMap();
class C {
    constructor() { privates.set(this, { secret: 1 }); }
    get() { return privates.get(this).secret; }
}

// ④ # 私有字段(推荐):语言级私有,编译期/运行时都拦截
class D {
    #secret = 1;
    get() { return this.#secret; }
    // 判断某对象是否有该私有字段(不会抛错)
    static isD(obj) { return #secret in obj; }
}

该用哪个

新代码一律用 #。它是唯一无法从外部绕过的方案(Object.getOwnPropertyNames 也看不到),且没有闭包方案的内存开销。需要兼容旧环境时,WeakMap 是次优选择。

五、继承的实现演进

理解这条演进路线,就理解了 class extends 的本质。

function Parent(name) { this.name = name; this.colors = ['red']; }
Parent.prototype.getName = function () { return this.name; };

① 原型链继承——子类原型指向父类实例。

function Child() {}
Child.prototype = new Parent('parent');

❌ 所有实例共享同一份引用类型属性:一个实例 push 到 colors,其他实例全部受影响;且无法向父构造函数传参。

② 构造函数继承——在子构造函数里调用父构造函数。

function Child(name) { Parent.call(this, name); }

✅ 解决了引用共享和传参问题。❌ 但继承不到父类原型上的方法(getName 拿不到)。

③ 组合继承——上面两者结合,最经典的写法。

function Child(name) { Parent.call(this, name); }      // 第二次调用 Parent
Child.prototype = new Parent();                          // 第一次调用 Parent
Child.prototype.constructor = Child;

❌ 父构造函数被调用了两次,实例上会存在一份被原型上同名属性遮蔽的多余数据。

④ 寄生组合式继承——公认的最优解,也是 class extends 的编译产物。

function inherit(Child, Parent) {
    // 不调用 Parent 构造函数,只继承它的原型
    Child.prototype = Object.create(Parent.prototype);
    Child.prototype.constructor = Child;
    // 让子类也能继承父类的静态方法(对应 class 中 Child.__proto__ === Parent)
    Object.setPrototypeOf(Child, Parent);
}

function Child(name, age) {
    Parent.call(this, name);      // 只调用一次
    this.age = age;
}
inherit(Child, Parent);

⑤ class extends——上面这套的语法糖:

class Child extends Parent {
    constructor(name, age) {
        super(name);        // 必须先调用 super,否则 this 处于 TDZ
        this.age = age;     // super 之后才能使用 this
    }
    getName() {
        return `child: ${super.getName()}`;   // super 调用父类原型方法
    }
}

为什么必须先 super 再 this

派生类的 this 由父类构造函数创建,super() 返回前 this 尚未初始化,访问会抛 ReferenceError。这与 ES5 的"先创建 this 再继承"顺序相反,也是 class 无法用 function 完全模拟的地方之一(继承内置类型 Array、Error 时尤其明显)。

六、多态

JS 是动态类型语言,多态几乎是免费的——不需要接口声明,只要对象有对应的方法就能被调用,这就是鸭子类型:

// 不关心 animal 是什么类型,只关心它会不会 speak
const speak = animal => console.log(animal.speak());

speak({ speak: () => '汪' });
speak({ speak: () => '喵' });

方法重写(override)也天然支持:子类原型上的同名方法会遮蔽父类的。

JS 没有函数重载

同名函数后定义的会覆盖先定义的。需要"不同参数不同行为"时,用参数个数/类型判断,或者用策略对象分发:

function on(target, ...args) {
    if (typeof args[0] === 'object') return batchOn(target, args[0]);   // on(el, { click: fn })
    return singleOn(target, args[0], args[1]);                           // on(el, 'click', fn)
}

TypeScript 的重载签名只在类型层面存在,运行时仍然只有一个实现体。

七、组合优于继承

继承是编译期确定的强耦合,它的代价常被低估:

  • 脆弱基类:改父类会波及所有子类,且你无法预知子类怎么用它;
  • 层级僵化:Animal → Bird → Penguin,直到发现企鹅不会飞——这就是里氏替换原则被破坏的典型;
  • 功能组合爆炸:又要会飞又要会游泳的,只能再造一个类。

用组合/mixin 把能力拼装进来:

// 每个 mixin 是一个"能力",可以任意组合
const canFly = Base => class extends Base {
    fly() { return `${this.name} 起飞`; }
};
const canSwim = Base => class extends Base {
    swim() { return `${this.name} 游泳`; }
};

class Animal { constructor(name) { this.name = name; } }
class Duck extends canFly(canSwim(Animal)) {}

new Duck('唐老鸭').fly();   // 唐老鸭 起飞
new Duck('唐老鸭').swim();  // 唐老鸭 游泳

React 生态的演进正是这条路:createClass mixin →(命名冲突、依赖不透明)→ HOC →(嵌套地狱、props 冲突)→ Hooks,一路都在用组合替代继承。这也是 React 官方明确不推荐组件继承的原因。

八、OOP 在前端的实际位置

不要为了 OOP 而 OOP

现代前端的组件模型(React 函数组件、Vue Composition API)本身在远离类继承。类真正合适的场景是:

  • 需要维护内部状态 + 一组相关操作的实体:Player、WebSocketClient、Editor、Chart;
  • 需要多个实例、每个实例状态独立;
  • 需要明确的生命周期(init / destroy)。

而"只是一组不相关的工具函数"用模块导出就够了,包成一个全是静态方法的类是没有意义的。

延伸阅读 👉 设计原则与模式总览 | 函数式编程基础

上次更新:
贡献者: Joe