iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

1
自我挑戰組

花三十天找到 JavaScript 沙漠中的綠洲系列 第 31

番外篇(1)一起來做計算機!

距離完賽已經過了一陣子,前天想自己刻刻看計算機,拆解任務、實際執行後才發現知識量不足,導致無法順利完成。
https://ithelp.ithome.com.tw/upload/images/20210122/20129635kNko9a21iM.png

因為閱讀文字時常覺得很複雜、看不懂,我自己在學習上比較喜歡透過影片的方式(有時候直接看英文影片的吸收力,比讀中文文章更好XD) 這次的計算機也是跟著這位講師一起實作!

在此附上我在實作時的步驟、知識點與心得跟大家分享。如果未來有餘力,也許會再把這些錄成影片。
github // github page

釐清需求

在開始前要先釐清需求,我們要做出的畫面大致像是這樣:
畫面操作影片

  • 可以計算
  • 計算後的結果顯示在螢幕上
  • 數字不會超出螢幕
  • 按 ac 會清空算式
  • 按 backspace 會刪掉前一個數字
  • 不能重複按兩次以上的小數點

之前聽廖洧杰老師分享,說嘗試把大任務拆成中任務、小任務,再開始逐步完成每個任務,可以幫忙釐清自己到底是哪個部分出問題?是知識量不夠?還是有 bug ?因此我也將這樣的建議貫徹進練習當中。

以上是我在看影片以及實際做完後,拆出的任務。大家也可以嘗試自己思考看看,製作計算機會需要達到哪些功能?跟我還未開始前,自己拆解出的任務兩相比較:我本來想做出的畫面更為簡單,因此任務拆解時並沒有列出小數點以及 backspace 的功能。同時,我也沒有想到數字可能超出螢幕這種 bug 。

以下就帶著大家一步一步完成計算機!(大致都直接照著講師的程式打,少部分做一點點調整)
https://ithelp.ithome.com.tw/upload/images/20210122/20129635oQjHzLu2bh.png

html

html 應該算是最簡單也基礎的部分了。這裡,我們沒有套用任何框架。

首先,導入<link rel="stylesheet" href="all.css"><script src="all.js"></script>。打一個標題<h1>HTML Calculator</h1>

因為計算機的畫面看起來就像欄位一樣,因此用類似 bootstrap 的概念,在外層包 .container ,內層一行用一個 .row 包。加上結果顯示欄位共六行,包六次。 .row 的裡頭包 .column 代表每一格。

除了最上方的結果欄位,其他按鍵都跟計算有關,幫他們設 .calc-btn。同時,為了區分數字鍵和運算符號鍵,再各自設一個 class 名稱:.calc-btn-num 和 .calc-btn-operator 。

當然也別忘了幫每格都設置專屬的 id 。並且在 div 中打上按鍵上該出現的字。 backspace 符號是以 ⇤ 表示,÷ 以 ÷ 表示。

最後你就可以得到下方的程式碼:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="all.css">
    <title>計算機</title>
</head>
<body>
    <h1>HTML Calculator</h1>
    <div class="container">
        <div class="row">
            <div class="column" id="calc-display-val">0</div>
        </div>
        <div class="row">
            <div class="calc-btn column" id="calc-clear">AC</div>
            <div class="calc-btn column" id="calc-backspace">⇤</div> <!--backspace btn-->
            <div class="calc-btn calc-btn-operator column" id="calc-divide">÷</div> <!--divide ÷ btn-->
        </div>
        <div class="row">
            <div class="calc-btn calc-btn-num column" id="calc-seven">7</div>
            <div class="calc-btn calc-btn-num column" id="calc-eight">8</div>
            <div class="calc-btn calc-btn-num column" id="calc-nine">9</div>
            <div class="calc-btn calc-btn-operator column" id="calc-mutiply">x</div>
        </div>
        <div class="row">
            <div class="calc-btn calc-btn-num column" id="calc-four">4</div>
            <div class="calc-btn calc-btn-num column" id="calc-five">5</div>
            <div class="calc-btn calc-btn-num column" id="calc-six">6</div>
            <div class="calc-btn calc-btn-operator column" id="calc-minus">-</div>
        </div>
        <div class="row">
            <div class="calc-btn calc-btn-num column" id="calc-one">1</div>
            <div class="calc-btn calc-btn-num column" id="calc-two">2</div>
            <div class="calc-btn calc-btn-num column" id="calc-three">3</div>
            <div class="calc-btn calc-btn-operator column" id="calc-plus">+</div>
        </div>
        <div class="row">
            <div class="calc-btn calc-btn-num column" id="calc-zero">0</div>
            <div class="calc-btn column" id="calc-decimal">.</div>
            <div class="calc-btn calc-btn-operator column" id="calc-equals">=</div>
        </div>
    </div>
    <script src="all.js"></script>
