Artigo original: https://www.freecodecamp.org/news/how-to-build-an-html-calculator-app-from-scratch-using-javascript-4454b8714b98/

Este é um artigo épico onde você aprende a construir uma calculadora do zero. Vamos nos concentrar no JavaScript que você precisa escrever — como pensar em construir a calculadora, como escrever o código e, por fim, como limpar seu código.

Até o final do artigo, você deve obter uma calculadora que funcione exatamente como a do iPhone (sem as funcionalidades +/- e porcentagem).

Cw7jNVIhWFV4NSNY8-Lv8uX4583Hr5LvzYFq

Pré-requisitos

Antes de começar o tutorial, certifique-se que tenha um bom domínio de JavaScript. No mínimo, você precisa conhecer:

  1. Instruções if/else (texto explicativo em inglês)
  2. Laços for (texto explicativo em inglês)
  3. Funções em JavaScript (texto explicativo em inglês)
  4. Arrow functions (texto explicativo em inglês)
  5. && e || operadores
  6. Como alterar o texto com a propriedade textContent
  7. Como adicionar eventListeners com o padrão de delegação de evento.

Antes de começar

Eu recomendo que você tente construir a calculadora sozinho antes de seguir o tutorial. É uma boa prática, porque você treinará pensar como um desenvolvedor.

Volte para o tutorial após ter tentado por uma hora (não importa se você teve sucesso ou não). Quando você tenta, você pensa, e isso o ajudará a absorver o tutorial duas vezes mais rápido.

Bom, vamos começar a entender como funciona uma calculadora.

Construindo a calculadora

Primeiro, vamos construir a calculadora.  

A calculadora consiste em duas partes: o display e  as teclas.

rfV0r9RtFghhau8sZU5CzOFMuJAT1H48tFeL
<div class=”calculator”>
  <div class=”calculator__display”>0</div>
  <div class=”calculator__keys”> … </div>
</div>

Podemos usar o CSS Grid para criar as teclas, uma vez que elas já estão dispostas em um formato de grade. Isto já foi criado no arquivo inicial. Você pode encontrar o arquivo inicial neste link.

.calculator__keys { 
  display: grid; 
  /* o resto do CSS necessário */ 
}

Para identificarmos os operadores, chaves decimais, limpador de tela e sinal de igual, vamos criar um atributo de ação de dados que descreve o que eles fazem.

<div class="calculator__keys">
  <button class="key--operator" data-action="add">+</button>
  <button class="key--operator" data-action="subtract">-</button
  <button class="key--operator" data-action="multiply">&times;</button>
  <button class="key--operator" data-action="divide">÷</button
  <button>7</button>
  <button>8</button>
  <button>9</button>
  <button>4</button>
  <button>5</button>
  <button>6</button>
  <button>1</button>
  <button>2</button>
  <button>3</button>
  <button>0</button>
  <button data-action="decimal">.</button>
  <button data-action="clear">AC</button>
  <button class="key--equal" data-action="calculate">=</button>
</div>

Ouvindo as teclas

Cinco coisas que podem acontecer quando alguém pega uma calculadora. Elas podem apertar:

  1. um número (0–9)
  2. um operador (+, -, ×, ÷)
  3. uma tecla decimal
  4. a tecla de igual
  5. a tecla para limpar

Os primeiros passos para construir a calculadora são:  (1) ouvir todas as teclas (2) determinar quais teclas foram pressionadas. Neste caso, podemos usar um padrão de delegação de eventos para ouvir as teclas, já que todas as teclas são filhas de .calculator__keys.

const calculator = document.querySelector(‘.calculator’)
const keys = calculator.querySelector(‘.calculator__keys’)

keys.addEventListener(‘click’, e => {
 if (e.target.matches(‘button’)) {
   // Fazer algo
 }
})

Em seguida,  podemos usar o atributo data-action para determinar qual tecla foi clicada.

const key = e.target
const action = key.dataset.action

Se a tecla não tiver um atributo data-action, ela deve ser uma tecla numérica.

if (!action) {
  console.log('number key!')
}
O console.log informará que foi pressionada uma tecla numérica

Se a tecla tiver um atributo data-action que seja add, subtract, multiply ou divide, sabemos que a tecla é um operador.

if (
  action === 'add' ||
  action === 'subtract' ||
  action === 'multiply' ||
  action === 'divide'
) {
  console.log('operator key!')
}
O console.log informará que foi pressionada uma tecla de operação

Se a tecla data-action for decimal, sabemos que o usuário clicou sobre a chave decimal.

Seguindo o mesmo pensamento, se a tecla data-action for clear, sabemos que o usuário clicou na tecla de limpar (aquela que diz AC). Se a chave data-action for calculate, sabemos que o usuário clicou na tecla de iguais.

if (action === 'decimal') {
  console.log('decimal key!')
}

if (action === 'clear') {
  console.log('clear key!')
}

if (action === 'calculate') {
  console.log('equal key!')
}
O console.log informará que foi pressionada a tecla do decimal, de limpeza da tela e do sinal de igual, respectivamente

Neste momento, você deve conseguir visualizar pelo console.log, uma resposta para cada tecla clicada.

lbXTncsu2Ni5V-Ejx6RYCO-kW8XJm7f5woGC

Construindo o caminho ideal

Vamos considerar o que um usuário comum faria quando pegasse um calculadora. Esse "o que um usuário comum faria" é chamado de caminho ideal.

Vamos chamar nosso usuário de Mary.

Quando Mary pega a calculadora, ela pode clicar em qualquer uma dessas teclas:

  1. uma tecla numérica (0–9)
  2. uma tecla de operador (+, -, ×, ÷)
  3. uma tecla decimal
  4. a tecla do sinal de igual
  5. a tecla para limpar (AC)

Pode ser muito pensar nas cinco teclas clicadas de uma só vez, então vamos passo a passo.

Quando o usuário clica em uma tecla numérica

Neste ponto, se a calculadora mostrar 0 (o número padrão), o número clicado deve substituir zero.

mpr4JFLSU-MHaq8LPMedsaDxnU5Y-MTx56SU

Se a calculadora mostrar um número diferente de zero, o número clicado deve ser incluído no número exibido.

PNfa-nAlgIBtFt1MaVEDvuzisaIps6Kdb482

Agora, precisamos saber de duas coisas:

  1. Qual tecla foi clicada
  2. O número atual exibido

Podemos obter esses dois valores através da propriedade textContent da tecla clicada e .calculator__display, respectivamente.

const display = document.querySelector('.calculator__display')

keys.addEventListener('click', e => {
  if (e.target.matches('button')) {
    const key = e.target
    const action = key.dataset.action
    const keyContent = key.textContent
    const displayedNum = display.textContent
    // ...
  }
})

Se a calculadora mostrar 0, queremos substituir o visor da calculadora pela tecla clicada. Podemos fazer isso substituindo o texto do visor por uma propriedade textContent.

if (!action) {
  if (displayedNum === '0') {
    display.textContent = keyContent
  }
}

Se a calculadora mostrar um número diferente de zero, queremos incluir a tecla clicada ao número exibido. Para incluir um número, concatenaremos uma string.

if (!action) {
  if (displayedNum === '0') {
    display.textContent = keyContent
  } else {
    display.textContent = displayedNum + keyContent
  }
}

Neste momento, Mary pode clicar em qualquer uma destas teclas:

  1. Uma tecla decimal
  2. Uma tecla de operador

Suponhamos que Mary clicou em uma tecla decimal.

Quando um usuário aperta a tecla decimal

