news 2026/8/29 10:30:43

设计模式实战:用观察者、策略、命令模式构建可扩展JavaScript计数器

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
设计模式实战:用观察者、策略、命令模式构建可扩展JavaScript计数器

1. 项目概述:从计数器看设计模式的实战价值

最近在重温《设计模式》这本书,发现很多朋友,包括当年的我自己,都有一个共同的困惑:书里的例子,比如那个经典的“鸭子模拟器”,虽然道理都懂,但总觉得离自己手头的业务代码有点远。抽象的概念记住了,一到实战就不知道怎么用。今天,我们不谈鸭子,我们来聊一个更接地气、几乎每个前端开发者都写过的东西——计数器

没错,就是一个简单的、能加能减能重置的数字计数器。你可能觉得这太简单了,几行代码就能搞定,跟“设计模式”这种听起来高大上的东西有什么关系?这正是我想分享的核心:设计模式的价值,恰恰在于用看似“过度设计”的方式,去解决简单代码在演化过程中必然会遇到的“复杂问题”。一个孤立的计数器按钮,确实不需要模式。但当这个计数器需要被多个视图同步显示、需要记录历史操作、需要根据不同的业务规则进行不同的计数行为、或者需要轻松切换数据存储方式时,原始的写法就会迅速变得难以维护。

我们这次的目标,就是用一个纯粹的JavaScript环境,不依赖任何框架(React, Vue, Angular),从零开始,用面向对象的思想,重构这个计数器。我们会看到,如何通过应用几个经典的设计模式,让这个简单的功能模块变得职责清晰、易于扩展、高度解耦。无论你是想巩固面向对象基础,还是想真正理解设计模式如何落地,这个例子都是一个绝佳的切入点。你会发现,模式不是枷锁,而是让代码在需求变化面前保持优雅和韧性的工具箱。

2. 核心思路:为什么计数器需要设计模式?

在动手写代码之前,我们必须先想清楚为什么要这么做。直接写一个全局变量let count = 0,然后绑定三个按钮的onclick事件,分别进行count++count--count = 0, 最后更新一个span标签的innerText。这可能是99%的初学者会写出的版本,功能完全正确。

但让我们设想几个非常真实的业务场景:

  1. 多视图同步:页面上不止一个地方显示这个计数值(比如顶部导航栏有个徽章,侧边栏有个统计面板,主内容区有个大数字)。每次操作后,你需要手动找到所有显示元素并更新它们。漏掉一个就是Bug。
  2. 操作历史与撤销:产品经理说需要“撤销”功能,用户可以回退到之前的计数状态。你的count变量只保存当前值,历史数据丢了。
  3. 业务规则复杂化:计数器不再是简单的加减。比如,VIP用户每次加10,普通用户加1;或者计数达到100时自动触发一个抽奖活动;又或者,需要支持“双击快速加5”等复合操作。
  4. 状态持久化:刷新页面后,计数不能丢失。你需要把count存到localStorageIndexedDB或者发送到后端服务器。数据存储的逻辑和业务逻辑搅在一起。
  5. 单元测试:你想测试“加”这个操作是否正确地更新了状态和视图。但视图操作(DOM更新)和状态变更紧密耦合,很难进行隔离测试。

面对这些场景,最初的“面条代码”会迅速演变成一堆难以阅读和维护的if...else和散落在各处的document.querySelector。其根本问题在于缺乏抽象职责混乱。状态管理、视图渲染、用户交互、业务规则、数据持久化全部揉成一团。

设计模式提供了一套经过验证的抽象方法。对于计数器,我们可以将其核心抽象为一个状态模型(Model),它只关心数字本身以及改变数字的规则。而视图(View)负责展示这个数字。两者之间通过一种通知机制(Observer Pattern)来同步,而不是直接互相调用。这样,无论增加多少种视图,模型都无需修改;无论模型的数据存储方式如何变化(从内存到本地存储),视图也无需关心。这就是我们重构的核心理念:基于观察者模式的模型-视图分离

3. 模式选型与架构设计

基于上述思路,我们为这个计数器项目选择并组合使用三个经典的设计模式,它们将共同构成一个清晰、稳固的架构。

3.1 观察者模式:实现模型与视图的解耦

