前端工程研究:關於 JavaScript 中 Number 型別的常見地雷與建議作法 | The Will Will Web

The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

前端工程研究:關於 JavaScript 中 Number 型別的常見地雷與建議作法

在 JavaScript 的型別系統中,數值型別(Number)應該是數一數二的地雷型別。今天這篇文章,我想來深度探討 Number 型別的各種魔鬼般的細節,也談談 Number 的常見地雷與建議作法。

數值的表示法

要透過 JavaScript 表示一個數值,比較常見的語法有:

  • 16 進制

    let num = 0xFFF;
    
  • 10 進制

    let num = 100;
    
  • 8 進制

    let num = 0700;
    

    這裡有點需要注意,從 ES2015 開始,在嚴格模式下的 JS 就不允許使用這種 8 進制數值表示法 (Octal literals)。

    (function() {
      'use strict';
      let num = 0700;
      console.log(num);
    })();
    

    2020-02-18_18-32-04

    如果要的話,必須改用 ES2015 全新的 8 進制表示法,必須以 0o0O 為前綴:

    (function() {
      'use strict';
      let num = 0o700;
      console.log(num);
    })();
    
  • 2 進制

    使用 2 進制必須以 0b0B 為前綴,此語法是 ES2015 才新增的表示法:

    let num = 0b1111;
    
  • 科學記號表示法 (Scientific notation)

    以下這段數值等於 10 乘以 10 的 5 次方:

    let num = 10e5;
    

    以下這段數值等於 3.1415 乘以 10 的 -3 次方:

    let num = 3.1415e-3;
    
  • 無窮大 (Infinity)

    正無窮大有兩種表示法,一種是瀏覽器內建的 Infinity 全域變數。另一種則是 ECMAScript 定義的 Number.POSITIVE_INFINITY 也可以直接使用:

    let num = Infinity;
    let inf = Number.POSITIVE_INFINITY;
    let sho = 1/0; // 更短的正無窮大寫法
    

    負無窮大也有兩種表示法:

    let num = -Infinity;
    let inf = Number.NEGATIVE_INFINITY;
    let sho = -1/0; // 更短的負無窮大寫法
    

    在瀏覽器中,這裡的 Infinity 其實是 window 根物件下的一個屬性,可以被視為全域變數,所以你也可以寫成:

    let num = window.Infinity;
    

    在 ES2015 版本裡,你可以透過一個 Number 型別內建的 Number.isFinite() 函式來判斷該數值物件是否為有限的數值

    Number.isFinite(46872394293) // true
    
  • NaN (Not a Number)

    這是一個相當特別的「數值」物件,從名稱上來看 NaN 就是 Not a Number 的縮寫,但是透過 typeof(NaN) 你又會得到 "number" 的結果,代表這是一個 number 型別,但本身不是一個數值,你無法對 NaN 物件做任何四則運算!

    let num = NaN;
    

    你要判斷一個變數是否為 NaN,絕對不能用以下判斷式:

    let num = NaN;
    num === NaN // false
    

    要判斷某數是否為 NaN 只能透過以下方法:

    let num = NaN;
    window.isNaN(num); // true
    
    // 由於 isNaN 是 window 下的一個屬性,因此可以視為全域的存在,因此可以簡化成以下寫法
    isNaN(num); // true
    

    在 ES2015 版本裡,你可以透過一個 Number 型別內建的 Number.isNaN() 函式來判斷該數值物件是否為NaN

    Number.isNaN(num); // true
    

目前 TC39 有個 Numeric Separators 提案已經抵達 Stage 3 階段 (瀏覽器相容性)。這份提案可以讓你在撰寫 數字 (numeric literals) 的時候,可以加上分隔符號 _ (底線),大幅提升可讀性!👍

1_000_000_000           // Ah, so a billion
101_475_938.38          // And this is hundreds of millions

let fee = 123_00;       // $123 (12300 cents, apparently)
let fee = 12_300;       // $12,300 (woah, that fee!)
let amount = 12345_00;  // 12,345 (1234500 cents, apparently)
let amount = 123_4500;  // 123.45 (4-fixed financial)
let amount = 1_234_500; // 1,234,500

數值型別轉換