Quando Mary aperta a tecla decimal, um decimal deve aparecer no visor. Se Mary aperta qualquer outro número decimal, ele deverá ser exibido no visor.

Para criar este efeito, podemos concatenar . ao número exibido.

if (action === 'decimal') {
  display.textContent = displayedNum + '.'
}

Em seguida, suponhamos que Mary continua seu cálculo apertando a tecla de algum operador.

Quando o usuário aperta uma tecla de operação  

Se Mary apertar uma tecla de operação, ele deverá ser destacado, desta maneira, Mary sabe que este operador está ativo.

VarwRgJGrN0mwcgYGpX1Zw54QRfbXdMmQNEG

Para isso, podemos adicionar a classe is-depressed a tecla do operador.

if (
  action === 'add' ||
  action === 'subtract' ||
  action === 'multiply' ||
  action === 'divide'
) {
  key.classList.add('is-depressed')
}

Uma vez que Mary tenha clicado em uma tecla do operador, ela clicará outra tecla numérica.

Quando o usuário clica em um número após selecionar um operador

Quando Mary selecionar um novo número, o número que estava no visor deverá ser substituído pelo novo número selecionado. A tecla do operador também deve liberar seu estado de pressão.

GDuLfupPob7rW0UWTH6RqI5CuQX36vcILKwo

Para liberar a tecla do operador selecionado, retiramos a classe is-depressed de todos os forEach loops:

keys.addEventListener('click', e => {
  if (e.target.matches('button')) {
    const key = e.target
    // ...
    
    // Remove a classe .is-depressed de todas as teclas
    Array.from(key.parentNode.children)
      .forEach(k => k.classList.remove('is-depressed'))
  }
})

A seguir, queremos atualizar o visor para a tecla selecionada. Antes de fazermos isso, precisamos verificar se a tecla anterior é uma tecla de operador.

Uma maneira de fazer isso é através de um atributo personalizado. Chamaremos esse atributo personalizado de data-previous-key-type.

const calculator = document.querySelector('.calculator')
// ...

keys.addEventListener('click', e => {
  if (e.target.matches('button')) {
    // ...
    
    if (
      action === 'add' ||
      action === 'subtract' ||
      action === 'multiply' ||
      action === 'divide'
    ) {
      key.classList.add('is-depressed')
      // Adiciona um atributo personalizado
      calculator.dataset.previousKeyType = 'operator'
    }
  }
})

Se o previousKeyType é um operador, queremos substituir o número exibido por um número clicado.

const previousKeyType = calculator.dataset.previousKeyType

if (!action) {
  if (displayedNum === '0' || previousKeyType === 'operator') {
    display.textContent = keyContent
  } else {
    display.textContent = displayedNum + keyContent
  }
}

Em seguida, suponhamos que Mary decide completar seu cálculo, apertando a tecla do sinal de igual (=).

Quando um usuário aperta a tecla do sinal de igual

Quando Mary pressiona a tecla do sinal de igual (=), a calculadora deveria calcular um resultado que depende de três valores:

  1. O primeiro número inserido na calculadora
  2. O operador
  3. O segundo número inserido na calculadora

Após o cálculo, o resultado deve substituir o valor exibido.

56105259-5aea-4a51-bfff-495aedc8a099

Neste momento,  sabemos apenas o segundo número — ou seja, o número que está sendo exibido atualmente.

if (action === 'calculate') {
  const secondValue = displayedNum
  // ...
}

Para obter o primeiro número, precisamos armazenar o valor exibido da calculadora antes de limpá-la. Uma maneira de salvar este primeiro número é adicioná-lo a um atributo personalizado quando o botão do operador for clicado.

Para obter o operador, também podemos utilizar a mesma técnica.

if (
  action === 'add' ||
  action === 'subtract' ||
  action === 'multiply' ||
  action === 'divide'
) {
  // ...
  calculator.dataset.firstValue = displayedNum
  calculator.dataset.operator = action
}

Assim que tivermos os três valores necessários, podemos fazer um cálculo. Eventualmente, queremos que o código se pareça com algo assim:

if (action === 'calculate') {
  const firstValue = calculator.dataset.firstValue
  const operator = calculator.dataset.operator
  const secondValue = displayedNum
  
  display.textContent = calculate(firstValue, operator, secondValue)
}

Isso significa que precisamos criar uma função calculate. Ela deve ter três parâmetros: o primeiro número, o operador, e o segundo número.

const calculate = (n1, operator, n2) => {
  // Realizar um cálculo e retornar o valor calculado
}

Se o operador for add, queremos somar os dois valores. Se o operador for subtract, queremos subtrair os valores, e assim por diante.

const calculate = (n1, operator, n2) => {
  let result = ''
  
  if (operator === 'add') {
    result = n1 + n2
  } else if (operator === 'subtract') {
    result = n1 - n2
  } else if (operator === 'multiply') {
    result = n1 * n2
  } else if (operator === 'divide') {
    result = n1 / n2
  }
  
  return result
}

Lembre-se de que firstValue e secondValue são strings. Se juntar os dois valores, você vai concatená-los (1 + 1 = 11).

Portanto, antes de calcular o resultado, queremos converter strings em números. Podemos fazer isso com as funções parseInt e parseFloat.

  • parseInt converte uma string em um número inteiro.
  • parseFloat converte uma string em um um número de ponto flutuante (um número com casas decimais).

Para uma calculadora, precisamos de um número com casas decimais.

const calculate = (n1, operator, n2) => {
  let result = ''
  
  if (operator === 'add') {
    result = parseFloat(n1) + parseFloat(n2)
  } else if (operator === 'subtract') {
    result = parseFloat(n1) - parseFloat(n2)
  } else if (operator === 'multiply') {
    result = parseFloat(n1) * parseFloat(n2)
  } else if (operator === 'divide') {
    result = parseFloat(n1) / parseFloat(n2)
  }
  
  return result
}

Isso é tudo para o caminho ideal!

Você pode pegar o código fonte para o caminho ideal através deste link (texto em inglês - role até o final e digite seu nome e endereço de e-mail, que o código fonte será enviado diretamente para o seu e-mail).

Casos mais extremos

O caminho ideal não é o suficiente. Para construir uma calculadora robusta, é necessário tornar a sua calculadora resistente a padrões de entrada estranhos. Para fazer isso, você deve imaginar um arruaceiro que tenta quebrar sua calculadora, batendo as teclas na ordem errada. Vamos chamar este arruaceiro de Tim.

Tim pode clicar nessas teclas em qualquer ordem:

  1. Uma tecla numérica (0–9)
  2. Uma tecla de operador (+, -, ×, ÷)
  3. Um número decimal
  4. A tecla de sinal de igual (=)
  5. A tecla para limpar (AC)

O que acontece se o Tim clicar na tecla decimal

Se o Tim clicar em uma tecla decimal quando o visor já mostrar um ponto decimal, nada deve acontecer.

Lbvc-ZcYHO2iWjXIjdYiOVJcmPTmtwkknBw5
Orj4wS6vgnPAMYFq1xI3DEYXBMS4PWLlSw8a

Aqui podemos ver que o numero exibido contém um . com o método  includes.

includes verifica strings para uma determinada correspondência. Se uma string não for encontrada, ela retornará true; caso contrário, retornará false.

Observação: includes faz distinção entre maiúsculas e minúsculas.

// Exemplo do funcionamento de includes.
const string = 'The hamburgers taste pretty good!'
const hasExclamation = string.includes('!')
console.log(hasExclamation) // true

Para verificar se a string já possui um ponto, fazemos assim:

// Não fazer nada se a string já tiver um ponto
if (!displayedNum.includes('.')) {
  display.textContent = displayedNum + '.'
}

Em seguida, se Tim pressionar a tecla decimal depois de pressionar uma tecla de operador, o visor deverá mostrar 0..

fLLhOqkyFZqsOZIxgMPAkpezrUisGpDKFEsw

Agora precisamos saber se a tecla anterior é um operador. Podemos dizer verificando o atributo personalizado, data-previous-key-type, que definimos na lição anterior.

data-previous-key-type ainda não está completo. Para identificar corretamente se previousKeyType é um operador, precisamos atualizar previousKeyType para cada tecla clicada.

if (!action) {
  // ...
  calculator.dataset.previousKey = 'number'
}

if (action === 'decimal') {
  // ...
  calculator.dataset.previousKey = 'decimal'
}

if (action === 'clear') {
  // ...
  calculator.dataset.previousKeyType = 'clear'
}

if (action === 'calculate') {
 // ...
  calculator.dataset.previousKeyType = 'calculate'
}

Assim que tivermos o previousKeyType correto, podemos usá-lo para verificar se a tecla anterior é um operador.

if (action === 'decimal') {
  if (!displayedNum.includes('.')) {
    display.textContent = displayedNum + '.'
  } else if (previousKeyType === 'operator') {
    display.textContent = '0.'
  }
  
calculator.dataset.previousKeyType = 'decimal'
}

O que acontece se Tim apertar uma tecla de operador

Se Tim apertar uma tecla de operador primeiro, a tecla deve acender (já abordamos esse caso extremo, mas como? Veja se você consegue identificar o que fizemos).

q3D72rgBjtPOPUltYm1MMIN06dvxGOKyJyUs

Em segundo lugar, nada deve acontecer se Tim pressionar a mesma tecla de operador várias vezes (já cobrimos este caso extremo também).

Observação: se você quiser fornecer um UX melhor, você pode mostrar o operador sendo clicado repetidamente com algumas alterações de CSS. Nós não fizemos isso aqui, mas veja se você consegue programar isso sozinho como um desafio extra de codificação.

IXW7zY77RWE7tNQ6HZMYma73hsxW44EjWg0n

Terceiro, se Tim pressionar outra tecla de operador depois de pressionar a primeira tecla de operador, a primeira tecla de operador deve ser liberada. Em seguida, a segunda tecla do operador deve ser pressionada (também cobrimos esse caso extremo - mas como?).

Rez20RY9AcS6ORFWIIumk69YWzwTyv8qseM7

Quarto, se Tim acertar um número, um operador, um número e outro operador, nessa ordem, o display deve ser atualizado para um valor calculado.

MAMWFTkNu6Ho8tlMGyJlTfjCbeYq8rO0bQyR

Isso significa que precisamos usar a função calculate quando firstValue, operator e secondValue existirem.

if (
  action === 'add' ||
  action === 'subtract' ||
  action === 'multiply' ||
  action === 'divide'
) {
  const firstValue = calculator.dataset.firstValue
  const operator = calculator.dataset.operator
  const secondValue = displayedNum
  
// Obs: basta verificar firstValue e o operador, pois o secondValue existe sempre
  if (firstValue && operator) {
    display.textContent = calculate(firstValue, operator, secondValue)
  }
  
key.classList.add('is-depressed')
  calculator.dataset.previousKeyType = 'operator'
  calculator.dataset.firstValue = displayedNum
  calculator.dataset.operator = action
}

Embora possamos calcular um valor quando a tecla do operador é clicada pela segunda vez, também introduzimos um bug neste ponto - cliques adicionais na tecla do operador calculam um valor quando não deveriam.

8ktjtHeYaRTEn-lPbOM3fhEg3qrvDl5WfOVY

Para evitar que a calculadora execute um cálculo em cliques subsequentes na tela do operador, precisamos verificar se o previousKeyType é um operador. Se for, não realizamos o cálculo.

if (
  firstValue &&
  operator &&
  previousKeyType !== 'operator'
) {
  display.textContent = calculate(firstValue, operator, secondValue)
}

Quinto, após a tecla do operador calcular um número, se Tim pressionar um número, seguido por um outro operador, o operador deve continuar com o cálculo, dessa maneira: 8 - 1 = 7, 7 - 2 = 5, 5 - 3 = 2.

RSsXyuKJe0biqkH-WPDdrGLhFBWmyZ2R1J2Y

No momento, nossa calculadora não pode fazer cálculos consecutivos. O segundo valor calculado está errado. Isso é o que temos: 99 - 1 = 98, 98 - 1 = 0.

0r9I8Gu7J9pMbfzUG4hL6tU7RCP-cDhsaGp1

O segundo valor é calculado incorretamente, porque colocamos valores errados na função calculate. Vamos ver algumas imagens para entender o que nosso código faz.

Entendendo nossa função de cálculo

Primeiro, digamos que um usuário clique em um número, 99. Nesse momento, nada é registrado na calculadora.

0hH4Cz5kOEaDOcTQ2PMPmkDl26a8JHSXNrJ7

Segundo, digamos que o usuário clique no operador de subtração. Depois que o usuário clica no operador de subtração, definimos firstValue como 99. Também definimos o operator para subtrair.

0K-KPTzdCBgfVvVaDNcVDYSjXfUO8p5LRs2v

Terceiro, digamos que o usuário em um segundo valor - desta vez será 1. Neste momento, o número exibido é atualizado para 1, mas nosso firstValue, operator e secondValue permanecem inalterados.

0MacG-A5Tl7rZeB6NLeNvghVyBpmSqaZQkn9

Quarto, o usuário clica em subtrair novamente. Logo após clicar em subtrair, antes de calcularmos o resultado, definimos secondValue como o número exibido.

RgDMKK92og4djxxmaYO1HUYiVoetKDK9x0j7

Quinto, realizarmos o cálculo com firstValue 99, operator subtrair e secondValue 1. O resultado é 98.

Assim que o resultado é calculado, definimos a exibição para o resultado. Em seguida, configuramos o operado operator para subtrair e firstValue para o número exibido anteriormente.

X3VFJ5ar--k84pP3pM5VDVODvYlX4fCwHcnS

Bem, isso está muito errado! Se quisermos continuar com o cálculo, precisamos atualizar firstValue com o valor calculado.

gp-lkqhUOjoo46fIwx-7oLtbV7CP7jZwzc9y
const firstValue = calculator.dataset.firstValue
const operator = calculator.dataset.operator
const secondValue = displayedNum

if (
  firstValue &&
  operator &&
  previousKeyType !== 'operator'
) {
  const calcValue = calculate(firstValue, operator, secondValue)
  display.textContent = calcValue
  
// Atualizar o valor calculado como firstValue
  calculator.dataset.firstValue = calcValue
} else {
  // Se não houver cálculo, definir displayedNum como o firstValue
  calculator.dataset.firstValue = displayedNum
}

key.classList.add('is-depressed')
calculator.dataset.previousKeyType = 'operator'
calculator.dataset.operator = action

Com isso corrigido, os cálculos consecutivos feitos pelas teclas do operador devem agora estar corretos.

tKZ-VlIHo7dRNHDR2BBxZChE1cgqIuMU0Uh-

O que acontece se Tim apertar a tecla de sinal de igual?

Primeiro, nada deve acontecer se Tim apertar a tecla de sinal de igual antes de qualquer tecla de operador.

FBvnFZadNPXTllID0R7JfAkrsDb5SLcWTUhV