这是整个架构的“脊柱”。观察者模式定义了一种一对多的依赖关系,当一个对象(主题,Subject)的状态发生改变时,所有依赖于它的对象(观察者,Observers)都会得到通知并自动更新。

在我们的计数器里:

  • 主题(Subject):就是我们的计数器模型(CounterModel)。它持有状态(计数值)。
  • 观察者(Observer):就是各个需要显示计数值的视图组件(CounterView),可能是一个数字显示框,一个进度条,或者一个图表。

CounterModel不需要知道具体有哪些CounterView,它只需要维护一个观察者列表,并在自己的状态(count)改变时,调用一个通用的notify方法,遍历列表,告诉每个观察者:“我变了,这是新值”。每个CounterView在接收到通知后,自己决定如何用这个新值去更新DOM。

这样做的好处是巨大的:我们可以动态地添加或移除视图(比如在某个条件下隐藏统计面板),而模型代码纹丝不动。视图和模型的修改可以独立进行,符合开放-封闭原则

3.2 策略模式:封装可互换的计数算法

虽然基础计数器只有加、减、重置,但我们可以预见未来可能会有不同的计数策略。比如,一个“安全计数器”在减到负数时抛出警告,一个“循环计数器”在达到最大值后归零。如果我们用if (mode === ‘safe’) {...} else if (mode === ‘circular’) {...}写在模型内部,代码会变得臃肿且难以增加新策略。

策略模式定义了一系列算法,并将每个算法封装起来,使它们可以相互替换。策略模式让算法的变化独立于使用它的客户端。

我们将创建一个CountStrategy接口(在JS中通常用一个函数或一个包含特定方法的对象来模拟),然后实现BasicCountStrategySafeCountStrategy等具体策略。CounterModel并不直接实现加减逻辑,而是持有一个CountStrategy的引用。当需要执行操作时,它把当前值和操作类型(‘increment‘, ’decrement‘)委托给当前策略对象去计算。这样,要增加一种新的计数规则,我们只需要新增一个策略类,然后在需要的时候注入到模型里即可,完全不用修改模型和视图的既有代码。

3.3 命令模式:实现操作历史与撤销

撤销/重做是一个经典需求。要实现它,我们需要将每次操作(如“加一”)封装成一个对象。命令模式就是将“请求”封装成对象,从而允许你用不同的请求对客户进行参数化,支持请求的排队、记录日志,以及可撤销的操作。

我们将创建一个Command接口,它可能有execute()undo()方法。具体的IncrementCommandDecrementCommand就封装了如何执行加一、以及如何撤销这个加一(即减一)。CounterModel不再直接修改count,而是接收一个Command对象并调用其execute()方法。同时,模型可以维护一个历史栈(commandHistory),每次执行命令后将其入栈。当用户点击“撤销”时,就从栈顶取出命令,调用其undo()方法,然后再出栈。

这不仅实现了撤销功能,还带来了额外好处:所有对模型状态的修改,现在都有了一个明确的、可记录的“意图”对象。这对于调试、日志记录、甚至实现宏命令(一键执行多个操作)都提供了可能。

架构总览:最终,我们的计数器将由以下核心类构成:

  1. CounterModel:继承自Subject,持有count状态和当前CountStrategy,接收并执行Command,维护命令历史,并在状态变更时通知所有视图。
  2. CounterView:实现Observer接口,在收到通知后更新特定的DOM元素。
  3. BasicCountStrategy/SafeCountStrategy:实现具体的计数算法。
  4. IncrementCommand/DecrementCommand/ResetCommand:封装具体的操作和撤销逻辑。
  5. 一个顶层的App或初始化脚本,负责组装这些对象:创建模型、创建视图并将视图注册到模型、创建按钮并将点击事件绑定到创建和执行相应的命令。

这个架构看起来比直接写count++复杂得多,但它为应对所有前述的复杂场景打下了坚实的基础,并且每个类的职责都非常单一,易于理解和测试。

4. 核心实现:从零构建模式化的计数器

理论说得再多,不如一行代码。我们现在就抛开任何框架,用纯ES6+的JavaScript来实现上面设计的架构。我会先给出关键代码片段,并解释其背后的意图。

4.1 实现观察者模式基础类