</body>
</html>

CSS

接著進行 css 的部分。我自己是用 scss 去做,影片中的講師則是使用 css 。有些 css 設定他直接使用 id 處理,基於想著重練習 js 我是沒有特別花時間改掉,這部份如果想要可以再自己調整的更漂亮一些。

首先 import google font:@import url('https://fonts.googleapis.com/css?family=Roboto:100'); ,在 body 做字型設定。

為了不要讓計算機上的文字被反白,有另外設了

-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Old versions of Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently supported by Chrome, Edge, Opera and Firefox */

不讓文字反白, google 關鍵字是 css do not highlight

外層 .container 用 flex 讓整個計算機置中。 .row .column 分別用 display: table;display: table-cell; 去做,並在 .row 設定寬度與 table-layout: fixed;

計算機按鈕 .calc-btn 設定長寬、顏色、背景、文字置中、 hover 等樣式,還可以加上 cursor: pointer; 讓使用者體驗更好。結果顯示欄位也是類似的流程,設定高度、顏色、背景、 hover 等樣式。為了不要讓數字超出欄位很難看,要加上

overflow: hidden;  //數字輸入太多時不要超過欄位 
white-space: nowrap; //數字輸入太多時不要換行

因為按鈕 0 和 backspace 鍵我們想讓他比較大顆,所以額外設定他的寬度。最後,在結果欄、 0 和 = 設定圓弧,讓計算機四個角正好是圓的。其他細部的 css 樣式我就不一一贅述,可以參考下方的 scss 程式碼:

@import url('https://fonts.googleapis.com/css?family=Roboto:100');

body{
    font-family: "Roboto","sans-serif";
    -webkit-touch-callout: none; /* iOS Safari */
    -webkit-user-select: none; /* Safari */
     -khtml-user-select: none; /* Konqueror HTML */
       -moz-user-select: none; /* Old versions of Firefox */
        -ms-user-select: none; /* Internet Explorer/Edge */
            user-select: none; /* Non-prefixed version, currently
                                 supported by Chrome, Edge, Opera and Firefox */
}
.container{
    display: flex;
    flex-direction: column;
    align-items: center;
}
.calc-btn{
    background: silver;
    color: #000;
    width: 25px;
    height: 45px;
    border: 1px solid gray;
    text-align: center;
    cursor: pointer;
    font-size: 32px;
    font-weight: 100;
    padding-top: 3px;
    &:hover{
        background: orange;
    }
}
.row{
    display: table;
    table-layout: fixed;
    width: 200px;
}
.column{
    display: table-cell;
}
#calc-zero, #calc-clear{
    width: 52.6666667px;
}
#calc-zero{
    border-radius: 0 0 0 7px;
}
#calc-display-val{
    height: 80px;
    color: white;
    text-align: right;
    border-left: 1px solid gray;
    border-right: 1px solid gray;
    border-top: 1px solid gray;
    font-size: 48px;
    background: #383838;
    overflow: hidden;  //數字輸入太多時不要超過欄位
    white-space: nowrap; //數字輸入太多時不要換行
    padding: 12px; //結果欄位的數字跟欄位的padding
    border-radius: 7px 7px 0 0;
}
.calc-btn-operator{
    background: orange;
    color: white;
}
h1{
    text-align: center;
    margin-top: 50px;
}
#calc-equals{
    border-radius: 0 0 7px 0;
}

JS

終於要進入主題的部分。

1.宣告