Sabemos que as teclas do operador ainda não foram clicadas se firstValue não estiver definido como um número. Podemos usar esse conhecimento para evitar que a tecla de sinal de igual seja calculada.

if (action === 'calculate') {
  const firstValue = calculator.dataset.firstValue
  const operator = calculator.dataset.operator
  const secondValue = displayedNum
  
if (firstValue) {
    display.textContent = calculate(firstValue, operator, secondValue)
  }
  
calculator.dataset.previousKeyType = 'calculate'
}

Segundo, se Tim acertar um número, seguido por um operador, seguido por um igual, a calculadora deve calcular o resultado de tal forma que:

  1. 2 + = —> 2 + 2 = 4
  2. 2 - = —> 2 - 2 = 0
  3. 2 × = —> 2 × 2 = 4
  4. 2 ÷ = —> 2 ÷ 2 = 1
MUgIi0ck8OJRV18hfJ-kdn8k7Ydyy5mDvV6z

Já levamos essa estranha entrada em consideração. Você consegue entender o porquê? :)

Terceiro, se Tim pressionar a tecla de sinal de igual após a conclusão de um cálculo, outro cálculo deverá ser realizado novamente. Veja como o cálculo deve ser lido:

  1. Tim aperta as teclas 5–1
  2. Tim aperta o sinal de igual. O valor calculado é 5 - 1 = 4
  3. Tim aperta o sinal de igual. O valor calculado é 4 - 1 = 3
  4. Tim aperta o sinal de igual. O valor calculado é 3 - 1 = 2
  5. Tim aperta o sinal de igual. O valor calculado é 2 - 1 = 1
  6. Tim aperta o sinal de igual. O valor calculado é 1 - 1 = 0

Infelizmente, nossa calculadora se atrapalha nesse cálculo. Veja o que nossa calculadora mostra:

  1. Tim aperta as teclas 5–1
  2. Tim aperta o sinal de igual. O valor calculado é 4
  3. Tim aperta o sinal de igual. O valor calculado é 1

Corrigindo o cálculo

Primeiro, suponhamos que o usuário clique no número 5. Neste momento, nada está registrado na calculadora ainda.

2vf5VGXNZ0vjGkyaY0y22PRTqqHDwgEKvCC3

Segundo, suponhamos que o usuário clique no operador de subtração. Depois que ele clicar no operador de subtração, definimos o firstValue para 5. Também definimos o operator para subtrair.

Fc-QupYbv3HInXqv1vHFCc1avhDe3iyEErhs

Terceiro, o usuário clica em um segundo valor. Digamos que seja 1. Nesse momento, o número exibido é atualizado para 1, mas nosso firstValue, operator e secondValue permanecem inalterados.

lW3CtoXJ1gxpUS5SZM3zh3zmqSB-ksM6E0vr

Quarto, o usuário clica na tecla do sinal de igual. Logo após clicar no sinal de igual, mas antes do cálculo, definimos secondValue como displayedNum.

yeQCYcu0ecbNbJlHa9aqEZopHj-FyTqXuRmw

Quinto, a calculadora calcula o resultado de 5 - 1 e dá 4. O resultado é atualizado para o visor. firstValue e operator são transportados para o próximo cálculo, pois não o atualizaremos.

YOsfq7AWCs0YbABkiebax-oaQVGc5tWsNyXJ

Sexto, quando o usuário clicar em igual novamente, definimos secondValue como displayedNum antes do cálculo.

BF7tBEUHJN4gnIwQqUTq9ctHIUIVcYM026Ro

Você pode dizer o que está errado aqui.

Ao invés de secondValue, queremos definir firstValue como o número exibido.

if (action === 'calculate') {
  let firstValue = calculator.dataset.firstValue
  const operator = calculator.dataset.operator
  const secondValue = displayedNum
  
if (firstValue) {
    if (previousKeyType === 'calculate') {
      firstValue = displayedNum
    }
    
display.textContent = calculate(firstValue, operator, secondValue)
  }
  
calculator.dataset.previousKeyType = 'calculate'
}

Também queremos levar o secondValue anterior para o novo cálculo. Para que secondValue persista no próximo cálculo, precisamos armazená-lo em outro atributo personalizado. Chamaremos esse atributo personalizado de modValue (significa valor do modificador).

if (action === 'calculate') {
  let firstValue = calculator.dataset.firstValue
  const operator = calculator.dataset.operator
  const secondValue = displayedNum
  
if (firstValue) {
    if (previousKeyType === 'calculate') {
      firstValue = displayedNum
    }
    
display.textContent = calculate(firstValue, operator, secondValue)
  }
  
// Definir o atributo modValue
  calculator.dataset.modValue = secondValue
  calculator.dataset.previousKeyType = 'calculate'
}

Se o previousKeyType é calculate, sabemos que podemos usar calculator.dataset.modValue como secondValue. Uma vez que sabemos disso, podemos fazer o cálculo.

if (firstValue) {
  if (previousKeyType === 'calculate') {
    firstValue = displayedNum
    secondValue = calculator.dataset.modValue
  }
  
display.textContent = calculate(firstValue, operator, secondValue)
}

Com isso, temos o cálculo correto quando a tecla do sinal de igual é clicada consecutivamente.

sjYX-ImohfhbFFbw1-FqmKagBvfFQKm0PzAu

Voltando para a tecla do sinal de igual

Quarto, se o Tim clicar em uma tecla decimal ou uma tecla numérica após a tecla da calculadora, o visor deve ser substituído por 0. ou pelo novo número, respectivamente.

Ao invés de apenas verificar se o previousKeyType é operator, também precisamos verificar se é calculate.

if (!action) {
  if (
    displayedNum === '0' ||
    previousKeyType === 'operator' ||
    previousKeyType === 'calculate'
  ) {
    display.textContent = keyContent
  } else {
    display.textContent = displayedNum + keyContent
  }
  calculator.dataset.previousKeyType = 'number'
}

if (action === 'decimal') {
  if (!displayedNum.includes('.')) {
    display.textContent = displayedNum + '.'
  } else if (
    previousKeyType === 'operator' ||
    previousKeyType === 'calculate'
  ) {
    display.textContent = '0.'
  }
  
calculator.dataset.previousKeyType = 'decimal'
}

Quinto, se Tim apertar uma tecla de operador logo após a tecla de sinal de igual, a calculadora não deve calcular.

uuifuJ41Oo86NXMsPj44RSQf7ExULROc2GaI

Para fazer isso, vamos verificar se o  previousKeyType é calculate antes de realizar cálculos com as teclas do operador.

if (
  action === 'add' ||
  action === 'subtract' ||
  action === 'multiply' ||
  action === 'divide'
) {
  // ...
  
if (
    firstValue &&
    operator &&
    previousKeyType !== 'operator' &&
    previousKeyType !== 'calculate'
  ) {
    const calcValue = calculate(firstValue, operator, secondValue)
    display.textContent = calcValue
    calculator.dataset.firstValue = calcValue
  } else {
    calculator.dataset.firstValue = displayedNum
  }
  
// ...
}

A chave para limpar (AC) tem dois usos:

  1. All Clear (denotado por AC) limpa tudo e restabelece a calculadora para seu estado inicial.
  2. Clear entry (denotado por CE) limpa a entrada atual. Mantém os números anteriores na memória.

Quando a calculadora estiver em seu estado padrão, deve ser mostrado AC.

22fj2VLJJ1SPexybqdWIqPRkj9JkrlI3AAYl

Primeiro, se o Tim clicar uma tecla (qualquer uma, exceto a de limpar), AC deve ser mudado para CE.

