跳转至

对象、原型与类 — 属性背后的机关,和「找不到就往上问」的继承

这一章讲三件事: 对象的属性远比「键值对」复杂——能只读、能隐藏、能挂钩子(getter/setter)、 能被 Proxy 整体拦截;原型——JavaScript 的继承真相:读一个属性找不到, 就沿着一条「后备链」一路问上去;以及 class——ES2015 的语法糖,糖衣底下仍是构造函数加原型, 唯一真正的新东西是 # 私有字段。读完你会明白:「class 只是语法糖」不是一句修辞,而是可验证的机制描述。

1. 先看现象:一个属性,远不止「键和值」

const ninja = { skillLevel: 100 };

最普通的对象。但马上出现三种普通属性解决不了的需求:1

  • 想禁止直接改 skillLevel(只能经批准的方法改);
  • 想读取时实时计算(skillLevel 由别的因素推导);
  • 想在被改时触发动作(记日志、做校验)。

这三件事同一个解法:getter 和 setter——一对特殊方法,让「读属性」「写属性」这两个动作 在执行时悄悄改道去调用函数。先分清楚两个常被混用的意思:2

  • 按约定叫的 getter/setter:普通方法起名 getFirstNinja()、setFirstNinja(x), 命名只是风格,语言不关心;
  • 语言特性 get/set:方法伪装成属性,读 obj.firstNinja 时函数在幕后执行。

本章讲的当然是后者。

主线例子:一个带钩子的忍者名册

全章前半段用它:一个名册对象,firstNinja 不是数据,而是「读时取数组第一项、写时改数组第一项」的 计算属性。带数走一遍(书里的 Listing 9.1):3

const ninjaCollection = {
  ninjas: ["Yoshi", "Kuma", "Hattori"],
  get firstNinja() {                       // get 定义读钩子
    console.log(`Getting firstNinja: ${this.ninjas[0]}`);
    return this.ninjas[0];
  },
  set firstNinja(newNinja: string) {       // set 定义写钩子
    console.log(`Setting firstNinja: ${newNinja}`);
    this.ninjas[0] = newNinja;
  },
};

console.assert(ninjaCollection.firstNinja === "Yoshi", "…");   // ① 触发 get
ninjaCollection.firstNinja = "Hachi";                          // ② 触发 set
console.assert(ninjaCollection.firstNinja === "Hachi"
            && ninjaCollection.ninjas[0] === "Hachi", "…");    // ③ 数据真的变了

运行输出三行日志,顺序揭露机制:

  Getting firstNinja: Yoshi    ← ① 读属性,get 方法执行,返回 ninjas[0]
  Setting firstNinja: Hachi    ← ② 写属性,set 方法执行,ninjas[0] 被改
  Getting firstNinja: Hachi    ← ③ 再读,拿到的是新值

看起来的属性访问,实际上是两次方法调用——这就是「方法伪装成值」的全部机关。 (firstNinja() 这样当函数调反而会报错:它是属性,不是方法。)

已存在的对象想补 getter/setter,用 Object.defineProperty(见 §2);get/set 关键字 只在新建对象字面量或类时能用。4

原书点出这个特性的行业意义:JavaScript API 可以用「像普通属性一样直接读写」的接口, 内部却藏着校验、日志、内部表示转换——Java 里那一族 getX()/setX() 样板代码, 在这里一行都不用写。5

2. Object.defineProperty:属性的完整「说明书」

用 Object.defineProperty(对象, 属性名, 描述符) 可以给已有对象定义(或重定义)属性。 描述符(descriptor)是一份说明书,分两个家族。

2.1 一个出乎意料的差异:Object.keys 看不见它

先做实验(书里的)。上面 §1 用 get/set 关键字定义的对象,和用 defineProperty 定义的等价物, 分别过一遍 Object.keys:6

// 字面量 get/set 版
Object.keys(ninjaCollection)   // [ 'ninjas', 'firstNinja' ]   ← firstNinja 在列

// defineProperty 版
Object.keys(ninjaCollection)   // [ 'ninjas' ]                 ← firstNinja 「消失」了!

同一个属性,一个可数一个不可数——区别在描述符的开关上。 defineProperty 创建的属性,默认不可枚举(enumerable: false)。 不可枚举意味着它被这些机制集体无视:Object.keys、Object.values、Object.entries、 for...in、JSON.stringify、对象展开/assign 拷贝。7

这个机制不新鲜:每个对象字面量自带 toString 方法(继承自 Object 原型,§3), 但 Object.keys 从不见它——它就是不可枚举的。让属性可枚举,描述符里加 enumerable: true 即可。一个常用模式由此而来:数据属性可枚举、方法不可枚举, 消费者就能用 const {...data} = obj 一把拷走全部数据,不带上方法。8

2.2 访问器描述符(accessor descriptor):get/set 加 enumerable、configurable 两个选项

描述符里出现 get 或 set(或都有),就叫访问器描述符。开关共四个:9

开关 默认 管什么
get / set 无 读/写时执行的函数
enumerable false 上面那串枚举机制见不见它
configurable false 定义本身还能不能改(重定义/delete)

只给 get 不给 set = 只读属性:写入时报错(严格模式)或静默失败(非严格——又一个用 strict 的理由)。 只给 set 不给 get = 只写属性:读出来恒为 undefined。书里「密码」的例子很传神:10