宣告每個數字按鈕、小數點、 AC 、 backspace 鍵、顯示結果區。

前面在做 html 時,有額外將數字與運算符號分兩種 class ,這個部分也需要做宣告。如此一來,等等在做監聽時就可以把符合 class 名稱的資料,藉由這個宣告,分別放進名為 calcNumBtns 與 calcOperatorBtns 的陣列中。等到有需要時再取出。

const oneBtn = document.getElementById("calc-one");
const twoBtn = document.getElementById("calc-two");
const threeBtn = document.getElementById("calc-three");
const fourBtn = document.getElementById("calc-four");
const fiveBtn = document.getElementById("calc-five");
const sixBtn = document.getElementById("calc-six");
const sevenBtn = document.getElementById("calc-seven");
const eightBtn = document.getElementById("calc-eight");
const nineBtn = document.getElementById("calc-nine");
const zeroBtn = document.getElementById("calc-zero");

const decimalBtn = document.getElementById("calc-decimal");
const clearBtn = document.getElementById("calc-clear"); 
const backspaceBtn = document.getElementById("calc-backspace"); 
const displayValElement = document.getElementById("calc-display-val");

let calcNumBtns = document.getElementsByClassName("calc-btn-num"); 
let calcOperatorBtns = document.getElementsByClassName("calc-btn-operator"); 

2.讓每個按鈕被監聽

先從需求倒回去想:我們要讓每個數字跟運算按鈕在點擊時,能夠紀錄相對應的數字,進一步顯示以及運算 > 這些按鍵每個都要被監聽,監聽後執行某個函式,再用這個函式讓他做顯示與運算等等我希望他做的事情。

當然可以每個按鍵寫一次 addEventListener ,但這樣很累。從這裡立刻聯想到可以使用 for 迴圈,讓他每個數字都執行一次監聽。設定 i 一開始為 0 ,當 i 小於 calcNumBtns.length 時,就讓 calcNumBtns[i] 能夠在點擊時,執行 updateDisplayVal 的函式。

那 calcNumBtns.length 會是多少呢?因為上面有宣告 calcNumBtns 就是所有 class 名稱為 calc-btn-num 的, html 在數字的地方都有設這個 class ,因此 0-9 共 10 個數字,都會執行監聽。
https://ithelp.ithome.com.tw/upload/images/20210122/20129635hEHl1nCIvh.png

運算鍵監聽邏輯相同,只是要執行的函式因為不同,另外命名為 performOperation ,其他過程在此不做贅述。

for(let i=0; i < calcNumBtns.length; i++){ 
    calcNumBtns[i].addEventListener("click",updateDisplayVal,false) 
}

for(let i=0; i < calcOperatorBtns.length; i++){ //這裡的解釋跟上方監聽數字按鈕相同
    calcOperatorBtns[i].addEventListener("click",performOperation,false)
}

3.按按鍵時,讓 btnText 等於按的數字

接著當然就是設置 updateDisplayVal 與 performOperation 兩個函式。因為我們會最後才處理 performOperation ,建議可以先將剛剛上面寫的

for(let i=0; i < calcOperatorBtns.length; i++){ //這裡的解釋跟上方監聽數字按鈕相同
    calcOperatorBtns[i].addEventListener("click",performOperation,false)
}

註解起來,等等要寫 performOperation 這個函式時,再把註解拿掉,避免測試接下來的功能時報錯、無法執行。

為了使按按鍵時,按下的數字被記錄到一個變數中,我們另外宣告一個變數 btnText 。要讓這個變數等於按下的按鍵的內文,會運用到 event Object 和 event target 的觀念。當事件被觸發,瀏覽器會為事件主動創造一個物件,這個物件會包含事件相關的所有資訊例如按鍵按了什麼 keyCode 、滑鼠點了哪裡 clientX 、事件名稱 type 、觸發事件元素 target 、事件是否在冒泡階段觸發 bubbles 等等,每個事件物件提供的屬性會根據觸發的事件不同。要取出屬性資訊,需要提供一個參數。