Hs9tjp3JQIYOaAgh8KDnxj5QShScU0nMkDa7

Fazemos isso verificando se data-action está clear. Caso não esteja clear, procuramos o botão clear e alteraremos seu textContent.

if (action !== 'clear') {
  const clearButton = calculator.querySelector('[data-action=clear]')
  clearButton.textContent = 'CE'
}

Em segundo lugar, se Tim apertar CE, o visor deve mostrar 0. Ao mesmo tempo, CE deverá ser revertido para AC para que Tim possa repor a calculadora em seu estado inicial.**

Dv6SFw5LY8wB0WqTFQBe46-QoraBiq8TvpdY
if (action === 'clear') {
  display.textContent = 0
  key.textContent = 'AC'
  calculator.dataset.previousKeyType = 'clear'
}

Terceiro, se Tim pressionar AC, a calculadora deverá ser redefinida para seu estado inicial.

Para redefinir a calculadora para seu estado inicial, precisamos limpar todos os atributos personalizados que definimos.

if (action === 'clear') {
  if (key.textContent === 'AC') {
    calculator.dataset.firstValue = ''
    calculator.dataset.modValue = ''
    calculator.dataset.operator = ''
    calculator.dataset.previousKeyType = ''
  } else {
    key.textContent = 'AC'
  }
  
display.textContent = 0
  calculator.dataset.previousKeyType = 'clear'
}

Então é isso — pelo menos para os casos mais extremos!

Você pode pegar o código fonte para a parte de casos extremos neste link (texto em inglês - role para baixo e digite seu endereço de e-mail, o código fonte será enviado diretamente para o seu e-mail).

Até agora, o código que criamos juntos é bastante confuso. Você provavelmente se perderá se tentar ler o código por conta própria. Vamos refatorá-lo para torná-lo mais limpo.

Refatorando o código

Quando você refatorar, muitas vezes você começará com as melhorias mais óbvias. Neste caso, vamos começar com calculate.

Antes de continuar, certifique-se de conhecer essas práticas/recursos de JavaScript. Vamos usá-los no refatoramento.

  1. Returns antecipados (texto explicativo em inglês)
  2. Operadores ternários
  3. Funções puras (texto explicativo em inglês)
  4. Desestruturação da ES6 (texto explicativo em inglês)

Com isso, vamos começar!

Refatorando a função de cálculo

Eis o que temos até agora.

const calculate = (n1, operator, n2) => {
  let result = ''
  if (operator === 'add') {
    result = parseFloat(n1) + parseFloat(n2)
  } else if (operator === 'subtract') {
    result = parseFloat(n1) - parseFloat(n2)
  } else if (operator === 'multiply') {
    result = parseFloat(n1) * parseFloat(n2)
  } else if (operator === 'divide') {
    result = parseFloat(n1) / parseFloat(n2)
  }
  
  return result
}

Você aprendeu que devemos reduzir o máximo possível as reatribuições. Aqui, podemos remover as tarefas se devolvermos o resultado do cálculo dentro das declarações de if e else if:

const calculate = (n1, operator, n2) => {
  if (operator === 'add') {
    return firstNum + parseFloat(n2)
  } else if (operator === 'subtract') {
    return parseFloat(n1) - parseFloat(n2)
  } else if (operator === 'multiply') {
    return parseFloat(n1) * parseFloat(n2)
  } else if (operator === 'divide') {
    return parseFloat(n1) / parseFloat(n2)
  }
}

Como retornamos todos os valores, podemos utilizar early returns (ou retornos antecipados, em tradução literal). Se o fizermos, não há necessidade das condições de else if.

const calculate = (n1, operator, n2) => {
  if (operator === 'add') {
    return firstNum + parseFloat(n2)
  }
  
  if (operator === 'subtract') {
    return parseFloat(n1) - parseFloat(n2)
  }
  
  if (operator === 'multiply') {
    return parseFloat(n1) * parseFloat(n2)
  }
  
  if (operator === 'divide') {
    return parseFloat(n1) / parseFloat(n2)
  }
}

E como temos uma declaração com a condição if, podemos remover os colchetes (embora alguns desenvolvedores afirmem que precisamos deles). Veja como ficaria o código:

const calculate = (n1, operator, n2) => {
  if (operator === 'add') return parseFloat(n1) + parseFloat(n2)
  if (operator === 'subtract') return parseFloat(n1) - parseFloat(n2)
  if (operator === 'multiply') return parseFloat(n1) * parseFloat(n2)
  if (operator === 'divide') return parseFloat(n1) / parseFloat(n2)
}

Chamamos parseFloat oito vezes na função. Podemos simplificá-lo criando duas variáveis para conter valores decimais:

const calculate = (n1, operator, n2) => {
  const firstNum = parseFloat(n1)
  const secondNum = parseFloat(n2)
  if (operator === 'add') return firstNum + secondNum
  if (operator === 'subtract') return firstNum - secondNum
  if (operator === 'multiply') return firstNum * secondNum
  if (operator === 'divide') return firstNum / secondNum
}

Terminamos calculate. Não acha que está mais fácil de ler em comparação com o código que tínhamos antes?

Refatorando o event listener (ouvinte de eventos)

O código que criamos para o ouvinte de eventos (event listener) é enorme. Veja o que temos no momento:

keys.addEventListener('click', e => {
  if (e.target.matches('button')) {
  
    if (!action) { /* ... */ }
    
    if (action === 'add' ||
      action === 'subtract' ||
      action === 'multiply' ||
      action === 'divide') {
      /* ... */
    }
    
    if (action === 'clear') { /* ... */ }
    if (action !== 'clear') { /* ... */ }
    if (action === 'calculate') { /* ... */ }
  }
})

Como você começaria a refatorar esse pedaço de código? Se você não conhece nenhuma prática recomendada de programação, pode ficar tentado a refatorar dividindo cada tipo de ação em uma função menor:

// Não faça isso!
const handleNumberKeys = (/* ... */) => {/* ... */}
const handleOperatorKeys = (/* ... */) => {/* ... */}
const handleDecimalKey = (/* ... */) => {/* ... */}
const handleClearKey = (/* ... */) => {/* ... */}
const handleCalculateKey = (/* ... */) => {/* ... */}

Não faça isso. Isso não ajuda, porque você está apenas dividindo blocos de código. Quando você faz isso, a função fica mais difícil de ler.

Uma maneira melhor é dividir o código em funções puras e impuras. Se fizer isso, você obterá um código parecido com este:

keys.addEventListener('click', e => {
  // Função pura
  const resultString = createResultString(/* ... */)
  
  // Cálculo "impuro"
  display.textContent = resultString
  updateCalculatorState(/* ... */)
})

Na código acima, createResultString é uma função pura que retorna o que precisa ser exibido na calculadora. updateCalculatorState é uma função impura que altera a aparência visual e os atributos personalizados da calculadora.

Criando createResultString

Como mencionado anteriormente, createResultString deve retornar o valor que precisa ser exibido na calculadora.
Você pode obter esses valores através de partes do código que diz display.textContent = 'some value.

display.textContent = 'some value'

Ao invés de display.textContent = 'some value', queremos retornar cada valor para que possamos usá-lo mais tarde.

// substitua o que temos acima por isso
return 'some value'

Vamos fazer um passo a passo, começando com as teclas numéricas.

Criando a string de resultado para teclas numéricas

Este é o código que temos para as teclas numéricas:

if (!action) {
  if (
    displayedNum === '0' ||
    previousKeyType === 'operator' ||
    previousKeyType === 'calculate'
  ) {
    display.textContent = keyContent
  } else {
    display.textContent = displayedNum + keyContent
  }
  calculator.dataset.previousKeyType = 'number'
}

O primeiro passo é copiar as partes que dizem display.textContent = 'some value' emcreateResultString. Ao fazer isso, certifique-se de mudar o display.textContent = em return.

const createResultString = () => {
  if (!action) {
    if (
      displayedNum === '0' ||
      previousKeyType === 'operator' ||
      previousKeyType === 'calculate'
    ) {
      return keyContent
    } else {
      return displayedNum + keyContent
    }
  }
}

Em seguida, podemos converter a declaração if/else para um operador ternário:

const createResultString = () => {
  if (action!) {
    return displayedNum === '0' ||
      previousKeyType === 'operator' ||
      previousKeyType === 'calculate'
      ? keyContent
      : displayedNum + keyContent
  }
}

Quando você refatorar, lembre-se de anotar uma lista de variáveis que você precisa. Voltaremos à lista mais tarde.

const createResultString = () => {
  // As variáveis necessárias são:
  // 1. keyContent
  // 2. displayedNum
  // 3. previousKeyType
  // 4. action
  
  if (action!) {
    return displayedNum === '0' ||
      previousKeyType === 'operator' ||
      previousKeyType === 'calculate'
      ? keyContent
      : displayedNum + keyContent
  }
}

Criando a string de resultado para a tecla decimal

Aqui está o código que temos para a tecla decimal:

if (action === 'decimal') {
  if (!displayedNum.includes('.')) {
    display.textContent = displayedNum + '.'
  } else if (
    previousKeyType === 'operator' ||
    previousKeyType === 'calculate'
  ) {
    display.textContent = '0.'
  }
  
  calculator.dataset.previousKeyType = 'decimal'
}

Assim como antes, queremos mover qualquer coisa que mude o display.textContent para createResultString.

const createResultString = () => {
  // ...
  
  if (action === 'decimal') {
    if (!displayedNum.includes('.')) {
      return = displayedNum + '.'
    } else if (previousKeyType === 'operator' || previousKeyType === 'calculate') {
      return = '0.'
    }
  }
}

Como queremos retornar todos os valores, podemos converter a declaração else if para early returns (retornos antecipados, em tradução livre).

const createResultString = () => {
  // ...
  
  if (action === 'decimal') {
    if (!displayedNum.includes('.')) return displayedNum + '.'
    if (previousKeyType === 'operator' || previousKeyType === 'calculate') return '0.'
  }
}

Um erro comum aqui é esquecer de retornar o número exibido no momento quando nenhuma condição é correspondida. Precisamos disso porque substituiremos display.textContent pelo valor retornado de createResultString. Se falharmos, createResultString retornará undefined, o que não é o que desejamos.

const createResultString = () => {
  // ...
  
  if (action === 'decimal') {
    if (!displayedNum.includes('.')) return displayedNum + '.'
    if (previousKeyType === 'operator' || previousKeyType === 'calculate') return '0.'
    return displayedNum
  }
}

Como sempre, observe as variáveis que são necessárias. Neste momento, as variáveis necessárias permanecem as mesmas de antes:

const createResultString = () => {
  // As variáveis necessárias são:
  // 1. keyContent
  // 2. displayedNum
  // 3. previousKeyType
  // 4. action
}

Criando a string de resultados para as teclas do operador

Esse é o código que escrevemos para as teclas do operador.

if (
  action === 'add' ||
  action === 'subtract' ||
  action === 'multiply' ||
  action === 'divide'
) {
  const firstValue = calculator.dataset.firstValue
  const operator = calculator.dataset.operator
  const secondValue = displayedNum
  
  if (
    firstValue &&
    operator &&
    previousKeyType !== 'operator' &&
    previousKeyType !== 'calculate'
  ) {
    const calcValue = calculate(firstValue, operator, secondValue)
    display.textContent = calcValue
    calculator.dataset.firstValue = calcValue
  } else {
    calculator.dataset.firstValue = displayedNum
  }
  
  key.classList.add('is-depressed')
  calculator.dataset.previousKeyType = 'operator'
  calculator.dataset.operator = action
}

Você já sabe o que fazer: queremos mover tudo o que altera display.textContent paracreateResultString. Isso é o que precisa ser movido:

const createResultString = () => {
  // ...
  if (
    action === 'add' ||
    action === 'subtract' ||
    action === 'multiply' ||
    action === 'divide'
  ) {
    const firstValue = calculator.dataset.firstValue
    const operator = calculator.dataset.operator
    const secondValue = displayedNum
    
    if (
      firstValue &&
      operator &&
      previousKeyType !== 'operator' &&
      previousKeyType !== 'calculate'
    ) {
      return calculate(firstValue, operator, secondValue)
    }
  }
}

Lembre-se: a createResultString precisa retornar o valor a ser exibido na calculadora. Se a condição if não corresponder, ainda assim queremos retornar o número exibido.

const createResultString = () => {
  // ...
  if (
    action === 'add' ||
    action === 'subtract' ||
    action === 'multiply' ||
    action === 'divide'
  ) {
    const firstValue = calculator.dataset.firstValue
    const operator = calculator.dataset.operator
    const secondValue = displayedNum
    
    if (
      firstValue &&
      operator &&
      previousKeyType !== 'operator' &&
      previousKeyType !== 'calculate'
    ) {
      return calculate(firstValue, operator, secondValue)
    } else {
      return displayedNum
    }
  }
}

Podemos então refatorar a declaração if/else em um operador ternário:

const createResultString = () => {
  // ...
  if (
    action === 'add' ||
    action === 'subtract' ||
    action === 'multiply' ||
    action === 'divide'
  ) {
    const firstValue = calculator.dataset.firstValue
    const operator = calculator.dataset.operator
    const secondValue = displayedNum
    
    return firstValue &&
      operator &&
      previousKeyType !== 'operator' &&
      previousKeyType !== 'calculate'
      ? calculate(firstValue, operator, secondValue)
      : displayedNum
  }
}

Se você olhar com atenção, perceberá que não há necessidade de armazenar uma variável secondValue. Podemos usar displayedNum diretamente na função calculate.

const createResultString = () => {
  // ...
  if (
    action === 'add' ||
    action === 'subtract' ||
    action === 'multiply' ||
    action === 'divide'
  ) {
    const firstValue = calculator.dataset.firstValue
    const operator = calculator.dataset.operator
    
    return firstValue &&
      operator &&
      previousKeyType !== 'operator' &&
      previousKeyType !== 'calculate'
      ? calculate(firstValue, operator, displayedNum)
      : displayedNum
  }
}

Por fim, tome nota das variáveis e propriedades necessárias. Desta vez, precisamos calculator.dataset.firstValue e calculator.dataset.operator.

const createResultString = () => {
  // As variáveis e propriedades necessárias são:
  // 1. keyContent
  // 2. displayedNum
  // 3. previousKeyType
  // 4. action
  // 5. calculator.dataset.firstValue
  // 6. calculator.dataset.operator
}

Criando a string de resultado para a tecla de limpar

Escrevemos o código abaixo para controlar a tecla clear.

if (action === 'clear') {
  if (key.textContent === 'AC') {
    calculator.dataset.firstValue = ''
    calculator.dataset.modValue = ''
    calculator.dataset.operator = ''
    calculator.dataset.previousKeyType = ''
  } else {
    key.textContent = 'AC'
  }
  
  display.textContent = 0
  calculator.dataset.previousKeyType = 'clear'
}

