PhysChen.com
主页
物理
笔记 科普 研究
教学
IB 课程
编程
笔记 项目
随笔
所感 所思
摄影
Shenzhen Portrait Cats Others Wuhan Japan
关于
主页
物理
笔记 科普 研究
编程
笔记 项目
摄影
Shenzhen Portrait Cats Others Wuhan Japan
教学
IB 课程
随笔
所感 所思
关于
文章目录
    JavaScript 6. 原型继承与面向对象编程 陈华的个人主页

    文章信息

    • 标题: JavaScript 6. 原型继承与面向对象编程
    • 发布时间: 2026 年 7 月 19 日
    • 来源: https://physchen.com/zh-Hans/programming/notes/javascript-prototype-inheritance-and-oop/
    • 摘要: 系统介绍 JavaScript 的原型继承、构造函数、class 语法、实例字段、私有元素、静态成员、派生类、super、instanceof 与组合设计,建立清晰的对象建模方法。

    目录

      JavaScript 6. 原型继承与面向对象编程

      发布于 2026 年 7 月 19 日
      • JavaScript 基础
      • JavaScript
      • OOP

      JavaScript 可以使用普通对象⁠、工厂函数⁠、构造函数和类组织程序⁠。对于只包含少量数据的记录⁠,普通对象通常已经足够⁠;当一组对象需要维护各自状态⁠、共享行为并保持明确的不变量时⁠,类和原型继承能够提供更清晰的结构⁠。

      JavaScript 的对象继承建立在原型链上⁠。class 并没有用另一套与原型无关的对象模型取代它⁠,而是为构造⁠、方法共享⁠、继承⁠、私有元素和静态成员提供了统一语法⁠。

      不过⁠,把类简单称为“⁠构造函数和原型的语法糖⁠”也不够准确⁠。类具有普通构造函数不自动具备的语义⁠,例如⁠:

      • 类构造器不能脱离 new 直接调用⁠;
      • 类体始终在严格模式下执行⁠;
      • 类方法默认不可枚举⁠;
      • 类声明具有暂时性死区⁠;
      • 类支持公有和私有字段⁠、私有方法⁠、静态块等专门语法⁠;
      • 派生类构造过程具有 super() 和 this 初始化约束⁠。

      因此⁠,理解 JavaScript 面向对象编程需要同时掌握两层内容⁠:

      1. 对象通过原型链继承属性和方法⁠;
      2. 类语法如何在这一模型上定义实例⁠、继承关系和封装边界⁠。

      第二章已经介绍普通函数与箭头函数的 this 绑定⁠。本章只讨论 this 在构造和实例方法中的建模作用⁠。

      6.1 面向对象建模的基本概念

      面向对象编程通常围绕对象的状态与行为组织代码⁠。

      const account = {
        balance: 100,
      
        deposit(amount) {
          this.balance += amount;
        },
      };

      这里⁠:

      • balance 表示状态⁠;
      • deposit() 表示行为⁠;
      • 对象把二者组织在同一接口下⁠。

      6.1.1 抽象

      抽象是从具体实现中提取稳定接口⁠。

      例如⁠,调用者只需要知道账户可以存款和读取余额⁠,不需要知道余额具体保存在普通属性⁠、私有字段还是外部数据库中⁠。

      account.deposit(50);
      console.log(account.balance);

      良好的抽象应使调用者依赖行为契约⁠,而不是依赖内部表示⁠。

      6.1.2 封装

      封装用于限制内部状态的直接访问⁠,并把验证规则集中到少量入口⁠。

      class BankAccount {
        #balance = 0;
      
        deposit(amount) {
          if (
            typeof amount !== "number"
            || !Number.isFinite(amount)
            || amount <= 0
          ) {
            throw new RangeError(
              "amount 必须是正的有限数值",
            );
          }
      
          this.#balance += amount;
        }
      
        get balance() {
          return this.#balance;
        }
      }

      调用者只能通过公开方法修改余额⁠,从而避免绕过验证规则⁠。

      6.1.3 继承

      继承允许一个对象或类复用另一个对象或类的公开行为⁠,并在此基础上扩展或重写⁠。

      class Animal {
        speak() {
          return "The animal makes a sound.";
        }
      }
      
      class Dog extends Animal {
        speak() {
          return "The dog barks.";
        }
      }

      继承表达的是一种较强的“⁠属于同一抽象类别⁠”关系⁠。并非所有代码复用都适合继承⁠,组合通常更加灵活⁠。

      6.1.4 多态

      多态表示不同对象可以通过相同接口被统一使用⁠:

      class Circle {
        area() {
          return Math.PI;
        }
      }
      
      class Rectangle {
        area() {
          return 2;
        }
      }
      
      function printArea(shape) {
        console.log(shape.area());
      }
      
      printArea(new Circle());
      printArea(new Rectangle());

      printArea() 不需要知道对象的具体类⁠,只要求对象提供可调用的 area() 方法⁠。这种基于行为的方式通常称为鸭子类型⁠。

      6.2 原型与原型链

      第五章已经介绍对象属性查找的基本过程⁠。本节从面向对象建模的角度进一步整理原型关系⁠。

      6.2.1 对象的 [[Prototype]]

      普通对象具有规范内部槽 [[Prototype]]⁠,其值为另一个对象或 null⁠。

      const animal = {
        eats: true,
      };
      
      const rabbit = Object.create(animal);
      
      rabbit.jumps = true;

      原型关系可以表示为⁠:

      rabbit
        ↓ [[Prototype]]
      animal
        ↓ [[Prototype]]
      Object.prototype
        ↓ [[Prototype]]
      null

      读取属性时⁠,运行时先检查对象自身⁠,再沿原型链向上查找⁠:

      console.log(rabbit.jumps); // true
      console.log(rabbit.eats);  // true

      检查属性来自自身还是原型⁠:

      console.log(
        Object.hasOwn(rabbit, "jumps"),
      ); // true
      
      console.log(
        Object.hasOwn(rabbit, "eats"),
      ); // false

      6.2.2 获取原型

      console.log(
        Object.getPrototypeOf(rabbit)
        === animal,
      ); // true

      应优先使用 Object.getPrototypeOf()⁠,而不是依赖历史访问器 __proto__⁠。

      6.2.3 设置原型

      创建对象时直接指定原型⁠:

      const rabbit = Object.create(
        animal,
      );

      也可以修改已有对象的原型⁠:

      Object.setPrototypeOf(
        rabbit,
        anotherAnimal,
      );

      但运行时修改原型会改变属性查找结构⁠,并可能使引擎已有优化失效⁠。对象的原型关系通常应在创建时确定⁠。

      6.2.4 prototype 属性与 [[Prototype]] 的区别

      函数对象的 prototype 是一个普通属性⁠:

      function User() {}
      
      console.log(
        typeof User.prototype,
      ); // object

      对象的 [[Prototype]] 是规范内部关系⁠:

      const user = new User();
      
      console.log(
        Object.getPrototypeOf(user)
        === User.prototype,
      ); // true

      两者不能混为一谈⁠:

      • User.prototype 是构造器上的普通属性⁠;
      • user.[[Prototype]] 是实例的内部原型关系⁠;
      • 执行 new User() 时⁠,通常会使用 User.prototype 建立新对象的原型⁠。

      函数对象自身也有自己的 [[Prototype]]⁠:

      console.log(
        Object.getPrototypeOf(User)
        === Function.prototype,
      ); // true

      因此⁠,一个类或构造函数同时参与两类关系⁠:

      1. 实例通过 Constructor.prototype 继承实例方法⁠;
      2. 构造函数对象自身也位于函数对象的原型链中⁠。

      6.2.5 原型方法中的 this

      const animal = {
        describe() {
          return `name: ${this.name}`;
        },
      };
      
      const rabbit = Object.create(animal);
      
      rabbit.name = "Rabbit";
      
      console.log(
        rabbit.describe(),
      ); // name: Rabbit

      方法在原型 animal 上找到⁠,但调用表达式是 rabbit.describe()⁠,因此普通方法内部的 this 是 rabbit⁠。

      原型决定从哪里找到方法⁠,调用形式决定普通函数中的 this⁠。二者是不同问题⁠。

      6.3 构造函数

      在类语法出现之前⁠,JavaScript 常使用可构造函数和原型建立同类对象⁠。

      6.3.1 基本形式

      function User(name) {
        this.name = name;
      }
      
      User.prototype.greet = function () {
        return `Hello, ${this.name}!`;
      };
      
      const alice = new User("Alice");
      const bob = new User("Bob");
      
      console.log(alice.greet());
      console.log(bob.greet());

      约定上⁠,可构造函数名称使用大驼峰⁠,以提示调用者需要使用 new⁠。

      6.3.2 [[Call]] 与 [[Construct]]

      ECMAScript 区分两个内部能力⁠:

      • [[Call]]⁠:作为普通函数调用⁠;
      • [[Construct]]⁠:通过 new 或其他构造语义创建对象⁠。

      许多普通函数同时具有二者⁠:

      function User(name) {
        this.name = name;
      }
      
      User("Alice");
      new User("Alice");

      两种调用形式语义不同⁠。

      并不是所有函数都可构造⁠。箭头函数⁠、对象方法简写⁠、异步函数和生成器函数等不具有普通构造能力⁠:

      const createUser = name => ({
        name,
      });
      
      // TypeError
      // new createUser("Alice");

      是否可以通过 new 调用⁠,取决于函数对象是否具有 [[Construct]]⁠,而不是仅看 typeof value === "function"⁠。

      6.3.3 new 的基本过程

      对于普通基类构造函数⁠,下面的代码⁠:

      const user = new User("Alice");

      可以从可观察语义上概括为⁠:

      1. 创建一个新对象⁠;
      2. 使用构造器的 prototype 属性确定新对象的原型⁠;
      3. 以新对象作为 this 调用构造函数⁠;
      4. 根据构造函数的显式返回值决定最终结果⁠。

      用于教学的近似实现⁠:

      function construct(
        Constructor,
        ...args
      ) {
        const prototype = (
          typeof Constructor.prototype
            === "object"
          && Constructor.prototype !== null
        )
          ? Constructor.prototype
          : Object.prototype;
      
        const instance = Object.create(
          prototype,
        );
      
        const result = Constructor.apply(
          instance,
          args,
        );
      
        const returnsObject = (
          result !== null
          && (
            typeof result === "object"
            || typeof result === "function"
          )
        );
      
        return returnsObject
          ? result
          : instance;
      }

      这段代码只用于说明普通构造函数的主要行为⁠,不能完整替代规范的 [[Construct]] 过程⁠,也不能正确模拟所有内置构造器⁠、代理⁠、类和派生构造器⁠。

      6.3.4 prototype 不是对象模板

      构造函数的 prototype 对象主要用于实例继承共享行为⁠:

      function Point(
        x,
        y,
      ) {
        this.x = x;
        this.y = y;
      }
      
      Point.prototype.distanceFromOrigin
        = function () {
          return Math.hypot(
            this.x,
            this.y,
          );
        };

      实例字段分别属于每个实例⁠:

      const first = new Point(3, 4);
      const second = new Point(5, 12);
      
      console.log(
        Object.hasOwn(first, "x"),
      ); // true

      方法由原型共享⁠:

      console.log(
        Object.hasOwn(
          first,
          "distanceFromOrigin",
        ),
      ); // false
      
      console.log(
        first.distanceFromOrigin
        === second.distanceFromOrigin,
      ); // true

      原型对象不是把全部属性复制给实例的“⁠模板⁠”⁠。实例通过原型链动态查找共享属性⁠。

      6.3.5 constructor 属性

      普通函数创建时⁠,其初始 prototype 对象通常具有指回函数的 constructor 属性⁠:

      function User() {}
      
      console.log(
        User.prototype.constructor
        === User,
      ); // true

      如果整体替换 prototype⁠,该默认属性会丢失⁠:

      User.prototype = {
        greet() {
          return "Hello";
        },
      };
      
      console.log(
        User.prototype.constructor
        === User,
      ); // false

      可以手动恢复⁠:

      Object.defineProperty(
        User.prototype,
        "constructor",
        {
          value: User,
          writable: true,
          configurable: true,
        },
      );

      不过⁠,constructor 是普通可变属性⁠,不应作为可靠的安全类型标记⁠。

      6.3.6 构造函数的返回值

      普通基类构造函数没有显式返回对象时⁠,new 返回创建的实例⁠:

      function User(name) {
        this.name = name;
      }
      
      const user = new User("Alice");

      显式返回原始值通常被忽略⁠:

      function User(name) {
        this.name = name;
        return 42;
      }
      
      console.log(
        new User("Alice").name,
      ); // Alice

      显式返回对象会替代默认实例⁠:

      function User(name) {
        this.name = name;
      
        return {
          name: "Replacement",
        };
      }
      
      console.log(
        new User("Alice").name,
      ); // Replacement

      这种写法会使原型关系和实例初始化变得难以推理⁠,普通构造函数通常不应返回替代对象⁠。

      6.3.7 new.target

      在函数或构造器内部⁠,new.target 表示当前构造调用所使用的构造器⁠;普通调用时通常为 undefined⁠:

      function User(name) {
        if (new.target === undefined) {
          throw new TypeError(
            "User 必须通过 new 调用",
          );
        }
      
        this.name = name;
      }

      类构造器已经由语言强制要求构造调用⁠,不需要用这种方式重复检查⁠。

      6.4 class 基础语法

      6.4.1 类声明

      class User {
        constructor(name) {
          this.name = name;
        }
      
        greet() {
          return `Hello, ${this.name}!`;
        }
      }

      创建实例⁠:

      const user = new User("Alice");
      
      console.log(user.greet());

      6.4.2 类是函数对象

      console.log(
        typeof User,
      ); // function
      
      console.log(
        User.prototype.constructor
        === User,
      ); // true

      类值可以像函数一样赋给变量⁠、传递和返回⁠:

      const UserClass = User;
      
      const instance = new UserClass(
        "Alice",
      );

      不过⁠,类构造器不能作为普通函数调用⁠:

      // TypeError
      // User("Alice");

      这不是普通严格模式函数调用的 this 问题⁠,而是类构造器本身不允许通过 [[Call]] 方式执行⁠。

      6.4.3 类声明具有暂时性死区

      // ReferenceError
      // const user = new User();
      
      class User {}

      类声明的绑定在声明执行前处于未初始化状态⁠。不能像函数声明一样在源码中的定义位置之前正常使用⁠。

      6.4.4 类表达式

      匿名类表达式⁠:

      const User = class {
        constructor(name) {
          this.name = name;
        }
      };

      具名类表达式⁠:

      const User = class InternalUser {
        static getInternalName() {
          return InternalUser.name;
        }
      };
      
      console.log(
        User.getInternalName(),
      ); // InternalUser
      
      // ReferenceError
      // console.log(InternalUser);

      内部类名只在类体内部可见⁠,类似具名函数表达式⁠。

      6.4.5 类体处于严格模式

      类体中的构造器⁠、方法⁠、字段初始化器和静态块都使用严格模式语义⁠。

      class Example {
        method() {
          return this;
        }
      }
      
      const method = (
        new Example()
      ).method;
      
      console.log(method()); // undefined

      把方法提取后独立调用⁠,this 不会自动变为全局对象⁠。

      6.4.6 类方法默认不可枚举

      class User {
        greet() {
          return "Hello";
        }
      }
      
      console.log(
        Object.keys(User.prototype),
      ); // []

      方法仍然存在⁠:

      console.log(
        typeof User.prototype.greet,
      ); // function

      查看描述符⁠:

      console.log(
        Object.getOwnPropertyDescriptor(
          User.prototype,
          "greet",
        ),
      );

      类方法的 enumerable 为 false⁠。这与直接使用普通赋值添加原型方法不同⁠:

      function LegacyUser() {}
      
      LegacyUser.prototype.greet
        = function () {
          return "Hello";
        };
      
      console.log(
        Object.keys(
          LegacyUser.prototype,
        ),
      ); // ["greet"]

      如果希望手写构造函数更接近类方法行为⁠,可以使用 Object.defineProperty()⁠。

      6.5 实例方法与实例字段

      6.5.1 原型方法

      类体中的普通实例方法定义在 Class.prototype 上⁠,由实例共享⁠:

      class Point {
        constructor(
          x,
          y,
        ) {
          this.x = x;
          this.y = y;
        }
      
        distanceFromOrigin() {
          return Math.hypot(
            this.x,
            this.y,
          );
        }
      }
      
      const first = new Point(3, 4);
      const second = new Point(5, 12);
      
      console.log(
        first.distanceFromOrigin
        === second.distanceFromOrigin,
      ); // true

      原型方法适合不需要每个实例保存独立函数对象的行为⁠。

      6.5.2 公有实例字段

      class Point {
        x = 0;
        y = 0;
      }

      每个实例都会获得自己的字段⁠:

      const first = new Point();
      const second = new Point();
      
      first.x = 10;
      
      console.log(first.x);  // 10
      console.log(second.x); // 0

      公有字段是实例自有属性⁠:

      console.log(
        Object.hasOwn(first, "x"),
      ); // true

      默认情况下⁠,公有字段创建的属性可写⁠、可枚举⁠、可配置⁠。

      6.5.3 字段初始化顺序

      字段初始化器按照类体中的声明顺序执行⁠:

      class Example {
        first = 10;
        second = this.first + 5;
      }
      
      console.log(
        new Example().second,
      ); // 15

      后面的字段可以读取前面已经初始化的字段⁠。前面的字段不能依赖尚未初始化的后续字段⁠:

      class Example {
        first = this.second;
        second = 20;
      }
      
      const example = new Example();
      
      console.log(
        example.first,
      ); // undefined

      对于基类⁠,实例字段在构造器函数体执行前初始化⁠:

      class Example {
        value = 10;
      
        constructor() {
          console.log(this.value); // 10
        }
      }

      对于派生类⁠,派生类字段在 super() 返回并建立 this 后初始化⁠,然后才继续执行派生构造器中 super() 后的语句⁠:

      class Parent {
        constructor() {
          console.log(
            "parent constructor",
          );
        }
      }
      
      class Child extends Parent {
        value = 10;
      
        constructor() {
          super();
      
          console.log(this.value); // 10
        }
      }

      父类字段属于父类的初始化过程⁠,派生类字段属于派生类的初始化过程⁠。

      6.5.4 字段初始化器中的 this

      字段初始化器中的 this 指向当前实例⁠:

      class User {
        name = "Alice";
        greeting = `Hello, ${this.name}!`;
      }

      字段初始化器不能使用构造器参数⁠,除非参数值已经通过其他方式可访问⁠。依赖构造参数的状态通常在构造器中设置⁠:

      class User {
        name;
      
        constructor(name) {
          this.name = name;
        }
      }

      6.5.5 箭头函数类字段

      class Button {
        label = "Save";
      
        handleClick = () => {
          console.log(this.label);
        };
      }

      每次创建实例时都会创建一个新的箭头函数⁠,并把它保存为实例自有属性⁠:

      const first = new Button();
      const second = new Button();
      
      console.log(
        first.handleClick
        === second.handleClick,
      ); // false

      箭头函数继承字段初始化环境中的 this⁠,因此提取后仍能访问原实例⁠:

      const handler = first.handleClick;
      
      handler(); // Save

      与原型方法相比⁠,它的优点是⁠:

      • 传递为回调时不容易丢失 this⁠;
      • 每个实例可以拥有独立函数状态⁠。

      代价是⁠:

      • 每个实例创建独立函数⁠;
      • 方法不位于原型上⁠;
      • 子类不能通过普通 super.handleClick() 调用该字段⁠;
      • 覆盖和测试方式与原型方法不同⁠。

      默认应使用原型方法⁠。只有在明确需要词法绑定 this⁠,并且实例数量和内存成本可接受时⁠,才使用箭头函数字段⁠。

      6.5.6 绑定原型方法

      另一种方式是在构造器中绑定原型方法⁠:

      class Button {
        constructor(label) {
          this.label = label;
          this.handleClick
            = this.handleClick.bind(this);
        }
      
        handleClick() {
          console.log(this.label);
        }
      }

      这同样会在实例上创建绑定函数⁠,但原始方法仍然定义在原型上⁠。

      在 DOM 事件监听中⁠,保存稳定函数引用也有助于正确移除监听器⁠:

      class ButtonController {
        constructor(element) {
          this.element = element;
          this.handleClick
            = this.handleClick.bind(this);
        }
      
        start() {
          this.element.addEventListener(
            "click",
            this.handleClick,
          );
        }
      
        stop() {
          this.element.removeEventListener(
            "click",
            this.handleClick,
          );
        }
      
        handleClick() {
          console.log("clicked");
        }
      }

      6.6 getter 与 setter

      第五章已经介绍普通对象的访问器属性⁠。类中的 getter 和 setter 默认定义在原型上⁠。

      6.6.1 getter

      class Rectangle {
        constructor(
          width,
          height,
        ) {
          this.width = width;
          this.height = height;
        }
      
        get area() {
          return (
            this.width
            * this.height
          );
        }
      }
      
      const rectangle = new Rectangle(
        4,
        5,
      );
      
      console.log(rectangle.area); // 20

      调用者以属性形式读取 area⁠,但每次读取都会执行 getter⁠。

      getter 不应接受参数⁠。需要参数时⁠,应使用普通方法⁠。

      6.6.2 setter

      class User {
        #age = 0;
      
        get age() {
          return this.#age;
        }
      
        set age(value) {
          if (
            !Number.isInteger(value)
            || value < 0
            || value > 150
          ) {
            throw new RangeError(
              "age 必须是 0 到 150 之间的整数",
            );
          }
      
          this.#age = value;
        }
      }
      
      const user = new User();
      
      user.age = 30;
      
      console.log(user.age); // 30

      setter 必须恰好接收一个参数⁠。

      6.6.3 只读外观

      只定义 getter⁠,不定义 setter⁠,可以为调用者提供只读外观⁠:

      class Circle {
        #radius;
      
        constructor(radius) {
          this.#radius = radius;
        }
      
        get radius() {
          return this.#radius;
        }
      }

      严格模式下给只读访问器赋值会抛出 TypeError⁠:

      const circle = new Circle(5);
      
      // TypeError
      // circle.radius = 10;

      这并不表示整个对象不可变⁠。其他公开字段和方法仍可能改变内部状态⁠。

      6.6.4 getter 与方法的选择

      使用 getter⁠:

      • 读取成本较低⁠;
      • 结果表现为对象当前状态的派生属性⁠;
      • 不需要参数⁠;
      • 调用不会产生令人意外的副作用⁠。

      使用方法⁠:

      • 操作可能较昂贵⁠;
      • 需要参数⁠;
      • 可能执行 I/O⁠;
      • 会显著改变状态⁠;
      • 调用失败具有业务意义⁠。

      例如⁠,account.balance 适合作为 getter⁠,而 account.transferTo(target, amount) 应是方法⁠。

      6.7 私有元素

      JavaScript 类使用 # 声明私有字段⁠、方法和访问器⁠。

      6.7.1 私有字段

      class BankAccount {
        #balance = 0;
      
        deposit(amount) {
          this.#balance += amount;
        }
      
        get balance() {
          return this.#balance;
        }
      }

      私有名称必须先在类体中声明⁠:

      class Example {
        #value;
      }

      不能在运行时动态创建新的私有字段⁠:

      class Example {
        method() {
          // SyntaxError:
          // #value 未在当前类中声明。
          // this.#value = 10;
        }
      }

      6.7.2 私有元素不是普通属性

      const account = new BankAccount();
      
      console.log(
        Object.keys(account),
      ); // []
      
      console.log(
        Reflect.ownKeys(account),
      ); // []

      私有字段不属于普通属性键集合⁠,不能通过字符串⁠、Symbol 或反射 API 访问⁠:

      console.log(
        account["#balance"],
      ); // undefined

      给 account["#balance"] 赋值只会创建一个名称为 "#balance" 的普通公开属性⁠,与真正私有字段无关⁠。

      6.7.3 类外访问是语法错误

      // SyntaxError
      // console.log(account.#balance);

      这是语法层面的限制⁠,而不是返回 undefined 的普通属性查找失败⁠。

      部分开发者工具可能为了调试而放宽交互式控制台限制⁠,但这不属于普通 JavaScript 程序的语言行为⁠。

      6.7.4 同一类可以访问其他实例的私有字段

      class Point {
        #x;
      
        constructor(x) {
          this.#x = x;
        }
      
        distanceTo(other) {
          return Math.abs(
            this.#x - other.#x,
          );
        }
      }
      
      const first = new Point(3);
      const second = new Point(10);
      
      console.log(
        first.distanceTo(second),
      ); // 7

      私有访问权属于类体⁠,而不是只属于当前 this⁠。

      如果 other 不是具有相同私有品牌的对象⁠,访问会抛出错误⁠。

      6.7.5 私有品牌检查

      class Point {
        #x;
      
        constructor(x) {
          this.#x = x;
        }
      
        static isPoint(value) {
          return (
            value !== null
            && (
              typeof value === "object"
              || typeof value === "function"
            )
            && #x in value
          );
        }
      }

      使用⁠:

      console.log(
        Point.isPoint(
          new Point(1),
        ),
      ); // true
      
      console.log(
        Point.isPoint({
          x: 1,
        }),
      ); // false

      #x in value 检查的是特定私有名称对应的品牌⁠,而不是检查普通属性名⁠。

      右侧必须是对象⁠,否则会抛出 TypeError⁠,因此示例先进行了对象检查⁠。

      6.7.6 私有方法

      class User {
        #normalizeName(name) {
          return name.trim();
        }
      
        constructor(name) {
          this.name = this.#normalizeName(
            name,
          );
        }
      }

      私有方法适合只供类内部使用的行为⁠。

      6.7.7 私有访问器

      class Counter {
        #value = 0;
      
        get #normalizedValue() {
          return Math.max(
            0,
            this.#value,
          );
        }
      
        get value() {
          return this.#normalizedValue;
        }
      }

      6.7.8 子类不能访问父类私有元素

      class Parent {
        #value = 10;
      
        getValue() {
          return this.#value;
        }
      }
      
      class Child extends Parent {
        readValue() {
          // SyntaxError
          // return this.#value;
        }
      }

      父类私有元素只在声明它们的类体中可见⁠。它们不是“⁠受保护成员⁠”⁠,也不会因为继承关系自动对派生类开放⁠。

      子类应通过父类公开或受控接口访问相关状态⁠:

      class Parent {
        #value = 10;
      
        get value() {
          return this.#value;
        }
      }
      
      class Child extends Parent {
        doubleValue() {
          return this.value * 2;
        }
      }

      JavaScript 没有原生 protected 关键字⁠。以下划线开头的属性只是团队约定⁠,不具有语言级访问限制⁠:

      class Parent {
        _value = 10;
      }

      6.7.9 私有字段与 Proxy

      私有元素依赖对象品牌⁠,而普通 Proxy 不会自动把私有访问转发到目标对象⁠:

      class Counter {
        #value = 0;
      
        increment() {
          this.#value += 1;
        }
      }
      
      const counter = new Counter();
      const proxy = new Proxy(
        counter,
        {},
      );
      
      // TypeError:this 是 proxy,
      // proxy 不具有 Counter 的私有品牌。
      // proxy.increment();

      可以在代理中绑定方法到目标对象⁠,但这会改变一般方法和访问器语义⁠,必须谨慎设计⁠。

      私有元素适合封装类内部状态⁠,但与透明代理⁠、对象序列化和部分元编程技术并不总能直接组合⁠。

      6.8 静态成员

      静态成员属于类构造器本身⁠,而不属于实例⁠。

      6.8.1 静态方法

      class Point {
        constructor(
          x,
          y,
        ) {
          this.x = x;
          this.y = y;
        }
      
        static distance(
          first,
          second,
        ) {
          return Math.hypot(
            first.x - second.x,
            first.y - second.y,
          );
        }
      }
      
      const first = new Point(0, 0);
      const second = new Point(3, 4);
      
      console.log(
        Point.distance(
          first,
          second,
        ),
      ); // 5

      实例不能直接访问静态方法⁠:

      console.log(
        first.distance,
      ); // undefined

      静态方法适合⁠:

      • 工厂方法⁠;
      • 类型判断⁠;
      • 与该类相关的工具函数⁠;
      • 多个实例之间的比较⁠;
      • 缓存和注册管理⁠。

      6.8.2 静态字段

      class User {
        static count = 0;
      
        constructor(name) {
          this.name = name;
          User.count += 1;
        }
      }
      
      new User("Alice");
      new User("Bob");
      
      console.log(User.count); // 2

      如果希望派生类分别计数⁠,使用 this 还是基类名会产生不同语义⁠,需要明确设计⁠。

      6.8.3 静态 this

      静态方法中的 this 是调用该方法的类对象⁠:

      class User {
        static label = "User";
      
        static describe() {
          return this.label;
        }
      }
      
      console.log(
        User.describe(),
      ); // User

      派生类继承静态方法时⁠:

      class Administrator extends User {
        static label = "Administrator";
      }
      
      console.log(
        Administrator.describe(),
      ); // Administrator

      这里 this 是 Administrator⁠。

      6.8.4 静态私有元素

      class Identifier {
        static #nextValue = 1;
      
        static create() {
          const value = Identifier.#nextValue;
          Identifier.#nextValue += 1;
          return value;
        }
      }

      使用类名访问意味着方法固定操作 Identifier 的私有静态状态⁠。

      如果写成⁠:

      class Identifier {
        static #nextValue = 1;
      
        static create() {
          const value = this.#nextValue;
          this.#nextValue += 1;
          return value;
        }
      }

      直接调用 Identifier.create() 可以正常工作⁠,但继承调用可能失败⁠:

      class ChildIdentifier
        extends Identifier {}
      
      // TypeError:ChildIdentifier
      // 不具有 Identifier 的私有静态品牌。
      // ChildIdentifier.create();

      因此⁠:

      • 静态方法需要对派生类多态时⁠,可以使用公开静态字段和 this⁠;
      • 静态私有状态只属于声明类时⁠,通常使用声明类名访问更加明确⁠。

      6.8.5 静态初始化块

      静态块在类定义求值期间按声明顺序执行⁠,可访问类的私有静态元素⁠:

      class Configuration {
        static #settings;
      
        static {
          const environment = (
            globalThis.APP_ENV
            ?? "development"
          );
      
          Configuration.#settings = {
            environment,
          };
        }
      
        static get settings() {
          return {
            ...Configuration.#settings,
          };
        }
      }

      静态块适合⁠:

      • 多语句静态初始化⁠;
      • try...catch⁠;
      • 条件分支⁠;
      • 使用临时变量⁠;
      • 初始化私有静态状态⁠。

      静态块同步执行⁠,不能直接使用 await⁠。需要异步初始化时⁠,应提供显式异步工厂或初始化方法⁠,而不是在类定义阶段隐藏异步过程⁠。

      6.8.6 静态成员的初始化顺序

      类元素按照声明顺序求值和初始化⁠。静态字段和静态块可以交错⁠:

      class Example {
        static first = 1;
      
        static {
          console.log(
            Example.first,
          ); // 1
        }
      
        static second = (
          Example.first + 1
        );
      }

      依赖尚未初始化的后续静态字段可能得到 undefined 或产生错误⁠,因此初始化顺序应保持清晰⁠。

      6.9 继承

      6.9.1 extends

      class Animal {
        constructor(name) {
          this.name = name;
        }
      
        speak() {
          return (
            `${this.name} makes a sound.`
          );
        }
      }
      
      class Dog extends Animal {
        speak() {
          return `${this.name} barks.`;
        }
      }

      实例⁠:

      const dog = new Dog("Max");
      
      console.log(dog.speak());
      // Max barks.

      extends 后的表达式必须求值得到可作为父类的构造器或 null⁠。最常见写法是直接使用父类标识符⁠。

      6.9.2 两条原型关系

      console.log(
        Object.getPrototypeOf(
          Dog.prototype,
        ) === Animal.prototype,
      ); // true

      这条关系使 Dog 实例可以继承 Animal 的实例方法⁠。

      console.log(
        Object.getPrototypeOf(Dog)
        === Animal,
      ); // true

      这条关系使 Dog 类可以继承 Animal 的静态成员⁠。

      实例链⁠:

      dog
        ↓
      Dog.prototype
        ↓
      Animal.prototype
        ↓
      Object.prototype
        ↓
      null

      类对象链⁠:

      Dog
        ↓
      Animal
        ↓
      Function.prototype
        ↓
      Object.prototype
        ↓
      null

      6.9.3 默认构造器

      没有显式构造器的基类⁠,行为近似⁠:

      class Base {
        constructor() {}
      }

      没有显式构造器的派生类⁠,行为近似⁠:

      class Derived extends Base {
        constructor(...args) {
          super(...args);
        }
      }

      因此⁠,简单派生类可以省略只负责转发参数的构造器⁠:

      class Dog extends Animal {
        speak() {
          return `${this.name} barks.`;
        }
      }

      6.9.4 派生构造器与 super()

      派生类显式定义构造器时⁠,通常必须在访问 this 之前调用 super()⁠:

      class Dog extends Animal {
        constructor(
          name,
          breed,
        ) {
          super(name);
      
          this.breed = breed;
        }
      }

      以下写法会抛出 ReferenceError⁠:

      class Dog extends Animal {
        constructor(name) {
          // ReferenceError
          // this.name = name;
      
          super(name);
        }
      }

      基类构造器在构造过程中建立并初始化实例⁠,super() 返回后⁠,派生构造器中的 this 才可用⁠。

      一种少见的边界情况是⁠,派生构造器可以显式返回另一个对象而不使用 this⁠,从而不调用 super()⁠:

      class Replacement
        extends Animal {
        constructor() {
          return {
            replacement: true,
          };
        }
      }
      
      console.log(
        new Replacement(),
      );
      // { replacement: true }

      这种写法会绕过正常实例和原型建模⁠,通常不应在普通业务代码中使用⁠。

      6.9.5 super() 的实际目标

      在派生构造器中⁠,super() 调用父构造器⁠,但实际使用的 new.target 仍然是最初被 new 调用的派生类⁠。

      class Parent {
        constructor() {
          console.log(
            new.target.name,
          );
        }
      }
      
      class Child extends Parent {}
      
      new Child(); // Child

      这使父构造器能够为最终派生类型参与构造过程⁠,而不只是创建一个固定的 Parent 实例⁠。

      6.9.6 方法重写

      class Animal {
        speak() {
          return "sound";
        }
      }
      
      class Dog extends Animal {
        speak() {
          return "bark";
        }
      }

      读取 dog.speak 时⁠,先在 Dog.prototype 找到方法⁠,因此不会继续使用 Animal.prototype.speak⁠。

      6.9.7 super.method()

      子类可以扩展父类方法⁠:

      class Animal {
        speak() {
          return (
            `${this.name} makes a sound`
          );
        }
      }
      
      class Dog extends Animal {
        speak() {
          return (
            `${super.speak()} and barks`
          );
        }
      }

      调用⁠:

      const dog = new Dog();
      dog.name = "Max";
      
      console.log(dog.speak());
      // Max makes a sound and barks

      super.speak() 从父级原型取得方法⁠,但调用时的 this 仍然是当前实例 dog⁠。

      6.9.8 [[HomeObject]]

      使用方法定义语法创建的方法可以具有规范内部槽 [[HomeObject]]⁠。super 通过方法定义时的所属对象确定父级查找起点⁠。

      const parent = {
        greet() {
          return "parent";
        },
      };
      
      const child = {
        __proto__: parent,
      
        greet() {
          return (
            `${super.greet()} child`
          );
        },
      };

      即使把方法赋给另一个对象⁠,super 的查找起点仍由原方法的 [[HomeObject]] 决定⁠:

      const another = {
        greet: child.greet,
      };
      
      console.log(
        another.greet(),
      );
      // parent child

      这里普通方法内部的 this 是 another⁠,但 super 的父级查找起点仍与 child 的方法定义相关⁠。

      [[HomeObject]] 是规范内部槽⁠,不能作为普通属性访问或修改⁠。

      6.9.9 super 不是普通变量

      不能把 super 保存到变量中⁠,也不能在任意函数中使用⁠。它只允许出现在具有相应语法环境的方法或派生构造器中⁠。

      箭头函数没有自己的 super 绑定⁠,但可以在合法方法内部词法使用外层的 super⁠:

      class Child extends Parent {
        method() {
          const action = () => {
            return super.method();
          };
      
          return action();
        }
      }

      6.9.10 继承静态成员

      class Animal {
        static category = "animal";
      
        static describeCategory() {
          return this.category;
        }
      }
      
      class Dog extends Animal {
        static category = "dog";
      }
      
      console.log(
        Dog.describeCategory(),
      ); // dog

      静态成员通过类对象原型链继承⁠。

      父类私有静态元素不对派生类开放⁠,相关边界见前文⁠。

      6.10 instanceof 与类型判断

      6.10.1 基本语义

      class Animal {}
      class Dog extends Animal {}
      
      const dog = new Dog();
      
      console.log(
        dog instanceof Dog,
      ); // true
      
      console.log(
        dog instanceof Animal,
      ); // true
      
      console.log(
        dog instanceof Object,
      ); // true

      普通情况下⁠,instanceof 检查右侧构造器的 prototype 是否出现在左侧对象的原型链中⁠。

      6.10.2 instanceof 不等同于“⁠由它创建⁠”

      function User() {}
      
      const object = Object.create(
        User.prototype,
      );
      
      console.log(
        object instanceof User,
      ); // true

      该对象并没有执行 new User()⁠,但其原型链包含 User.prototype⁠。

      反过来⁠,修改原型也可能改变结果⁠:

      function User() {}
      
      const user = new User();
      
      console.log(
        user instanceof User,
      ); // true
      
      User.prototype = {};
      
      console.log(
        user instanceof User,
      ); // false

      因此⁠,instanceof 描述当前原型链关系⁠,不是不可变的创建历史记录⁠。

      6.10.3 Symbol.hasInstance

      类或函数可以自定义 instanceof⁠:

      class PositiveNumber {
        static [
          Symbol.hasInstance
        ](value) {
          return (
            typeof value === "number"
            && value > 0
          );
        }
      }
      
      console.log(
        10 instanceof PositiveNumber,
      ); // true

      自定义后⁠,instanceof 可以完全不依赖普通原型链⁠,因此不能把它理解为始终执行固定地址比较⁠。

      6.10.4 跨 Realm 问题

      不同 iframe⁠、Window 或其他 Realm 具有不同的内置构造器和原型对象⁠。

      来自另一个 Realm 的数组可能无法通过当前 Realm 的 Array 检查⁠:

      // arrayFromAnotherRealm
      //   instanceof Array
      // 可能为 false。

      检测数组应使用⁠:

      Array.isArray(value);

      对于日期⁠、错误对象和自定义类⁠,跨 Realm 类型判断也需要根据接口设计选择更可靠的品牌⁠、结构验证或序列化边界⁠。

      6.10.5 isPrototypeOf

      已知原型对象时⁠,可以直接检查⁠:

      console.log(
        Animal.prototype.isPrototypeOf(
          dog,
        ),
      ); // true

      它与普通 instanceof 的关注点相近⁠,但左侧直接是原型对象⁠,不经过构造器和 Symbol.hasInstance⁠。

      6.10.6 私有品牌作为类型检查

      class User {
        #id;
      
        constructor(id) {
          this.#id = id;
        }
      
        static isUser(value) {
          return (
            value !== null
            && (
              typeof value === "object"
              || typeof value === "function"
            )
            && #id in value
          );
        }
      }

      这种检查确认对象具有当前类的私有品牌⁠,比单纯原型链更难被普通对象伪造⁠。

      它也具有局限⁠:

      • 只适用于拥有私有元素的类⁠;
      • 不能跨不共享类定义的 Realm 或重复模块实例⁠;
      • 需要由类主动暴露判断方法⁠;
      • 类型兼容仍不等于业务数据有效⁠。

      6.10.7 鸭子类型

      很多函数只需要对象提供特定行为⁠:

      function printArea(shape) {
        if (
          shape === null
          || typeof shape.area
            !== "function"
        ) {
          throw new TypeError(
            "shape 必须提供 area() 方法",
          );
        }
      
        console.log(shape.area());
      }

      调用者可以传入不同类的实例⁠,也可以传入普通对象⁠:

      printArea({
        area() {
          return 10;
        },
      });

      这比强制要求某个 instanceof 关系更灵活⁠,也更符合 JavaScript 的动态类型特点⁠。

      6.11 多态与方法分派

      6.11.1 基于原型的方法分派

      class Shape {
        area() {
          throw new Error(
            "Subclass must implement area()",
          );
        }
      }
      
      class Circle extends Shape {
        constructor(radius) {
          super();
          this.radius = radius;
        }
      
        area() {
          return (
            Math.PI
            * this.radius ** 2
          );
        }
      }
      
      class Rectangle extends Shape {
        constructor(
          width,
          height,
        ) {
          super();
          this.width = width;
          this.height = height;
        }
      
        area() {
          return (
            this.width
            * this.height
          );
        }
      }

      统一使用⁠:

      const shapes = [
        new Circle(2),
        new Rectangle(3, 4),
      ];
      
      const areas = shapes.map(
        shape => shape.area(),
      );

      每个对象根据自身原型链找到相应实现⁠。

      6.11.2 JavaScript 没有抽象类关键字

      上面的 Shape 只是一种约定⁠。JavaScript 没有原生 abstract class 语法⁠。

      可以阻止直接创建基类⁠:

      class Shape {
        constructor() {
          if (new.target === Shape) {
            throw new TypeError(
              "Shape 不能直接实例化",
            );
          }
        }
      
        area() {
          throw new Error(
            "area() 必须由子类实现",
          );
        }
      }

      这是一种运行时检查⁠,而不是静态类型系统中的抽象类约束⁠。

      如果项目使用 TypeScript⁠,可以在类型层提供接口和抽象类检查⁠,但那属于 TypeScript⁠,而不是 JavaScript 本身⁠。

      6.11.3 不应在基类构造器调用可重写方法

      class Base {
        constructor() {
          this.initialize();
        }
      
        initialize() {}
      }
      
      class Derived extends Base {
        value = 10;
      
        initialize() {
          console.log(this.value);
        }
      }

      创建 Derived 时⁠,基类构造器调用派生类重写后的 initialize()⁠。此时派生类字段尚未完成初始化⁠,因此 this.value 可能是 undefined⁠。

      new Derived();
      // undefined

      基类构造器应只处理基类自身能够保证已经建立的状态⁠。需要派生初始化时⁠,可以⁠:

      • 在派生构造器中显式调用初始化方法⁠;
      • 使用工厂函数⁠;
      • 把依赖作为构造参数⁠;
      • 避免构造期间的动态分派⁠。

      6.11.4 不变量与方法边界

      类的价值不只是把函数放进对象⁠,而是使状态始终满足明确条件⁠。

      class Temperature {
        #celsius;
      
        constructor(celsius) {
          this.#setCelsius(celsius);
        }
      
        #setCelsius(value) {
          if (
            typeof value !== "number"
            || !Number.isFinite(value)
          ) {
            throw new TypeError(
              "temperature 必须是有限数值",
            );
          }
      
          if (value < -273.15) {
            throw new RangeError(
              "温度不能低于绝对零度",
            );
          }
      
          this.#celsius = value;
        }
      
        get celsius() {
          return this.#celsius;
        }
      
        get kelvin() {
          return (
            this.#celsius
            + 273.15
          );
        }
      }

      无论通过哪个公开入口创建或修改对象⁠,都应维护同一不变量⁠。

      6.12 继承与组合

      6.12.1 继承适合稳定的 is-a 关系

      class Employee {}
      class Manager extends Employee {}

      如果所有 Manager 都应当作为 Employee 使用⁠,并且父类接口稳定⁠,这种关系可能适合继承⁠。

      6.12.2 组合适合 has-a 关系

      class Logger {
        log(message) {
          console.log(message);
        }
      }
      
      class UserService {
        constructor(logger) {
          this.logger = logger;
        }
      
        createUser(name) {
          this.logger.log(
            `Creating ${name}`,
          );
        }
      }

      UserService 拥有 Logger⁠,而不是 Logger 的一种⁠。

      组合的优点包括⁠:

      • 依赖可以替换⁠;
      • 更容易测试⁠;
      • 不受单继承限制⁠;
      • 各组件职责更清晰⁠;
      • 不需要暴露完整父类接口⁠。

      6.12.3 优先组合并非禁止继承

      “⁠组合优于继承⁠”是一条设计倾向⁠,不是绝对规则⁠。

      继承适合⁠:

      • 明确且稳定的抽象层次⁠;
      • 派生对象能够完整替代父类对象⁠;
      • 需要复用受控的公开协议⁠;
      • 框架明确要求继承⁠;
      • 内置类型扩展具有清晰目的⁠。

      组合适合⁠:

      • 只需要复用部分能力⁠;
      • 行为需要运行时替换⁠;
      • 不同能力需要自由组合⁠;
      • 父类变化可能影响大量子类⁠;
      • 关系更接近“⁠拥有⁠”而不是“⁠属于⁠”⁠。

      6.12.4 工厂函数

      不需要继承⁠、私有字段和 instanceof 时⁠,工厂函数可能更简单⁠:

      function createCounter(
        initialValue = 0,
      ) {
        let value = initialValue;
      
        return {
          increment() {
            value += 1;
          },
      
          getValue() {
            return value;
          },
        };
      }

      它使用闭包封装状态⁠。第二章已经介绍这一模式⁠。

      类与工厂函数并无普遍优劣⁠,应根据接口⁠、实例数量⁠、继承需求⁠、序列化和测试方式选择⁠。

      6.12.5 Mixins

      JavaScript 类只支持单继承⁠。需要组合多个行为时⁠,可以使用函数把方法复制或定义到类原型上⁠:

      const canSerialize = {
        toJSON() {
          return {
            ...this,
          };
        },
      };
      
      class User {
        constructor(name) {
          this.name = name;
        }
      }
      
      Object.assign(
        User.prototype,
        canSerialize,
      );

      这种简单 mixin 只复制可枚举自有属性⁠,并可能丢失描述符或发生命名冲突⁠。

      保留描述符⁠:

      Object.defineProperties(
        User.prototype,
        Object.getOwnPropertyDescriptors(
          canSerialize,
        ),
      );

      仍需处理⁠:

      • constructor⁠;
      • Symbol 属性⁠;
      • 方法冲突⁠;
      • 私有元素无法复制⁠;
      • super 的 [[HomeObject]] 语义⁠;
      • 类型判断⁠;
      • 初始化状态⁠。

      对于复杂系统⁠,显式组合对象通常比大规模 mixin 更容易维护⁠。

      6.13 类与内置对象

      6.13.1 内置类也使用原型方法

      const values = [
        1,
        2,
        3,
      ];
      
      console.log(
        Object.getPrototypeOf(values)
        === Array.prototype,
      ); // true

      数组实例通过原型链使用 map()⁠、filter() 等方法⁠。

      6.13.2 扩展内置类

      class NumberList extends Array {
        average() {
          if (this.length === 0) {
            return null;
          }
      
          const total = this.reduce(
            (
              sum,
              value,
            ) => sum + value,
            0,
          );
      
          return total / this.length;
        }
      }
      
      const values = new NumberList(
        1,
        2,
        3,
      );
      
      console.log(
        values.average(),
      ); // 2

      扩展内置类虽然可行⁠,但应谨慎评估⁠:

      • 内置方法返回值是否继续使用子类⁠;
      • 序列化和跨 Realm 行为⁠;
      • 第三方代码是否预期普通内置实例⁠;
      • 子类构造参数语义⁠;
      • 是否真的需要继承全部内置接口⁠。

      很多场景下⁠,包装一个普通数组比继承 Array 更容易控制⁠。

      6.13.3 Symbol.species

      某些内置派生对象的方法会使用构造器相关协议决定返回对象类型⁠。Symbol.species 曾被用于自定义派生结果构造器⁠。

      现代代码通常不应主动依赖这一机制⁠,因为它增加优化和推理成本⁠,而且新标准方法逐渐避免使用 species 模式⁠。除非维护已有类库⁠,否则不需要把它作为一般 OOP 工具⁠。

      6.14 完整示例⁠:图形对象模型

      下面的示例综合使用⁠:

      • 抽象基类约定⁠;
      • 私有字段⁠;
      • getter⁠;
      • 静态工厂⁠;
      • 继承⁠;
      • 方法重写⁠;
      • super⁠;
      • 多态⁠;
      • 组合式格式化⁠。
      class Shape {
        #name;
      
        constructor(name) {
          if (new.target === Shape) {
            throw new TypeError(
              "Shape 不能直接实例化",
            );
          }
      
          this.#name = name;
        }
      
        get name() {
          return this.#name;
        }
      
        area() {
          throw new Error(
            "area() 必须由子类实现",
          );
        }
      
        describe() {
          return (
            `${this.name}: `
            + `area = ${this.area()}`
          );
        }
      
        static isShape(value) {
          return value instanceof Shape;
        }
      }
      
      class Circle extends Shape {
        #radius;
      
        constructor(radius) {
          super("Circle");
      
          if (
            typeof radius !== "number"
            || !Number.isFinite(radius)
            || radius <= 0
          ) {
            throw new RangeError(
              "radius 必须是正的有限数值",
            );
          }
      
          this.#radius = radius;
        }
      
        get radius() {
          return this.#radius;
        }
      
        area() {
          return (
            Math.PI
            * this.#radius ** 2
          );
        }
      
        describe() {
          return (
            `${super.describe()}, `
            + `radius = ${this.#radius}`
          );
        }
      
        static fromDiameter(diameter) {
          return new this(
            diameter / 2,
          );
        }
      }
      
      class Rectangle extends Shape {
        #width;
        #height;
      
        constructor(
          width,
          height,
        ) {
          super("Rectangle");
      
          if (
            typeof width !== "number"
            || !Number.isFinite(width)
            || width <= 0
          ) {
            throw new RangeError(
              "width 必须是正的有限数值",
            );
          }
      
          if (
            typeof height !== "number"
            || !Number.isFinite(height)
            || height <= 0
          ) {
            throw new RangeError(
              "height 必须是正的有限数值",
            );
          }
      
          this.#width = width;
          this.#height = height;
        }
      
        get width() {
          return this.#width;
        }
      
        get height() {
          return this.#height;
        }
      
        area() {
          return (
            this.#width
            * this.#height
          );
        }
      }

      使用⁠:

      const shapes = [
        Circle.fromDiameter(10),
        new Rectangle(4, 5),
      ];
      
      for (const shape of shapes) {
        console.log(
          shape.describe(),
        );
      }

      统一处理总面积⁠:

      const totalArea = shapes.reduce(
        (
          total,
          shape,
        ) => {
          return total + shape.area();
        },
        0,
      );
      
      console.log(totalArea);

      这个模型仍然可以进一步改进⁠。例如⁠,重复的数值验证可以提取为普通函数⁠,而不必通过继承复用⁠:

      function assertPositiveFinite(
        value,
        name,
      ) {
        if (
          typeof value !== "number"
          || !Number.isFinite(value)
          || value <= 0
        ) {
          throw new RangeError(
            `${name} 必须是正的有限数值`,
          );
        }
      }

      这体现了继承与组合可以同时使用⁠:类型层次通过继承表达⁠,独立验证逻辑通过函数组合复用⁠。

      6.15 常见设计问题

      6.15.1 把所有数据都包装成类

      简单记录不一定需要类⁠:

      const user = {
        id: 1,
        name: "Alice",
      };

      如果对象主要用于 JSON 传输⁠、配置或数据库记录⁠,普通对象通常更加直接⁠。

      类更适合⁠:

      • 需要维护不变量⁠;
      • 具有较多行为⁠;
      • 实例长期存在⁠;
      • 需要原型方法共享⁠;
      • 需要私有状态⁠;
      • 存在明确继承层次⁠。

      6.15.2 只为了复用代码而继承

      class Report extends Array {}

      如果 Report 只是“⁠内部拥有若干条目⁠”⁠,它未必应该继承 Array⁠。包装通常更明确⁠:

      class Report {
        #items = [];
      
        add(item) {
          this.#items.push(item);
        }
      
        get items() {
          return [
            ...this.#items,
          ];
        }
      }

      6.15.3 过深继承层次

      Base
        ↓
      Intermediate
        ↓
      Specialized
        ↓
      VerySpecialized

      继承层次过深会增加⁠:

      • 方法查找和重写理解成本⁠;
      • 父类修改的影响范围⁠;
      • 构造顺序复杂度⁠;
      • super 调用依赖⁠;
      • 隐式耦合⁠。

      通常应保持层次浅⁠,并把正交能力拆为组合对象或普通函数⁠。

      6.15.4 公开可变内部对象

      class Team {
        members = [];
      }

      调用者可以任意修改⁠:

      team.members.length = 0;

      如果成员列表需要受控⁠,应使用私有字段并返回副本或只读视图⁠:

      class Team {
        #members = [];
      
        addMember(member) {
          this.#members.push(member);
        }
      
        get members() {
          return [
            ...this.#members,
          ];
        }
      }

      返回浅副本只能保护数组结构⁠,不能自动冻结成员对象本身⁠。

      6.15.5 在构造器中执行复杂异步工作

      构造器不能是 async 函数⁠,也不能返回一个供调用者自然 await 的普通实例初始化过程⁠。

      不推荐⁠:

      class User {
        constructor(id) {
          void this.load(id);
        }
      
        async load(id) {
          // 初始化时机不明确。
        }
      }

      可以使用异步静态工厂⁠:

      class User {
        constructor(data) {
          this.id = data.id;
          this.name = data.name;
        }
      
        static async load(id) {
          const response = await fetch(
            `/api/users/${id}`,
          );
      
          if (!response.ok) {
            throw new Error(
              `HTTP ${response.status}`,
            );
          }
      
          const data = await response.json();
      
          return new User(data);
        }
      }
      
      const user = await User.load(1);

      这样⁠,实例返回时已经完成必要初始化⁠。

      6.15.6 依赖 constructor.name

      console.log(
        instance.constructor.name,
      );

      类名可能因以下因素变化⁠:

      • 代码压缩⁠;
      • 重命名⁠;
      • 原型修改⁠;
      • 跨 Realm⁠;
      • 构造器属性被覆盖⁠。

      它适合调试⁠,不适合作为稳定业务类型标识⁠。需要持久类型标识时⁠,应定义明确字段或协议⁠。

      6.15.7 滥用 instanceof

      instanceof 适合检查原型关系⁠,但不适合所有输入验证⁠。

      从网络或 JSON 得到的数据没有自定义类原型⁠:

      const data = JSON.parse(
        '{"radius":5}',
      );
      
      console.log(
        data instanceof Circle,
      ); // false

      外部数据应先验证字段⁠,再显式创建类实例⁠:

      const circle = new Circle(
        data.radius,
      );

      6.15.8 用类隐藏全局可变状态

      class Registry {
        static items = [];
      }

      静态字段仍然是全局生命周期的共享可变状态⁠,只是被放在类命名空间中⁠。它可能带来⁠:

      • 测试互相影响⁠;
      • 模块加载顺序依赖⁠;
      • 状态难以重置⁠;
      • 并发请求之间污染⁠。

      应根据需要使用实例化服务⁠、依赖注入或模块级受控接口⁠。

      6.16 类⁠、工厂函数与普通对象的选择

      方式优点适用场景主要限制
      普通对象简单⁠、直接⁠、易于 JSON 化配置⁠、记录⁠、一次性数据行为复用和封装能力有限
      工厂函数可使用闭包封装⁠,组合灵活小型状态对象⁠、函数式设计每个实例可能创建独立方法
      构造函数与原型兼容旧代码⁠,原型关系明确维护旧式库和底层模式语法分散⁠,私有元素支持不自然
      类结构统一⁠,支持字段⁠、私有元素⁠、继承和静态成员状态丰富⁠、行为较多的对象模型容易被过度使用或形成深继承
      组合对象依赖可替换⁠,职责清晰服务对象⁠、策略⁠、插件能力需要显式设计协作接口

      选择时可以依次询问⁠:

      1. 这只是一个数据记录吗⁠?
      2. 对象是否需要维护不变量⁠?
      3. 是否需要隐藏内部状态⁠?
      4. 多个实例是否共享相同行为⁠?
      5. 是否存在稳定的继承关系⁠?
      6. 能否用组合表达得更清楚⁠?
      7. 数据是否需要频繁序列化和跨边界传输⁠?
      8. 测试时是否需要替换依赖⁠?

      6.17 本章小结

      本章从原型模型出发⁠,系统介绍了 JavaScript 的面向对象机制⁠:

      1. JavaScript 对象通过 [[Prototype]] 关系形成原型链⁠,属性查找会沿原型链逐级进行⁠;
      2. 构造函数的 prototype 属性与实例的 [[Prototype]] 是不同概念⁠;
      3. 可构造函数具有 [[Construct]] 能力⁠,不能根据 typeof value === "function" 判断其一定可构造⁠;
      4. new 会创建实例⁠、建立原型关系⁠、以实例作为 this 调用构造器⁠,并根据显式返回值决定最终结果⁠;
      5. 构造函数原型上的方法由实例共享⁠,实例字段分别属于各实例⁠;
      6. 类建立在原型继承模型上⁠,但还具有严格模式⁠、暂时性死区⁠、不可直接调用⁠、私有元素等专门语义⁠;
      7. 类方法定义在原型上且默认不可枚举⁠,公有实例字段是实例自身可写⁠、可枚举⁠、可配置的属性⁠;
      8. 基类字段在基类构造器函数体前初始化⁠,派生类字段在 super() 返回后初始化⁠;
      9. 箭头函数类字段能够词法绑定实例 this⁠,但每个实例都会保存独立函数⁠,并且不能像原型方法一样通过 super 重用⁠;
      10. getter 和 setter 适合表示派生属性⁠、验证写入和提供受控访问⁠;
      11. 私有元素不是普通属性⁠,不能通过字符串⁠、Symbol 或反射 API 访问⁠;
      12. 同一类体可以访问同类其他实例的私有字段⁠,#field in object 可以进行私有品牌检查⁠;
      13. 子类不能直接访问父类私有元素⁠,JavaScript 也没有原生 protected 关键字⁠;
      14. 静态成员属于类构造器本身⁠,并可以通过类对象的原型链继承⁠;
      15. 静态私有元素只属于声明类⁠,继承静态方法中使用 this.#field 可能因派生类缺少私有品牌而失败⁠;
      16. extends 同时建立实例原型链和类对象原型链⁠;
      17. 派生构造器通常必须先调用 super()⁠,随后才能访问 this⁠;
      18. super.method() 从父级取得方法⁠,但方法调用中的 this 仍然是当前实例⁠;
      19. 方法的 [[HomeObject]] 决定 super 的查找起点⁠,super 不是普通变量⁠;
      20. instanceof 通常检查构造器原型是否位于对象原型链中⁠,但其行为可以被 Symbol.hasInstance 自定义⁠;
      21. instanceof 描述当前原型关系⁠,不是可靠的创建历史记录⁠,并且可能受跨 Realm 影响⁠;
      22. JavaScript 多态常通过共享方法接口和鸭子类型实现⁠,不一定要求共同父类⁠;
      23. 基类构造器不应调用可能被派生类重写的方法⁠,以免访问尚未初始化的派生状态⁠;
      24. 继承适合稳定的“⁠属于⁠”关系⁠,组合适合可替换的“⁠拥有⁠”关系⁠;
      25. 普通对象⁠、工厂函数和类各有适用场景⁠,不应把所有数据都包装为类⁠;
      26. 良好的类设计应围绕不变量⁠、公开接口和依赖边界⁠,而不只是把数据和函数放在同一个语法块中⁠。

      参考资料

      1. javascript.info: Prototypal inheritance
      2. javascript.info: Constructor, operator new
      3. javascript.info: Class basic syntax
      4. javascript.info: Class inheritance
      5. javascript.info: Static properties and methods
      6. javascript.info: Private and protected properties and methods
      7. javascript.info: Class checking with instanceof
      8. MDN JavaScript Guide: Inheritance and the prototype chain
      9. MDN JavaScript Guide: Using classes
      10. MDN JavaScript Reference: Classes
      11. MDN JavaScript Reference: Private elements
      12. MDN JavaScript Reference: instanceof
      13. ECMAScript 2026 Language Specification: ECMAScript Language—Classes
      14. ECMAScript 2026 Language Specification: Ordinary and Exotic Object Behaviours
      上一篇 JavaScript 5. 对象、集合与数组 2026 年 7 月 19 日 下一篇 JavaScript 7. 现代语法、协议与元编程 2026 年 7 月 19 日
      © 2026 CHEN Hua All rights reserved
      闽ICP备2026003335号 · 粤公网安备44030002014022号
      © Hua Chen / PhysChen.com