如何用JavaScript建立一个计算器应用程序

225 阅读15分钟

这个史诗般的教程通过描述如何用JavaScript语言开发一个简单的计算器应用,为JavaScript新手提供了一个坚实的锻炼机会。

我们将在本教程中开发的计算器应用是一个非常简单的应用。它或多或少与杂货店里的那些计算器的方式类似。该应用的布局是用CSS网格精心设计的。如果你想了解它是如何实现的,一定要看看这个教程,因为这个教程只关注使计算器工作的JavaScript逻辑。

JavaScript Calculator demo

这里有一个完成项目的现场演示。你可以先玩一玩,感受一下你要构建的东西,然后再进入。

前提条件

本教程假定你有JavaScript的基本知识。我将尽可能地分解每一个步骤,所以即使你没有在浏览器中构建应用程序的经验,也应该很容易掌握。

在你开始之前

你可以在JSFiddle上找到本教程的起点。它包含所有用于构建计算器布局的必要标记和样式。该标记与我讨论如何制作计算器布局的前一个教程的最终状态几乎相同,但我做了一些小改动,所以请务必使用这个标记。

先把代码分叉到一个新的fiddle,然后跟着打出每一步,直到最后。如果你愿意的话,可以在其他在线代码操场或在你的本地机器上做这个教程。

开始学习

任何人都应该能够在我们的计算器应用程序上进行四种最常见的算术运算(加法、减法、乘法和除法),通过使用输入按钮构建一个有效的表达式,并将结果显示在屏幕上。下面是一个有效表达式的例子。

12 + 10

为了构建一个有效的算术表达式,我们需要跟踪一些东西:第一个操作数(12)、运算符(+)和第二个操作数(10)。

让我们先创建一个对象来帮助我们跟踪这些值。在JSFiddle的JavaScript窗格的顶部添加以下代码。

const calculator = {
  displayValue: '0',
  firstOperand: null,
  waitingForSecondOperand: false,
  operator: null,
};

上面的calculator 对象包含了我们构建一个有效表达式所需要的一切。

  • displayValue 持有一个字符串值,代表用户的输入或一个操作的结果。它是我们跟踪应该在屏幕上显示的内容的方式。
  • firstOperand 将存储任何表达式的第一个操作数。现在它被设置为 。null
  • operator 键将存储一个表达式的操作数。它的初始值也是null
  • waitingForSecondOperand 它的作用是检查第一操作数和运算符是否都已输入。如果是 ,用户输入的下一个数字将构成第二操作数。true

更新显示

此刻,计算器的屏幕是空白的。我们需要displayValue 属性的内容一直在屏幕上显示。我们将为此创建一个函数,这样,无论何时在应用程序中进行操作,我们都可以随时调用它,用displayValue 的内容更新屏幕。

继续前进,在calculator 对象下面输入这个。

function updateDisplay() {
  // select the element with class of `calculator-screen`
  const display = document.querySelector('.calculator-screen');
  // update the value of the element with the contents of `displayValue`
  display.value = calculator.displayValue;
}

updateDisplay();

如果你看一下这个应用程序的HTML代码,你会发现 "屏幕 "实际上只是一个禁用的文本输入。

<input type="text" class="calculator-screen" value="" disabled />

我们不能直接用键盘输入,但我们可以用JavaScript改变它的值。这就是updateDisplay 函数的作用。现在你应该看到计算器的屏幕上显示了零。

喘口气,看看本步骤最后的完整代码

处理按键

我们在计算器上有四组按键:数字(0-9)、运算符(+、-、⨉、÷、=)、小数点(。)和复位键(AC)。在这一节中,我们将监听对计算器按键的点击,并确定被点击的按键类型。

在JavaScript标签的底部添加这个代码片段。