你可以將任意物件「嘗試轉型」為「數值型別」,但要注意並非每種物件都可以順利轉成 number 型別,只要轉不成功就會變成 NaN (非數值的數值)!

  • 字串轉數字

    你可以透過 Number 函式來轉換字串數值

    Number('3.14')   // 3.14
    Number('100')    // 100
    Number(' ')      // 0
    Number('')       // 0
    Number('a123')   // NaN
    Number('1,000')  // NaN
    

    也可以透過 + (加號) 來轉換數值,這算是一個相當常見的數值轉換技巧:

    +'3.14'   // 3.14
    +'100'    // 100
    +' '      // 0
    +''       // 0
    +'a123'   // NaN
    +'1,000'  // NaN
    

    另一種常見字串數值的方式,則是使用 parseIntparseFloat 函式來解析字串。

    這裡你必須特別注意這兩個函式的定義:

    1. parseInt(str [, radix])

      第 2 個參數 radix (基數) 代表第 1 個參數的內容代表哪種進制,預設值為 10 進制。你可以設定 2 ~ 36 的數字都可以,最大值不能超過 36 進制。

      所謂 36 進制,代表你可以用 0-9A-Z 等 36 個字元當成數值表示。

    2. parseFloat(str)

      這個函式只能固定轉換十進制的實數。

    這兩個函式在傳入第 1 個參數時,都要務必確認此參數只能是 字串 (string) 型態,千萬不要傳入 非字串 的物件進來,否則你就會得到以下詭異的結果:

    parseInt(0.1) // 0
    parseInt(0.01) // 0
    parseInt(0.001) // 0
    parseInt(0.0001) // 0
    parseInt(0.00001) // 0
    parseInt(0.000001) // 0
    parseInt(0.0000001) // 1
    

    為什麼數值小到一定程度,就會導致 parseInt 的結果發生異常呢?那是因為從 0.0000001 開始,該數值會被轉成科學記號表示法,實際上會變成 1e-7,而 parseInt 需要將第一個參數轉為字串,因此你傳入的 字串 其實是 "1e-7",而對 parseInt 來說,他會從字串最左邊開始解析每個認得的數字符號,遇到無法解析的字元就會自動忽略所有剩餘的內容。以這個例子來說,parseInt 指任何 1e-71 而已,因為預設 radix10 進制,因此 e 這個字元是看不懂的,所以最終結果為 1,非常意外吧! 😅

    如果要解析複雜的數值字串,可以考慮採用 Numeral.js 函式庫。

  • 布林轉數值

    使用 Number(false) 會將布林值 false 轉為數字為 0

    使用 Number(true) 會將布林值 true 轉為數字為 1

    因此,以下運算式就不足為奇,不是嗎? 😅

    1 < 2 < 3 // true
    
    3 > 2 > 1 // false
    
  • 日期轉數值

    在 JavaScript 中的 Date 型別用來指向某一個時間點,但事實上在骨子裡儲存的其實是 number 型態,你可以透過 .valueOf() 方法取得內部的數值:

    new Date(2020, 1, 20, 0, 0, 0).valueOf() // 1582128000000
    

    也可以透過 + (加號) 來轉型:

    +new Date(2020, 1, 20, 0, 0, 0) // 1582128000000
    

    或是透過 Number() 來轉型:

    Number(new Date(2020, 1, 20, 0, 0, 0)) // 1582128000000
    
  • 數字轉字串 (預設為 10 進制)

    let num = 566;
    num.toString(); // "566"
    
  • 數字轉字串 (指定基數)

    將數值轉為 16 進制的字串

    let num = 65535;
    num.toString(16); // "ffff"
    

    將數值轉為 36 進制的字串

    let num = 65535;
    num.toString(36); // "1ekf"
    

整數運算的地雷

JavaScript 採用 IEEE-754 Floating Point 規定的浮點數計算所有數值,但是有計算上的精準度問題,任何整數數值超過 2^53-1 (9007199254740991) 就會開始產生誤差,這是一個非常地雷的數值邊界,必須特別注意。

由於這個數值非常特別,又稱為最大安全整數,因此在 JavaScript 中,有個 Number.MAX_SAFE_INTEGER 靜態屬性來代表這個數值:

Number.MAX_SAFE_INTEGER == 2**53-1 // true

我們可以來看一下超過這個數值的誤差情形,絕對會讓你大開眼界:

Number.MAX_SAFE_INTEGER+0 // 9007199254740991
Number.MAX_SAFE_INTEGER+1 // 9007199254740992
Number.MAX_SAFE_INTEGER+2 // 9007199254740992
Number.MAX_SAFE_INTEGER+3 // 9007199254740994
Number.MAX_SAFE_INTEGER+4 // 9007199254740996
Number.MAX_SAFE_INTEGER+5 // 9007199254740996
Number.MAX_SAFE_INTEGER+6 // 9007199254740996
Number.MAX_SAFE_INTEGER+7 // 9007199254740998
Number.MAX_SAFE_INTEGER+8 // 9007199254741000
Number.MAX_SAFE_INTEGER+9 // 9007199254741000

