0%

JS Dungeon for Rookies Third Boss: Calculator

新手JavaScript地下城第三關 - 計算機

系列文的第三篇,這次回過頭來完成前一關的線上計算機。

[design concept]

  • 首先,快速瀏覽一下設計稿,可以發現大致上分為三個區塊:
    • 上方的算式和當下結果顯示
    • 中間的數字按鍵和四則運算符號
    • 下方的功能按鍵,含清除(AC)、倒退(backspace)、等於符號

重要的處理步驟和技巧

HTML, CSS
  • HTML 結構設計和 CSS 排版上,並沒有太困難的地方,上方的算式和結果顯示採用兩個區塊元素,並讓文字內容靠右對齊 text-align: right ,接著調整文字和背景顏色、字體大小、邊框圓弧後即可完成此區設計。
  • 中下方的數字區、功能區,從圖面上可以看出是將寬度四等分切成小元素區塊的作法,所以我一齊採用 flex 排版來完成。實作上,在外層元素下 {display: flex; justify-content: space-around; flex-wrap: wrap} 這幾個設定,並對內層的按鍵元素用 % 設定寬度,將游標設定成一般顯示 cursor: pointer ,再稍微調整一下內外距、圓弧化即可完成,最後記得加上一些滑鼠移入元素的效果 (hover)。
JavaScript
  • 這是我第一次試圖使用 Vue.js 這類資料驅動 (data-driven) 的框架來完成的地下城挑戰,中間不斷的試誤過程,讓自己對 JS 以及 Vue.js 有了更深入的理解。
  • 在計算機挑戰使用 Vue.js 的好處:
    • 因計算機的操作包含了較複雜的流程,有很多的數據需要記憶、運算、判斷、顯示,使用 Vue.js 和其專屬的開發工具 (Vue.js devtools) ,可以讓開發者很快速地掌握情況,加快開發速度。
  • 首先,對每個 HTML 的按鍵元素上加上對應的觸發事件,並在 Vue.js 實體內開出對應的方法,如下:
1
2
3
4
5
6
7
8
9
<div class="container" id="app">
<header></header>
<div class="box-wrap d-flex space-around flex-wrap">
<!-- 數字按鍵 1 -->
<div class="box" @click="enterOne">1</div>
<!-- 運算按鍵 加號 -->
<div class="box math-symbol" @click="clickPlus">&#43;</div>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
var app = new Vue({
el:"#app",
data:{
//...
},
methods:{
enterOne: function(){
//... 對應使用者按下 "1" 數字鍵的函數
},
clickPlus: function(){
//... 對應使用者按下 "+" 運算鍵的函數
}
}
})
  • 接著,開始構思如何顯示輸入的數值到結果區。在實作中,我前後採用了兩種方法,一個是使用數值型別的做法,另一個則是使用字串型別的做法,後來發現在小數點相關的計算時,使用數值型別輸入會相當棘手,所以後來改為採用字串型別的方法,範例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var app = new Vue({
el:"#app",
data:{
currentValue: 0
// 先設定一個初始值來接數字鍵輸入的數值,"現有值"
},
methods:{
enterOneNum: function(){
this.currentValue = this.currentValue * 10 + 1;
},
// 數值型別做法:因計算機是十進位,先按 1 再按 2,預期是 12
// 也就是"現有值"乘以 10,再加上該按鍵的數字
enterOneString: function(){
this.currentValue += "1";
},
// 字串型別做法:字串的加法,就是在現有值後方直接加上按鍵的字串
// 不論現有值是 "1" 或 1,在加了 "2" 之後,都會變成 "12"

clickPoint: function(){
this.currentValue += ".";
},
// 使用字串型別讓小數點的輸入變得相當直覺
}
})
  • 但在採用了字串來儲存現有值後,會發現當使用者不照一般情境操作,會出現不合理的現有值,如: “001”, “0.00.1” 但是計算機仍能繼續操作的現象。因此,我在 data 中加入了一組布林值來記錄小數點狀況,並調整了數字輸入鍵的邏輯如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var app = new Vue({
el:"#app",
data:{
currentValue: 0,
isDecimal: false,
},
methods:{
enterZero: function(){
this.currentValue += "0";
if (this.isDecimal === false){
this.currentValue = Number(this.currentValue);
}
// 對進入小數狀態的現有值,不轉回數字,才能順利輸入 1.01, 1.001
// 但非小數狀態的現有值,則與其他數字輸入鍵相同,轉回數值型別
},
enterOne: function(){
this.currentValue += "1";
this.currentValue = Number(this.currentValue);
// 非 0, 00 輸入鍵,現有值一律轉回數值型別儲存
},

clickPoint: function(){
this.isDecimal = true;
// 按下小數點,使得 isDecimal 轉為 true
this.currentValue += ".";
},
}
})
  • 完成以上步驟後,接著處理四則運算和等號的部分,我採用的方法是將四則運算代號化,在 data 中開出一項來儲存,並在算式顯示區和等號的函數中,使用多層判斷式的方法來處理。另外,在四則運算時,需要將現有值丟入另一個儲存空間,以便騰出空間來輸入新的現有值,範例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