const keys = document.querySelector('.calculator-keys');
keys.addEventListener('click', (event) => {
  // Access the clicked element
  const { target } = event;

  // Check if the clicked element is a button.
  // If not, exit from the function
  if (!target.matches('button')) {
    return;
  }

  if (target.classList.contains('operator')) {
    console.log('operator', target.value);
    return;
  }

  if (target.classList.contains('decimal')) {
    console.log('decimal', target.value);
    return;
  }

  if (target.classList.contains('all-clear')) {
    console.log('clear', target.value);
    return;
  }

  console.log('digit', target.value);
});

在上面的代码段中,我们正在监听类为calculator-keys 的元素上的click 事件。由于计算器上的所有按键都是这个元素的子元素,click 事件也会过滤到它们。这就是所谓的事件委托

在事件监听器的回调函数中,我们使用重构赋值来提取点击事件的target 属性,重构赋值使我们很容易将对象属性解压到不同的变量中。

const { target } = event;
// is equivalent to
const target = event.target;

target 变量是一个代表被点击的元素的对象。如果这个元素不是一个按钮(比如你点击了按钮之间的空格),我们将通过提前返回来退出这个函数。否则,被点击的按钮类型将和它的值一起被记录到控制台。

在进行下一步之前,请务必尝试一下。打开你的浏览器控制台,点击任何一个按钮。键的类型和值应该相应地被记录到控制台。

The correct type of key is detected and logged to the console

检测到正确的键的类型并记录到控制台中

喘口气,看看本步骤结束时的完整代码

输入数字

在这一步,我们将使数字按钮工作,以便当任何一个按钮被点击时,相应的数值将显示在屏幕上。

Feedback is displayed to the user when a digit key is clicked

由于calculator 对象的displayValue 属性代表用户的输入,我们需要在任何一个数字被点击时修改它。在calculator 对象下面创建一个名为inputDigit 的新函数。

function inputDigit(digit) {
  const { displayValue } = calculator;
  // Overwrite `displayValue` if the current value is '0' otherwise append to it
  calculator.displayValue = displayValue === '0' ? digit : displayValue + digit;
}

接下来,在click 事件监听器回调函数中替换以下一行。

console.log('digit', target.value);

用下面的代码。

inputDigit(target.value);
updateDisplay();

inputDigit 函数中,三元运算符(?) 被用来检查计算器上显示的当前值是否为零。如果是,calculator.displayValue ,则用被点击的任何数字覆盖。否则,如果displayValue 是一个非零的数字,该数字将通过字符串连接附加到它上面。

最后,updateDisplay() 函数被调用,以便在每个按钮被点击后,displayValue 属性的新内容被反映在屏幕上。通过点击任何一个数字按钮来试一下。显示屏应该更新为你所点击的数字。

喘口气,看看这一步结束时的完整代码

输入一个小数点

当点击小数点键时,我们需要在屏幕上显示的任何内容上附加一个小数点,除非它已经包含了小数点。

Calculator showing decimal point being inputted

下面是我们如何实现这一目的。在inputDigit 下面创建一个名为inputDecimal 的新函数。

function inputDecimal(dot) {
  // If the `displayValue` property does not contain a decimal point
  if (!calculator.displayValue.includes(dot)) {
    // Append the decimal point
    calculator.displayValue += dot;
  }
}

inputDecimal 函数中,使用includes()方法来检查displayValue 是否已经包含小数点。如果是,则在数字上添加一个点。否则,该函数退出。

在这之后,将键的事件监听器回调函数中的以下几行替换掉。

console.log('decimal', target.value);

用以下几行代码。

inputDecimal(target.value);
updateDisplay();

在这一点上,你应该能够成功地添加输入一个小数点并在屏幕上显示出来。

喘口气,看看这一步结束时的完整代码

处理运算符

下一步是让计算器上的运算符(+, -, ⨉, ÷, =)工作。有三种情况需要考虑。

1.当用户在输入第一个操作数后点击运算符时

这时,displayValue 的内容应该存储在firstOperand 属性下,operator 属性应该更新为被点击的任何运算符。