const obj = { passwordHash: "" };
Object.defineProperty(obj, "password", {
  set(newPassword: string) {                    // 只有写钩子
    this.passwordHash = crypto.hash("sha1", newPassword);
  },
});
obj.password = "12345";
console.assert(obj.password === undefined, "The password is read-only");      // 读:undefined
console.assert(obj.passwordHash.length === 40, "sha1 produces a 40-char hash"); // 写:存的是哈希

密码只能往里写、读永远 undefined,底层存的是 40 字符的 SHA-1 哈希——单向属性的教科书实现。

configurable: false 的定义是永久的:再 defineProperty 覆盖它、或 delete 它,都抛错。 设成 true 则允许覆盖和删除。11

2.3 数据描述符(data descriptor):没有函数的值

只想存个值,但要求「不可枚举」或「只读」?那是数据描述符的领地:value + 三个开关 (enumerable/configurable 同上,外加 writable,默认 false——即默认只读)。12

const obj = {};
Object.defineProperty(obj, "surfNinjaCount", { value: 3 });
obj.surfNinjaCount = 4;   // 严格模式 TypeError:不可写

「non-configurable + non-writable」= 事实上的永久常量;「writable 但 non-configurable」= 值能改、性质不能改(比如不能把数据属性改成 getter)。13

3. Proxy:不只拦一个属性,而是拦「整个对象」

getter/setter 管一个属性;Proxy(代理) 管一个对象的全部交互——读、写、删、枚举、 方法调用,统统能被拦下自定义。原书的定位:Proxy 是 getter/setter 的推广。14

结构两件套:

  • new Proxy(target, handler):target 是被代理的原对象;handler 是一组陷阱(traps), 每个陷阱对应一类操作(get、set、deleteProperty、ownKeys、defineProperty……);
  • Reflect:Proxy 的配套工具箱,把「默认操作」做成函数 (Reflect.get(target, key, receiver) ≈ target[key],receiver 是 this 应指的对象)。

机制一句话:对 proxy 做任何操作,引擎先看 handler 里有没有对应陷阱;有,交给陷阱裁决; 没有,直通原对象。(Figure 9.2 画的就是这个岔路。)15

书里「天皇与代理人」的例子把 get/set 陷阱走了一遍:16

const emperor = { name: "Komei" };
const representative = new Proxy(emperor, {
  get: (target, key, receiver) => {
    return key in target
      ? Reflect.get(target, key, receiver)     // 有的属性:正常透传
      : "Don't bother the emperor!";           // 没有的:代理人挡驾
  },
  set: (target, key, value, receiver) => {
    if (!(key in target)) {
      console.warn("Setting this key is impermissible.");
      return false;                            // 拒绝这次写入
    }
    return Reflect.set(target, key, value, receiver);
  },
});

representative.name;        // "Komei"                  —— 透传
representative.name = "Tenno";
emperor.name;               // "Tenno"                  —— 写入直达 target
representative.nickname;    // "Don't bother the emperor!" —— get 陷阱挡驾
representative.nickname = "x"; // 警告 + 拒绝          —— set 陷阱挡驾

带数走:读 name → 陷阱查 name in emperor 为真 → Reflect.get 透传 → "Komei"; 写 name → 陷阱放行 → emperor 的 name 真被改了(proxy 是通道,不是拷贝); 读 nickname → in 为假 → 返回挡驾字符串。注意:没有陷阱介入的操作(比如 in 本身、JSON 化) 走默认路径。17

Proxy 的实用坐标(书里给了三个):18

  1. 调试与性能调优:get 陷阱可以给对象的所有方法自动包上计时日志——前提是架构上留好了 「关键对象都经工厂函数产出」的口子,才好在工厂里换成 proxy 版;
  2. 测试框架的自动 mock:Vitest 的 vi.mock(..., { spy: true }) 能把模块的所有导出 换成间谍函数,底层就是 Proxy;
  3. 语言级黑科技:Sindre Sorhus 的 negative-array 用 proxy 实现 Python 风格的负索引数组 (ES2022 的 .at(-1) 是它的官方缩水版)。

以及一句性能警告:引擎遇到 proxy 要做大量额外工作,性能关键路径别用。19

4. 原型:继承就是「找不到,就问下一个」

4.1 回退语义

JavaScript 的继承机制说穿了一句话:

读一个对象的属性,它自己身上没有,就去它的原型(prototype)上找; 原型上没有,再去原型的原型上找——这条「原型链(prototype chain)」一路问到底。20

注意方向只此一条:读有回退,写没有——赋值永远落在自己身上(已经有同名属性才覆盖)。 也注意 JavaScript 的继承不区分「数据」和「方法」,一切属性同等可继承。 (in 运算符测的是「整条链上可不可见」,不是「自己有没有」——后者用 Object.hasOwn。)

主线例子带数走(书里的 yoshi/hattori/kuma,本章后半程的承重件):21

const yoshi  = { skulk: true };
const hattori = { sneak: true };
const kuma   = { creep: true };

"sneak" in yoshi;              // false  —— 初始:yoshi 只会 skulk
Object.setPrototypeOf(yoshi, hattori);
"sneak" in yoshi;              // true   —— hattori 成为后备:yoshi「会」sneak 了
Object.setPrototypeOf(hattori, kuma);
"creep" in hattori;            // true
"creep" in yoshi;              // true   —— 链式回退:yoshi → hattori → kuma

三步的查找路径,最后一次 yoshi.creep 的解析是:

  yoshi 自己有 creep 吗?  没有
      ↓ 问原型 hattori
  hattori 有 creep 吗?    没有
      ↓ 问原型 kuma
  kuma 有 creep 吗?      有 → 返回 true

