Javascript 中的 Getter 和 Setter:有什么意义?
GenAI LIVE! | 2025年6月4日
原因
在 FreeCodeCamp 和 Odin 项目中,你经常会看到Thermometer
一些关于 JavaScript 中 getter 和 setter 的入门项目。你肯定知道这个:
class Thermostat{
constructor(fahrenheit){
this.fahrenheit = fahrenheit;
}
get temperature(){
return 5/9 * (this.fahrenheit-32)
}
set temperature(tempInC){
this.fahrenheit = tempInC * 9/5+32
}
}
const thermos = new Thermostat(76); // Setting in Fahrenheit scale
console.log(thermos.temperature); // 24.44 in Celsius
thermos.temperature = 26;
console.log(thermos.temperature); // 26 in Celsius
这真是太棒了。它完全符合我们的要求,为对象的属性定义了一个漂亮的接口。但它很糟糕,因为不仅它是一个暴露的属性,它也是。既然属性本来就是公开的,那么 getter 和 setter 又有什么意义呢?temperature
Thermostat
temperature
fahrenheit
更多原因
我们可以通过使用 ES6 的私有属性来解决这个问题,只需这样做:
class Thermostat{
constructor(fahrenheit){
this.#fahrenheit = fahrenheit;
}
get temperature(){
return 5/9 * (this.#fahrenheit-32)
}
set temperature(tempInC){
this.#fahrenheit = tempInC * 9/5+32
}
}
现在,从外部来看,Thermostat.fahrenheit
它已经不存在了。它是私有财产。感谢 ES6!
然而,我并不赞同。私有属性或方法(以及私有静态属性或方法)感觉就像是对一个根本不存在的问题的敷衍了事的临时解决方案。为什么?因为我们已经有了私有属性。
什么
为数据设置私有“沙盒”并不是什么新鲜事。JavaScript 一直以来都为函数保留私有作用域。如果你对此有所了解,就会看到对闭包的引用。闭包由两个独立的部分组成:
- 私有作用域(包含在函数内)
- 访问该范围内变量的一些方法。
你看,函数执行后,创建私有作用域,设置变量,执行指令,然后悄悄地被垃圾回收器清除。一旦函数中的变量不再被观察,其数据就会被垃圾回收器回收,从而释放内存供其他代码使用。
但我们不必允许这种情况发生。通过返回一些即使在函数执行完成后仍持续观察该函数作用域的内容,我们可以继续维护和更新其中包含的值。
让我们再次看一下该Thermometer
示例,这次使用闭包:
const Thermostat = (fahrenheit) => {
// here, we have the variable fahrenheit.
// completely hidden from the outside world.
// we'll define those same getters and setters
// but note we access the variable, not a property
return {
get temperature(){
return 5/9 * (fahrenheit-32)
},
set temperature(tempInC){
fahrenheit = tempInC * 9/5+32
}
}
}
// note this: we aren't using Thermometer as an
// object constructor, simply as an executed function.
const thermos = Thermostat(76);
// and from here on, it works exactly the same!
console.log(thermos.temperature); // 24.44 in Celsius
thermos.temperature = 26;
console.log(thermos.temperature); // 26 in Celsius
所以我们在闭包中的变量中保存了私有数据。我们定义了一个访问器对象并返回它。这就定义了我们用来访问这些私有数据的接口。
陷阱
再说一次,我在 Odin 项目的 Discord 服务器上回答问题时,每周都会遇到好几次同样的问题。这个问题很棘手,而且并不总是合情合理。想想看:
const TicTacToe = ()=>{
let board = new Array(9).fill("");
let player1 = {name: 'Margaret', icon: 'X'};
let player2 = {name: 'Bert', icon: 'O'};
let currentPlayer = player1;
const switchPlayers = () => {
if(currentPlayer===player1){
currentPlayer=player2;
} else {
currentPlayer=player1;
}
}
// and our return interface:
return {
switchPlayers,
currentPlayer,
board
}
};
// let's make a board!
const game = TicTacToe();
// And let's play a little!
game.board[4] = game.currentPlayer.icon;
console.log(game.board);
// [null, null, null, null, 'X', null, null, null, null]
// switch to player2...
game.switchPlayers();
game.board[0] = game.currentPlayer.icon;
console.log(game.board)
// ['X', null, null, null, 'X', null, null, null, null]
你注意到了吗?我们最后返回的game.board[0]
,我们设置为game.currentPlayer.icon
,是错误的播放器!我们的game.switchPlayers()
不起作用了吗?
事实上确实如此。如果你打开浏览器的开发工具并检查闭包内的变量,你会看到currentPlayer===player2
。但 game.currentPlayer
仍然指的是player1
。
这是因为,当我们在闭包中创建并返回对象时,我们将该变量作为创建时值的静态引用。我们对该原始值进行了快照。然后我们更新该变量,将其指向新的内存位置,但对象属性与变量完全断开了关联!
“是的,但是那怎么办呢game.board
?我们正在更新对象,它正在更新变量,对吗?”
你完全正确。我们确实这样做了game.board[4]='X'
,也就是同时更新变量和返回的对象属性。原因是什么?因为我们修改了那个数组。我们只是在修改它的内部,而没有修改变量和属性的引用。假设我们想重置棋盘,可以这样做:
game.board = new Array(9).fill("");
清除game.board
,一切准备就绪,准备处理其他变量!我们刚才做的是同一个问题的反向操作。我们改变了 的引用game.board
,将其指向内存中的新位置,但变量仍然引用原来的。
嗯,这根本不是我们的本意!
再次问为什么
为什么会发生这种情况?因为我们在某种程度上抛弃了面向对象开发的一个基本原则。主要有三点:
- 封装(我们如何隐藏我们的东西?)
- 沟通(我们如何设置和获取隐藏的东西?)
- 后期实例化*(我们可以在执行时动态地创建新的东西吗?)
我们已经完全掌握了第三点,但前两点却有点儿被践踏了。通过将我们的数据直接暴露在返回的对象上,它不再是封装的,我们的通信也变得值得怀疑。
如何
解决方案?我们创建一个接口并返回它!我们希望能够switchPlayers
,并且能够获取currentPlayer
。我们还希望随时查看的状态,但绝不能直接设置它。我们可能还希望能够在某个时候重置棋盘。board
因此让我们考虑一下界面:
- 对于玩家来说,我们可能希望能够获取他们的名字和图标。差不多就是这样。
- 对于棋盘来说,如果能够获取或设置特定单元格的值、重置棋盘并获取整个棋盘的值,那就太好了。
- 对于游戏,我们如何公开该棋盘(界面,而不是数据),创建 switchPlayers 函数,并使 currentPlayer 成为界面方法,而不是直接公开数据?
差不多就是这样了。我们可以把这个checkForWin
功能添加到棋盘或游戏中,但这与数据封装练习无关。
有了它,我们开始编码吧!
const Player = (name, icon) => {
return {
get name(){ return name; },
get icon(){ return icon; },
}
}
const Board = () => {
let board = new Array(9).fill("");
// .at will be an interface method,
// letting us get and set a board member
const at = (index) => ({
get value(){ return board[index] },
set value(val){ board[index] = val; }
})
const reset = () => board.fill("");
return {
at,
reset,
get value(){ return [...board];}
}
}
const TicTacToe = (player1Name, player2Name)=>{
let board = Board();
let player1 = Player(player1Name, 'X');
let player2 = Player(player2Name, 'O');
let currentPlayer = player1;
const switchPlayers = () => {
if(currentPlayer===player1){
currentPlayer=player2;
} else {
currentPlayer=player1;
}
}
// and our return interface:
return {
switchPlayers,
board,
get currentPlayer(){ return currentPlayer; }
}
};
// now we can:
const game = TicTacToe('Margaret','Bert');
game.board.at(4).value=game.currentPlayer.icon;
console.log(game.board.value);
// ['','','','','X','','','','']
// all good so far, but now:
game.switchPlayers();
game.board.at(0).value=game.currentPlayer.icon;
console.log(game.board.value);
// ['O','','','','X','','','','']
太棒了!现在,因为我们不直接操作数据,所以我们可以通过一个干净、一致的接口来操作数据。如果我们使用board
接口方法,我们始终引用内部状态数据,而不是暴露的引用点。
现在,这里有一个严重的问题需要考虑。如果我们这样做,会发生什么?
game.board = new Array(9).fill('');
board
这样一来,我们又一次切断了内部变量和暴露接口之间的联系。我们什么board
都没解决!
嗯,我们有了,但还差一步。我们需要保护我们的数据。所以,对所有工厂方法进行一些小改动:
const Player = (name, icon) => {
return Object.freeze({
get name(){ return name; },
get icon(){ return icon; },
});
};
const Board = () => {
// all the same code here...
return Object.freeze({
at,
reset,
get value(){ return [...board];}
});
};
const TicTacToe = (player1Name, player2Name)=>{
// all this stays the same...
return Object.freeze({
switchPlayers,
board,
get currentPlayer(){ return currentPlayer; }
});
};
通过应用于Object.freeze()
每个工厂返回的对象,我们可以防止它们被覆盖或意外添加方法。此外,我们的 getter 方法(例如board.value
)是真正只读的。
回顾
因此,出于多种原因,我认为在工厂环境中使用 getter 和 setter 非常合理。首先,它们是与真正私有变量交互的对象方法,因此具有特权。其次,只需定义一个 getter,我们就可以快速轻松地定义只读属性,这又回到了实体接口的本质。
关于 getter 和 setter,我非常喜欢另外两个不太明显的点:
-
当我们
Object.freeze()
使用对象时,该对象上的任何原始数据都是 immutable 的。这确实很有用,但是我们暴露的 setter 呢?是的,它们仍然有效。它们是方法,而不是原始数据。 -
但是,当我们 时
typeof game.board.at
,我们会被告知它是 类型的数据function
。当我们 时typeof game.board.at(0).value
,我们会被告知它是 类型的数据string
。即使我们知道它是一个函数!
第二点非常有用,但经常被忽视。为什么?因为当我们 时JSON.stringify(game)
,它的所有function
元素都将被删除。JSON.stringify()
爬取一个对象,丢弃所有函数,然后将嵌套对象或数组转换为字符串。所以,如果我们这样做:
json.stringify(game);
/****
* we get this:
*
*{
* "board": {
* "value": [
* "O",
* "",
* "",
* "",
* "X",
* "",
* "",
* "",
* ""
* ]
* },
* "currentPlayer": {
* "name": "Bert",
* "icon": "O"
* }
*}
****/
这听起来可能有点傻——但它的意思是,有了定义明确的 getter,我们可以为对象提供可保存的状态。由此,我们可以重新创建大部分game
后续操作。我们可能想添加一个players
getter,得到一个包含玩家本身的数组,但重点依然是……getter 和 setter 比我们乍一看的要有用得多!