在這裡因為要讓執行這個函式時,將按下的按鍵中的數字,帶回 btnText 這個變數中,因此幫參數取名為 clickObj , btnText 等於按下觸發事件時的參數(target)的內文(innerHTML)。如此便將按下的數字存起來了。

let updateDisplayVal = (clickObj) =>{ 
    let btnText = clickObj.target.innerHTML;
}

4. 預設上方欄顯示為0,宣告變數讓數字打完後可以放在該放的位置儲存

要使上方結果欄位預設顯示為 0 ,並讓數字打完後可存起來,需要宣告幾個變數:

  • 代表顯示結果區的變數 displayVal :當數字打完時,或是運算完得到結果後,應該出現在這裡,預設數字為 0
  • 變數 pendingVal :當數字打完,按下運算按鈕,要輸入下一組數字時,前一組數字必須被儲存下來,預設為空字串
  • 運算區變數 evalStringArray : 預設為空陣列

剛剛的函式還沒做完,回過頭來處理一下。顯示結果區的變數,要等於目前已經顯示在結果區的數字,加上 btnText 紀錄起來的數字。全部加好的數再讓 displayValElement 顯示。

let displayVal = "0"; 
let pendingVal = ""; //
let evalStringArray = []; 

let updateDisplayVal = (clickObj) =>{ 
    let btnText = clickObj.target.innerHTML;
    
    displayVal += btnText;
    displayValElement.innerHTML = displayVal;
}

5.顯示結果區如果本來只有 0 ,不要讓他秀出來

還有一個小細節,我們不想讓計算機上方結果欄出現 012345 之類的數字,因此需要放一個 if 判斷式進來。如果 displayVal === "0" ,讓他變成空字串(也就是空白,不要秀 0 ,只秀 12345 )。

let updateDisplayVal = (clickObj) =>{ 
    let btnText = clickObj.target.innerHTML; 

    if(displayVal === "0"){
        displayVal = '';
    }
    
    displayVal += btnText;
    displayValElement.innerHTML = displayVal;
}

6. 讓clearBtn有作用

接著要完成 AC 鍵功能,可以使用 .onclick 來處理。一樣,先釐清需求並拆解任務:我們想要讓使用者按下這個按鈕時,能夠讓顯示區變數變回預設值 0 ,本來儲存的變數 pendingVal 清空,運算區變數 evalStringArray 也要回到預設值,最後讓顯示區的內文等同顯示區變數。

於是你就得到下方的程式碼:

clearBtn.onclick = () => {
    displayVal = "0"; //按下時顯示區變數為0
    pendingVal = undefined; //本來儲存的pendingVal刪除
    evalStringArray = []; //運算區清空
    displayValElement.innerHTML = displayVal; //顯示區等同顯示區變數
}

pendingVal 的部分宣告了,但沒有要存的數字,因此是 undefined 。

7. 讓backspace btn有作用

製作 backspace 鍵,目的是要讓最後一個數字被刪除,細部拆解任務和需求後還會加上一項:刪到最後一個數字後,上方顯示區要變回預設的 0 ,而不是一個數字都不剩變空白畫面。

於是會用到 slice(begin,end) 的觀念,它可以用來操控陣列和字串,作用是將陣列中的東西複製一份出來。 begin 為從哪開始複製, end 為複製到哪之前要結束。如果沒寫 end ,則得到所有元素。寫負數時,代表從後面開始算起。若想要看例題,可以點擊這裡

由此可知我們會需要先用 .length 取得變數 displayVal 目前的數字長度(因為使用者每次打的數字長度可能不同),運用 slice 讓他從第一筆資料抓到倒數第一筆資料前。如此一來就會刪掉最後一筆資料,達到 backspace 的作用。

設 if 判斷式,如果 displayVal 已經是空字串,讓 displayVal 變為 0 。

backspaceBtn.onclick = () =>{
    let lengthOfDisplayVal = displayVal.length;
    displayVal = displayVal.slice(0, lengthOfDisplayVal -1); 
    
    if(displayVal === ""){ 
        displayVal = "0";
    }

    displayValElement.innerHTML = displayVal;   
}

8. 讓小數點有自己的函式可以執行