var app = new Vue({
el:"#app",
data:{
currentValue: 0,
isDecimal: false,
mathOperator: ""
// 初始化先帶入空值
previousValue: ""
// 待運算的值
resultValue: ""
// 結果值
},
methods:{
//...
clickPlus: function(){
this.previousValue = this.currentValue;
this.currentValue = 0;
// 歸零現有值
this.mathOperator = "plus";
this.isDecimal = false;
// 讓新輸入的值有能也有小數點
},
clickEqual: function(){
if(this.mathOperator === "plus"){
this.resultValue = this.previousValue + this.currentValue;
} else if (this.mathOperator === "minus"){
this.resultValue = this.previousValue - this.currentValue;
} else if (this.mathOperator === "multiply"){
this.resultValue = this.previousValue * this.currentValue;
} else if (this.mathOperator === "divide"){
this.resultValue = this.previousValue / this.currenValue;
}
// 搭配 mathOperator 的判斷式,讓使用者按下等於時可以計算結果
},
}
})
  • 至於在算式的記憶顯示上,因程式語言使用的加減乘除符號與日常使用的寫法不同,我在 HTML 和 JS 都用了另外的方法處理:
1
2
3
4
5
6
<div class="box math-symbol" @click="clickDivide">&#247;</div> <!-- 除號 -->
<div class="box math-symbol" @click="clickMultiply">&#215;</div> <!-- 乘號 -->
<div class="box math-symbol" @click="clickPlus">&#43;</div> <!-- 加號 -->
<div class="box math-symbol" @click="clickMinus">&#8722;</div> <!-- 減號 -->
<div class="box clean" @click="clickBack">&#9003;</div> <!-- 倒退 -->
<span class="equal">&#61;</span> <!-- 等號 -->
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var app = new Vue({
el:"#app",
computed:{
showOperator: function(){
if (this.mathOperator === "plus") {
return "\u002B";
// 當按下加運算符號時,回傳加號
} else if (this.mathOperator === "minus") {
return "\u2212";
// 回傳減號
} else if (this.mathOperator === "multiply") {
return "\u00D7";
// 回傳乘號
} else if (this.mathOperator === "divide") {
return "\u00F7";
// 回傳除號
} else {
return "";
// 初始狀態,回傳空字串
}
},
},
})
  • 在最上排的算式顯示部分,經過多次嘗試後,最後採用的方法是區分「結果態」「運算態」的做法,並使用 computed 在 HTML 中進行顯示,詳細的程式碼如下方範例:
1
2
3
4
5
6
7
8
<header>
<div class="formula">
{{equationDisplay}}
</div>
<div class="result">
{{mainDisplay}}
</div>
</header>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
var app = new Vue({
el:"#app",
data:{
notResult: true,
previousValue: "",
currentValue: 0,
mathOperator: "",
equationText: "",
resultValue: ""
},
computed:{
equationDisplay: function(){
if (this.notResult === true){
// "運算態" (還在輸入兩個數字跟運算符號) 時,動態顯示在 .formula
return (this.previousValue + " " + this.showOperator + " " + this.currentValue);
} else {
// 等號事件(clickEqual)最後會組成的字串,於"結果態"時顯示
// 因最後會清空 previousValue, mathOperator,並讓 currentValue = resultValue
// 所以先組成字串丟進去顯示
return this.equationText;
}
},
mainDisplay: function(){
// 主顯示區,"運算態"時顯示 currentValue,"結果態"時顯示 resultValue
if (this.notResult === true){
return this.currentValue;
} else {
return this.resultValue;
}
},
showOperator: function(){
//...
},
},
})
  • 其他按鍵的部分,也就是倒退 (Backspace) 和清空 (AC) 的功能,則相對容易處理很多。倒退鍵使用的是將當前值轉換成字串,再轉成陣列,並用陣列方法去除最末端元素的做法;清空功能則是將所有資料回歸初始設定狀態,範例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var app = new Vue({
el: "#app",
data:{
//...
},
methods:{
clickBack: function(){
// 將 currentValue 字串化
let currentString = String(this.currentValue);
// 依照字元組成陣列元素
let currentStringArray = currentString.split("");
// 清除最後一個陣列元素
currentStringArray.pop();
// 組回字串再轉回數字
this.currentValue = Number(currentStringArray.join(""));
},
clickClean: function(){
// 讓 data 的內容回歸初始設定
this.currentValue = 0;
this.isDecimal = false;
this.previousValue = "";
this.mathOperator = "";
this.resultValue = "";
this.notResult = true;
this.equationText = "";
},
}
})
  • 最後是處理多位數字時的破版問題,也是本關卡明定要解決的議題,我參考的是最快破關者的做法,也就是在要破版時操控顯示區的 CSS 設定,讓字體從 font-size: 3.5rem 轉變成 font-size: 1.5rem,如此一來便可以讓顯示的數字縮小,在 currentValue 達到 Infinity 前都可以順利顯示。因此,我調整了位在 computed 下,主顯示區的函數 mainDisaply,讓其去抓 currentValue 的長度,並判斷要不要調整 CSS 的字體設定,範例如下:
1
2
3
<div class="result" ref="result">
{{mainDisplay}}
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
var app = new Vue({
el: "#app",
data: {
//...
},
computed:{
// 使用 Vue 的 $refs 功能來抓取 DOM 元素
mainDisplay: function(){
//...
let stringArray = String(this.currentValue).split("");
// 超過 10 的數字就會破版
if (stringArray.length > 10){
this.$refs.result.style.fontSize = "1.5rem";
} else {
// 初始化的時候就去抓 this.$refs.result 會是 undefined,加上判斷式來避免這種情形
if (this.$refs.result){
this.$refs.result.style.fontSize = "3.5rem"
}
}
//...
},
// 使用原生的 JS 語法選取的做法如下
mainDisplay: function(){
//...
const result = document.querySelector(".result");
let stringArray = String(this.currentValue).split("");
if (stringArray.length > 10){
result.setAttribute("style", "font-size: 1.5rem");
} else {
result.setAttribute("style", "font-size: 3.5rem");
}
},
},
})
  • ※ 注意,如果採用原生 JS 語法,DOM 元素選取的過程要在 Vue 的作用下,不可先選了才創造 Vue 的實體,這樣會無法成功操作。
1
2
3
4
5
6
7
8
9
10
// 會失敗的寫法 (元素選取在 Vue 實體外)
const result = document.querySelector(".result");
var app = new Vue({
el: "#app",
computed:{
mainDisplay: function(){
result.setAttribute("style","font-size: 1.5rem")
}
},
})

未解的問題

  • JavaScript number 的實作是二進位的浮點數標準,所以在計算 0.1 + 0.2 時,結果會變成 0.30000000000000004,一般比較常用的調整方法是先將小數轉成整數,算完後再除回小數this.resultValue = (this.previousValue * 10 + this.currentValue * 10) / 10,但因為這個關卡是計算機,無法事先得知使用者要輸入幾位小數,目前仍然沒有想到比較好的方法來處理這個議題。

參考資料與專案連結