Object.setPrototypeOf(a, b) 把 b 设为 a 的原型(原书用它在平级对象间演示; 生产代码应避免事后改链,引擎对「创建时就定好链」的对象有优化)。 链的尽头是 Object.prototype——所以 ({}).toString() 人人可用; Object.create(null) 造没有原型的对象(纯字典的推荐姿势,第 11 章还有更好的:Map)。22

这套机制的动态性值得记三笔(原书列的):原型上的方法随时可加可改,一改全体后备对象生效 (早年 MooTools 改 Element.prototype 给 DOM 加功能,所谓 monkey patching,今天被视为破坏); 对象自己定义同名属性就遮蔽原型上的;运行中整条链可以换掉(慎用)。23

4.2 构造函数与 prototype:配对出厂

对象字面量之外,造对象的正统是 new 一个构造函数。第 6 章讲过 new 的三步; 本章补上最关键的一件事实:

new X() 造出的对象,其原型是 X.prototype。

每个 function 声明/表达式在创建时,自动获得一个空对象作为它的 prototype 属性; 用这个函数当构造器时,造出的所有实例都以这个空对象为原型。24

function Ninja() {}                              // 空函数
Ninja.prototype.swingSword = () => true;         // 往它的 prototype 上加方法
const ninja = new Ninja();
ninja.swingSword();        // true —— 自己没有,原型上有
ninja instanceof Ninja;    // true —— Ninja.prototype 在 ninja 的原型链上

带数走:new Ninja() → 造空对象 obj → obj 的原型被设为 Ninja.prototype → 作为 this 进函数体 (空函数啥也不干)→ 返回 obj。ninja.swingSword():ninja 自己没有 → 原型上有 → 返回 true。 一个方法都不在实例上,却人人可调用——这就是继承省内存的方式:方法一份,实例共享。 (对照第 6 章「构造器里 this.skulk = function…」的写法:那样每个实例各拷一份函数,浪费。)

instanceof 的准确语义也由此定型:「X.prototype 在不在 obj 的原型链上」, 别和 typeof 混——typeof 任何实例 都是 "object"。25 彩蛋:ninja.constructor.name === "Ninja"(构造器属性指回函数,函数有名字属性)——调试小技巧, 生产慎用(改个函数名、过一遍压缩器,逻辑就断)。26

现在可以兑现本章标题里的判词了。原书明说:Listing 9.5 那套构造函数 + prototype 的写法, 在 post-ES2015 的世界里「显得没必要地绕」;而 class 做的「是同样的事」—— 类,本身就是一个函数加一个原型对象。 这是「语法糖」三个字在此处的精确含义:没有新机制, 只有新语法。下一节看糖怎么化。27

5. class:糖衣,以及糖里唯一的新东西

5.1 类 = 构造函数 + 原型,一次写完

class Ninja {
  name: string;
  level: number;

  constructor(name: string, level: number) {   // constructor 是关键字,不是函数名
    this.name = name;                          // 赋给实例的属性
    this.level = level;
  }

  swingSword() { return true; }                // 简写方法 → 挂在 Ninja.prototype 上
}
const ninja = new Ninja("Yoshi", 4);

三个 assert 把「糖」字坐实(书里的):28

  • ninja instanceof Ninja —— 类是构造器(Ninja 本身能被 new);
  • typeof Ninja.prototype.swingSword === "function" —— 实例方法住在原型对象上,不在实例上;
  • Object.hasOwn(ninja, "name") —— constructor 里赋的属性才是实例自己的。

一张对照表看穿糖纸:

class 语法 等价的老写法
constructor(...) {…} 函数体本身
swingSword() {…}(类体内简写) Ninja.prototype.swingSword = function…
new Ninja(...) 同左(类也必须 new,不能直接调)
static compare(...) Ninja.compare = function…(挂在函数上,不进原型)

static(静态)成员挂在类(函数)自己身上,实例摸不到——适合放「与某个实例无关」的工具方法 (如两个忍者比级别)。static 属性不继承(子类要自己再写,见下节)。29

5.2 extends 与 super:原型链的声明式

class Person {
  name: string;
  constructor(name: string) { this.name = name; }
  dance() { return true; }
}
class Ninja extends Person {                 // 声明链:Ninja 的原型链上接 Person
  sneakiness: number;
  constructor(name: string, sneakiness = 3) {
    super(name);                             // 先跑父类构造器(同一个 this!)
    this.sneakiness = sneakiness;
  }
  sneak() { return `Sneak level ${this.sneakiness}`; }
}

实例化的查找链(书里的 Figure 9.7):ninja.sneak() 在 Ninja.prototype 上找到; ninja.dance() 自己这一级没有,沿链回退到 Person.prototype 找到。 两个 instanceof 同时成立:ninja instanceof Ninja、ninja instanceof Person—— 链上每一环都算数。30

super 的纪律:在子类构造器里,用 this 之前必须先调 super()——父类要先完成初始化, 同一个 this 才能用;不定义构造器则默认继承父类的。继承是单线的:一个类只能 extends 一个类, 没有多继承。31

5.3 # 私有字段:糖里唯一的新机制

ES2022 引入,只在 class 里可用(普通对象没有这门语法):32

class Ninja {
  name: string;
  static #ninjas: Ninja[] = [];              // 静态私有字段

  constructor(name: string) {
    this.name = name;
    Ninja.#ninjas.forEach((ninja) => ninja.#greet(name));   // 类体内可用
    Ninja.#ninjas.push(this);
  }
  #greet(newNinjaName: string) {             // 私有方法
    console.log(`Greetings, ${newNinjaName}! I'm ${this.name}.`);
  }
}
new Ninja("Kasumi");   // Kasumi 报到,无人迎接
new Ninja("Ryu");      // 「Greetings, Ryu! I'm Kasumi.」
new Ninja("Ken");      // 两位前辈轮流迎接