inputDecimal 下面创建一个名为handleOperator 的新函数。

function handleOperator(nextOperator) {
  // Destructure the properties on the calculator object
  const { firstOperand, displayValue, operator } = calculator
  // `parseFloat` converts the string contents of `displayValue`
  // to a floating-point number
  const inputValue = parseFloat(displayValue);

  // verify that `firstOperand` is null and that the `inputValue`
  // is not a `NaN` value
  if (firstOperand === null && !isNaN(inputValue)) {
    // Update the firstOperand property
    calculator.firstOperand = inputValue;
  }

  calculator.waitingForSecondOperand = true;
  calculator.operator = nextOperator;
}

当按下一个操作键时,displayValue 的内容被转换为一个浮点数(指带有小数点的数字),其结果被存储在firstOperand 属性中。

operator 属性也被设置为被点击的任何操作键,同时waitingForSecondOperand 被设置为true ,这表明第一个操作数已经被输入,无论用户接下来输入什么数字,都将构成第二个操作数。

在这一点上,看看calculator 对象的属性是如何在每次按下按钮时被更新的是很有用的。在inputDigithandleOperator 两个函数的末尾添加以下一行。

console.log(calculator);

然后在按键的click 事件监听器回调函数中替换以下一行。

console.log('operator', target.value);

用下面的代码。

handleOperator(target.value);
updateDisplay();

在这一点上,尝试通过依次点击以下按键来构造一个有效的算术运算:12 + 。请注意,当按下+ 键时,firstOperandoperator 属性的值分别被更新为12+ ,同时waitingForSecondOperand 被设置为真,表明计算器现在正在等待输入第二个操作数。

Console showing calculator object

如果你试图输入第二个操作数,你会发现它被附加到第一个操作数上,而不是覆盖它。

Calculator showing how the second operand is appended to the first

让我们通过更新inputDigit 函数来解决这个问题,如下图所示。

function inputDigit(digit) {
  const { displayValue, waitingForSecondOperand } = calculator;

  if (waitingForSecondOperand === true) {
    calculator.displayValue = digit;
    calculator.waitingForSecondOperand = false;
  } else {
    calculator.displayValue = displayValue === '0' ? digit : displayValue + digit;
  }

  console.log(calculator);
}

如果waitingForSecondOperand 属性被设置为truedisplayValue 属性就会被点击的数字所覆盖。否则,就像以前一样进行检查,根据情况覆盖或追加到displayValue

喘口气,看看本步骤末尾的完整代码

2.当用户在输入第二个操作数后点击一个操作数时

我们需要处理的第二种情况发生在用户输入第二个操作数后,点击了一个操作数键。这时,评估表达式的所有成分都已存在,所以我们需要这样做,并在屏幕上显示结果。firstOperand 也需要被更新,以便在下一次计算中可以重复使用结果。

如下图所示,在handleOperator 下面创建一个名为calculate 的新函数。

function calculate(firstOperand, secondOperand, operator) {
  if (operator === '+') {
    return firstOperand + secondOperand;
  } else if (operator === '-') {
    return firstOperand - secondOperand;
  } else if (operator === '*') {
    return firstOperand * secondOperand;
  } else if (operator === '/') {
    return firstOperand / secondOperand;
  }

  return secondOperand;
}

这个函数将第一操作数、第二操作数和运算符作为参数,并检查运算符的值,以决定如何评估表达式。如果运算符是= ,第二操作数将被原样返回。

接下来,更新handleOperator 函数,如下图所示。

function handleOperator(nextOperator) {
  const { firstOperand, displayValue, operator } = calculator
  const inputValue = parseFloat(displayValue);

  if (firstOperand == null && !isNaN(inputValue)) {
    calculator.firstOperand = inputValue;
  } else if (operator) {
    const result = calculate(firstOperand, inputValue, operator);

    calculator.displayValue = String(result);
    calculator.firstOperand = result;
  }

  calculator.waitingForSecondOperand = true;
  calculator.operator = nextOperator;
  console.log(calculator);
}

