面向对象编程
一、两种范式的分野
| 面向对象(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 的两个坑
- 跨执行环境失效:iframe 或 Node 的不同 vm context 里各有一份
Array,跨环境传来的数组arr instanceof Array为false。判断类型请用Array.isArray()或Object.prototype.toString.call()。 - 可以被伪造:
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)。
而"只是一组不相关的工具函数"用模块导出就够了,包成一个全是静态方法的类是没有意义的。