三条铁律(书里的清单,逐条都是「撞墙即语法错」级别的硬):33

  1. 只能点号访问:ninja.#greet 行;ninja["#greet"] 是 undefined(字符串键和私有键是两个世界);
  2. 只能类体内访问:那对花括号之外写 #greet,解析期就报错——不是运行时检查,是语法层面封死;
  3. 不能被代理、枚举、删除:Proxy 陷阱看不见私有键;Object.keys 永远列不出;delete 不允许。

为什么用「field(字段)」这个词而不用「property」:私有键不是普通属性, 上面三条特殊性每一条都超出属性描述符的能力范围。34

实用价值(原书给了两层):硬隔离(真的访问不到,不是约定)是少数场景的需求; 更日常的价值是人机工学——# 开头的名字一眼可见是内部实现, IDE 也不会在外面自动补全它们。35

6. 作者的判断与证据

可亲手验证的机制(全章 assert 覆盖):

  • defineProperty 的属性默认不可枚举(Object.keys 对照实验);
  • new X() 的原型是 X.prototype(swingSword 演示);
  • 类=函数的判定(instanceof + typeof prototype.swingSword);
  • 私有键的访问限制(类体外引用是语法错,连运行都轮不到)。36

作者立场(当立场读):

  • 「monkey patching 已不合时宜、class+TypeScript 更可预测」是行业主流判断, 但原书留了一手:单元测试里改 Date.prototype.getTimezoneOffset 归一时区,仍是「有时是最好的选择」;
  • 「关键对象走工厂函数以便日后换 Proxy」是作者的架构建议,非通则;
  • 「引擎对运行中改原型链的优化假设」是性能提示,无定量数据。

7. 边界与局限

  • Object.setPrototypeOf 的生产禁令只给了一句话的理由(引擎优化假设), 细节要查引擎文档;__proto__ 这个旧 accessor 本章未提。
  • Proxy 陷阱的完整清单只点了五个名字,MDN 有全表;in 操作、Object.keys、 原型访问各自走哪个陷阱,本章未细分。
  • 类字段(ES2022 = 语法) 在本章只以私有字段形式出现;公有字段与它的原型/实例之别, 散见第 6 章(类字段是特殊箭头函数)。
  • 装饰器、抽象类、接口实现检查(TypeScript 的类扩展能力)不在本章,也不在 MEAP 任何已出章节。

8. 可带走的(合上这一章,你应该有这十条)

  1. get/set 让方法伪装成属性:读执行 get、写执行 set;只给 get = 只读,只给 set = 只写。
  2. defineProperty 默认不可枚举:Object.keys/JSON/展开都看不见;想要「数据可拷、方法不带」,方法设 non-enumerable。
  3. 描述符两家族:访问器(get/set + enumerable/configurable)管钩子,数据(value + 三开关)管裸值;writable 默认 false。
  4. Proxy 拦整个对象,陷阱按操作分派;Reflect 把默认操作做成函数——测试框架的自动 mock 底层就是它。
  5. 继承 = 读属性的链式回退:自己身上没有问原型,原型没有再问原型的原型;写永远落在自己身上。
  6. new X() 的原型 = X.prototype:方法放原型上(一份共享),数据 constructor 里赋(各实例自有)。
  7. instanceof 测「X.prototype 在不在链上」,不是测类型;typeof 对所有实例都只给 "object"。
  8. class 是糖:constructor=函数体、简写方法=prototype 赋值、static=挂函数自身;底层仍是函数+原型。
  9. extends 把链接上,super 是同一个 this 上先跑父类构造器;用 this 前必须先 super;单继承。
  10. # 私有字段是唯一新机制:类体内、点号、语法级封死——不可代理、不可枚举、不可删。

10. 深读一:getter/setter 的完整机制,从「约定」到「语言特性」

10.1 两种「getter/setter」的分界线

第 1 节开头就把一对易混的概念掰开了,这里展开:按约定叫的 getX/setX 只是普通方法, 命名风格而已,语言不在乎;语言特性 get/set 则是方法伪装成属性—— 调用方读写的是属性语法,执行的是你的函数。把书里的两版并排,差别一目了然:1

// 约定版:调用方要记「这是方法」
ninjaCollection.getFirstNinja();
ninjaCollection.setFirstNinja("Hachi");

// 特性版:调用方按普通属性读写,钩子在后头执行
ninjaCollection.firstNinja;            // 触发 get
ninjaCollection.firstNinja = "Hachi";  // 触发 set

特性版的行业意义(书里点破):API 可以用「像普通属性一样直接读写」的接口形态, 内部却藏着校验、日志、内部表示转换——Java 里成对的 getX/setX 样板,在这门语言里 一行都不用写。代价是调试时要多想一层:obj.x 可能不是读字段,而是在跑函数。

10.2 Listing 9.2 的 TypeScript 曲折,一段小档案

用 defineProperty 给已有对象补属性时,书里的代码有一段刻意的 TypeScript 麻烦, 值得当档案存:

const ninjaCollection = {
  ninjas: ["Yoshi", "Kuma", "Hattori"],
} as NinjaCollection;                      // ① as:断言它「将有」firstNinja

Object.defineProperty(ninjaCollection, "firstNinja", { get() {...}, set(...) {...} });