換句話說,若將 9,007,199,254,740,991 辛巴威幣(Zimbabwean dollar)換算成美金大約是 24,888,641,212,326.99 美元 (24 trillion USD),你如果用 JavaScript 來計算「整數金額」的話,這輩子大概沒機會算錯!XDD

所以只要現有的 number 數值超過 Number.MAX_SAFE_INTEGER 的話,建議就不要再算下去了。而且比對 = (等於) > (大於) < (小於) 也都會出現問題。如果我們再將整數加大一點,此時 JavaScript 更會將整數數值轉為科學記號表示法,如此一來數值就更不精準,四則運算就只能取「概略」的數值,不能做精密的計算。你可以參考以下範例:

999999999999999         // 999999999999999
9999999999999999        // 10000000000000000
10000000000000000 + 1.1 // 10000000000000002

9999999999999999999999 + 100 === 9999999999999999999999 // true

在 ES2015 版本裡,你可以透過一個 Number 型別內建的 Number.isSafeInteger() 函式來判斷該數值物件是否為安全整數

Number.isSafeInteger(46872394293) // true
Number.isSafeInteger(-4362794324) // true

在 ES2015 版本裡,你可以透過一個 Number 型別內建的 Number.isInteger() 函式來判斷該數值物件是否為整數

Number.isInteger(4687239); // true
Number.isInteger(4687239.4293); // false

超大整數 (BigInt)

由於 JavaScript 擁有最大安全整數的限制,各家瀏覽器也陸續推出解決方案,例如 Google Chrome 瀏覽器從 Chrome 67 版本開始,內建了 BigInt 型別。這是一個目前還在 TC39 Stage 4 的提案階段,但大部分現代主流瀏覽器都已經內建 bigint 型別,只有 IE11 與 Safari 不支援!(業內經常戲稱 Safari 是下一代 IE 並不是沒有道理) (如果想看瀏覽器相容性報告可以點擊這裡)

要透過 JavaScript 表示一個 bigint 數值,只要在整數數值後面加上一個 n 即可,如下範例:

let num = 39837212195743250943287503298475432n
typeof(num); // bigint

如果要對 bigint 進行四則運算,必須確保最終計算結果也必須為「整數」才行,而且不能與傳統 number 型別混用。例如:

50000n/2n === 25000n

如果你想要嘗試 bigint 超大整數的上限,可以試試 2 的 50000 次方,即便這麼大的整數,依然可以順利計算出結果,而且完全沒有誤差,其結果將有 15052 個位數,相當過癮!

2n**50000n

如果要將一個超大整數的字串轉換為 BigInt 型別,可以參考以下寫法:

BigInt('39837212195743250943287503298475432')

我們可以比對一下傳統 numberbigint 執行時的差異:

39837212195743250943287503298475432  // 3.983721219574325e+34
39837212195743250943287503298475432n // 39837212195743250943287503298475432n

2020-02-20_01-32-51

以下則是一些錯誤的 bigint 用法:

BigInt(999999999999999999999999) // 999999999999999983222784n
BigInt(1.5)                      // RangeError: 不能有小數
BigInt('1.5')                    // SyntaxError: 語法錯誤
1 + 1n                           // TypeError: 不能混用型別
new BigInt(123)                  // TypeError: 不能用 new 建立物件

浮點數運算的地雷

江湖中有一句話是這樣說的:

算錢用浮點,遲早被人扁!

沒錯,由於 JavaScript 使用浮點數計算所有數值,主要原因還是在於計算機最底層都採用 2 進制描述所有數值,而二進制大於 0 的最小整數為 1,如果想表示 0.5 怎麼辦?我們可以利用 Number 型別的 toString(radix) 方法,將十進制的數值表示法轉換為二進制,如下範例:

(1).toString(2)        // 1
(0.5).toString(2)      // 0.1
(0.25).toString(2)     // 0.01
(0.125).toString(2)    // 0.001
(0.0625).toString(2)   // 0.0001
(0.03125).toString(2)  // 0.00001

你可以想像一下,如果想用二進制表示十進制0.1 要怎樣寫?

(0.1).toString(2) // "0.0001100110011001100110011001100110011001100110011001101"

