跳到主要内容

JavaScript 私有字段

在JavaScript的发展历程中,一直缺乏一种标准的方法来声明类的私有成员。这导致开发者必须使用各种变通方法(如命名约定或闭包)来模拟私有性。随着ECMAScript 2019 (ES10)的到来,JavaScript终于引入了正式的私有字段语法,使用 # 前缀来标识私有成员。

为什么需要私有字段?

在面向对象编程中,封装是一个核心原则,它允许我们隐藏对象内部的实现细节,只暴露必要的接口给外部使用。私有字段直接支持这一原则,带来以下优势:

  • 数据保护:防止外部代码直接访问和修改对象的内部状态
  • API稳定性:允许你自由更改内部实现而不影响使用者
  • 命名冲突减少:私有成员不会与子类或实例中的其他属性发生冲突

私有字段语法

私有字段的声明和使用非常简单,只需要在字段名称前添加井号(#)即可:

javascript
class Person {
#name; // 声明私有字段

constructor(name) {
this.#name = name; // 使用私有字段
}

greet() {
return `Hello, my name is ${this.#name}`;
}
}

const person = new Person('Alice');
console.log(person.greet()); // 输出: Hello, my name is Alice

// 尝试直接访问私有字段会导致错误
try {
console.log(person.#name);
} catch (error) {
console.log('错误:', error.message);
// 输出: 错误: Private field '#name' must be declared in an enclosing class
}
备注

私有字段必须在类体内声明,不能在构造函数或其他方法中动态添加。

私有字段 vs 约定命名

在私有字段语法出现前,开发者通常使用下划线前缀(如 _name)作为约定来表示一个属性应该被视为私有。但这只是一种约定,并没有实际的访问限制:

javascript
class PersonWithConvention {
constructor(name) {
this._name = name; // 约定命名的"私有"属性
}

greet() {
return `Hello, my name is ${this._name}`;
}
}

const person2 = new PersonWithConvention('Bob');
console.log(person2.greet()); // 输出: Hello, my name is Bob

// 仍然可以直接访问
console.log(person2._name); // 输出: Bob
person2._name = 'Charlie'; // 可以随意修改
console.log(person2.greet()); // 输出: Hello, my name is Charlie

使用真正的私有字段,这种直接访问是不可能的,从而增强了封装性。

私有方法和访问器

除了私有字段,我们还可以声明私有方法、私有静态字段和方法,以及私有的getter和setter:

javascript
class BankAccount {
#balance = 0; // 私有字段初始化
#transactionLog = []; // 另一个私有字段

constructor(initialBalance) {
if (initialBalance > 0) {
this.deposit(initialBalance);
}
}

deposit(amount) {
if (this.#validateAmount(amount)) {
this.#balance += amount;
this.#logTransaction('deposit', amount);
return true;
}
return false;
}

withdraw(amount) {
if (this.#validateAmount(amount) && amount <= this.#balance) {
this.#balance -= amount;
this.#logTransaction('withdraw', amount);
return true;
}
return false;
}

// 私有方法
#validateAmount(amount) {
return typeof amount === 'number' && amount > 0;
}

#logTransaction(type, amount) {
this.#transactionLog.push({
type,
amount,
timestamp: new Date()
});
}

// 公有的getter
get balance() {
return this.#balance;
}

// 私有的getter和setter
get #transactionCount() {
return this.#transactionLog.length;
}

// 查看交易历史的公共方法
getTransactionHistory() {
// 返回交易日志副本,而不是原始引用
return [...this.#transactionLog];
}

// 公共方法访问私有getter
getTransactionCount() {
return this.#transactionCount;
}

// 静态私有方法
static #calculateFees(transactions) {
return transactions.length * 0.5;
}

// 公共静态方法使用私有静态方法
static estimateFees(account) {
return BankAccount.#calculateFees(account.getTransactionHistory());
}
}

使用示例:

javascript
const account = new BankAccount(1000);
console.log(account.balance); // 输出: 1000

account.withdraw(500);
console.log(account.balance); // 输出: 500

console.log(account.getTransactionHistory());
/* 输出类似:
[
{ type: 'deposit', amount: 1000, timestamp: 2023-01-01T... },
{ type: 'withdraw', amount: 500, timestamp: 2023-01-01T... }
]
*/

console.log(account.getTransactionCount()); // 输出: 2

// 尝试访问私有成员会失败
try {
console.log(account.#balance);
} catch (error) {
console.log('不能直接访问私有字段');
}

// 尝试调用私有方法会失败
try {
account.#validateAmount(100);
} catch (error) {
console.log('不能直接调用私有方法');
}

console.log(BankAccount.estimateFees(account)); // 输出: 1 (2个交易 * 0.5)

私有静态字段

私有静态字段和方法属于类本身,而不是实例。它们在所有实例间共享,并且只能从类内部访问:

javascript
class Counter {
static #count = 0;

constructor() {
Counter.#increment();
}

static #increment() {
this.#count++;
}

static get count() {
return Counter.#count;
}
}

const c1 = new Counter();
const c2 = new Counter();
const c3 = new Counter();

console.log(Counter.count); // 输出: 3