Como acima, queremos mover tudo o que muda o display.textContent para createResultString.

const createResultString = () => {
  // ...
  if (action === 'clear') return 0
}

Construindo a string de resultado para a tecla de igual

Aqui está o código que temos para a tecla de igual:

if (action === 'calculate') {
  let firstValue = calculator.dataset.firstValue
  const operator = calculator.dataset.operator
  let secondValue = displayedNum
  
  if (firstValue) {
    if (previousKeyType === 'calculate') {
      firstValue = displayedNum
      secondValue = calculator.dataset.modValue
    }
    
    display.textContent = calculate(firstValue, operator, secondValue)
  }
  
  calculator.dataset.modValue = secondValue
  calculator.dataset.previousKeyType = 'calculate'
}

Como acima, queremos copiar tudo o que muda o display.textContent para createResultString. Abaixo o que precisa ser copiado:

if (action === 'calculate') {
  let firstValue = calculator.dataset.firstValue
  const operator = calculator.dataset.operator
  let secondValue = displayedNum
  
  if (firstValue) {
    if (previousKeyType === 'calculate') {
      firstValue = displayedNum
      secondValue = calculator.dataset.modValue
    }
    display.textContent = calculate(firstValue, operator, secondValue)
  }
}

Ao copiar o código para createResultString, certifique-se de retornar valores para todos os cenários possíveis:

const createResultString = () => {
  // ...
  
  if (action === 'calculate') {
    let firstValue = calculator.dataset.firstValue
    const operator = calculator.dataset.operator
    let secondValue = displayedNum
    
    if (firstValue) {
      if (previousKeyType === 'calculate') {
        firstValue = displayedNum
        secondValue = calculator.dataset.modValue
      }
      return calculate(firstValue, operator, secondValue)
    } else {
      return displayedNum
    }
  }
}

Em seguida, queremos reduzir reatribuições. Podemos fazer isso passando os valores corretos para calculate por meio de um operador ternário.

const createResultString = () => {
  // ...
  
  if (action === 'calculate') {
    const firstValue = calculator.dataset.firstValue
    const operator = calculator.dataset.operator
    const modValue = calculator.dataset.modValue
    
    if (firstValue) {
      return previousKeyType === 'calculate'
        ? calculate(displayedNum, operator, modValue)
        : calculate(firstValue, operator, displayedNum)
    } else {
      return displayedNum
    }
  }
}

É possível simplificar ainda mais o código acima com outro operador ternário se você se sentir confortável com ele:

const createResultString = () => {
  // ...
  
  if (action === 'calculate') {
    const firstValue = calculator.dataset.firstValue
    const operator = calculator.dataset.operator
    const modValue = calculator.dataset.modValue
    
    return firstValue
      ? previousKeyType === 'calculate'
        ? calculate(displayedNum, operator, modValue)
        : calculate(firstValue, operator, displayedNum)
      : displayedNum
  }
}

Agora, queremos anotar novamente as propriedades e variáveis necessárias:

const createResultString = () => {
  // As variáveis e propriedades necessárias são:
  // 1. keyContent
  // 2. displayedNum
  // 3. previousKeyType
  // 4. action
  // 5. calculator.dataset.firstValue
  // 6. calculator.dataset.operator
  // 7. calculator.dataset.modValue
}

Passando as variáveis necessárias

Precisamos de sete propriedades/variáveis em createResultString:

  1. keyContent
  2. displayedNum
  3. previousKeyType
  4. action
  5. firstValue
  6. modValue
  7. operator

Podemos obter keyContent e action a partir de key. Também podemos obter firstValue, modValue, operator e previousKeyType a partir de calculator.dataset.

Isso significa que a função createResultString precisa de três variáveis — key, displayedNum e calculator.dataset. Como calculator.dataset representa o estado da calculadora, vamos usar uma variável chamada state.

const createResultString = (key, displayedNum, state) => {
  const keyContent = key.textContent
  const action = key.dataset.action
  const firstValue = state.firstValue
  const modValue = state.modValue
  const operator = state.operator
  const previousKeyType = state.previousKeyType
  // ... Refatore conforme seja necessário
}

// Usando createResultString
keys.addEventListener('click', e => {
  if (e.target.matches('button')) return
  const displayedNum = display.textContent
  const resultString = createResultString(e.target, displayedNum, calculator.dataset)
  
  // ...
})

Sinta-se à vontade para desestruturar variáveis, se desejar:

const createResultString = (key, displayedNum, state) => {
  const keyContent = key.textContent
  const { action } = key.dataset
  const {
    firstValue,
    modValue,
    operator,
    previousKeyType
  } = state
  
  // ...
}

Consistência nas declarações if

Em createResultString, usamos as seguintes condições para testar o tipo de teclas que foram clicadas:

// Se a tecla for numérica
if (!action) { /* ... */ }

// Se a tecla for decimal
if (action === 'decimal') { /* ... */ }

// Se a tecla for um operador
if (
  action === 'add' ||
  action === 'subtract' ||
  action === 'multiply' ||
  action === 'divide'
) { /* ... */}

// Se a tecla for a de limpar
if (action === 'clear') { /* ... */ }

// Se a tecla for um calcular
if (action === 'calculate') { /* ... */ }

Eles não são consistentes, por isso são difíceis de ler. Se possível, queremos torná-las consistentes para que possamos escrever algo assim:

if (keyType === 'number') { /* ... */ }
if (keyType === 'decimal') { /* ... */ }
if (keyType === 'operator') { /* ... */}
if (keyType === 'clear') { /* ... */ }
if (keyType === 'calculate') { /* ... */ }

Para isso, podemos criar uma função chamada getKeyType. Essa função deve retornar o tipo de tecla que foi clicada.

const getKeyType = (key) => {
  const { action } = key.dataset
  if (!action) return 'number'
  if (
    action === 'add' ||
    action === 'subtract' ||
    action === 'multiply' ||
    action === 'divide'
  ) return 'operator'
  // Para todo o resto, retorne a ação (action)
  return action
}

Veja como você usaria a função:

const createResultString = (key, displayedNum, state) => {
  const keyType = getKeyType(key)
  
  if (keyType === 'number') { /* ... */ }
  if (keyType === 'decimal') { /* ... */ }
  if (keyType === 'operator') { /* ... */}
  if (keyType === 'clear') { /* ... */ }
  if (keyType === 'calculate') { /* ... */ }
}

Terminamos com a função createResultString. Vamos continuar com updateCalculatorState.

Construindo updateCalculatorState

updateCalculatorState é uma função que altera a aparência visual e os atributos personalizados da calculadora.

Assim como createResultString, precisamos verificar qual tecla foi clicada. Nesse caso, vamos reutilizar a função getKeyType.

const updateCalculatorState = (key) => {
  const keyType = getKeyType(key)
  
  if (keyType === 'number') { /* ... */ }
  if (keyType === 'decimal') { /* ... */ }
  if (keyType === 'operator') { /* ... */}
  if (keyType === 'clear') { /* ... */ }
  if (keyType === 'calculate') { /* ... */ }
}

Se você observar o código restante, poderá notar que mudamos os dados data-previous-key-type para cada tipo de tecla. Veja abaixo como o código se parece:

const updateCalculatorState = (key, calculator) => {
  const keyType = getKeyType(key)
  
  if (!action) {
    // ...
    calculator.dataset.previousKeyType = 'number'
  }
  
  if (action === 'decimal') {
    // ...
    calculator.dataset.previousKeyType = 'decimal'
  }
  
  if (
    action === 'add' ||
    action === 'subtract' ||
    action === 'multiply' ||
    action === 'divide'
  ) {
    // ...
    calculator.dataset.previousKeyType = 'operator'
  }
  
  if (action === 'clear') {
    // ...
    calculator.dataset.previousKeyType = 'clear'
  }
  
  if (action === 'calculate') {
    calculator.dataset.previousKeyType = 'calculate'
  }
}

Isso é redundante porque já conhecemos o tipo de tecla com o getKeyType. Podemos refatorar o código acima para:

const updateCalculatorState = (key, calculator) => {
  const keyType = getKeyType(key)
  calculator.dataset.previousKeyType = keyType
    
  if (keyType === 'number') { /* ... */ }
  if (keyType === 'decimal') { /* ... */ }
  if (keyType === 'operator') { /* ... */}
  if (keyType === 'clear') { /* ... */ }
  if (keyType === 'calculate') { /* ... */ }
}

Construindo updateCalculatorState para teclas de operador

Visualmente, precisamos garantir que todas as teclas liberem seu estado deprimido. Abaixo, podemos copiar e colar o código que tínhamos antes:

const updateCalculatorState = (key, calculator) => {
  const keyType = getKeyType(key)
  calculator.dataset.previousKeyType = keyType
  
  Array.from(key.parentNode.children).forEach(k => k.classList.remove('is-depressed'))
}

Aqui está o que sobrou do que escrevemos para as teclas de operador, depois de mover as partes relacionadas a display.textContent para createResultString.

if (keyType === 'operator') {
  if (firstValue &&
      operator &&
      previousKeyType !== 'operator' &&
      previousKeyType !== 'calculate'
  ) {
    calculator.dataset.firstValue = calculatedValue
  } else {
    calculator.dataset.firstValue = displayedNum
  }
  
  key.classList.add('is-depressed')
  calculator.dataset.operator = key.dataset.action
}

Você pode perceber que podemos encurtar o código com um operador ternário:

if (keyType === 'operator') {
  key.classList.add('is-depressed')
  calculator.dataset.operator = key.dataset.action
  calculator.dataset.firstValue = firstValue &&
    operator &&
    previousKeyType !== 'operator' &&
    previousKeyType !== 'calculate'
    ? calculatedValue
    : displayedNum
}

Como anteriormente, observe as variáveis e propriedades que você precisa. Aqui, nós vamos precisar de calculatedValue e displayedNum.

const updateCalculatorState = (key, calculator) => {
  // Variáveis e propriedades necessárias
  // 1. key
  // 2. calculator
  // 3. calculatedValue
  // 4. displayedNum
}

Construindo updateCalculatorState para a tecla de limpar (AC)

Aqui está o código restante para a tecla de limpeza:

if (action === 'clear') {
  if (key.textContent === 'AC') {
    calculator.dataset.firstValue = ''
    calculator.dataset.modValue = ''
    calculator.dataset.operator = ''
    calculator.dataset.previousKeyType = ''
  } else {
    key.textContent = 'AC'
  }
}

if (action !== 'clear') {
  const clearButton = calculator.querySelector('[data-action=clear]')
  clearButton.textContent = 'CE'
}

Não há muito o que possamos refatorar aqui. Sinta-se à vontade para copiar e colar tudo em updateCalculatorState.

Construindo updateCalculatorState para a tecla de sinal de igual

Abaixo o código que escrevemos para a chave de igual:

if (action === 'calculate') {
  let firstValue = calculator.dataset.firstValue
  const operator = calculator.dataset.operator
  let secondValue = displayedNum
  
  if (firstValue) {
    if (previousKeyType === 'calculate') {
      firstValue = displayedNum
      secondValue = calculator.dataset.modValue
    }
    
    display.textContent = calculate(firstValue, operator, secondValue)
  }
  
  calculator.dataset.modValue = secondValue
  calculator.dataset.previousKeyType = 'calculate'
}

Eis o que nos resta se removermos tudo o que diz respeito a display.textContent.

if (action === 'calculate') {
  let secondValue = displayedNum
  
  if (firstValue) {
    if (previousKeyType === 'calculate') {
      secondValue = calculator.dataset.modValue
    }
  }
  
  calculator.dataset.modValue = secondValue
}

Podemos refatorar isto para o seguinte:

if (keyType === 'calculate') {
  calculator.dataset.modValue = firstValue && previousKeyType === 'calculate'
    ? modValue
    : displayedNum
}

Como sempre, anote as propriedades e variáveis usadas:

const updateCalculatorState = (key, calculator) => {
  // Variáveis e propriedades necessárias
  // 1. key
  // 2. calculator
  // 3. calculatedValue
  // 4. displayedNum
  // 5. modValue
}

Passando variáveis necessárias

Sabemos que precisamos de cinco propriedades/variáveis para updateCalculatorState:

  1. key
  2. calculator
  3. calculatedValue
  4. displayedNum
  5. modValue

Como o modValue pode ser acessado de calculator.dataset,  só precisamos passar quatro valores:

const updateCalculatorState = (key, calculator, calculatedValue, displayedNum) => {
  // ...
}

keys.addEventListener('click', e => {
  if (e.target.matches('button')) return
  
  const key = e.target
  const displayedNum = display.textContent
  const resultString = createResultString(key, displayedNum, calculator.dataset)
  
  display.textContent = resultString
  
  // Passe os valores necessários
  updateCalculatorState(key, calculator, resultString, displayedNum)
})

Refatorando updateCalculatorState novamente

Mudamos três tipos de valores em updateCalculatorState:

  1. calculator.dataset
  2. A classe para operadores pressionados/despressionados
  3. AC para CE

Se quiser tornar ainda mais claro, você pode dividir (2) e (3) em outra função — updateVisualState. Veja como pode ficar o  updateVisualState:

const updateVisualState = (key, calculator) => {
  const keyType = getKeyType(key)
  Array.from(key.parentNode.children).forEach(k => k.classList.remove('is-depressed'))
  
  if (keyType === 'operator') key.classList.add('is-depressed')
  
  if (keyType === 'clear' && key.textContent !== 'AC') {
    key.textContent = 'AC'
  }
  
  if (keyType !== 'clear') {
    const clearButton = calculator.querySelector('[data-action=clear]')
    clearButton.textContent = 'CE'
  }
}

Finalizando

O código fica muito mais limpo após a refatoração. Se você examinar o ouvinte de eventos (event listener), saberá o que cada função faz. Abaixo, está a versão final do ouvinte de eventos (event listener):

keys.addEventListener('click', e => {
  if (e.target.matches('button')) return
  const key = e.target
  const displayedNum = display.textContent
  
  // Funções puras
  const resultString = createResultString(key, displayedNum, calculator.dataset)
  
  // Estados (states) atualizados
  display.textContent = resultString
  updateCalculatorState(key, calculator, resultString, displayedNum)
  updateVisualState(key, calculator)
})

Você pode pegar o código fonte para a parte de refatoração através deste link (texto em inglês - role até o final e digite seu nome e endereço de e-mail, que o código fonte será enviado diretamente para o seu e-mail).

Espero que tenha gostado deste artigo. Caso tenha gostado, talvez se interesse por Learn JavaScript ("Aprenda Javascript", em inglês) —um curso onde mostrarei como construir 20 componentes, passo a passo, da mesma maneira que construímos essa calculadora.

Observação: podemos melhorar ainda mais a calculadora adicionando suporte ao teclado e recursos de acessibilidade, como regiões, ao vivo. Quer saber como? Confira em Learn JavaScript (em inglês) :)