添加到handleOperatorelse if 块检查operator 属性是否被分配了一个运算符。如果是,calculate 函数被调用,结果被保存在result 变量中。

这个结果随后通过更新displayValue 属性显示给用户。同时,firstOperand 的值也被更新为结果,以便在下一个计算器中使用。

试一试吧。在计算器中输入12 + 10 = ,注意屏幕上显示的是正确的结果。当你将一连串的运算串联起来时,它也能发挥作用。所以5 * 20 - 14 = ,结果应该是86

Calculator showing the result of 5 * 20 - 14

这是因为按下减号键触发了第一个操作(5 * 20 )的计算,其结果(100 )随后被设置为下一个计算的firstOperand ,所以当我们输入14 作为第二个操作数并按下= 键时,calculate 函数再次被执行,其结果是86 ,也被设置为下一个操作的firstOperand ,如此循环。

喘口气,看看本步骤结束时的完整代码

3.当连续输入两个或多个操作符时

改变自己想要执行的操作类型是很常见的,所以计算器必须正确处理这个问题。

比方说,你想把7和2加在一起,你会点击7 + 2 = ,这将产生正确的结果。但假设在点击7 + ,你改变了主意,决定从7中减去2。与其清空计算器并重新开始,不如点击- ,覆盖之前输入的+

请记住,在输入运算符的时候,waitingForSecondOperand 将被设置为true ,因为计算器希望在运算符键之后输入第二个操作数。我们可以利用这个质量来更新运算符键,防止任何计算,直到第二个操作数被输入。

修改handleOperator 函数,使其看起来像这样。

function handleOperator(nextOperator) {
  const { firstOperand, displayValue, operator } = calculator
  const inputValue = parseFloat(displayValue);

  if (operator && calculator.waitingForSecondOperand)  {
    calculator.operator = nextOperator;
    console.log(calculator);
    return;
  }

  if (firstOperand == null && !isNaN(inputValue)) {
    calculator.firstOperand = inputValue;
  } else if (operator) {
    const result = calculate(firstOperand, inputValue, operator);
    calculator.displayValue = String(result);
    calculator.firstOperand = result;
  }

  calculator.waitingForSecondOperand = true;
  calculator.operator = nextOperator;
  console.log(calculator);
}

相关的修改在上面突出显示。if 语句检查一个operator 是否已经存在,以及waitingForSecondOperand 是否被设置为true 。如果是,operator 属性的值被替换为新的运算符,函数退出,这样就不会进行计算。

试一试吧。在输入一些数字后点击多个运算符,在控制台中监控计算器对象。注意,operator 属性每次都会被更新,在你提供第二个操作数之前不进行计算。

喘口气,看看本步骤末尾的完整代码

重置计算器

最后一项任务是确保用户可以通过按键将计算器重置到初始状态。在大多数计算器中,AC 按钮是用来将计算器重置为默认状态的,所以这就是我们在这里要做的。

继续前进,在calculate 下面创建一个新的函数,如下图所示。

function resetCalculator() {
  calculator.displayValue = '0';
  calculator.firstOperand = null;
  calculator.waitingForSecondOperand = false;
  calculator.operator = null;
  console.log(calculator);
}

然后在按键的事件监听器回调函数中替换以下一行。

console.log('all clear', target.value)

用下面的代码。

resetCalculator();
updateDisplay();

resetCalculator 函数将calculator 对象的所有属性设置为原始值。现在点击计算器上的AC 键应该能像预期那样工作。你可以在控制台中检查calculator 对象来确认。

喘口气,看看本步骤末尾的完整代码

修复小数点错误

如果你在点击运算符后输入小数点,它会被附加到第一个运算符上,而不是成为第二个运算符的一部分。

Calculator gif showing decimal bug

我们可以通过对inputDecimal 函数做如下修改来修复这个错误。

