计算器看起来简单:数字、运算符、小数点、等号。真写起来却容易变成一坨 if/else——刚按完 + 又按 × 怎么办?结果出来后直接敲数字呢?小数点能不能连点两次?
这次我用 状态模式(State Pattern) 把这些规则拆开,做了一台网页计算器。在线预览:tool.dmax.wang/JavaScriptCalculator
为什么用状态模式
状态模式的核心就一句:对象在不同状态下,对同一事件的反应不同。
计算器很适合这种模型。同样按 .:
- 刚输入完数字 → 可以加小数点
- 已经有小数点了 → 应该报错
- 还没开机 → 提示没有开机
如果全堆在一个函数里,分支会越来越长,改一处牵一片。换成当前状态对象处理按键,每个状态只关心自己那点规则,代码反而好读。
先画状态图
我给计算器定了 5 个状态:
| 状态 | 含义 | 典型约束 |
|---|
PowerOffState | 关机 | 除 Power 外一律拒绝 |
UnlimitedState | 普通输入 | 数字 / 运算符 / 小数点都可 |
LimitedDotKey | 已出现小数点 | 禁止再按 . |
OnlyNumberKey | 刚按完运算符 | 只能接着输数字 |
CalculatiedComplete | 刚算完结果 | 再按数字会开新式子 |
状态之间大致这么跳:
1
2
3
4
5
6
7
8
9
10
11
| PowerOff ──Power──► Unlimited
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
LimitedDot OnlyNumber (按 = 后)
│ │ │
│ │ ▼
│ │ CalculatiedComplete
│ │ │
└────────────┴── 按数字 / AC ──► Unlimited
|
UnlimitedState 是默认行为的基类,其它状态继承它,只覆盖需要改的按键处理——少写很多重复代码。
状态类:把规则写进方法里
基类负责正常输入:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| class UnlimitedState {
constructor(calculator) {
this.calculator = calculator;
}
pressNumberKey(textContent) {
this.calculator.removeErrorMsg();
this.calculator.appendNumberCharacter(textContent);
this.calculator.ChangeStateTo(this.calculator.unlimitedState);
}
pressOperatorKey(textContent) {
this.calculator.removeErrorMsg();
this.calculator.appendDotOrOperatorCharacter(textContent);
// 运算符后面必须跟数字
this.calculator.ChangeStateTo(this.calculator.onlyNumberKey);
}
pressDotKey(textContent) {
this.calculator.removeErrorMsg();
this.calculator.appendDotOrOperatorCharacter(textContent);
// 小数点只能出现一次(当前数字段)
this.calculator.ChangeStateTo(this.calculator.limitedDotKey);
}
pressEqualsKey() {
this.calculator.removeErrorMsg();
this.calculator.calaulatedExpression(this.calculator.display.value);
// 结果里有没有小数点,决定下一次能不能再点 .
this.calculator.display.value.indexOf('.') === -1 ?
this.calculator.ChangeStateTo(this.calculator.calculatiedComplete) :
this.calculator.ChangeStateTo(this.calculator.limitedDotKey);
}
pressSwitchKey() {
this.calculator.removeErrorMsg();
this.calculator.setPowerStateText('Power:Off');
this.calculator.ChangeStateTo(this.calculator.powerOffState);
this.calculator.display.value = '';
}
pressClearKey() {
this.calculator.removeErrorMsg();
this.calculator.toZero();
this.calculator.ChangeStateTo(this.calculator.unlimitedState);
}
}
|
关机状态几乎全部拦截:
1
2
3
4
5
6
7
8
9
10
11
12
13
| class PowerOffState extends UnlimitedState {
pressNumberKey() { this.calculator.errorMsg(`没有开机!`, false); }
pressOperatorKey() { this.calculator.errorMsg(`没有开机!`, false); }
pressDotKey() { this.calculator.errorMsg(`没有开机!`, false); }
pressEqualsKey() { this.calculator.errorMsg(`没有开机!`, false); }
pressClearKey() { this.calculator.errorMsg(`没有开机!`, false); }
pressSwitchKey() {
this.calculator.removeErrorMsg();
this.calculator.ChangeStateTo(this.calculator.unlimitedState);
this.calculator.display.value = '0';
this.calculator.setPowerStateText('Power:On');
}
}
|
其它几个状态只改不听话的那几下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| // 已经有小数点:禁止再点 .
class LimitedDotKey extends UnlimitedState {
pressNumberKey(textContent) {
this.calculator.removeErrorMsg();
this.calculator.appendNumberCharacter(textContent);
}
pressDotKey() {
this.calculator.errorMsg(`不能输入'.'号!`);
}
}
// 刚按完运算符:只能输数字
class OnlyNumberKey extends UnlimitedState {
pressOperatorKey() { this.calculator.errorMsg(`只能输入数字!`); }
pressDotKey() { this.calculator.errorMsg(`只能输入数字!`); }
pressEqualsKey() { this.calculator.errorMsg(`只能输入数字!`); }
}
// 刚算完:再按数字 = 开新一行,而不是拼在结果后面
class CalculatiedComplete extends UnlimitedState {
pressNumberKey(textContent) {
this.calculator.removeErrorMsg();
this.calculator.newLine(textContent);
this.calculator.ChangeStateTo(this.calculator.unlimitedState);
}
}
|
这样读代码的时候,不用在脑子里模拟一整棵条件树——打开某个状态类,规则就摆在眼前。
Calculator:上下文对象
状态类不直接操作 DOM 细节太多,真正干活的是 Calculator。它持有全部状态实例,并暴露改显示、算式、报错等方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| class Calculator {
constructor() {
this.unlimitedState = new UnlimitedState(this);
this.limitedDotKey = new LimitedDotKey(this);
this.onlyNumberKey = new OnlyNumberKey(this);
this.calculatiedComplete = new CalculatiedComplete(this);
this.powerOffState = new PowerOffState(this);
// 初始关机
this.currentState = this.powerOffState;
this.display = display;
this.tips = tips;
this.powerState = powerState;
}
ChangeStateTo(state) {
this.currentState = state;
}
appendNumberCharacter(character) {
this.display.value =
this.display.value === '0' ? character : this.display.value + character;
}
appendDotOrOperatorCharacter(character) {
this.display.value = this.display.value + character;
}
newLine(character) {
this.display.value = '' + character;
}
toZero() {
this.display.value = '0';
}
calaulatedExpression(displayExpression) {
const expression = displayExpression
.replace(/÷/g, '/')
.replace(/×/g, '*');
// 演示项目用了 eval;正式产品建议换成安全的表达式解析
this.display.value = parseFloat(eval(expression).toFixed(15)).toString();
}
errorMsg(errorMsg, inputToRed = true) {
if (inputToRed) this.display.classList.add('border-red-500');
this.tips.innerText = errorMsg;
}
removeErrorMsg() {
this.display.classList.remove('border-red-500');
this.tips.innerText = '';
}
setPowerStateText(text) {
this.powerState.innerText = text;
}
}
|
注意两点:
- 切换状态只有一处入口:
ChangeStateTo,谁当前当家一目了然。 eval 仅适合玩具/演示。真实计算器请用自己的 tokenizer + 运算符优先级,别把用户输入直接丢进 eval。
按键事件:一律转交给当前状态
HTML 上按钮用 data-action 分类:number / operator / dot / equal / clear / switch。JS 只负责把点击转给 currentState:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| const calculator = new Calculator();
for (const numberKey of numberKeys) {
numberKey.addEventListener('click', function () {
calculator.currentState.pressNumberKey(this.textContent);
});
}
for (const operatorKey of operatorKeys) {
operatorKey.addEventListener('click', function () {
calculator.currentState.pressOperatorKey(this.textContent);
});
}
dotKey.addEventListener('click', function () {
calculator.currentState.pressDotKey(this.textContent);
});
equalKey.addEventListener('click', function () {
calculator.currentState.pressEqualsKey();
});
clearKey.addEventListener('click', function () {
calculator.currentState.pressClearKey();
});
switchKey.addEventListener('click', function () {
calculator.currentState.pressSwitchKey();
});
|
UI 层不知道现在能不能按小数点,它只问当前状态;状态层也不关心按钮长什么样。职责切开之后,后面加键盘快捷键或改皮肤都轻松很多。
走一遍真实按键
开机 → 1 → . → 5 → + → 2 → =:
Power → PowerOff → Unlimited,显示 01 → 显示 1,仍在 Unlimited. → 显示 1.,切到 LimitedDotKey- 此时再按
. → 提示不能输入 . 号 5 → 显示 1.5+ → 显示 1.5+,切到 OnlyNumberKey- 此时按
= 或再按 + → 提示只能输入数字 2 → 显示 1.5+2,回到 Unlimited= → 算出 3.5,因为结果带小数点,进入 LimitedDotKey- 再按数字比如
9 → 若在 CalculatiedComplete 会新开一行;这里因结果含 . 走了另一条分支,可按自己的产品预期再微调
规则变了,多半只改某一个状态类,而不是翻遍整个文件找 if。
UI 与样式
界面用 Tailwind 拼了一套浅色拟物按钮,显示框只读,关机时 Power:Off,开机后变成 Power:On。结构很薄:一块显示区 + 几排 button,重点都在状态机上。
本地可以这样看:
1
2
3
| # 若改了 style.css,用 Tailwind CLI 重新生成 output.css
npx tailwindcss -i ./src/style.css -o ./src/output.css
# 直接用浏览器打开 index.html 即可
|
小结
这台计算器不大,但把状态模式的味道用上了:
- 同一按键,不同状态不同反应——关机、输入中、算完之后各管各的
- 用继承复用默认行为,子状态只覆盖例外
- 上下文(Calculator)管数据和副作用,状态对象管能不能按、按完去哪
- 事件层只管转发,不掺业务判断
设计模式不是为了显得高级,而是在规则开始变多时,给代码一个能长的骨架。下次再写向导步骤、播放器、表单校验流程,也可以先问自己一句:这件事是不是一堆状态 × 事件的组合?如果是,状态模式往往比嵌套 if 更省心。
源码目录:tool-demo-master/JavaScriptCalculator。有兴趣可以直接打开玩,或者拿去改成支持括号、退格、科学计算——那时你会更感谢当初把状态拆开了。