用 JS 状态模式做一台简易计算器

用状态模式拆开计算器的开机、输入、小数点、运算符和计算结果约束,写一台行为清晰的网页计算器。

计算器看起来简单:数字、运算符、小数点、等号。真写起来却容易变成一坨 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;
    }
}

注意两点:

  1. 切换状态只有一处入口ChangeStateTo,谁当前当家一目了然。
  2. 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=

  1. PowerPowerOffUnlimited,显示 0
  2. 1 → 显示 1,仍在 Unlimited
  3. . → 显示 1.,切到 LimitedDotKey
  4. 此时再按 . → 提示不能输入 .
  5. 5 → 显示 1.5
  6. + → 显示 1.5+,切到 OnlyNumberKey
  7. 此时按 = 或再按 + → 提示只能输入数字
  8. 2 → 显示 1.5+2,回到 Unlimited
  9. = → 算出 3.5,因为结果带小数点,进入 LimitedDotKey
  10. 再按数字比如 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 即可

小结

这台计算器不大,但把状态模式的味道用上了:

  1. 同一按键,不同状态不同反应——关机、输入中、算完之后各管各的
  2. 用继承复用默认行为,子状态只覆盖例外
  3. 上下文(Calculator)管数据和副作用,状态对象管能不能按、按完去哪
  4. 事件层只管转发,不掺业务判断

设计模式不是为了显得高级,而是在规则开始变多时,给代码一个能长的骨架。下次再写向导步骤、播放器、表单校验流程,也可以先问自己一句:这件事是不是一堆状态 × 事件的组合?如果是,状态模式往往比嵌套 if 更省心。

源码目录:tool-demo-master/JavaScriptCalculator。有兴趣可以直接打开玩,或者拿去改成支持括号、退格、科学计算——那时你会更感谢当初把状态拆开了。