以 # 开头的私有字段、方法和访问器只能在声明它们的类体内使用。访问时,引擎还会检查接收者是否带有该类的私有标记。
私有元素不是普通属性,不参与反射、展开或自动序列化。它们也不跟随原型链,错误的 this、Proxy 或派生类都可能触发 TypeError。
把私有状态留在类内,通过经过校验的公开方法返回投影;测试真实的回调、代理与继承调用路径,并显式设计序列化边界。
是什么,为什么存在
私有字段(private field) 是名称以 # 开头的类元素,例如 #balance。同一套语法也能声明私有方法、getter、setter 和静态成员。它们给类提供语言层面的 封装(encapsulation) 边界,不必再把 _balance 这样的命名约定当成访问控制。
私有名称只能写在声明它的类体内。account.#balance 若出现在类外,整个脚本会在解析阶段产生 SyntaxError,因此外层 try...catch 也接不到这个错误。account['#balance'] 虽然语法合法,却访问的是名为 "#balance" 的普通公开属性,与真正的私有字段无关。
你会在需要维护不变量的类中遇到私有元素,例如余额只能经过金额校验后修改,缓存内部索引不能由调用方替换。私有边界还能让实现者在不改变公开 API 的情况下调整内部表示。不过,它不是加密、权限检查或秘密存储机制;类自己的代码仍能公开其值,调试器和进程内攻击面也不受它约束。
私有字段适合由一个类完整拥有的状态。如果框架必须枚举字段、通用序列化器必须复制它们,或者子类需要直接扩展该状态,普通属性配合明确的公开契约往往更合适。应先决定谁能读取和修改状态,再选择语法。
工作原理
解析类定义时,引擎会建立一组词法私有名称。每个 #name 必须能在当前类体中解析到声明;同名字符串或 Symbol 都不能替代它。这个静态关系让拼错的私有名称在代码运行前就失败。
创建实例时,类会把自己的私有元素安装到对象上。随后执行 object.#name 时,引擎先做 私有标记(private brand) 检查:接收者必须经过该类的初始化。检查依据不是 instanceof、构造函数名称或原型形状,所以伪造相同公开属性不会通过。
声明与初始化
私有字段必须在类体中声明,可以在声明处初始化,也可以先取得 undefined,再由构造函数赋值。字段初始化器按源码顺序执行。较早的初始化器能读到已经初始化的字段,却不能提前读取尚未安装的后续私有字段。
实例字段属于各个实例,静态字段属于定义它们的类构造函数。私有方法与访问器使用同样的词法访问限制。delete this.#name 是语法错误;需要表示「没有值」时,应把字段设为 undefined、null 或业务定义的哨兵值。
标记与接收者
私有访问检查的是表达式左侧的实际对象。类方法可以读取另一个同类实例的私有字段,因为访问代码仍位于声明类体内,而且另一个实例带有相同标记。把该方法脱离实例调用,则会让 this 变成 undefined 或其他对象,并在访问私有字段时抛出 TypeError。
在声明类体内,#name in value 可以检查对象是否带有相应标记。它不会搜索原型链,也不会等同于 '#name' in value。右侧若是 null 或其他非对象值,该运算仍会抛出 TypeError,所以面向未知输入的检查应先排除这些值。
私有元素不是属性
普通属性由字符串键或 Symbol 键标识,并有可写、可枚举、可配置等属性描述符。私有元素不属于这套属性模型,所以 Object.keys()、Reflect.ownKeys()、Object.getOwnPropertyDescriptors() 和 for...in 都看不到它们。对象展开、Object.assign() 与 JSON.stringify() 也不会自动复制或输出它们。
这种不可见性避免了意外暴露,却不会自动保护字段引用的可变对象。如果公开 getter 直接返回私有数组,调用方仍能修改同一个数组。真正的封装取决于公开 API 是否返回只读视图、必要的副本或经过筛选的数据,而不只取决于 #。
示例
下面四个示例依次展示基础封装、标记检查、代理接收者和静态私有继承。所有输出都由本地 Node 24.14.0 执行对应文件得到。
用公开 API 维护余额
Wallet 只允许整数分币值进入状态,并通过 toJSON() 明确选择可公开的数据。反射只能看到普通属性,因此这个实例没有可枚举的自有键。
class Wallet {
#cents;
constructor(openingCents = 0) {
this.#checkAmount(openingCents);
this.#cents = openingCents;
}
#checkAmount(cents) {
if (!Number.isInteger(cents) || cents < 0) {
throw new RangeError('amount must be a non-negative integer');
}
}
deposit(cents) {
this.#checkAmount(cents);
this.#cents += cents;
return this.#cents;
}
toJSON() {
return { balanceCents: this.#cents };
}
}
const wallet = new Wallet(2000);
console.log(wallet.deposit(500));
console.log(Reflect.ownKeys(wallet));
console.log(JSON.stringify(wallet));2500
[]
{"balanceCents":2500}#cents 没有因为不可枚举而神秘地进入 JSON。输出余额来自显式的 toJSON() 方法;删除这个方法后,实例会序列化成 {}。因此,序列化内容是公开 API 的决定,而不是私有字段的自动行为。
校验方法也是私有元素。调用方只能通过构造函数和 deposit() 改变余额,这让「分币值必须是非负整数」这一不变量集中在类内。若其他公开方法也写入余额,它们仍须走同一套校验。
检查标记并区分同名字段
私有性按声明类区分,而不是按文本名称区分。GuestPass 可以再次声明 #code;它与 AccessPass 的 #code 是两个独立元素。
class AccessPass {
#code;
constructor(code) {
this.#code = code;
}
static hasBrand(value) {
return typeof value === 'object' && value !== null && #code in value;
}
readCodeOf(other) {
return other.#code;
}
}
class GuestPass extends AccessPass {
#code = 'lobby';
codes() {
return [this.readCodeOf(this), this.#code].join(',');
}
}
const first = new AccessPass('A-17');
const second = new AccessPass('B-42');
const guest = new GuestPass('G-07');
console.log(AccessPass.hasBrand(first));
console.log(AccessPass.hasBrand({ code: 'A-17' }));
console.log(first.readCodeOf(second));
console.log(guest.codes());true
false
B-42
G-07,lobbyfirst 能读取 second,说明这里是类级私有,而不是「只有当前实例自己能读」。对象字面量即使保存相同字符串,也没有 AccessPass 的标记。通常不应把标记检查当成完整输入校验,它只说明对象经过了该类的初始化。
派生实例在 super() 期间取得基类私有元素,所以继承来的基类方法可以正常读取基类的 #code。但 GuestPass 的源码不能直接写基类的私有名称;它在自己的类体中写出的 #code 只会解析到派生类声明。
代理不会转移私有标记
Proxy 包装目标对象后,方法调用中的默认接收者是代理。代理能转发普通属性读取,但不会继承目标对象的私有标记。
class Meter {
#value = 0;
add(step) {
this.#value += step;
return this.#value;
}
read() {
return this.#value;
}
static hasBrand(value) {
return #value in value;
}
}
const target = new Meter();
const directProxy = new Proxy(target, {});
try {
directProxy.add(1);
} catch (error) {
console.log(`direct proxy: ${error.name}`);
}
const boundProxy = new Proxy(target, {
get(targetObject, property) {
const value = Reflect.get(targetObject, property, targetObject);
return typeof value === 'function' ? value.bind(targetObject) : value;
},
});
console.log(boundProxy.add(2));
console.log(Meter.hasBrand(target), Meter.hasBrand(boundProxy));direct proxy: TypeError
2
true false绑定后的包装器让方法在 target 上运行,所以示例可以工作,但它并没有把标记复制到代理。这个通用 get trap 每次还可能创建新的绑定函数,并改变方法标识。生产代码通常应为需要代理的操作写明确的转发方法,而不是假设包装器完全透明。
同一种故障也会出现在脱离实例的方法中,例如把 meter.add 直接交给回调 API。应传入 (step) => meter.add(step),或在注册时绑定一次接收者。测试时必须走真实回调路径,单独测试 meter.add(1) 看不到问题。
静态私有字段属于声明类
静态公开方法会被派生类继承,但基类的静态私有字段不会成为派生类自己的私有字段。使用多态的 this.#next 时,调用方选择的接收者会影响标记检查。
class IdSource {
static #next = 100;
static takeViaThis() {
return this.#next++;
}
static takeFromBase() {
return IdSource.#next++;
}
}
class RegionalSource extends IdSource {
static #next = 900;
static takeRegional() {
return this.#next++;
}
}
console.log(IdSource.takeViaThis());
try {
RegionalSource.takeViaThis();
} catch (error) {
console.log(`derived receiver: ${error.name}`);
}
console.log(RegionalSource.takeFromBase());
console.log(RegionalSource.takeRegional());100
derived receiver: TypeError
101
900RegionalSource.takeViaThis() 中的 this 是派生类构造函数,它没有基类的静态私有标记。派生类恰好也声明了 #next 并不会改变结果,因为基类方法中的私有名称在词法上绑定到 IdSource 的声明。
如果计数器必须由整个继承层次共享,基类方法应明确使用 IdSource.#next,并接受它不再多态这一点。如果每个派生类都需要独立计数器,则应设计公开的注册表或让各类各自实现方法,不要试图让一个私有名称同时满足两种所有权。
陷阱
把语法错误放进 try...catch
修复方法: 通过类提供的公开方法测试行为。若确实要验证非法语法,应把源码字符串交给隔离的解析器或 Function 构造器,并断言它在编译阶段失败,不要把非法表达式直接写进当前文件。
返回私有可变对象
修复方法: 按 API 契约返回投影、迭代器或必要深度的副本。若元素对象本身也可变,只复制外层数组仍不够;应说明哪些层允许共享,并对修改路径编写测试。
假设子类能直接访问
修复方法: 基类若有意提供扩展点,应暴露范围明确的公开或受控方法。静态状态要明确属于基类还是各个派生类,再选择基类名称、公开注册表或派生类自己的实现。
丢失方法接收者
修复方法: 在 API 边界使用明确的包装箭头函数,或者只绑定一次方法。代理应逐项设计转发语义,并测试方法标识、getter、setter 与私有访问,而不是只检查一个普通属性读取。
把冻结和克隆当成私有状态操作
修复方法: 不可变性应由类的公开修改接口保证。持久化或跨线程传输时,定义显式的数据格式与重建函数,并测试往返结果;不要把通用对象工具的成功返回当成完整类实例复制。
初始化、标记与对象边界
初始化发生的时点
基类实例字段在基类构造函数体开始前初始化。派生类实例字段则在 super() 返回后、派生构造函数余下语句执行前初始化。因此,基类构造函数能看到自己的私有字段,却看不到派生类尚未安装的字段或公开字段。
同一个类中的字段初始化器按声明顺序运行。较早初始化器读取后续公开字段通常得到 undefined,读取尚未安装的后续私有字段则会因标记检查失败而抛出 TypeError。把有依赖关系的字段按顺序声明,复杂校验放进构造函数,可让初始化顺序更清楚。
| 元素 | 初始化时点 | 所有者 |
|---|---|---|
| 基类实例私有字段 | 基类构造函数体之前 | 每个实例 |
| 派生类实例私有字段 | super() 返回之后 | 每个派生实例 |
| 静态私有字段 | 求值类定义时 | 声明它的类构造函数 |
字段初始化器中的 this 是正在构造的实例,静态字段初始化器中的 this 是当前类。初始化器可以调用方法,但被调用代码可能读取尚未初始化的后续字段。构造测试应覆盖真实的继承路径,而不只实例化叶子类或基类中的一种。
类私有不等于实例私有
私有名称的可见范围是声明类体,因此类代码能访问任何带相应标记的对象,而不局限于当前 this。这允许比较两个实例的内部状态,也意味着接收任意对象的类方法必须自己处理标记不匹配。直接访问会抛 TypeError;需要布尔结果时,先验证输入是对象,再使用 #name in value。
正常构造的派生实例同时带有基类和派生类各自安装的标记。基类方法因此可以在派生实例上运行,但派生类源码仍看不到基类私有名称。私有访问不沿原型链搜索,改变原型也不能添加或移除标记。
代理是新的对象标识。即使其目标带有标记,代理本身也不会因此通过检查,而且私有访问不触发 get、set 或 has trap。若库依赖代理观察所有状态变化,# 私有字段会形成这个观察机制看不到的通道。
反射、完整性与复制
下面这些 API 回答的是不同问题,不能互换:
| 操作 | 能否处理私有元素 | 实际结果 |
|---|---|---|
Reflect.ownKeys(value) | 否 | 只返回字符串键和 Symbol 键 |
Object.hasOwn(value, '#x') | 否 | 检查同名普通字符串属性 |
类体内的 #x in value | 是 | 检查声明类的私有标记 |
Object.freeze(value) | 否 | 限制普通自有属性,不阻止方法修改私有字段 |
{ ...value } 与 Object.assign() | 否 | 只复制符合条件的普通属性 |
JSON.stringify(value) | 否 | 除非公开 toJSON() 主动输出相应数据 |
structuredClone(value) | 否 | 不复制私有元素,结果不带原类标记 |
私有字段没有属性描述符,也没有可枚举或可配置标志。把它们称为「不可枚举属性」容易让人误以为 Object.getOwnPropertyNames() 仍能找到它们;更准确的说法是,它们根本不是属性。相应地,属性完整性 API 也不管理这部分状态。
Object.freeze() 后,访问私有字段的类方法仍可给字段重新赋值或修改其引用的对象。这不违反冻结规则,因为规则只覆盖自有属性描述符。若类型承诺逻辑不可变,就不要提供修改私有状态的方法,并确保返回值不会泄漏可变引用。
序列化需要独立契约。可以用 toJSON()、toRecord() 或明确的传输对象选择字段,再由静态工厂验证并重建实例。不要直接序列化秘密,也不要假设反序列化后的普通对象自动恢复私有标记、方法或不变量。
方法、访问器与静态状态
私有方法适合不属于公开协议的校验与状态转换。私有 getter 和 setter 可以组织内部访问,但它们不会比字段多提供一层安全边界;声明类中的其他代码仍可调用它们。若简单字段已经清楚,就不必为了形式统一增加访问器。
实例私有字段通常由每个对象独立拥有。静态私有字段只有声明类能直接访问,适合真正属于类定义的注册信息或计数器。只要公开静态方法允许派生类调用,就必须决定其内部使用固定基类名还是多态 this,两者表达不同所有权。
用固定基类名访问会让所有派生类共享一份基类状态。用 this.#field 则要求实际接收者带有声明类的静态标记,派生类构造函数通常不满足。不要根据公开静态方法会被继承,就推断私有静态字段也以同样方式继承。
构建产物与测试契约
转译器可能把 # 语法降级为 WeakMap、辅助函数或普通属性,具体结果取决于工具、版本和目标配置。源码层面的语义承诺必须与实际部署产物核对,尤其是仍面向旧运行环境的库。不要仅凭编辑器接受源码,就断言生产构建保留完全相同的反射或错误行为。
测试应优先断言公开行为和不变量,同时覆盖失败边界。至少要用同类实例、伪造对象、派生实例、代理和脱离方法的调用各走一次关键路径。若类支持持久化,还应测试「实例转记录、记录再重建」的完整往返,而不是比较对象展开结果。
如果某个框架以代理跟踪字段、按键枚举模型或自动把实例复制成数据记录,先验证它对私有元素的明确支持。框架要求和类封装目标冲突时,公开只读访问器、显式快照方法或组合式数据对象往往比绕过私有语法更容易维护。
4个问题 · 2 道输出预测题 · 1 道找错题