不知道有沒有人發現,剛剛在 html 標籤裡,小數點並沒有設置到 .calc-btn-num 。其實我們也可以幫它設定這個 class ,但是這樣會讓小數點可以無限次打上去,例如 100.3.234.34 這種不合理的數字。因此這裡直接取用它的 id ,幫它設一個它自己專屬的函式。

我們的任務是讓小數點顯示在顯示區變數中,以及如果已經顯示過,就不要再顯示第二次。換句話說,點擊 . 這個按鈕時,如果顯示區變數還沒出現過 . ,則讓顯示區數字加上 . 。這裡用到 .includes 的觀念。我之前不知道變數錢也可以加上 ! 作為不的意思,這次從這裡學習到了。

decimalBtn.onclick = () => {
    if(!displayVal.includes('.')){ 
        displayVal +="."; 
    }
    displayValElement.innerText = displayVal; 
}

9.讓按鈕秀的就是打的並進行運算

終於要回來做 performOperation ,若剛剛有先註解起來別忘了取消。

一樣是利用 .target ,將按下的運算符號帶回operator這個變數中,幫參數取名為 clickObj , operator 等於按下(觸發事件)時的參數的內文(innerHTML)。如此便將按下的運算符號存起來了。

接著會利用** switch 判斷式**,判斷按下的是哪種運算符號,決定要做怎樣的運算。 switch 判斷式不難,主要是在 case 後面加上條件,如果達成條件就執行下方函式,並搭配 break 強迫終止不繼續做下方判斷。 switch 還可以設定 default ,當上面所有條件都不符合時,就執行 default 。我做了一個簡單的例題可供還不太懂的人參考。

判斷是加減乘除之後又要讓他幹嘛呢?當然就是要讓他進行運算囉!運算的部分,可以使用 eval() 來做,他會將傳入的字串當作 js 指令來執行,如果傳入的是數字就會進行加減乘除的運算。

仔細拆解任務後,我們會想到幾件要做的事情:並不是按完加減乘除後就要立刻得到運算結果,而是按完等於後才做。我們應該要先把本來顯示在 displayVal 變數的資料存放到 pendingVal ,接著清空 displayVal 讓他顯示 0 ,讓 0 顯示到畫面上,把 pendingVal push 進 evalStringArray 陣列中,最後把加號也 push 進去。

最後才去利用 evalStringArray 陣列做 eval() 運算。就這樣依序設定完加減乘除。

在等號的鍵,因為使用者按完最後一組數字後,不會再按加減乘除,而是直接按等號,所以要把還在 displayVal 變數裡的資料先 push 進 evalStringArray 陣列。接著讓陣列 evalStringArray 執行 eval() ,並運用 join() 的觀念,它的作用在於將陣列中所有元素連接合併成一個字串(例如本來是 ['1' '+' '2'] 會變成 '1+2'),並回傳。我們需要宣告一個變數 evaluation ,讓做完以上這些事情的資料可以帶到這個變數中。再讓剛剛執行完的 evaluation 以字串顯示,並重新成為 displayVal 變數。執行後 evalStringArray 也要記得清空,下次才能以空白陣列重新紀錄新的值。

let performOperation = (clickObj) => {
    let operator = clickObj.target.innerHTML; 
    switch(operator){
        case '+':
            pendingVal = displayVal; 
            displayVal = '0'; 
            displayValElement.innerText = displayVal; 
            evalStringArray.push(pendingVal); 
            evalStringArray.push('+'); 
            break;
        case '-':
            pendingVal = displayVal;
            displayVal = '0';
            displayValElement.innerText = displayVal;
            evalStringArray.push(pendingVal);
            evalStringArray.push('-');
            break;  
        case 'x':
            pendingVal = displayVal;
            displayVal = '0';
            displayValElement.innerText = displayVal;
            evalStringArray.push(pendingVal);
            evalStringArray.push('*');
            break;
        case '÷':
            pendingVal = displayVal;
            displayVal = '0';
            displayValElement.innerText = displayVal;
            evalStringArray.push(pendingVal);
            evalStringArray.push('/');
            break;  
        case '=':
            evalStringArray.push(displayVal); 
            let evaluation = eval(evalStringArray.join(' ')); 
            displayVal = evaluation + ''; 
            displayValElement.innerText = displayVal; 
            evalStringArray = []; 
            break; 
        default:
            break;
    }
}