function inputDecimal(dot) {
  if (calculator.waitingForSecondOperand === true) {
  	calculator.displayValue = '0.'
    calculator.waitingForSecondOperand = false;
    return
  }

  if (!calculator.displayValue.includes(dot)) {
    calculator.displayValue += dot;
  }
}

如果waitingForSecondOperand 被设置为true ,并且输入了小数点,displayValue 变成0.waitingForSecondOperand 被设置为false,这样任何额外的数字都会被附加到第二个操作数的一部分。

GIF showing fixed bug

这个错误现在被修复了

喘口气,看看本步骤末尾的完整代码

重构事件监听器

更新按键的事件监听器,如下所示。所有的if 块都被替换成了一个switch 块,并且updateDisplay() 只在函数的最后被调用一次。

keys.addEventListener('click', event => {
  const { target } = event;
  const { value } = target;
  if (!target.matches('button')) {
    return;
  }

  switch (value) {
    case '+':
    case '-':
    case '*':
    case '/':
    case '=':
      handleOperator(value);
      break;
    case '.':
      inputDecimal(value);
      break;
    case 'all-clear':
      resetCalculator();
      break;
    default:
      // check if the key is an integer
      if (Number.isInteger(parseFloat(value))) {
        inputDigit(value);
      }
  }

  updateDisplay();
});

这样一来,给计算器添加新的函数就容易多了,你不再需要在每次操作后调用updateDisplay()

喘口气,请看本步骤末尾的完整代码

浮点精度

我想提请你注意一个问题,当一个操作的结果是浮点数时,会出现这个问题。例如,0.1 + 0.2 ,产生0.30000000000000004 ,而不是你可能期望的0.3

Floating-point precision bug

在其他情况下,你会得到预期的结果。例如,0.1 + 0.4 ,产生0.5 。关于为什么会发生这种情况的详细解释可以在这里找到。请确保你阅读它。

上述链接页面中建议的解决这个问题的潜在方法之一是将结果格式化为固定的小数位数,这样其他的小数位数就被丢弃了。我们可以将JavaScriptparseFloat函数与Number.toFixed方法结合起来,在我们的计算器应用中实现这个解决方案。

handleOperator 函数中,替换下面一行。

calculator.displayValue = String(result);

替换为下面的代码。

calculator.displayValue = `${parseFloat(result.toFixed(7))}`;

toFixed() 方法接受一个介于0和20之间的值,并确保小数点后的数字被限制在该值内。如果有必要,返回值可以被四舍五入或用零填充。

在前面的例子中,0.1 + 0.2 ,产生了0.30000000000000004 。对结果使用toFixed(7) ,将限制小数点后的数字为7位。

0.30000000000000004.toFixed(7) // 0.3000000

那些额外的零并不重要,所以我们可以用parseFloat ,把它们去掉。

parseFloat(0.30000000000000004.toFixed(7)) // 0.3

这就是我们如何能够在我们的应用程序中解决这个问题。在这种情况下,我选择了7这个数字,因为对于这个计算器应用来说,它的精度足够好。在其他情况下,可能需要更大的精度。

GIF showing fixed floating-point precision

作为额外的奖励,现在的结果适合在屏幕上显示。

喘口气,看看本步骤末尾的完整代码

奖励内容

我为我的Patreon支持者们准备了一些对这个计算器应用的进一步增强。应用程序中加入了以下功能:sin、cos、tan、log、平方、平方根、阶乘、百分比、加/减、π和ce。

JavaScript Calculator Bonus

Patreon上支持我,以解锁本教程的下一节。

如果你想获得我所有的奖励内容(包括这个),请考虑在Patreon上支持Freshman。你的支持将帮助我以更快的速度创作更多的教程。

总结

我的教程到此结束,我希望你从中学到了很多东西。如果文章的某个部分对你来说不够清楚,请随时留言,并订阅我的新闻通讯,以便在你的收件箱中获得更多像这样的精彩教程。

谢谢你的阅读,并祝你编码愉快