备忘录模式是个挺实在的设计模式,它干的事情很简单:把对象内部的状态拍个快照存起来,等你想回退的时候,再把这个快照恢复回去。听起来很完美,但只要一用到编辑器撤销栈和游戏存档这种真实场景,你就会发现快照拍得越细,内存越是不堪重负。这篇文章不扯虚的,咱们就围绕这个矛盾,用生活里的例子把它讲透,看看怎么在“存得够细”和“存得省内存”之间找一个平衡点。
一、先搞懂备忘录模式到底在干什么
拿玩单机游戏来说,你辛辛苦苦打到第二个大关,血条不多了,弹药也快光了。这时候你找了个安全屋,把游戏进度存了个档。等后面不小心挂了,你读档,瞬间回到存那个档那一刻的状态,角色还是那个位置,弹药还是那么多。这就是备忘录模式最朴素的用法:把状态“路过”一下,存在外面,等需要时光倒流的时候再拿回来。
在代码里,这个模式一般有三个角色。一个是“发起人”,就是那个有内部状态、需要被保存的对象,比如一个编辑器文档;一个是“备忘录”,装状态的盒子,它可能就是一坨数据,不暴露具体逻辑;还有一个是“管理者”,负责保存备忘录,比如撤销栈,它只关心备忘录放进去和取出来,不关心里面是什么。这就像你把东西寄存在火车站储物柜,柜子管理者只知道柜号,不知道里面装的是衣服还是食物。
1.1 一个最简单的备忘录长啥样
我们下面所有例子都用 JavaScript。先写一个最基础的备忘录实现,看看它的骨架是什么。这里用一个“玩家角色”当发起人,它只有血量和坐标两个状态。
// 技术栈:JavaScript(Node.js 12+ 环境,直接运行即可)
class Player {
constructor(hp, x, y) {
this.hp = hp; // 血量
this.x = x; // x坐标
this.y = y; // y坐标
}
// 少血,模拟战斗受伤
takeDamage(damage) {
this.hp = Math.max(0, this.hp - damage);
}
// 移动角色
move(newX, newY) {
this.x = newX;
this.y = newY;
}
// 创建快照(备忘录)
createSnapshot() {
// 返回一个对象,把需要保存的状态拷一份
return {
hp: this.hp,
x: this.x,
y: this.y,
};
}
// 从快照恢复
restore(snapshot) {
this.hp = snapshot.hp;
this.x = snapshot.x;
this.y = snapshot.y;
}
// 打印当前状态,方便观察
describe() {
console.log(`当前玩家状态:血量=${this.hp}, 位置=(${this.x}, ${this.y})`);
}
}
// 管理者:简单保存一个存档
class SaveManager {
constructor() {
this.savePoints = []; // 存档列表
}
save(snapshot) {
this.savePoints.push(snapshot); // 放到列表里
}
loadLast() {
return this.savePoints.pop(); // 取出最近一个存档
}
}
// 模拟使用过程
const player = new Player(100, 0, 0);
player.describe(); // 初始状态
const saveManager = new SaveManager();
saveManager.save(player.createSnapshot()); // 在安全屋存个档
player.takeDamage(30);
player.move(50, 20);
player.describe(); // 受伤+移动后的状态
// 角色挂了,读档
const lastSnapshot = saveManager.loadLast();
player.restore(lastSnapshot);
player.describe(); // 恢复到了存档那一刻
这个例子极简,但已经把核心思想表达清楚了:createSnapshot 把当前状态复制一份,restore 把状态倒回去。注意,这里直接返回了一个对象,意味着里面的值都是基本类型,所以复制是干净的。如果状态里还有嵌套对象或者数组,就得做深拷贝,不然存的是引用,后面改来改去,快照就跟着变了。
二、编辑器撤销栈:快照粒度怎么选
编辑器里的撤销功能,是备忘录模式的经典应用。你敲一行字,撤销一步;再敲一行,又撤销一步。但这里有个问题:快照粒度到底是按“每一个字符”存,还是按“每一次回车”存,还是按“每一次连续输入停顿”存?粒度太细,比如每敲一个键就存一次快照,内存会爆炸;粒度太粗,比如每十分钟存一次,那你想撤销到刚才打错的一个字都做不到。
2.1 按操作分组存快照
实际生产中,我们通常会做一个“操作合并”的动作。比如把用户在短时间内连续输入的字符当成一次操作,存一个快照。这样撤销一下,会撤销这一段连续的输入。这就是粒度选择。
我们写一个迷你文本编辑器,它有撤销功能,但快照不是每个字符都存,而是“输入空闲300毫秒以上”后的暂存点。这里用定时器来模拟“停顿”。
// 技术栈:JavaScript(浏览器环境,但不需要DOM,用Node运行即可)
class TextEditor {
constructor() {
this.content = ''; // 编辑器里的文本
this.undoStack = []; // 撤销栈,存快照
this.typingTimer = null; // 输入停顿计时器
this.lastSnapshotTime = 0; // 上次生成快照的时刻
}
// 生成当前文本快照
_snapshot() {
// 存字符串引用即可,字符串不可变,天然防止修改影响快照
return { text: this.content, timestamp: Date.now() };
}
// 压入快照,这里做了一些合并逻辑
_pushSnapshotIfNeeded() {
const now = Date.now();
// 如果距离上次快照超过300ms,就生成新快照
// 如果当前和上次快照文本相同,则不生成
if (now - this.lastSnapshotTime > 300 && this.content !== (this.undoStack[this.undoStack.length - 1]?.text ?? '')) {
this.undoStack.push(this._snapshot());
console.log('生成一次快照,当前撤销栈长度:', this.undoStack.length);
this.lastSnapshotTime = now;
}
}
// 模拟输入一个字符
type(text) {
console.log(`输入:${text}`);
this.content += text;
// 每次输入都重置计时器
clearTimeout(this.typingTimer);
this.typingTimer = setTimeout(() => {
this._pushSnapshotIfNeeded();
}, 300);
}
// 撤销一次
undo() {
const snapshot = this.undoStack.pop();
if (snapshot) {
this.content = snapshot.text;
console.log('撤销操作,当前文本:', this.content);
} else {
console.log('没有可撤销的快照');
}
}
// 显示当前文本
show() {
console.log('编辑器文本:', this.content);
}
}
// 使用演示
const editor = new TextEditor();
editor.type('今');
editor.type('天');
editor.type('天');
editor.type('气');
editor.type('不');
editor.type('错');
// 模拟停顿400ms,触发快照
setTimeout(() => {
editor.type(',');
editor.type('我');
editor.type('们');
editor.type('出');
editor.type('去');
editor.type('玩');
// 再停顿,触发第二个快照
setTimeout(() => {
console.log('--- 开始撤销 ---');
editor.undo(); // 应该撤销掉“,我们出去玩”
editor.show();
editor.undo(); // 应该撤销掉“今天天气不错”
editor.show();
editor.undo(); // 没有更多快照
}, 400);
}, 400);
这个例子里的 type 方法每次输入都会推迟计时器,只有在用户停下超过300毫秒时,才会真正把当前文本存成快照。所以即使你连续输入了十个字,就算一个快照,撤销的时候直接回到这十个字之前。这就是“粒度”的取舍:牺牲一点撤销精度,换来了内存占用的大幅降低。试想一下,如果每敲一个字母都存一个快照,一个几万字的文档,撤销栈里就得有几十万个字符串,那内存肯定扛不住。
2.2 内存里的坑:引用类型快照
刚才我们存的是字符串,字符串是不可变对象,所以它作为快照很安全。但如果对象的属性是数组或普通对象,直接把引用存进快照里,后续你修改了原始对象的这个属性,快照里的内容也会跟着变,因为它们指向同一个内存地址。
看看下面这个错误案例:
// 技术栈:JavaScript(Node.js 环境)
class BadExample {
constructor() {
this.items = ['苹果', '香蕉'];
}
createSnapshot() {
// 这个快照有危险!
return { items: this.items }; // 直接引用原数组
}
restore(snapshot) {
this.items = snapshot.items;
}
addItem(item) {
this.items.push(item); // 修改原数组
}
}
const bad = new BadExample();
const snapshot = bad.createSnapshot();
bad.addItem('西瓜');
console.log(snapshot.items); // 输出 ['苹果', '香蕉', '西瓜'] —— 快照被污染了!
所以,对于可变结构,生成快照时必须做深拷贝或者至少拷贝一层。深拷贝有几种常用方式:JSON.parse(JSON.stringify(obj)) 最简单,但要保证对象里没有函数、undefined、Date 之类的特殊类型;也可以用 structuredClone 处理更复杂的场景;或者自己写循环复制。不光是备忘录模式,任何需要保存历史状态的场景都要注意引用共享问题。
三、游戏存档:全量快照太重了怎么办
游戏里的存档比文本编辑器更吃内存,因为一份完整存档里包含了地图上的每个 NPC 坐标、每个掉落物的状态、玩家背包里几十件物品、任务进度、血量蓝量饥饿值,等等。如果每次都存全量快照,存档一次就是好几 MB,存几十个槽位就几百 MB,游戏直接卡死。
3.1 游戏里的全量存档
我们先按最简单的思路模拟一个游戏存档系统。有一个 GameWorld 对象,包含玩家位置、NPC 位置、物品列表。每次存档,就把整个世界的状态深拷贝一份。
// 技术栈:JavaScript(Node.js 环境)
class GameWorld {
constructor() {
this.player = { hp: 100, x: 0, y: 0 };
this.npcs = [
{ name: '村长', x: 10, y: 10 },
{ name: '铁匠', x: 20, y: 30 },
];
this.items = [
{ name: '药草', count: 5 },
{ name: '铁剑', count: 1 },
];
}
// 生成全量快照
createSnapshot() {
console.log('正在生成全量快照……');
// 用 JSON 深拷贝一份
return JSON.parse(JSON.stringify({
player: this.player,
npcs: this.npcs,
items: this.items,
}));
}
// 恢复快照
restore(snapshot) {
this.player = snapshot.player;
this.npcs = snapshot.npcs;
this.items = snapshot.items;
console.log('读取存档完成');
}
// 改变世界状态
updateWorld() {
this.player.hp -= 10;
this.player.x += 5;
this.npcs[0].x += 1;
this.items[0].count -= 1;
}
}
// 存档管理:最多存3个
class ArchiveManager {
constructor(maxSlots) {
this.slots = []; // 存档槽位
this.maxSlots = maxSlots;
}
save(world) {
if (this.slots.length >= this.maxSlots) {
this.slots.shift(); // 满的话,删掉最老的
console.log('存档槽位已满,删除最老的存档');
}
this.slots.push(world.createSnapshot());
console.log(`已存档,当前槽位数量:${this.slots.length}`);
}
load(index) {
return this.slots[index];
}
}
// 使用演示
const world = new GameWorld();
const archive = new ArchiveManager(3);
archive.save(world); // 存档1
world.updateWorld();
world.updateWorld();
archive.save(world); // 存档2
world.updateWorld();
archive.save(world); // 存档3
console.log('\n玩家当前血量:', world.player.hp);
const oldSave = archive.load(1);
world.restore(oldSave);
console.log('读取第二个存档后,玩家血量:', world.player.hp);
这看起来没问题,但问题的根源在于:游戏越复杂,GameWorld 里的对象越多,一次全量快照的成本奇高。高频自动存档的时候,比如每五分钟自动存一次,内存和 CPU 都会被拉爆。
3.2 增量快照:只保存变化
矛盾的核心在这里:同一个游戏世界里,相邻两次存档之间,变化的往往只有一小部分。比如玩家从 A 点走到 B 点,NPC 可能只移动了半步,物品可能只少了一个。全量快照把那些没变化的数据反复复制,纯属浪费。那怎么办?我们可以保存“增量”——只记录相对于上一次存档的变化。
实现增量存档没那么复杂,我们可以用一个“公共状态池”,每个存档节点只记录一个“差异”。读档的时候,从某个基础节点开始,应用对应的差异,就能复原当时的状态。
下面用一个简化示例演示增量存档。假设世界就三个字段:玩家坐标、血量、背包里药草数量。我们只把变化了的字段记录进快照,没变化的字段不存。
// 技术栈:JavaScript(Node.js 环境)
class WorldState {
constructor() {
this.x = 0;
this.y = 0;
this.hp = 100;
this.herbs = 10; // 药草数量
}
// 返回当前状态作为一个基础参照对象(深拷贝)
state() {
return {
x: this.x,
y: this.y,
hp: this.hp,
herbs: this.herbs,
};
}
// 生成相对于 base 的增量快照
// 传入上一个状态对象,返回差异部分
createDelta(previousState) {
const current = this.state();
const delta = {};
// 对比每个字段,不同的才记录
if (previousState.x !== current.x) delta.x = current.x;
if (previousState.y !== current.y) delta.y = current.y;
if (previousState.hp !== current.hp) delta.hp = current.hp;
if (previousState.herbs !== current.herbs) delta.herbs = current.herbs;
return delta;
}
// 用基础状态+增量,恢复出新的状态
applyDelta(base, delta) {
return { ...base, ...delta };
}
}
// 存档管理者,保存一系列增量,同时保留最初始的基础状态
class IncrementalArchive {
constructor() {
this.baseState = null; // 最原始状态
this.deltas = []; // 增量列表,下标0对应第一次存档后的增量
}
// 第一次使用时,保存基础状态
initIfNeeded(state) {
if (!this.baseState) {
this.baseState = { ...state };
}
}
addDelta(delta) {
this.deltas.push(delta);
}
// 通过基础状态和增量,重建第 n 个存档对应的完整状态
rebuild(index) {
let result = { ...this.baseState };
for (let i = 0; i <= index; i++) {
result = { ...result, ...this.deltas[i] };
}
return result;
}
}
// 演示增量存档
const world = new WorldState();
const archive = new IncrementalArchive();
archive.initIfNeeded(world.state()); // 把初始状态存为基础
// 第一次变化:玩家向右走5格,血量下降10,药草没变
world.x = 5;
world.hp = 90;
archive.addDelta(world.createDelta(world.state())); // 注意:这里写错了,需要传入上一个状态
// 纠正:上面这个示例是故意让你看到问题,我们先修正一下。
等一下,刚刚上面那段代码我故意埋了个错误,createDelta 需要传入“上一个状态”,但我在变化之后才调用 world.state(),那拿到的就是变化后的自己,对比不出来差异。正确用法是:在发生状态变化之前,先保存一个“参照状态”。我重新写一个干净的版本。
// 技术栈:JavaScript(Node.js 环境)
class WorldState {
constructor() {
this.x = 0;
this.y = 0;
this.hp = 100;
this.herbs = 10;
}
state() {
return {
x: this.x,
y: this.y,
hp: this.hp,
herbs: this.herbs,
};
}
// 对比当前状态和 previousState,返回差异
createDelta(previousState) {
const current = this.state();
const delta = {};
if (previousState.x !== current.x) delta.x = current.x;
if (previousState.y !== current.y) delta.y = current.y;
if (previousState.hp !== current.hp) delta.hp = current.hp;
if (previousState.herbs !== current.herbs) delta.herbs = current.herbs;
return delta;
}
}
class IncrementalArchive {
constructor() {
this.baseState = null;
this.deltas = [];
this.lastState = null; // 记住上一次完整状态,用于下一次生成增量
}
// 初始化:记录基础状态和 lastState
init(state) {
this.baseState = { ...state };
this.lastState = { ...state };
}
// 保存一次增量:传入当前世界,用 lastState 做基准
save(world) {
const delta = world.createDelta(this.lastState);
this.deltas.push(delta);
this.lastState = world.state(); // 更新 lastState
console.log('生成的增量:', delta);
}
// 重建索引为 index 的存档完整状态
rebuild(index) {
let result = { ...this.baseState };
for (let i = 0; i <= index; i++) {
result = { ...result, ...this.deltas[i] };
}
return result;
}
}
// 演示过程
const world = new WorldState();
const archive = new IncrementalArchive();
archive.init(world.state());
// 第一次变化
world.x = 5;
world.hp = 90;
archive.save(world);
// 第二次变化
world.y = 3;
world.herbs = 7;
archive.save(world);
// 第三次变化
world.hp = 50;
archive.save(world);
console.log('\n重建第0个存档:', archive.rebuild(0));
console.log('重建第1个存档:', archive.rebuild(1));
console.log('重建第2个存档:', archive.rebuild(2));
你看,每次保存的增量很小,第三次存档只记录了 hp 变化,其他没变的数据根本不进快照。如果游戏里几百个对象都没变,只变了一个玩家的血量,那增量数据就是几个字节,和全量快照差了几个数量级。不过增量模式也不是白来的好处,它有个代价:读档时必须从基础状态开始,把所有增量按顺序应用一遍。如果是第 100 个存档,就得把 100 个增量全部重放,读档速度会比全量慢一些。这就引出了另一个优化思路:定期做一次“全量快照”作为新的基准,之前的增量就可以清理掉,这样既控制了重放距离,又降低了内存。
四、更多缓解矛盾的实用招
除了上面说到的分组合并快照和增量存档,日常开发中还有很多办法可以缓解快照粒度与内存之间的矛盾。
4.1 限制快照数量与槽位
就像游戏里存档槽位只有几个一样,编辑器的撤销栈也可以限制长度。比如只保留最近 100 步,超过就把最老的快照丢掉。这能保证内存开销有上限。JavaScript 里实现很简单,用数组配合 shift() 或维护头尾指针。
// 技术栈:JavaScript(Node.js)
class LimitedUndoStack {
constructor(limit) {
this.stack = [];
this.limit = limit;
}
push(snapshot) {
this.stack.push(snapshot);
// 超出上限,丢掉最老的
if (this.stack.length > this.limit) {
this.stack.shift();
}
}
pop() {
return this.stack.pop();
}
size() {
return this.stack.length;
}
}
const stack = new LimitedUndoStack(3);
stack.push('状态1');
stack.push('状态2');
stack.push('状态3');
stack.push('状态4');
console.log('当前栈长度:', stack.size()); // 3
console.log('最老的快照:', stack.stack[0]); // 状态2
4.2 命令模式代替完整状态快照
有时候我们根本不需要存整个对象状态,只需要存“怎么撤销这个操作”。比如编辑器里“插入文字”的操作,撤销动作就是“删除这些文字”;“删除文字”的操作,撤销动作就是“重新插入这些文字”。这种思路叫做命令模式,它和备忘录模式相辅相成。命令模式保存的是操作步骤,不是完整快照,所以内存占用极小。
实现一个简单的“可撤销命令”:
// 技术栈:JavaScript(Node.js)
class DocEditor {
constructor() {
this.doc = '';
}
execute(command) {
command.execute();
this.undoStack.push(command); // 保存可撤销命令
}
undo() {
const command = this.undoStack.pop();
if (command) {
command.undo();
}
}
}
class InsertCommand {
constructor(docEditor, index, text) {
this.editor = docEditor; // 目标的编辑器
this.index = index; // 插入位置
this.text = text; // 插入的文本
}
execute() {
// 在指定位置插入文本,这是一次真正的修改
this.editor.doc =
this.editor.doc.slice(0, this.index) + this.text + this.editor.doc.slice(this.index);
}
undo() {
// 撤销:删除这段文本
this.editor.doc =
this.editor.doc.slice(0, this.index) + this.editor.doc.slice(this.index + this.text.length);
}
}
const editor = new DocEditor();
editor.execute(new InsertCommand(editor, 0, '你好'));
editor.execute(new InsertCommand(editor, 2, '世界'));
console.log(editor.doc); // 你好世界
editor.undo();
console.log(editor.doc); // 你好
editor.undo();
console.log(editor.doc); // (空字符串)
命令对象本身只占一个小对象的内存,并且可以无限多,但不会因为文档变大而膨胀。需要提醒一下,命令模式更适合那种操作可以被精确反转的场景,如果操作过程很复杂,比如搜索替换时涉及很多不可预测的修改,那还是备忘录模式稳妥。
4.3 序列化与压缩快照
如果实在需要保存全量快照,可以先把对象序列化成压缩格式再存。比如游戏里用二进制格式保存地图数据,要比 JSON 节省一半以上空间。这个方向可以跟“存档文件”结合,把快照压缩到硬盘而不是内存中。对于编辑器来说,也可以把旧的撤销快照存储到磁盘上,内存里只保留最近几步。
五、技术优缺点与注意事项
备忘录模式本身最大的优点是:它隔离了状态保存和业务逻辑,发起人不关心管理者怎么处理快照,管理者也不关心快照内容的含义。这带来很高的灵活性。它的缺点也明显:首先,快照的生成和恢复都需要成本,频繁快照会拖慢程序流畅性;其次,快照往往难以共享,造成大量重复数据,内存开销大;再者,深拷贝问题需要谨慎处理,否则会出现快照被后期修改污染的情况。
在实际使用备忘录模式时,有这几个坑要特别留意:
- 深拷贝的正确性:快照里如果包含对象引用,一定要做合理的克隆。最简单用
JSON.parse(JSON.stringify(...)),但注意它会把undefined删掉,会丢失Date类型,也可能把方法丢掉。更稳妥的方案是使用structuredClone或者自己写递归复制。 - 大对象快照频率:不要做一个方法就存一次快照,那是灾难。每次操作结束后,可以考虑用一个短时间的“合并窗口”,类似前面编辑器那个 300ms 停顿方案,把连续操作合并为一次快照。
- 撤销栈的长度:并不是无限撤销才是好功能,大部分产品里限制 50-100 步就足够。超出范围的步骤直接丢掉,内存有上限,性能更稳定。
- 增量快照的基础版本管理:增量方案必须有一个基础全量快照,并且定期刷新基础快照,否则增量链条太长,读档时重放会有严重延迟。
- 快照不可变性:一旦生成快照,就应该保证它不会被后续任何操作修改。你可以用
Object.freeze来冻结快照对象,也可以把快照存储在只读结构里。 - 注意内存泄漏:旧快照可能在栈里持有大量对象引用,如果用完不清除,GC 无法回收。记得在删除存档的时候把引用断开。
六、文章总结
回到开头的问题:备忘录模式捕获并外部化对象内部状态之后,在编辑器撤销栈和游戏存档里,快照粒度与内存消耗的矛盾,本质上是用“细节”换“成本”的问题。你存得越细,能撤回的步子越小,但内存占用越大;你存得越粗,内存省了,但没法做到精确回退。
破解这个矛盾的手段并不是“二选一”,而是混搭。对文本编辑器,我们可以用输入停顿合并粒度;对游戏存档,我们可以用增量快照或者定期全量快照加增量记录;对可以精确反转的操作,可以用命令模式完全绕开快照。再配合限制撤销栈长度、序列化压缩、快照不可变性等技术手段,就能在实用性和内存消耗之间找到一个自己满意的平衡点。
最后说一句,设计模式不是教条,备忘录模式也好,命令模式也好,都是你工具箱里的工具。理解它背后的取舍,然后在合适的地方灵活运用,这比背下来一百种 UML 类图都更有价值。
评论
围绕“备忘录模式捕获并外部化对象内部状态,在编辑器撤销栈与游戏存档实现中,快照粒度与内存消耗的矛盾怎么解?”参与讨论