首先,我们需要实现观察者模式的通用部分。这通常包含两个角色:Subject(主题/被观察者)和Observer(观察者)。在JavaScript中,我们可以用类来模拟。

// 观察者接口(在JS中通常约定一个特定方法,如 `update`) class Observer { update(data) { throw new Error('子类必须实现 update 方法'); } } // 主题基类 class Subject { constructor() { this.observers = new Set(); // 使用Set避免重复注册 } // 注册观察者 attach(observer) { if (!(observer instanceof Observer)) { throw new TypeError('观察者必须实现 Observer 接口'); } this.observers.add(observer); console.log(`观察者已注册,当前总数:${this.observers.size}`); } // 移除观察者 detach(observer) { const deleted = this.observers.delete(observer); if (deleted) { console.log(`观察者已移除,当前总数:${this.observers.size}`); } } // 通知所有观察者 notify(data) { console.log(`主题状态变更,正在通知 ${this.observers.size} 个观察者...`); for (const observer of this.observers) { // 异步通知,避免某个观察者的错误阻塞其他观察者 Promise.resolve().then(() => { try { observer.update(data); } catch (error) { console.error('通知观察者时发生错误:', error, observer); } }); } } }

实现要点

  • 这里用Set存储观察者,保证了唯一性。
  • attachdetach方法提供了动态管理观察者的能力。
  • notify方法采用异步通知(Promise.resolve().then),这是一个重要的实践技巧。它确保了即使某个视图更新时抛出异常(比如DOM操作错误),也不会影响其他视图的更新流程,提高了系统的健壮性。同时,将通知逻辑包裹在try...catch中,便于错误定位。
  • Observer基类中抛出错误,强制子类实现update方法,这是一种接口的模拟。

4.2 实现计数器模型

CounterModel是我们的核心,它继承自Subject,管理状态,并处理命令。

class CounterModel extends Subject { constructor(initialCount = 0, strategy = new BasicCountStrategy()) { super(); // 调用父类Subject的构造函数 this._count = initialCount; this._strategy = strategy; this._history = []; // 命令历史栈 this._historyIndex = -1; // 当前历史指针 console.log(`计数器模型初始化,初始值:${this._count}`); } get count() { return this._count; } set count(value) { if (this._count !== value) { this._count = value; // 状态改变,通知所有观察者 this.notify({ count: this._count }); } } get strategy() { return this._strategy; } set strategy(newStrategy) { if (this._strategy !== newStrategy) { this._strategy = newStrategy; console.log('计数策略已切换为:', newStrategy.constructor.name); // 策略切换可能影响当前值的显示逻辑(比如安全策略下负数无效),可以选择性地通知一次 // this.notify({ count: this._count }); } } // 执行命令 executeCommand(command) { // 执行新命令前,清空“历史指针”之后的历史(即重做分支) if (this._historyIndex < this._history.length - 1) { this._history.splice(this._historyIndex + 1); } command.execute(this); // 命令执行,会修改 this.count this._history.push(command); this._historyIndex++; console.log(`命令执行成功,历史记录数:${this._history.length}, 当前指针:${this._historyIndex}`); } // 撤销 undo() { if (this._historyIndex >= 0) { const command = this._history[this._historyIndex]; command.undo(this); // 命令撤销 this._historyIndex--; console.log(`撤销成功,当前指针:${this._historyIndex}`); } else { console.warn('没有更多历史可以撤销'); } } // 重做 redo() { if (this._historyIndex < this._history.length - 1) { this._historyIndex++; const command = this._history[this._historyIndex]; command.execute(this); // 重新执行命令 console.log(`重做成功,当前指针:${this._historyIndex}`); } else { console.warn('没有更多历史可以重做'); } } // 直接操作(不经过命令历史,用于初始化或特定场景) setCountDirectly(newCount) { this.count = newCount; } }

关键设计解析