那用二進制表示十進制0.2 呢?

(0.2).toString(2) // "0.001100110011001100110011001100110011001100110011001101"

是的,十進制0.10.2 換算成二進制之後,都是無法精準表達的數值。

若你想透過 JavaScript 的浮點數運算特性,將 0.1 + 0.2 的話,就會得到一個相當詭異的結果:

0.1 + 0.2 != 0.3
0.2 + 0.4 != 0.6
0.3 + 0.6 != 0.9
0.4 + 0.8 != 1.2

實際上計算的結果是:

0.1 + 0.2 // 0.30000000000000004
0.2 + 0.4 // 0.6000000000000001
0.3 + 0.6 // 0.8999999999999999
0.4 + 0.8 // 1.2000000000000002

所以說啊,這不叫地雷,什麼才叫地雷? 😂

其實這種問題,還可以衍生出更多小數計算的問題,例如:

Math.ceil(0.1*0.2*100)   // 3
Math.ceil(0.1*0.2*1000)  // 21
Math.ceil(0.1*0.2*10000) // 201

看到這裡,你應該可以知道 算錢用浮點,遲早被人扁! 真正的涵義了吧?! 😅

目前 TC39 也有個 BigDecimal 的提案正在 Stage 1 階段,可以讓你用十進制精準無誤差的計算小數。他跟 BigInt 的用法很像,不過這算是非常早期的提案,也還沒有瀏覽器支援,但值得期待! 👍

如果不想等瀏覽器內建 BigDecimal 型別,可以考慮使用 decimal.js 函式庫。

數值型別的最大值與最小值

我們稍早有看到一個 Number 型別有 最大整數 的限制,也有 正無窮大負無窮大 的數值存在。但在 Number 型別中,還有兩個特別的靜態屬性叫做 Number.MAX_VALUENumber.MIN_VALUE,其值如下:

Number.MAX_VALUE == 1.7976931348623157e+308
Number.MIN_VALUE == 5e-324

一般人不特別看文件的話,一定會將 Number.MIN_VALUE 想像成是一個最小的數值,大部分的人都會覺得是一個 非常小的負數,這是一個非常普遍的認知誤差。

事實上,Number.MIN_VALUE 其實是一個超級超級小正數,而 5e-324 其實是 5 乘以 10 的 -324 次方的結果,所以你會得到以下結果:

Number.MIN_VALUE > 0 // true

而在 Math 物件中,有兩個取最大值取最小值的函式,其正常的使用方式如下:

Math.max(2, 4, 6, 8); // 8
Math.min(1, 3, 5, 7); // 1

但有人發現底下這種不帶參數的寫法,非常讓人匪夷所思,這怎麼可能呢?

Math.max() > Math.min(); // -> false
Math.min() > Math.max(); // -> true

從可讀性的角度上來看,這種程式碼不但沒有意義,還很容易誤導大家理解。

其實 Math.max() 的意思非常明顯,就是取得參數中的「最大值」,但是在你沒有傳入任何數值時,最大的數值就是 負無窮大。相反的,Math.min() 是取得參數中的「最小值」,但是在你沒有傳入任何數值時,最小的數值就是 正無窮大。這真的是跌破大家眼鏡!👓

這部分語言特性在 ECMAScript 5.1 規格書的 15.8.2.11 max([value1[,value2[,…]]]) 章節中,有非常明確的說明。

其他技巧

  • 自動取整數

    parseInt(2.99)
    

    請注意:由於 parseInt 的第一個參數必須傳入字串型別的物件,若傳入的是一個 number 的話,預設會自動先轉成字串,才會開始進行解析。

    ~~2.99
    

    因為 ~ 是一種位元運算子,所有位元運算子運算元皆會被轉換成二補數系統下的帶號32位元整數。所以,如果你使用 ~~ 技巧來取整數,須注意數值不可大於等於 2 的 31 次方 (2147483648),否則會發生溢位,而導致結果為負數!(詳見 MDN位元運算子 說明)

  • 自動取小數幾位

    parseInt(2.99 * 10) / 10
    
    +(2.999).toFixed(3)
    
  • 四捨五入 (負數超過 0.5 才會進位)

    Math.round(2.5) == 3
    Math.round(-2.5) == -2
    Math.round(-2.51) == -3
    
  • 無條件捨去 (小於自己的最大整數)

    Math.floor(2.99) == 2
    
  • 無條件進位 (大於自己的最小整數)

    Math.ceil(2.11) == 3
    