麻烦的根源书里说得坦白:TypeScript 的变量类型是静态的、不是动态的—— 同一个变量在程序的不同位置不能有不同的类型。要么让类型停在字面量那一步 (没有 firstNinja),要么用 as 提前断言它有(尽管 defineProperty 运行之前它真没有)。 书里选了后者,因为类型会在 defineProperty 之后变正确。 这是「动态特性 vs 静态类型」这一根本张力的一处缩影,不是 bug。2

defineProperty 的常见实战场景书里也点了:测试——在模拟 DOM 环境里永久覆盖 一个你不控制的对象上的值(比如 window.location),defineProperty 是那把钥匙。

11. 深读二:描述符的完整账本

11.1 访问器描述符的四个选项,逐个走样例

开关 默认 样例行为(书里的断言)
get 无 只给 get:读正常,写 TypeError(严格)
set 无 只给 set:写执行 set,读恒 undefined
enumerable false false 时 Object.keys/JSON/展开全不见它
configurable false false 时 delete 和重定义都 TypeError

「只给 set」的密码例子值得再读一遍——它是「写钩子有、读钩子无」的最实用形态:

const obj = { passwordHash: "" };
Object.defineProperty(obj, "password", {
  set(newPassword: string) {
    this.passwordHash = crypto.hash("sha1", newPassword);   // 只存哈希
  },
});
obj.password = "12345";
console.assert(obj.password === undefined, "The password is read-only");
console.assert(obj.passwordHash.length === 40, "sha1 produces a 40-char hash");

密码只能往里写、读永远 undefined,底层是 40 字符的 SHA-1——单向属性的教科书实现。 顺带:非严格模式下写只读属性是静默失败——strict mode 又一次成了你的报警钟。3

configurable 的语义有一个「永久」的层次值得记牢:non-configurable + non-writable = 事实上常量;writable 但 non-configurable = 值能改、性质不能改(不能再变成 getter/setter,也不能改 enumerable)。4

11.2 数据描述符:什么时候值得用它

只想存个值,什么时候不用 obj.x = 3 而要用 defineProperty?书里的两个触发条件:

  • 想让它不可枚举(Object.keys 看不见,比如内部状态字段);
  • 想让它只读但没有 getter 的运行时开销(writable: false 由引擎直接拦, 不经过函数调用)。

数据描述符的默认值口诀(书里散落各处,合并):value 给什么是什么,三个开关 enumerable/configurable/writable 全都默认 false——所以裸写 Object.defineProperty(obj, "x", { value: 3 }) 得到的属性是: 可读、不可枚举、不可改、不可删。想要常见行为得自己开灯。5

11.3 Symbol 键的枚举特殊性

书里有一个边栏级的细则:Symbol 键的属性也能用 defineProperty 定义、 也能设 enumerable——但就算 enumerable 为 true,Object.keys 和 JSON.stringify 照样忽略它。唯一在乎 Symbol 属性 enumerable 的地方,是 Object.assign 和对象展开拷贝它的时候。所以「Symbol + 默认」就是一道天然的隐藏层: 故意不进入序列化产物的元数据,挂 Symbol 键上。6

12. 深读三:Proxy 的完整机制与陷阱清单

12.1 运作流程,逐步

  对 proxy 做任何操作(读、写、删、枚举、调方法……)
      │
      ▼
  引擎查 handler:有对应陷阱吗?
      │
      ├─ 有 ──► 陷阱函数接管,它的返回值/行为决定操作结果
      │         (陷阱里通常用 Reflect 把默认行为转发给 target,再加自己的逻辑)
      └─ 无 ──► 直通 target,如同 proxy 不存在

Reflect 是这套机制的另一半:没有它,陷阱里想做「默认行为 + 一点附加」 就得手写默认实现;有了它,Reflect.get(target, key, receiver) 一行就把 「按默认规则读属性」转发出去,receiver 还保住了 this 的正确指向。 Proxy 管拦截、Reflect 管转发,两者是配套的。7

12.2 陷阱清单(书里点名的五个,加注释)

陷阱 拦截什么 典型用途
get 读属性 默认值、日志、挡驾(书里的天皇代理人)
set 写属性 校验、只读拒绝
deleteProperty delete 操作 禁止删除
ownKeys Object.keys / for...in 的键列表 隐藏键、键排序
defineProperty defineProperty 及「给新属性赋值」 冻结结构

MDN 有全表(书里给了链接);记住「每个对象操作都有对应陷阱」这个原则即可—— get/set 只是最常见的两个。8

12.3 三个真实用例的完整坐标

书里给了三个 Proxy 的实用坐标,逐个展开:

  1. 调试计时:get 陷阱可以为对象的每个方法包一层计时日志—— 「哪个方法被调了、耗时多久」。前提:关键对象都经工厂函数产出, 才好在工厂里「狸猫换太子」换成 proxy 版。这是架构层面的建议, 不是写完代码再想换就能换的;
  2. 测试框架的自动 mock:Vitest 的 vi.mock("./math.js", { spy: true }) 能把模块的所有导出换成间谍函数,背后就是 Proxy—— 你「感知的魔法」是拦截器机制的量产版;
  3. 语言级黑科技:Sindre Sorhus 的 negative-array 用 proxy 实现 Python 风格的 负索引(arr[-1] 从尾部数)。ES2022 的 .at(-1) 是它的官方缩水版—— 库能抢先语言一步,正是 Proxy 这种通用机制的存在意义。9

一句性能警告(书里加粗):引擎处理 proxy 要做大量额外工作,性能关键路径别用—— 拦截的通用性是用运行时开销换的。10