// 不能访问私有静态字段
try {
console.log(Counter.#count);
} catch (error) {
console.log('不能访问私有静态字段');
}

实际应用场景

场景1:数据验证和状态管理

javascript
class FormField {
#value = '';
#validators = [];
#errors = [];

constructor(initialValue = '', validators = []) {
this.#validators = validators;
this.value = initialValue; // 使用setter进行验证
}

set value(newValue) {
this.#value = newValue;
this.#validate();
}

get value() {
return this.#value;
}

#validate() {
this.#errors = [];

for (const validator of this.#validators) {
const error = validator(this.#value);
if (error) this.#errors.push(error);
}
}

get errors() {
return [...this.#errors];
}

get isValid() {
return this.#errors.length === 0;
}
}

// 使用示例
const requiredValidator = value => !value ? '此字段为必填项' : null;
const emailValidator = value => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return value && !regex.test(value) ? '请输入有效的电子邮件地址' : null;
};

const emailField = new FormField('', [requiredValidator, emailValidator]);

console.log(emailField.isValid); // false
console.log(emailField.errors); // ['此字段为必填项']

emailField.value = 'invalid';
console.log(emailField.errors); // ['请输入有效的电子邮件地址']

emailField.value = 'user@example.com';
console.log(emailField.isValid); // true
console.log(emailField.errors); // []

场景2:缓存实现

javascript
class DataFetcher {
#cache = new Map();
#ttl = 60000; // 缓存生存时间(毫秒)
#apiKey;

constructor(apiKey, ttlInSeconds = 60) {
this.#apiKey = apiKey;
this.#ttl = ttlInSeconds * 1000;
}

async fetchData(endpoint) {
const cacheKey = this.#generateCacheKey(endpoint);

// 检查缓存
if (this.#isCacheValid(cacheKey)) {
console.log('返回缓存数据');
return this.#cache.get(cacheKey).data;
}

// 无缓存,调用API
console.log('从API获取新数据');
const data = await this.#callApi(endpoint);
this.#updateCache(cacheKey, data);
return data;
}

#generateCacheKey(endpoint) {
return `${endpoint}`;
}

#isCacheValid(key) {
if (!this.#cache.has(key)) return false;

const cachedItem = this.#cache.get(key);
const now = Date.now();
return now - cachedItem.timestamp < this.#ttl;
}

#updateCache(key, data) {
this.#cache.set(key, {
timestamp: Date.now(),
data
});
}

async #callApi(endpoint) {
// 模拟API调用
return new Promise(resolve => {
setTimeout(() => {
resolve({
result: `来自${endpoint}的数据`,
timestamp: new Date().toISOString()
});
}, 500);
});
}

clearCache() {
this.#cache.clear();
}
}

// 使用示例
async function demoDataFetcher() {
const fetcher = new DataFetcher('my-api-key', 5); // 5秒TTL

console.log('第一次调用:');
let result = await fetcher.fetchData('/users');
console.log(result);

console.log('\n第二次调用(应从缓存返回):');
result = await fetcher.fetchData('/users');
console.log(result);

console.log('\n等待6秒后...');
await new Promise(resolve => setTimeout(resolve, 6000));

console.log('\n第三次调用(缓存过期):');
result = await fetcher.fetchData('/users');
console.log(result);
}

// demoDataFetcher(); // 异步函数,需要在浏览器或Node环境中运行

相关概念和限制

在子类中访问私有字段

私有字段在子类中不可访问,这与某些其他编程语言中的protected成员类似:

javascript
class Parent {
#privateField = 42;

getPrivateField() {
return this.#privateField;
}
}

class Child extends Parent {
accessPrivate() {
try {
// 错误:子类无法访问父类的私有字段
return this.#privateField;
} catch (error) {
return '无法访问父类的私有字段';
}
}

// 但可以通过父类的公共方法访问
accessViaMethod() {
return this.getPrivateField();
}
}

const child = new Child();
console.log(child.accessPrivate()); // 抛出错误
console.log(child.accessViaMethod()); // 输出: 42

访问不同实例的私有字段

可以在类方法内访问同一类的其他实例的私有字段:

javascript
class Widget {
#value;

constructor(value) {
this.#value = value;
}

// 比较两个Widget实例的私有值
equals(other) {
if (!(other instanceof Widget)) {
return false;
}
// 可以访问other实例的私有字段
return this.#value === other.#value;
}
}

const widget1 = new Widget(5);
const widget2 = new Widget(5);
const widget3 = new Widget(10);

console.log(widget1.equals(widget2)); // true
console.log(widget1.equals(widget3)); // false

实际使用建议

  1. 识别需要保护的数据:不是所有属性都需要是私有的。识别那些应该限制访问的敏感数据或内部实现细节。

  2. 提供公共接口:确保为外部代码提供足够的公共方法来与你的类交互。

  3. 避免滥用:不要仅仅因为可以使用私有字段就把所有东西都设为私有。有时,让一个字段公开或使用符合约定的命名(如_fieldName)更加合适。

  4. 考虑测试需求:私有字段可能会使单元测试变得更加困难。如果需要在测试中访问私有状态,请考虑添加专门的测试辅助方法。

浏览器兼容性

私有字段特性在现代浏览器中有良好的支持,包括:

  • Chrome 74+
  • Firefox 90+
  • Safari 14.1+
  • Edge 79+

在使用前,请确保目标环境支持此特性,或使用Babel等工具进行转译。

总结

JavaScript私有字段是对该语言的重要补充,它们提供了一种标准的方式来实现真正的封装。通过使用#前缀语法,我们可以:

  • 创建私有字段和方法
  • 保护内部数据不被外部直接访问
  • 清晰地区分类的内部API和外部API
  • 增强代码的安全性和可维护性

私有字段的引入使JavaScript的面向对象特性更加完善,也使得编写更加健壮和可维护的代码成为可能。

练习

  1. 创建一个Password类,它使用私有字段存储密码散列,并提供验证密码的方法,但不允许直接访问散列值。

  2. 实现一个Logger类,它使用私有计数器记录日志条目的数量,并提供检索日志条目总数的方法。

  3. 创建一个CachedAPI类,使用私有字段存储缓存内容,并实现缓存失效机制。

扩展资源

通过掌握私有字段,你将能够编写出更加专业、健壮的JavaScript代码,同时更好地遵循面向对象编程的原则。