結論

在 JavaScript 中使用 number 型別,無論是「整數」或「小數」都有不少地雷,任何一位 JavaScript 開發人員都應該特別深入理解 number 型別的特性與限制,否則未來真的遇到問題時,只會覺得丈二金剛摸不著頭緒。

如果真的不想面對這些地雷,建議多多利用網路上現成的函式庫來處理數值,透過成熟穩定的數值函式庫,可以幫助你寫出不容易出問題的代碼,無論四則運算或解析字串格式的數值,都比較不容易出錯。

我們寫程式的時候,最重要的就是維持程式碼的可讀性,如果過多的開發技巧會干擾程式碼閱讀,那麼就非常建議透過自定函式庫或引用外部函式庫來處理數值計算,減少維護程式碼的難度,也提升程式碼維護性。以下是相關函式庫推薦:

  • Numeral.js ( GitHub )

    numeral('10,000.12').value()  // 10000.12
    numeral('$10,000.00').value() // 10000
    
    numeral(974)          // 974
    numeral(0.12345)      // 0.12345
    numeral('10,000.12')  // 10000.12
    numeral('23rd')       // 23
    numeral('$10,000.00') // 10000
    numeral('100B')       // 100
    numeral('3.467TB')    // 3467000000000
    numeral('-76%')       // -0.76
    numeral('2:23:57')    // NaN
    
  • decimal.js ( GitHub )

    +Decimal(0.1).add(Decimal(0.2))          // 0.3
    
    x = new Decimal(9)                       // '9'
    y = new Decimal(x)                       // '9'
    
    new Decimal('5032485723458348569331745.33434346346912144534543')
    new Decimal('4.321e+4')                  // '43210'
    new Decimal('-735.0918e-430')            // '-7.350918e-428'
    new Decimal('5.6700000')                 // '5.67'
    new Decimal(Infinity)                    // 'Infinity'
    new Decimal(NaN)                         // 'NaN'
    new Decimal('.5')                        // '0.5'
    new Decimal('-0b10110100.1')             // '-180.5'
    new Decimal('0xff.8')                    // '255.5'
    
    new Decimal(0.046875)                    // '0.046875'
    new Decimal('0.046875000000')            // '0.046875'
    
    new Decimal(4.6875e-2)                   // '0.046875'
    new Decimal('468.75e-4')                 // '0.046875'
    
    new Decimal('0b0.000011')                // '0.046875'
    new Decimal('0o0.03')                    // '0.046875'
    new Decimal('0x0.0c')                    // '0.046875'
    
    new Decimal('0b1.1p-5')                  // '0.046875'
    new Decimal('0o1.4p-5')                  // '0.046875'
    new Decimal('0x1.8p-5')                  // '0.046875'
    
  • bignumber.js ( GitHub )

    new BigNumber(43210)                       // '43210'
    new BigNumber('4.321e+4')                  // '43210'
    new BigNumber('-735.0918e-430')            // '-7.350918e-428'
    new BigNumber('123412421.234324', 5)       // '607236.557696'
    new BigNumber('-Infinity')                 // '-Infinity'
    
    new BigNumber(NaN)                         // 'NaN'
    new BigNumber(-0)                          // '0'
    new BigNumber('.5')                        // '0.5'
    new BigNumber('+2')                        // '2'
    
    new BigNumber(-10110100.1, 2)              // '-180.5'
    new BigNumber('-0b10110100.1')             // '-180.5'
    new BigNumber('ff.8', 16)                  // '255.5'
    new BigNumber('0xff.8')                    // '255.5'
    
    BigNumber.config({ DECIMAL_PLACES: 5 })
    new BigNumber(1.23456789)                  // '1.23456789'
    new BigNumber(1.23456789, 10)              // '1.23457'
    
    new BigNumber('5032485723458348569331745.33434346346912144534543')
    new BigNumber('4.321e10000000')
    
    new BigNumber('.1*')                       // 'NaN'
    new BigNumber('blurgh')                    // 'NaN'
    new BigNumber(9, 2)                        // 'NaN'
    
  • big.js ( GitHub )

    x = new Big(9)                       // '9'
    y = new Big(x)                       // '9'
    Big(435.345)                         // 'new' is optional
    new Big('5032485723458348569331745.33434346346912144534543')
    new Big('4.321e+4')                  // '43210'
    new Big('-735.0918e-430')            // '-7.350918e-428'
    
  • What is the difference between big.js, bignumber.js and decimal.js?

相關課程

相關連結