Omawiamy cudzy projekt z Githuba, kalkulator w czystym JS. Proszę zwrócić uwagę na kwestie takie jak brak TypeScripta oraz brak frameworka i jak wpływa to na pisanie i czytelność kodu. Zaczynajmy.
Autor – WebDevSimplified, z jego Githuba. Świetny projekt, ale brak TSa przy OOP…. No ja tak przynajmniej miałem z Vanilla JSowymi kodami OOP, najpierw taki zachwyt, jakie to piękne, on używa klas i manipuluje DOMem, a potem takie (już po lepszym poznaniu TSa) obrzydzenie, on pisze kod OOP bez TSa, nic nie wiadomo o co chodzi, jak chcesz przeczytać i zrozumieć.
Dobra, ale projekt sam w sobie jest świetny. Po prostu brakuje mi TypeScripta. Zaczynajmy, zobaczmy jaki HTML:
<body>
<div class="calculator-grid">
<div class="output">
<div data-previous-operand class="previous-operand"></div>
<div data-current-operand class="current-operand"></div>
</div>
<button data-all-clear class="span-two">AC</button>
<button data-delete>DEL</button>
<button data-operation>÷</button>
<button data-number>1</button>
<button data-number>2</button>
<button data-number>3</button>
<button data-operation>*</button>
<button data-number>4</button>
<button data-number>5</button>
<button data-number>6</button>
<button data-operation>+</button>
<button data-number>7</button>
<button data-number>8</button>
<button data-number>9</button>
<button data-operation>-</button>
<button data-number>.</button>
<button data-number>0</button>
<button data-equals class="span-two">=</button>
</div>
</body>
Ok, fajnie. Jak widać te datasety są tylko po to, aby je łapać query selectorem, co też autor czyni:
const numberButtons = document.querySelectorAll('[data-number]')
const operationButtons = document.querySelectorAll('[data-operation]')
const equalsButton = document.querySelector('[data-equals]')
const deleteButton = document.querySelector('[data-delete]')
const allClearButton = document.querySelector('[data-all-clear]')
const previousOperandTextElement = document.querySelector('[data-previous-operand]')
const currentOperandTextElement = document.querySelector('[data-current-operand]')
Potem tworzy instancję kalkulatora oraz dodaje event listenery:
const calculator = new Calculator(previousOperandTextElement, currentOperandTextElement)
numberButtons.forEach(button => {
button.addEventListener('click', () => {
calculator.appendNumber(button.innerText)
calculator.updateDisplay()
})
})
operationButtons.forEach(button => {
button.addEventListener('click', () => {
calculator.chooseOperation(button.innerText)
calculator.updateDisplay()
})
})
equalsButton.addEventListener('click', button => {
calculator.compute()
calculator.updateDisplay()
})
allClearButton.addEventListener('click', button => {
calculator.clear()
calculator.updateDisplay()
})
deleteButton.addEventListener('click', button => {
calculator.delete()
calculator.updateDisplay()
})
Ok, to już znamy wszystkie metody, no prawie wszystkie:
- Każdy button wywoła swoją akcję i metode updateDisplay
- Numeryczne buttony mają akcję appendNumber, do której przekazują liczbę/kropkę
- Operacyjne buttony mają metodę chooseOperation, do której przekazują swój operator
- Znak równości ma metodę compute, do której nic nie przekazuje
- Podobnie clear i delete mają metody clear i delete…
To jest generalnie świetny kod, choć ma kilka dziwactw, tylko 1000x lepiej by się to czytało w TSie. Nawet może przetłumaczymy niedługo to sobie.
Ok, jednej rzeczy nie będziemy tłumaczyć, i jest to metoda util, do formatowania liczb:
getDisplayNumber(number) {
const stringNumber = number.toString()
const integerDigits = parseFloat(stringNumber.split('.')[0])
const decimalDigits = stringNumber.split('.')[1]
let integerDisplay
if (isNaN(integerDigits)) {
integerDisplay = ''
} else {
integerDisplay = integerDigits.toLocaleString('en', { maximumFractionDigits: 0 })
}
if (decimalDigits != null) {
return `${integerDisplay}.${decimalDigits}`
} else {
return integerDisplay
}
}
Tu generalnie facet robi explode (mówiąc PHPem) na stringu, rozbija na część intową i floatową i upewnia się, że ma piękne formatowanie, takie amerykańskie, czyli przecinki w intach co 3, część dziesiętna po kropce i bez przecinków.
Możemy zrobić return number, żeby to nam konstrukcji nie psuło, jak nie ogarniamy. Nieważne. Zwróćmy uwagę na output:
<div class="output">
<div data-previous-operand class="previous-operand"></div>
<div data-current-operand class="current-operand"></div>
</div>
I te elementy będzie przyjmować nasza klasa jako konstruktor. W TSie (powtarzam się, bo zdążyłem już TSa polubić) byłoby to lepiej widać, ale ok:
class Calculator {
constructor(previousOperandTextElement, currentOperandTextElement) {
this.previousOperandTextElement = previousOperandTextElement
this.currentOperandTextElement = currentOperandTextElement
this.clear()
}
//(...)
}
Przypisuje sobie te elementy, aby mógł nimi manipulować w update potem. Jeszcze robi clear:
clear() {
this.currentOperand = ''
this.previousOperand = ''
this.operation = undefined
}
Powtórzę to raz jeszcze – w TSie wszystko byłoby lepiej widać. Tutaj tworzy pola i jednocześnie przypisuje im wartość. Ta funkcja de facto łamie zasady jednego przeznaczenia. Jak?
Ano tak, że z jednej strony tworzy pola, z drugiej ma swoje przeznaczenie, jakim jest czyszczenie tych pól. Cóż, tak się robi OOP w JS. Oczywiście, możemy te pola wpisać sobie do konstruktora w ten sposób i wywalić clear, też można. Mimo wszystko trudno się to czyta.
Kolejna rzecz to pytanie, gdzie wyznaczyć granice. Człowiek człowiekowi nierówny, jeden napisze klasę dla każdej akcji (to znaczy klasa akcja, instancje + – / i tak dalej), jeszcze inny buildera kalkulatorów, bo w sumie czemu nie. Masz zrobić OOP kalkulator, tylko gdzie postawisz sobie granicę?
Moim zdaniem granica jest tam, gdzie robisz coś skończonego, choć nie działającego. Jak część HTMLowo-CSSową i autor tak zrobił, a ta klasa musi polegać na architekturze nakreślonej przez właśnie HTML i CSS.
Ok, ale wracając, oto delete:
delete() {
this.currentOperand = this.currentOperand.toString().slice(0, -1)
}
Ja to sobie ulepszyłem, bo jak nam kropka zostaje, to niech ją też zje:
delete() {
this.currentOperand = this.currentOperand.toString().slice(0, -1)
if(this.currentOperand.endsWith("."))
this.delete();
}
Ok, appendNumber:
appendNumber(number) {
if (number === '.' && this.currentOperand.includes('.')) return
this.currentOperand = this.currentOperand.toString() + number.toString()
}
Ma być tylko jedna kropka w całym stringu liczbowym (może być na początku, nie będzie problemu, .5 to 0.5). No i dodajemy po prawej nową cyfrę. Proste.
Ok, chooseOperation:
chooseOperation(operation) {
if (this.currentOperand === '') return
if (this.previousOperand !== '') {
this.compute()
}
this.operation = operation
this.previousOperand = this.currentOperand
this.currentOperand = ''
}
Jak nie mamy currenta, czyli nic nie wpisaliśmy, to nie ma wybierania operacji, return. Jak mamy poprzedni, czyli już daliśmy jakąś liczbę, np. 2, operację, np. + i kolejną liczbę, np. 3 i teraz wciskamy +, to ma nam obliczyć i zwrócić 5 + jako prev.
No to oblicza, potem do operacji przypisuje operację, previous to teraz current, zaś current wyczyszczony, czeka na nowe cyferki. Ok, ale tej metody nie zrozumiemy, jak nie zobaczymy, jak działa compute:
compute() {
let computation
const prev = parseFloat(this.previousOperand)
const current = parseFloat(this.currentOperand)
if (isNaN(prev) || isNaN(current)) return
switch (this.operation) {
case '+':
computation = prev + current
break
case '-':
computation = prev - current
break
case '*':
computation = prev * current
break
case '÷':
computation = prev / current
break
default:
return
}
this.currentOperand = computation
this.operation = undefined
this.previousOperand = ''
}
Ok, robi parseFloat na poprzednim i obecnym. Sprawdza, czy wszystko dobrze (isNaN). Jak taki, robi switch na this.operation i wykonuje odpowiednia operację.
Następnie do currentOperand przypisuje (jako number) wynik tej operacji (po to są te toStringi wyżej, po to także używamy TSa…), operacja to teraz undefined, previousOperand to pusty string.
I teraz mamy takie coś, 2, +, 3, +. To kalkulator ma pola 2, +, 3, a chooseOperation dostaje argument +. To wykonuje się compute na 2 + 3, mamy 5, 5 idzie do currenta, operacja to undefined, prev to pusty string.
Ale teraz, jeszcze zostaje nam ten + jako argument chooseOperation. Ten ostatni. To on idzie do operation, current jest czyszczony, wpierw przypisany do prev.
I jako prevText wyświetla się 5+, jako klasa mamy prev 5, operation + i current ”. Tym, aby dobrze się wyświetlało zajmuje się update:
getDisplayNumber(number) {
const stringNumber = number.toString()
const integerDigits = parseFloat(stringNumber.split('.')[0])
const decimalDigits = stringNumber.split('.')[1]
let integerDisplay
if (isNaN(integerDigits)) {
integerDisplay = ''
} else {
integerDisplay = integerDigits.toLocaleString('en', { maximumFractionDigits: 0 })
}
if (decimalDigits != null) {
return `${integerDisplay}.${decimalDigits}`
} else {
return integerDisplay
}
}
updateDisplay() {
this.currentOperandTextElement.innerText =
this.getDisplayNumber(this.currentOperand)
if (this.operation != null) {
this.previousOperandTextElement.innerText =
`${this.getDisplayNumber(this.previousOperand)} ${this.operation}`
} else {
this.previousOperandTextElement.innerText = ''
}
}
}
Pierwszy element update sprawia, że na ekranie reaktywnie widzimy dodawanie kolejnej cyferki po prawej na dole, czyli current. Drugi element patrzy, czy jakaś operacja nie wisi, jak wisi to mamy preva i operację (np. 5 +) i to wyświetla, w przeciwnym wypadku – kasuje zawartość tekstową preva.
No jak jest operacja, to znaczy, że pod this.prev i this.operation coś jest i trzeba to odzwierciedlić w tekstowej reprezentacji preva. Jak nie ma, to trzeba się upewnić, że tam jest pusto.
Jak komuś przeszkadza reaktywność bez Reacta to zapraszamy do Reacta. To bodaj najtrudniejszy element tego projektu. Bo tutaj mamy trzy rzeczy, jeden to interfejs HTMLowy, drugi to logika obiektowa, która ma wyciągnąć z tego interfejsu możliwość wykonywania operacji matematycznych tak, jak w kalkulatorze, zaś trzeci, to logika reaktywna (bez reacta), która ma za zadanie robić odpowiedni update wyglądu co każdą akcję, nawet dodanie cyfry.
I to proste nie jest, ale koniec końców ładnie wygląda i dobrze działa. Polecam przepisać na TSa, może nawet taki projekcik zrobimy.
Warto też zwrócić uwagę na jedną rzecz, a mianowicie jak zrobimy np. 10 + 2, mamy 12, del wtedy powinien wyczyścić 12, zaś każda liczba (np. 3) powinna zastąpić 12, nie dać 123.
Cóż, jeżeli czujemy się na siłach, możemy dodać taką flagę i poprawić to. Taka flaga powinna być ustawiana po zrobieniu =. A właśnie – może to do nas nie dotarło, ale nie tylko chooseOperation wywołuje compute.
Robi to też wciśnięcie znaku równości:
equalsButton.addEventListener('click', button => {
calculator.compute()
calculator.updateDisplay()
})
Myślę, że mimo świetnego kodu, ten projekt pokazuje nam 2 rzeczy, czyli po co jest TypeScript oraz po co jest React. I to nie dzięki użyciu tychże, ale poprzez ich brak i to jest mistrzostwo.