這樣我們就完成了所有的功能!完整的程式碼如下:

const oneBtn = document.getElementById("calc-one");
const twoBtn = document.getElementById("calc-two");
const threeBtn = document.getElementById("calc-three");
const fourBtn = document.getElementById("calc-four");
const fiveBtn = document.getElementById("calc-five");
const sixBtn = document.getElementById("calc-six");
const sevenBtn = document.getElementById("calc-seven");
const eightBtn = document.getElementById("calc-eight");
const nineBtn = document.getElementById("calc-nine");
const zeroBtn = document.getElementById("calc-zero");

const decimalBtn = document.getElementById("calc-decimal"); 
const clearBtn = document.getElementById("calc-clear"); 
const backspaceBtn = document.getElementById("calc-backspace"); 
const displayValElement = document.getElementById("calc-display-val"); 

let calcNumBtns = document.getElementsByClassName("calc-btn-num"); 
let calcOperatorBtns = document.getElementsByClassName("calc-btn-operator"); 

let displayVal = "0"; 
let pendingVal = ""; 
let evalStringArray = []; 
 
let updateDisplayVal = (clickObj) =>{ 
    let btnText = clickObj.target.innerHTML; 
    if(displayVal === "0"){
        displayVal = '';
    }
    
    displayVal += btnText;
    displayValElement.innerHTML = displayVal;
}

let performOperation = (clickObj) => {
    let operator = clickObj.target.innerHTML; 
    switch(operator){
        case '+':
            pendingVal = displayVal; 
            displayVal = '0'; 
            displayValElement.innerText = displayVal; 
            evalStringArray.push(pendingVal); 
            evalStringArray.push('+'); 
            break;
        case '-':
            pendingVal = displayVal;
            displayVal = '0';
            displayValElement.innerText = displayVal;
            evalStringArray.push(pendingVal);
            evalStringArray.push('-');
            break;  
        case 'x':
            pendingVal = displayVal;
            displayVal = '0';
            displayValElement.innerText = displayVal;
            evalStringArray.push(pendingVal);
            evalStringArray.push('*');
            break;
        case '÷':
            pendingVal = displayVal;
            displayVal = '0';
            displayValElement.innerText = displayVal;
            evalStringArray.push(pendingVal);
            evalStringArray.push('/');
            break;  
        case '=':
            evalStringArray.push(displayVal); 
            let evaluation = eval(evalStringArray.join(' ')); 
            displayVal = evaluation + ''; 
            displayValElement.innerText = displayVal; 
            evalStringArray = []; 
            break; 
        default:
            break;
    }
}

for(let i=0; i < calcNumBtns.length; i++){ 
    calcNumBtns[i].addEventListener("click",updateDisplayVal,false) 
}

for(let i=0; i < calcOperatorBtns.length; i++){ 
    calcOperatorBtns[i].addEventListener("click",performOperation,false)
}

clearBtn.onclick = () => {
    displayVal = "0"; 
    pendingVal = undefined; 
    evalStringArray = []; 
    displayValElement.innerHTML = displayVal; 
}

backspaceBtn.onclick = () =>{
    let lengthOfDisplayVal = displayVal.length; 
    displayVal = displayVal.slice(0, lengthOfDisplayVal -1); 
    
    if(displayVal === ""){
        displayVal = "0";
    }

    displayValElement.innerHTML = displayVal;  
}

decimalBtn.onclick = () => {
    if(!displayVal.includes('.')){ 
        displayVal +="."; 
    }
    displayValElement.innerText = displayVal; 
}

這是我一邊製作時,一邊用 git commit 紀錄的製作過程
https://ithelp.ithome.com.tw/upload/images/20210122/20129635QA7O0bMo2B.png

希望大家還滿意這篇文章!


上一篇
30 總結
下一篇
番外篇(2)一起來做 To Do List!- 知識篇
系列文
花三十天找到 JavaScript 沙漠中的綠洲35

尚未有邦友留言

立即登入留言