13. 深读四:原型链的完整推导

13.1 「回退」语义的三个精确性质

书里的原型继承有三个性质,逐条都值得对着实验记:

  1. 只回退读:赋值永远落在对象自己身上;已有同名属性才被覆盖。原型上的同名 属性不会因为「你写了一个」而改变——它一直在,只是被遮蔽了;
  2. 数据与方法同等可继承:不像某些语言区分「字段继承」和「方法继承」, JavaScript 里一切属性都是键值对,一律可继承。书里的 yoshi/hattori/kuma 用的全是数据属性,照样构成继承演示;
  3. 链的深度不限:yoshi → hattori → kuma 是两层,链可以一直接到 Object.prototype 尽头。任何一次属性读,最差走完整条链。11

13.2 构造器与原型的配对,一句定理

把第 4 节的机制压成一句可引用的定理:

每个 function 声明/表达式在创建时,自动获得一个空对象作为它的 prototype; 用这个函数当构造器造出的所有实例,都以这个空对象为原型。

推论:ninja instanceof Ninja 的真值条件是「Ninja.prototype 在 ninja 的原型链上」 ——所以 instanceof 不是类型检查,是链成员检查。而 typeof ninja 只给 "object",连类名都不肯透露。想知道对象出自哪个类?ninja.constructor.name 是个 调试技巧(构造器属性指回函数,函数有名字属性),但书里的警告照录: 生产代码别依赖——改个函数名、过一遍压缩器,逻辑就断。12

对比两种「给实例方法」的写法,内存账一目了然:

// 写法一:构造器里挂——每个实例各拷一份函数
function Ninja() { this.skulk = function () { return this; }; }
// 写法二:prototype 上挂——所有实例共享一份
Ninja.prototype.swingSword = () => true;

一个类一万个实例,写法一是一万份函数字码,写法二是两份(构造器 + 原型方法)。 「方法放原型」是 JavaScript 对象模型的内存根基——class 语法默认就是这么做的。13

13.3 动态性的三笔账与 monkey patching 的兴衰

原型的动态性(书里列了三条):原型属性随时可加可改,一改全体后备对象生效; 对象自己定义同名属性就遮蔽原型;运行中整条链可换(Object.setPrototypeOf,慎用—— 引擎对「创建时就定好链」的对象有优化)。

