Skip to content

封装、继承与多态 ​

难度等级:🟡 中级 分类: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.iterator
    • await 只要求对象有 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. 这两个例子都是"核心只依赖约定的接口,具体行为由外部传入",属于鸭子类型的多态:

    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 的 createRenderer

    Vue 的 diff、组件更新等渲染逻辑在 @vue/runtime-core 里,不直接操作 DOM,而是调用传进来的节点操作方法。createRenderer 接收一组平台相关的实现,返回该平台的 createApp:

    js
    import { 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、终端输出的实现,同一套渲染逻辑就能跑在对应平台上。 ↩︎

个人面试题归档,持续更新中