Appearance
封装、继承与多态
难度等级:🟡 中级 分类:JavaScript 归档日期:2026-10-12
在 JavaScript 中如何理解封装、继承和多态?
难度与考察点
难度: ★★★
考察点:
- OOP 三大特性的基本概念
- JS 基于原型的本质,以及
class是语法糖 - 封装的不同实现手段及其差异
- 原型链继承机制,ES5 与 ES6 继承的区别
- 多态的形式,尤其是鸭子类型
- 资深信号:组合优于继承、多态在工程中的应用
核心原理
一、前提:JS 是基于原型的语言
封装、继承、多态是面向对象的通用概念,但 JS 不是基于类的语言。ES6 的 class 底层仍是构造函数和原型链,所以不能完全套用 Java 的理解。原型链本身详见 原型、原型链。
二、封装
概念: 把数据和操作数据的方法组合在一起,隐藏内部细节,只通过受控接口与外界交互。
目的: 维护对象状态的合法性(不变量),降低耦合,内部实现可以重构而不影响调用方。
JS 中的实现:
| 方式 | 私有性 | 特点 |
|---|---|---|
_name 约定 | 无 | 只是规范 |
| 闭包 | 真私有 | 每个实例复制一份方法,有内存开销 |
Symbol key | 半私有 | 可通过 getOwnPropertySymbols 获取 |
#field(ES2022) | 语言级私有 | 方法共享在原型上;#x in obj 可做品牌检查 |
TS private | 仅编译期 | 运行时可以访问 |
js
class Account {
#balance = 0;
deposit(n) {
if (n <= 0) throw new Error('invalid'); // 受控入口,保证状态合法
this.#balance += n;
}
get balance() { return this.#balance; } // 只读暴露
}三、继承
概念: 子类获得父类的属性和方法并加以扩展,表达 is-a 关系,目的是复用。
JS 的本质: 原型链委托,不是复制。属性在自身找不到时,沿 [[Prototype]] 向上查找。
class Dog extends Animal 会建立两条链:
Dog.prototype.__proto__ === Animal.prototype:继承实例方法Dog.__proto__ === Animal:继承静态方法
ES5 与 ES6 继承的区别:
- ES5:先创建子类的
this,再用Parent.call(this)添加父类属性。 - ES6:先由父类构造出
this,再交给子类加工。所以必须先调用super()才能使用this,也因此能正确继承Array、Error等内置对象。
ES5 的最佳方案是寄生组合式继承(详见 寄生组合式继承与 class 继承):
js
function Dog(name) { Animal.call(this, name); }
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;代价: 父子强耦合,父类改动影响所有子类,层级深了难以维护,因此工程上提倡组合优于继承。React 类组件转向 Hooks、Vue mixins 转向 Composition API,都是用组合替代继承来复用逻辑。
四、多态
概念: 同一个接口作用于不同对象时,表现出不同的行为。本质是调用方只依赖约定,与具体实现解耦,新增类型时不用改调用方,符合开闭原则。
JS 中的形式:
- 方法重写:调用
this.method()时,从实例沿原型链查找最近的实现,在运行时决定执行哪个方法(动态派发)。父类写流程骨架、子类实现具体步骤,就是模板方法模式。 - 鸭子类型:JS 最主要的多态方式。不看类型,只看有没有对应方法,不需要继承关系。内置协议都是这样设计的:
for...of、展开运算符只要求对象实现Symbol.iteratorawait只要求对象有then方法Symbol.toPrimitive、toJSON控制类型转换和序列化
- 重载:JS 不支持。同名函数会被覆盖,只能在函数内部根据参数类型或数量判断(如 jQuery 的
css)。TS 的重载签名只是类型声明,实现仍只有一个。 - TS 接口与泛型:TS 是结构化类型,相当于"有类型检查保护的鸭子类型";泛型属于参数多态。
js
class Range {
constructor(s, e) { this.s = s; this.e = e; }
*[Symbol.iterator]() { for (let i = this.s; i <= this.e; i++) yield i; }
}
[...new Range(1, 3)]; // [1, 2, 3],未继承 Array,只实现了迭代协议工程应用:
- 策略模式替代大量 if-else(如表单校验规则)
- 插件机制(Vite、Webpack、Babel 插件只需实现约定的钩子)
- Vue 3 的
createRenderer:传入不同平台的节点操作实现,同一套渲染逻辑可用于 DOM、Canvas、小程序 - 依赖接口而非实现,单测时可以传入 mock 对象
注意事项:
- 鸭子类型出错要到运行时才暴露,可用 TS 接口约束,或在父类中抛错模拟抽象方法。
- 方法被单独取出作为回调时会丢失
this,动态派发失效,需要bind或箭头函数。 - 类型少且固定时,简单的
switch更直观,不要为了多态而多态。
五、三者关系
封装定义接口,继承复用实现,多态让同一接口拥有多种实现。 封装是基础,继承是复用的手段之一,多态是让系统易于扩展的最终目的。
口语化答案(约 3 分钟)
封装、继承、多态是面向对象的三大特性。不过 JS 是基于原型的动态语言,ES6 的
class本质是原型的语法糖,所以理解这三个特性要从原型和动态类型的角度来看。封装,是把数据和操作数据的方法组合在一起,隐藏内部细节,只暴露受控的接口。我理解它的核心价值是维护对象状态的合法性,所有修改都走有校验的入口。JS 早期没有访问修饰符,主要靠闭包或下划线约定,闭包有内存开销,约定没有强制力。现在 ES2022 有了
#私有字段,是语言级的真私有。另外要注意,TS 的private只在编译期生效,运行时仍然可以访问。继承,是子类复用父类的属性和方法。JS 的继承本质是原型链委托,属性找不到时沿原型链往上查。
class extends会建立两条链,分别继承实例方法和静态方法。ES5 和 ES6 有个本质区别:ES5 是子类先创建this,再调用父构造函数往上加属性;ES6 是父类先构造出this,再交给子类,所以必须先调用super,也因此 ES6 才能正确继承Array、Error这些内置对象。ES5 时代最完善的方案是寄生组合式继承。但在工程上我不太推崇深层继承,父子类耦合太强,父类一改,子类都受影响。我更倾向组合优于继承,React 转向 Hooks、Vue 转向 Composition API,本质上都是用组合替代继承来复用逻辑。
多态,是同一个接口作用于不同对象时表现出不同的行为,本质是让调用方和具体实现解耦。JS 里有两种主要形式:一是子类重写父类方法,调用时沿原型链在运行时找到实际的实现,也就是动态派发;二是鸭子类型,这是 JS 更本质的方式,不需要继承关系,只要对象实现了约定的方法就行。JS 的很多内置机制都是这样设计的,比如
for...of只要求对象实现Symbol.iterator,await只要求对象有then方法。另外 JS 不支持真正的函数重载,只能在函数内部根据参数做判断。工程上,多态最常见的应用是用策略模式替代大量 if-else,以及插件机制,比如 Vite 插件只需实现约定的钩子,核心代码不用改。Vue 3 的
createRenderer也是很好的例子,传入不同平台的节点操作,同一套渲染逻辑就能跑在不同平台上[1]。总结一下,封装解决边界问题,继承解决复用问题,多态解决扩展问题。在 JS 里我会优先用封装加组合加鸭子类型,继承只用在层级浅、关系稳定的场景。
项目经验插入模板(请替换为自己的真实经历):
比如在 ____ 项目中,我们的 ____(如:埋点 SDK / 上传组件 / 图表渲染)一开始用继承实现,后来出现了 ____(如:子类越来越多、改父类影响一大片)的问题,我们改成了 ____(如:插件机制 / 策略模式 + 统一接口)。之后新增类型只需实现约定的接口,不用修改核心代码。
常见错误
- 把
class当成真正的类,认为 JS 和 Java 一样是类继承。 - 认为 TS 的
private在运行时也是私有的。 - 说 JS 支持函数重载。
- 把多态只理解为"子类重写父类方法",不知道鸭子类型。
- 只背概念、不谈取舍,没有体现组合优于继承等工程判断。
面试官可能的追问
1. new 做了什么?
创建一个以构造函数 prototype 为原型的新对象,以该对象为 this 执行构造函数;如果构造函数返回对象或函数,就用返回值,否则返回新对象。详见 new 操作符具体干了什么。
js
function myNew(Ctor, ...args) {
const obj = Object.create(Ctor.prototype);
const res = Ctor.apply(obj, args);
return res !== null && (typeof res === 'object' || typeof res === 'function') ? res : obj;
}2. instanceof 的原理?
沿左侧对象的原型链查找,看能否找到右侧构造函数的 prototype。可通过 Symbol.hasInstance 自定义。跨 iframe 时会失效,因为不同 realm 的内置原型不是同一个对象,判断数组应使用 Array.isArray。
3. 为什么子类构造函数必须先调用 super?
ES6 中 this 由父类构造。调用 super 前 this 未初始化,访问会抛出 ReferenceError。
4. # 私有字段和闭包有什么区别?
# 字段所在类的方法定义在原型上、所有实例共享,没有闭包那样每个实例复制方法的开销。#x in obj 可做品牌检查,判断对象是否由该类构造,比 instanceof 更可靠,无法通过修改原型伪造。
5. 组合和继承怎么选?
关系是稳定的 is-a 且层级浅(1~2 层)时可以用继承,如自定义 Error 子类;关系是 has-a 或 can-do,或需要跨类型复用能力时,用组合、mixin 或 Hooks。
6. 多态和策略模式是什么关系?
策略模式是多态的典型应用:每个策略实现同一接口,上下文只依赖接口调用,新增策略不修改上下文代码。在 JS 中策略常常直接用函数或对象字面量表示,比传统 OOP 写法更轻量。
7. 直接修改原型上的方法有什么风险?
会影响所有实例;修改内置原型会污染全局,并可能与未来标准冲突。例如 Array.prototype.flatten 因 MooTools 改过原型而被迫改名为 flat。
这两个例子都是"核心只依赖约定的接口,具体行为由外部传入",属于鸭子类型的多态:
1. Vite 插件
插件就是一个带
name和若干钩子方法的普通对象,不需要继承任何基类。Vite 在构建流程的各个阶段依次调用所有插件的同名钩子,比如resolveId(解析路径)、load(读取内容)、transform(转换代码)、configureServer(扩展开发服务器)。核心只管"什么时候调哪个钩子",不关心插件内部做什么。js// 让 .md 文件可以直接 import 成字符串 export default function mdPlugin() { return { name: 'md-plugin', transform(code, id) { if (id.endsWith('.md')) { return `export default ${JSON.stringify(code)}`; } }, }; }新增能力只要写一个新插件,Vite 源码不用改,这就是开闭原则。
2. Vue 3 的
createRendererVue 的 diff、组件更新等渲染逻辑在
@vue/runtime-core里,不直接操作 DOM,而是调用传进来的节点操作方法。createRenderer接收一组平台相关的实现,返回该平台的createApp:jsimport { createRenderer } from '@vue/runtime-core'; const { createApp } = createRenderer({ createElement(type) { /* 创建平台节点 */ }, insert(el, parent, anchor) { /* 插入节点 */ }, remove(el) { /* 删除节点 */ }, patchProp(el, key, prevValue, nextValue) { /* 更新属性 */ }, createText(text) { /* 创建文本节点 */ }, setElementText(el, text) { /* 设置文本 */ }, // ...parentNode、nextSibling 等 });我们平时用的
vue包,就是runtime-dom把document.createElement、insertBefore这些 DOM API 传给了createRenderer。换成 Canvas 绘制、小程序的setData、终端输出的实现,同一套渲染逻辑就能跑在对应平台上。 ↩︎