这套动态性早年是被重度使用的:MooTools 当年直接改 Element.prototype 给浏览器 DOM 加新功能——所谓 monkey patching(猴子补丁,运行时改别人的代码)。 今天它被普遍皱眉:class 与 TypeScript 代表的静态化路线让代码更可预测。 但书里留了一个正当的残留场景:单元测试——比如把 Date.prototype.getTimezoneOffset 改成恒定值,让时区相关的测试在任何机器上都稳定(副作用是字符串格式化不受它影响, 作者照实承认「sometimes that's the best you can do」)。14

14. 深读五:class 的糖纸之下,与私有字段的三铁律

14.1 「糖」的逐条等价证明

书里的三个 assert 把「class 是糖」钉死了,我们把等价关系再展开成表:15

class Ninja {
  name: string;                            // TypeScript 属性声明
  constructor(name: string) { this.name = name; }
  swingSword() { return true; }            // 简写方法
  static compare(a: Ninja, b: Ninja) { return a.level - b.level; }
}
class 写法 等价的老写法 证据
class Ninja {...} 一个函数(构造器)+ Ninja.prototype 对象 ninja instanceof Ninja 成立
constructor(...) 函数体本身 Ninja 本身能被 new
简写方法 Ninja.prototype.swingSword = function... typeof Ninja.prototype.swingSword === "function"
constructor 里 this.x = ... 实例自己的属性 Object.hasOwn(ninja, "name")
static compare Ninja.compare = function... 挂在类上,不进原型

一句语法点书里特意标了:constructor 是关键字,不是函数名—— 换个标识符就会被当作普通实例方法。类不定义 constructor 也合法: 空构造器造空对象,或继承父类的构造器。16

14.2 extends/super 的完整规则集

书里的规则合起来六条:

  1. extends 声明链接:子类的原型链上接父类,于是 ninja instanceof Person 和 ninja instanceof Ninja 同时成立;
  2. 子类构造器里用 this 之前必须先 super()——父类要先完成初始化,同一个 this 才存在;
  3. super 在构造器里是「父类构造器」;在类体别处是「父类本身」(可访问父类的 静态成员和其他方法);
  4. 不定义构造器 = 默认继承父类的;
  5. 静态属性不继承——要同样的静态成员,子类自己再写;
  6. 继承是单线的:一个类只能 extends 一个类,没有多继承。17

14.3 私有字段:为什么叫「field」不叫「property」

ES2022 的 # 语法是 class 话题里唯一的新机制。书里的用词讲究值得复述: # 开头的标识符不是普通属性,所以术语用「field(字段)」—— 它们有三条超出属性描述符能力范围的性质:

  1. 只能点号访问:ninja.#greet 行;ninja["#greet"] 是 undefined—— 字符串键和私有键是两个世界,方括号语法探测不到它;
  2. 只能类体内访问:在那对花括号之外写 #greet,是语法错误—— 不是运行时检查,是解析期就拒绝,连跑的机会都没有;
  3. 不能被代理、枚举、删除:Proxy 的陷阱看不见私有键;Object.keys 永远列不出; delete 不允许。18

实用价值两层(书里分得清楚):硬隔离是真的访问不到,只有少数场景真正需要; 更日常的是人机工学——# 开头一眼可见是内部实现,IDE 也不会在外面 自动补全它们。给 getter/setter 对存内部值,是书里的典型用例。19

15. 原文地图

主题 原书章 原文位置
属性的三种进阶需求 Chapter 9: Objects, prototypes, and classes text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:36(搜「prevent skillLevel」)
getter/setter 两种含义 同上 :43(搜「a bit loosely」)
Listing 9.1 名册与三行日志 同上 :65(搜「Listing 9.1」) · :99(搜「Getting firstNinja: Yoshi」)
get/set 语法图与行业意义 同上 :111(搜「The syntax for defining getters」) · :116(搜「powerhouse features」)
Object.keys 对照实验 同上 :170(搜「two different results」)
不可枚举的排除清单 同上 :186(搜「excluded from」)
toString 不可枚举的先例 同上 :192(搜「inherited from the Object prototype」)
数据可枚举/方法不可枚举模式 同上 :214(搜「common pattern」)
只读属性(TypeError) 同上 :235(搜「accessor descriptors」) · :246(搜「randomNumber = 3」)
密码单向属性示例 同上 :252(搜「passwordHash」)
configurable 语义 同上 :275(搜「configurable option」)
数据描述符与 writable 同上 :297(搜「DATA DESCRIPTORS」) · :319(搜「writable」)
Proxy 定义与陷阱 同上 :331(搜「surrogate」) · :381(搜「traps」)
天皇代理人完整例子 同上 :342(搜「Listing 9.3」)
Reflect 的定位 同上 :386(搜「sibling API」)
调试/mock/negative-array 用例 同上 :415(搜「debugging and performance tuning」) · :420(搜「automocking」)
Proxy 性能警告 同上 :439(搜「significant amount of extra work」)
原型回退定义 同上 :455(搜「Fallbacks as inheritance」)
yoshi/hattori/kuma 主线 同上 :463(搜「Listing 9.4」)
链式查找路径(Figure 9.5) 同上 :519(搜「stops when there are no more prototypes」)
setPrototypeOf 生产禁令 同上 :531(搜「should be avoided」)
hasOwnProperty / hasOwn 同上 :536(搜「IS IT THE OBJECT'S OWN PROPERTY」)
构造函数与 prototype 配对 同上 :556(搜「inherits the constructor function's prototype」)
Listing 9.5 swingSword 同上 :569(搜「Listing 9.5」)
实例方法一份共享的机制 同上 :592(搜「automatically inherited」)
instanceof 语义与 constructor.name 同上 :721(搜「prototype is in the given object's prototype chain」) · :727(搜「ninja.constructor.name」)
「class 做的同样的事」 同上 :601(搜「unnecessarily convoluted」) · :603(搜「just a function with a prototype object」)
Listing 9.6 简单类 同上 :671(搜「Listing 9.6」)
static 与清单 同上 :736(搜「static keyword」) · :739(搜「Listing 9.7」)
extends/super 与继承链 同上 :771(搜「Listing 9.8」) · :816(搜「Figure 9.7」)
super 纪律与单继承 同上 :824(搜「must call super before it uses this」) · :830(搜「linear in JavaScript」)
# 私有字段三铁律 同上 :835(搜「private element syntax」) · :866(搜「unique qualities」)
私有字段实用价值 同上 :875(搜「internal value for a getter/setter pair」)
全章总结 同上 :888(搜「Summary」)

  1. 出处:「Chapter 9: Objects, prototypes, and classes」第 43–63 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:44,搜「a bit loosely」)。约定版 getter/setter 与「The naming has no effect」。 ↩↩

  2. 出处:「Chapter 9: Objects, prototypes, and classes」第 130–168 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:160,搜「static, not dynamic」)。TypeScript 静态类型与动态 defineProperty 的张力及 as 选择。 ↩↩

  3. 出处:「Chapter 9: Objects, prototypes, and classes」第 229–272 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:255,搜「passwordHash」)。单向密码属性示例与两个断言;「fail silently」的 strict 提醒见第 248–249 段。 ↩↩

  4. 出处:「Chapter 9: Objects, prototypes, and classes」第 275–295 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:279,搜「permanent」)。configurable 的「永久」语义。 ↩↩

  5. 出处:「Chapter 9: Objects, prototypes, and classes」第 297–329 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:306,搜「surfNinjaCount」)。数据描述符的样例与「read-only, without the performance overhead」的动机(第 299–301 段)。 ↩↩

  6. 出处:「Chapter 9: Objects, prototypes, and classes」第 218–226 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:218,搜「SYMBOLS AND ENUMERATION」)。Symbol 键的枚举特殊性边栏。 ↩↩

  7. 出处:「Chapter 9: Objects, prototypes, and classes」第 386–393 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:386,搜「sibling API」)。Reflect 的定位与 receiver 语义。 ↩↩

  8. 出处:「Chapter 9: Objects, prototypes, and classes」第 397–413 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:398,搜「deleteProperty」)。陷阱清单与 MDN 指引。 ↩↩

  9. 出处:「Chapter 9: Objects, prototypes, and classes」第 415–437 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:422,搜「automocking」);与第 408–412 段(搜「negative-array」)。三个真实用例与 automocking 的示例代码。 ↩↩

  10. 出处:「Chapter 9: Objects, prototypes, and classes」第 439–440 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:439,搜「significant amount of extra work」)。Proxy 的性能警告原文。 ↩↩

  11. 出处:「Chapter 9: Objects, prototypes, and classes」第 455–554 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:455,搜「Fallbacks as inheritance」)。回退语义与「doesn't make any distinction」的数据/方法同等继承(第 496–498 段)。 ↩↩

  12. 出处:「Chapter 9: Objects, prototypes, and classes」第 556–600 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:566,搜「inherits the constructor function's prototype」)。构造器-原型配对定理;instanceof 语义与 constructor.name 警告见第 721–733 段。 ↩↩

  13. 出处:「Chapter 9: Objects, prototypes, and classes」第 592–599 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:593,搜「automatically inherited」)。「new X 造的对象自动继承 X.prototype」与共享一份的机制说明。 ↩↩

  14. 出处:「Chapter 9: Objects, prototypes, and classes」第 625–654 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:638,搜「monkey patching」)。动态性三笔账、MooTools 史与测试改 Date.prototype 的正当残留。 ↩↩

  15. 出处:「Chapter 9: Objects, prototypes, and classes」第 601–719 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:603,搜「just a function with a prototype object」)。「class 做的同样的事」判词与 Listing 9.6 的三个 assert。 ↩↩

  16. 出处:「Chapter 9: Objects, prototypes, and classes」第 671–719 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:713,搜「constructor is a keyword」)。constructor 关键字的语法点与空构造器的说明(第 717–720 段)。 ↩↩

  17. 出处:「Chapter 9: Objects, prototypes, and classes」第 771–833 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:827,搜「must call super before it uses this」)。super 纪律、静态不继承(第 828–829 段)与单继承(第 830–831 段)。 ↩↩

  18. 出处:「Chapter 9: Objects, prototypes, and classes」第 835–882 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:866,搜「unique qualities」)。私有字段三铁律清单。 ↩↩

  19. 出处:「Chapter 9: Objects, prototypes, and classes」第 875–882 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:877,搜「developer ergonomics」)。硬隔离与人机工学两层的实用价值。 ↩↩

  20. 出处:「Chapter 9: Objects, prototypes, and classes」第 455–460 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:457,搜「fall back on that object's prototype」)。原型回退语义的定义与「prototype chaining」术语。 ↩

  21. 出处:「Chapter 9: Objects, prototypes, and classes」第 463–530 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:463,搜「Listing 9.4」)。yoshi/hattori/kuma 三步设置与断言链。 ↩

  22. 出处:「Chapter 9: Objects, prototypes, and classes」第 611–623 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:611,搜「AN OBJECT WITH NO PROTOTYPE」)。Object.prototype 尽头、({}).toString 的来历、Object.create(null) 与「Map 通常更好」的预告。 ↩

  23. 出处:「Chapter 9: Objects, prototypes, and classes」第 625–654 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:638,搜「monkey patching」)。动态性三条与 MooTools 史、测试改 Date.prototype 的正当残留用例。 ↩

  24. 出处:「Chapter 9: Objects, prototypes, and classes」第 556–600 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:566,搜「inherits the constructor function's prototype」)。「new X 造出的对象以 X.prototype 为原型」的关键事实与「Every function…receives an empty object」。 ↩

  25. 出处:「Chapter 9: Objects, prototypes, and classes」第 721–725 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:721,搜「The instanceof keyword works by checking if the」)。instanceof 准确语义;typeof 的局限同段。 ↩

  26. 出处:「Chapter 9: Objects, prototypes, and classes」第 727–733 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:727,搜「ninja.constructor.name」)。constructor.name 调试技巧及「not recommended for production code」的警告。 ↩

  27. 出处:「Chapter 9: Objects, prototypes, and classes」第 601–604 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:603,搜「just a function with a prototype object」)。「class is doing the same thing」的原文判词。 ↩

  28. 出处:「Chapter 9: Objects, prototypes, and classes」第 671–719 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:671,搜「Listing 9.6」)。简单类的四个断言与「constructor is a keyword, not a function name」的语法点。 ↩

  29. 出处:「Chapter 9: Objects, prototypes, and classes」第 734–769 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:736,搜「static keyword」)。static 成员的定位与「Static properties are not inherited」(第 828–829 段)。 ↩

  30. 出处:「Chapter 9: Objects, prototypes, and classes」第 771–823 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:775,搜「Listing 9.8」)。extends/super 示例与「a ninja is also a person」的双 instanceof。 ↩

  31. 出处:「Chapter 9: Objects, prototypes, and classes」第 824–831 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:827,搜「must call super before it uses this」)。super 纪律;「linear」「no notion of multiple inheritance」见第 830–831 段。 ↩

  32. 出处:「Chapter 9: Objects, prototypes, and classes」第 832–839 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:836,搜「ES2022 introduced a syntax for making properties only」)。ES2022 私有元素语法及其 class-only 性。 ↩

  33. 出处:「Chapter 9: Objects, prototypes, and classes」第 866–874 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:866,搜「unique qualities」)。三铁律清单原文。 ↩

  34. 出处:「Chapter 9: Objects, prototypes, and classes」第 867–868 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:867,搜「term 'field' is preferred」)。为什么叫 field 不叫 property。 ↩

  35. 出处:「Chapter 9: Objects, prototypes, and classes」第 875–882 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:877,搜「developer ergonomics」)。IDE 不补全的内部字段——人机工学层价值。 ↩

  36. 出处:「Chapter 9: Objects, prototypes, and classes」第 84–91 段(text/12-ch09-chapter-9-objects-prototypes-and-classes.txt:80,搜「firstNinja === "Yoshi"」);与 :569(搜「Listing 9.5」);与 :842(搜「Listing 9.9」)。可复现断言的代表处。 ↩