  1. 状态管理count被设置为getter/setter。在setter中,我们比较新旧值,只有真正发生变化时才调用this.notify()。这避免了不必要的视图渲染,是性能优化的小细节。
  2. 命令历史_history数组和_historyIndex指针共同实现了经典的“撤销栈”。executeCommand方法在添加新命令时,会清空当前指针之后的历史,这是大多数编辑器的标准行为(执行新操作后,重做分支被丢弃)。
  3. 策略注入:通过strategysetter,我们可以在运行时动态切换计数策略,模型内部的其他代码完全不受影响。

4.3 实现策略模式

策略是独立的算法对象。我们先定义策略接口(约定),然后实现几个具体策略。

// 策略接口:约定所有策略类必须实现 calculate 方法 class CountStrategy { calculate(currentValue, operation, operand = 1) { throw new Error('子类必须实现 calculate 方法'); } } // 基础策略:简单的加减 class BasicCountStrategy extends CountStrategy { calculate(currentValue, operation, operand = 1) { switch (operation) { case 'increment': return currentValue + operand; case 'decrement': return currentValue - operand; case 'reset': return 0; default: throw new Error(`不支持的运算类型: ${operation}`); } } } // 安全策略:禁止结果为负数 class SafeCountStrategy extends CountStrategy { calculate(currentValue, operation, operand = 1) { let newValue; switch (operation) { case 'increment': newValue = currentValue + operand; break; case 'decrement': newValue = currentValue - operand; // 核心:检查结果,如果为负则返回当前值(或抛出错误) if (newValue < 0) { console.warn('安全策略:计数结果不能为负数,操作被阻止。'); return currentValue; // 阻止操作,返回原值 } break; case 'reset': newValue = 0; break; default: throw new Error(`不支持的运算类型: ${operation}`); } return newValue; } } // 循环策略:在指定范围内循环(例如 0-9) class CircularCountStrategy extends CountStrategy { constructor(min = 0, max = 9) { super(); this.min = min; this.max = max; } calculate(currentValue, operation, operand = 1) { let newValue; switch (operation) { case 'increment': newValue = currentValue + operand; if (newValue > this.max) { newValue = this.min; // 超过最大值,回到最小值 } break; case 'decrement': newValue = currentValue - operand; if (newValue < this.min) { newValue = this.max; // 小于最小值,回到最大值 } break; case 'reset': newValue = this.min; break; default: throw new Error(`不支持的运算类型: ${operation}`); } return newValue; } }

策略模式的灵活性:可以看到,增加一个新的计数规则(比如“每次乘2”),我们只需要新建一个DoubleCountStrategy类即可。CounterModel的代码一行都不用改。这就是“对扩展开放,对修改关闭”。

4.4 实现命令模式

命令对象封装了操作细节和撤销逻辑。

// 命令接口 class Command { execute(model) { throw new Error('子类必须实现 execute 方法'); } undo(model) { throw new Error('子类必须实现 undo 方法'); } } // 具体的“增加”命令 class IncrementCommand extends Command { constructor(operand = 1) { super(); this.operand = operand; this.previousCount = null; // 用于撤销 } execute(model) { // 执行前保存旧值,用于撤销 this.previousCount = model.count; const newCount = model.strategy.calculate(model.count, 'increment', this.operand); model.setCountDirectly(newCount); // 使用直接设置,避免触发命令历史循环 } undo(model) { if (this.previousCount !== null) { model.setCountDirectly(this.previousCount); } } } // 具体的“减少”命令 class DecrementCommand extends Command { constructor(operand = 1) { super(); this.operand = operand; this.previousCount = null; } execute(model) { this.previousCount = model.count; const newCount = model.strategy.calculate(model.count, 'decrement', this.operand); model.setCountDirectly(newCount); } undo(model) { if (this.previousCount !== null) { model.setCountDirectly(this.previousCount); } } } // 重置命令 class ResetCommand extends Command { constructor() { super(); this.previousCount = null; } execute(model) { this.previousCount = model.count; const newCount = model.strategy.calculate(model.count, 'reset'); model.setCountDirectly(newCount); } undo(model) { if (this.previousCount !== null) { model.setCountDirectly(this.previousCount); } } }

命令模式的关键:每个命令对象都是一个完整的“操作快照”。execute方法知道如何应用操作,undo方法知道如何回退。previousCount属性保存了执行前的状态,这是实现撤销的一种简单方式。对于更复杂的操作,命令对象可能需要保存更多的上下文信息。

4.5 实现视图

视图是观察者,它订阅模型的变化。

class CounterView extends Observer { constructor(elementId, label = '') { super(); this.element = document.getElementById(elementId); if (!this.element) { throw new Error(`未找到ID为 "${elementId}" 的DOM元素`); } this.label = label; this.update({ count: 0 }); // 初始化显示 console.log(`计数器视图已创建,绑定到元素: #${elementId}`); } update(data) { // 收到模型通知,更新DOM const displayText = this.label ? `${this.label}: ${data.count}` : data.count; // 使用textContent比innerHTML更安全、性能更好 this.element.textContent = displayText; // 可以在这里根据数值添加一些样式变化,比如负数变红 if (data.count < 0) { this.element.style.color = 'red'; } else { this.element.style.color = ''; // 恢复默认 } console.log(`视图更新: ${this.element.id} -> ${displayText}`); } }

视图的职责单一CounterView只做一件事——当update被调用时,用新数据更新自己绑定的DOM元素。它不知道模型内部如何计算,也不知道还有其他什么视图。这种隔离使得视图极易测试和复用。

4.6 应用组装与启动

最后,我们需要一个“粘合剂”把所有这些部分组装起来,并连接到真实的HTML页面。

假设我们有如下HTML结构:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>设计模式实战:计数器</title> <style> body { font-family: sans-serif; margin: 2em; } .counter { margin: 1em 0; padding: 1em; border: 1px solid #ccc; } #display, #display2 { font-size: 2em; font-weight: bold; margin: 0.5em; } button { margin: 0.2em; padding: 0.5em 1em; } .history-btn { background-color: #f0f0f0; } .strategy-select { margin: 1em 0; } </style> </head> <body> <h1>设计模式实战:可扩展计数器</h1> <div class="strategy-select"> <label>选择计数策略:</label> <select id="strategySelector"> <option value="basic">基础策略</option> <option value="safe">安全策略(禁止负数)</option> <option value="circular">循环策略 (0-9)</option> </select> </div> <div class="counter"> <p>主显示区:</p> <div id="display">0</div> <button id="btnIncrement">加一 (+1)</button> <button id="btnDecrement">减一 (-1)</button> <button id="btnReset">重置 (0)</button> <button id="btnIncrement5">加五 (+5)</button> </div> <div class="counter"> <p>副显示区(同步):</p> <div id="display2">0</div> </div> <div class="counter"> <p>操作历史:</p> <button id="btnUndo" class="history-btn">撤销 (Undo)</button> <button id="btnRedo" class="history-btn">重做 (Redo)</button> <button id="btnClearHistory" class="history-btn">清空历史</button> </div> <script src="counter-patterns.js"></script> <!-- 上面所有的JS代码放在这个文件 --> <script src="app.js"></script> <!-- 下面的组装代码放在这个文件 --> </body> </html>

然后,在app.js中,我们进行组装:

// app.js - 应用组装与启动 document.addEventListener('DOMContentLoaded', function() { console.log('应用启动...'); // 1. 创建模型实例,使用基础策略 const counterModel = new CounterModel(0, new BasicCountStrategy()); // 2. 创建多个视图实例,并注册到模型 const mainDisplay = new CounterView('display', '当前计数'); const secondaryDisplay = new CounterView('display2', '同步计数'); counterModel.attach(mainDisplay); counterModel.attach(secondaryDisplay); // 3. 获取DOM按钮元素 const btnIncrement = document.getElementById('btnIncrement'); const btnDecrement = document.getElementById('btnDecrement'); const btnReset = document.getElementById('btnReset'); const btnIncrement5 = document.getElementById('btnIncrement5'); const btnUndo = document.getElementById('btnUndo'); const btnRedo = document.getElementById('btnRedo'); const btnClearHistory = document.getElementById('btnClearHistory'); const strategySelector = document.getElementById('strategySelector'); // 4. 绑定按钮事件:创建并执行命令 btnIncrement.addEventListener('click', () => { const command = new IncrementCommand(1); counterModel.executeCommand(command); }); btnDecrement.addEventListener('click', () => { const command = new DecrementCommand(1); counterModel.executeCommand(command); }); btnReset.addEventListener('click', () => { const command = new ResetCommand(); counterModel.executeCommand(command); }); btnIncrement5.addEventListener('click', () => { const command = new IncrementCommand(5); // 操作数变为5 counterModel.executeCommand(command); }); // 5. 绑定撤销/重做事件 btnUndo.addEventListener('click', () => { counterModel.undo(); }); btnRedo.addEventListener('click', () => { counterModel.redo(); }); btnClearHistory.addEventListener('click', () => { // 清空历史需要稍微“绕一下”,因为模型没有直接暴露_history。 // 一种简单方式:重置模型(注意也会重置count) // 更好的方式是在模型上增加一个 clearHistory 方法。这里为了演示,我们简单重置。 const currentCount = counterModel.count; const currentStrategy = counterModel.strategy; // 重新创建一个新模型,并重新附加视图 // 在实际项目中,应在CounterModel类中添加 clearHistory 方法。 console.warn('清空历史功能需要扩展模型方法,此处演示重置模型。'); // 此处省略更优雅的实现,建议在CounterModel中增加方法。 }); // 6. 绑定策略切换事件 strategySelector.addEventListener('change', (event) => { let newStrategy; switch (event.target.value) { case 'basic': newStrategy = new BasicCountStrategy(); break; case 'safe': newStrategy = new SafeCountStrategy(); break; case 'circular': newStrategy = new CircularCountStrategy(0, 9); break; default: newStrategy = new BasicCountStrategy(); } counterModel.strategy = newStrategy; // 切换策略后,可以用当前值重新通知一次视图,确保显示符合新策略(例如安全策略下负数被纠正) counterModel.notify({ count: counterModel.count }); }); console.log('应用初始化完成。'); // 初始通知一次,确保视图显示初始值 counterModel.notify({ count: counterModel.count }); });

组装逻辑解析:这段代码是典型的“依赖组装”或“组合根”。它创建了所有对象,并建立了它们之间的关系(模型持有策略,视图观察模型,按钮触发命令执行)。所有具体的类名和依赖关系都集中在这里,其他部分都是高内聚、低耦合的模块。这种模式非常有利于测试,因为你可以轻松地用模拟对象(Mock)替换掉真实依赖。

5. 模式优势与扩展场景

通过上面的完整实现,我们已经拥有了一个功能强大且高度灵活的计数器。现在,让我们回头审视一下,引入这些模式究竟带来了哪些实实在在的好处,以及如何应对更复杂的扩展需求。

5.1 已实现优势的总结

  1. 视图与模型彻底解耦CounterView只知道有一个update方法会被调用,并传入数据。它不关心数据从哪里来,如何计算。我们可以轻松添加第三个、第四个视图(比如一个图形化的柱状图BarChartView),只需要让它实现Observer接口并注册到模型。模型代码无需任何改动
  2. 算法(策略)可动态替换:通过下拉框,用户可以在运行时切换计数策略。从模型的视角看,它只是换了一个strategy对象,所有后续的操作都自动适应新规则。增加一个“百分比计数器”或“随机计数器”策略,只需新增一个类。
  3. 操作历史与撤销/重做:命令模式使得记录和回退操作变得非常自然。每个命令对象都是一个独立的数据结构,存储了足够的信息来执行和撤销自己。历史栈的管理逻辑被封装在CounterModel中,对外提供简单的undo/redo接口。
  4. 易于单元测试:我们可以单独测试BasicCountStrategy.calculate方法是否正确。可以单独测试IncrementCommandexecuteundo是否逻辑正确。可以模拟(Mock)一个Observer来测试CounterModelnotify是否在正确时机被调用。由于依赖都是注入的,测试时可以轻松替换真实DOM或网络请求。
  5. 代码职责清晰,可读性高:每个类都有单一、明确的职责。CounterModel管状态和通知,CounterView管显示,CountStrategy管计算规则,Command管操作封装。新人阅读代码时,很容易找到相关逻辑所在的位置。

5.2 应对更复杂的扩展需求

假设产品经理又提出了新需求:

需求一:计数器值需要自动保存到localStorage,页面刷新后恢复。

  • 原始写法困境:需要在每个修改count的地方(按钮点击事件)都加上localStorage.setItem,散落各处,容易遗漏。
  • 模式化解决方案
    • 方案A(观察者模式):创建一个PersistenceView(虽然叫View,但它不渲染DOM)。它同样实现Observer接口,在update方法里将data.count保存到localStorage。然后将其注册到CounterModel。这样,任何导致模型状态变化的操作(无论是通过按钮命令还是其他方式),都会自动触发持久化。新增一个类,一行模型代码都不用改
    • 方案B(装饰者模式):创建一个PersistentCounterModel,它“装饰”原有的CounterModel。它内部持有一个CounterModel实例,并实现同样的executeCommandundoredo等方法。在这些方法中,先调用内部模型的对应方法,然后再执行持久化逻辑。对于外部调用者来说,它就像一个普通的CounterModel,但多了自动保存的功能。这种方式更适用于需要对模型行为进行增强或修改的场景。

需求二:需要为每次操作添加日志,发送到服务器进行分析。

  • 解决方案:与持久化需求类似。可以创建一个LoggingView观察者,在update方法中异步发送日志。或者,在Commandexecute方法中添加日志逻辑。由于命令对象本身就代表了“一次操作”,在这里记录操作类型、操作数、时间戳等信息非常合适。命令模式让操作本身成为了可被记录和传递的一等公民。

需求三:实现一个“宏命令”,比如“一键加十”,它由10次“加一”命令组成。

  • 解决方案:这正是命令模式擅长的。我们可以创建一个MacroCommand类,它内部维护一个命令数组。它的execute方法会按顺序执行数组中的所有命令,undo方法则按相反顺序撤销它们。然后,我们就可以将new IncrementCommand(1)重复10次,装进一个MacroCommand,模型执行这个宏命令即可。这实现了操作的组合和批量执行。

需求四:在不同页面组件间共享同一个计数器状态。

  • 解决方案:这引出了另一个重要的模式——单例模式。我们可以确保整个应用中只有一个CounterModel实例。在组装文件(app.js)中,我们将创建好的counterModel实例导出到全局作用域(或使用模块导出),其他任何需要访问或修改计数器状态的模块,都使用这个唯一的实例。这保证了状态的一致性。结合观察者模式,所有订阅了该实例的视图都会同步更新。

6. 常见问题、调试技巧与性能考量

在实际使用和教学过程中,我总结了一些容易遇到的问题和值得注意的细节。

6.1 常见问题与排查

  1. 视图没有更新

    • 检查点1:观察者是否成功注册?CounterModel.attach方法中添加console.log,确认视图实例被正确添加到this.observers中。
    • 检查点2:notify是否被调用?CounterModelset countsetterexecuteCommand方法中,确认this.notify()在状态改变后被调用。注意setter中做了新旧值比较,如果值没变,不会触发通知。
    • 检查点3:视图的update方法是否正确实现?确保视图类继承了Observer并正确定义了update(data)方法。检查方法内的DOM操作是否正确,element引用是否有效(元素ID是否正确,DOM是否已加载)。
    • 检查点4:异步通知问题。我们的notify用了Promise.resolve().then进行异步通知。这意味着视图更新是微任务,会稍晚于同步代码执行。如果在这期间有代码依赖于更新后的DOM状态,可能会出错。在绝大多数UI场景下这是可接受的,但需要知晓这个特性。
  2. 撤销/重做功能紊乱

    • 检查点1:命令的executeundo是否对称?确保execute中保存的previousCount能在undo中正确用于恢复状态。一个常见的错误是在execute中直接使用model.count++,这会导致previousCount保存的不是原始值。
    • 检查点2:历史栈管理是否正确?重点检查executeCommand方法中清空“重做分支”的逻辑 (this._history.splice(this._historyIndex + 1))。如果新命令执行后没有清空其后的历史,重做时会出现意外行为。
    • 检查点3:是否使用了setCountDirectly在命令的executeundo中,我们必须调用model.setCountDirectly来修改状态,而不是model.executeCommand,否则会造成递归调用和死循环。
  3. 策略切换后行为不符合预期

    • 检查点:策略对象的calculate方法是否被正确调用?CounterModel.executeCommand中,我们通过model.strategy.calculate(...)来委托计算。确保传入的参数(当前值、操作类型、操作数)是正确的。调试时可以在calculate方法开始处打印日志。
    • 注意:策略切换本身不会改变当前计数值。例如,从“基础策略”切换到“安全策略”,如果当前值是-5,它不会自动变成0。你需要决定是否在切换策略时自动校正当前值(可以在strategysetter中增加校正逻辑并通知)。

6.2 性能考量与优化建议

  1. 观察者数量:如果观察者数量非常多(例如成百上千),notify方法遍历列表可能会成为性能瓶颈。可以考虑:
    • 批量更新:如果状态频繁变化,可以引入一个“脏检查”机制或使用requestAnimationFrame对通知进行节流,在一帧内只通知一次。
    • 按需订阅:让视图只订阅它关心的特定状态变化,而不是模型的所有变化。这需要更精细的观察者模式变体(如“发布/订阅”模式,带事件类型)。
  2. 命令历史内存占用:如果操作非常频繁且命令对象很大(比如保存了复杂的快照),历史栈可能会占用大量内存。可以考虑:
    • 限制历史长度:只保留最近N条记录。
    • 使用增量快照:对于某些操作,undo可能需要的信息很少,不必保存完整的前状态。
  3. 策略对象的创建:如果策略是无状态的(如BasicCountStrategy),可以将其实现为单例,避免重复创建对象。对于有状态的策略(如CircularCountStrategy带有min/max参数),则每次都需要新实例。

6.3 面向对象与设计模式的取舍

最后,必须强调一点:不要为了使用模式而使用模式。这个计数器示例展示了如何用模式构建一个灵活、可扩展的架构,但对于一个真正简单的、永远只有单一视图、不需要撤销、规则固定的计数器,最初的几行“面条代码”反而是更优选择——因为它简单、直接、一目了然。

设计模式是解决特定复杂问题的工具箱,而不是必须遵守的教条。当你预见到需求可能会变化,或者代码已经开始出现“坏味道”(如重复代码、冗长的条件判断、紧耦合)时,再考虑引入合适的设计模式进行重构。本次实战的目的,正是为了让你在需要这个工具箱时,能清楚地知道里面有什么工具,以及每件工具该怎么用。通过这个小小的计数器,我希望你能感受到,良好的设计是如何让代码从容应对变化的,这才是面向对象编程和设计模式的精髓所在。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/29 10:29:32

免费aigc检测查重能用于学校提交吗?AI降重结果不能代替正式报告

免费aigc检测查重能用于学校提交吗&#xff1f;AI降重结果不能代替正式报告 材料类别在提交包中的位置是否必交最终论文文件学校任务要求的主文件通常需要&#xff0c;格式以通知为准AIGC或查重报告学校明确要求上传时才放入由本校当届通知决定免费自查结果留在个人工作目录一…

作者头像 李华
网站建设 2026/8/29 10:29:03

实时视频问诊中的医疗AI:多模态引擎与工程落地

这两年医疗 AI 的讨论热度一直很高&#xff0c;但大多数演示还停留在“上传一张 CT 图片&#xff0c;模型输出一段诊断建议”这种离线场景。真正落到线上问诊系统时&#xff0c;输入从静态图片变成了一路连续的视频流&#xff0c;模型要一边听患者说话、一边观察面色和肢体动作…

作者头像 李华
网站建设 2026/8/29 10:27:13

Netdata Windows 监控指南:三步把 Windows 服务器接入实时监控

Netdata Windows 监控指南&#xff1a;三步把 Windows 服务器接入实时监控 【免费下载链接】netdata The fastest path to AI-powered full stack observability, even for lean teams. 项目地址: https://gitcode.com/GitHub_Trending/ne/netdata 凌晨三点&#xff0c;…

作者头像 李华
网站建设 2026/8/29 10:25:23

Netdata Windows监控怎么装?5分钟跑通第一张监控图

Netdata Windows监控怎么装&#xff1f;5分钟跑通第一张监控图 【免费下载链接】netdata The fastest path to AI-powered full stack observability, even for lean teams. 项目地址: https://gitcode.com/GitHub_Trending/ne/netdata Netdata 是一个开源监控工具&…

作者头像 李华
网站建设 2026/8/29 10:25:04

MinerU 版本升级指南:从 1.x 到 2.7 的完整迁移路径

MinerU 版本升级指南&#xff1a;从 1.x 到 2.7 的完整迁移路径 【免费下载链接】MinerU Transforms complex documents like PDFs and Office docs into LLM-ready markdown/JSON for your Agentic workflows. 项目地址: https://gitcode.com/GitHub_Trending/mi/MinerU …

作者头像 李华