【封裝小技巧】數字處理函式的封裝

未覺雨聲 發表於 2022-06-28
伸手請直接跳到【方法合集】~

這次我們來聊聊關於數字處理的一些問題。專案中對數字的處理一定是避不開的,畢竟資料就是由數字組成的嘛(大霧),所以對於一下常見的數字處理場景我們進行適當的封裝也能有效簡潔程式碼,下面就由簡單到複雜的順序來介紹幾個。

將任意值轉為有效數字

在一些場合,我們可能會得到一些型別不安全的值,並需要將其作為數字進行處理,這種場景在寫庫的時候尤為常見。當我們直接使用 parseFloat 轉化時,在不合法數字時會得到 NaN,這將導致後續所有的運算全部變成 NaN,因此我們需要將其回退到 0 確保後續運算正常。

function toNumber(value: unknown) {
  const number = parseFloat(value as string)

  return Number.isNaN(number) ? 0 : number
}

將數值限定在一個範圍

這個場景和上面很相似,同樣是為了保證數字的正確性,需要將數字限定在一個特定的範圍中,針對的是數字的值。

function boundRange(number: number | string, min: number, max: number) {
  return Math.max(min, Math.min(max, toNumber(number)))
}

這兩個場景都是很簡單的處理,旨在簡化多次出現的繁雜程式碼。

將個位數變成兩位

這個太簡單,不多說了,像是在處理日期、時間上常常會出現這樣的需求。

function doubleDigits(number: number) {
  return number < 10 ? `0${number}` : number.toString()
}

將數字格式化成三位階

換而言之,就是常見的將數字按照三位一組分隔開的記數法,在提高一些大數字的可讀性上,或者金額的顯示上用的比較多:

function segmentNumber(number: number | string, separator = ',', segment = 3) {
  if (typeof number !== 'number') {
    number = parseFloat(number)
  }

  if (Number.isNaN(number)) return '0'

  let [integer, decimal] = String(number).split('.')

  const formatRegExp = new RegExp(`(\\d+)(\\d{${segment}})`)

  while (formatRegExp.test(integer)) {
    integer = integer.replace(formatRegExp, `$1${separator}$2`)
  }

  decimal = decimal ? `.${decimal}` : ''

  return `${integer}${decimal}`
}

將數字保留特定位數的小數

這是我們這一次的重磅選手,在 js 中將數字保留特定位數是個技術活,因為 js 的小數存在精度丟失問題,我們先來看一個例子:

當我們想把 17.275 這個小數遵循四捨五入保留至兩位的時候,學過小學數學的都應該知道結果為 17.28,我們先來用 js 中最常規的做法 toFixed

17.275.toFixed(2)

image.png

js 引擎小學數學沒學好 這就是小數精度丟失,具體原理這裡就不展開了,還不瞭解的話應該快快地找找相關資料了。

我們通過下面程式碼,其實可以發現 toFixed 的表現和 Math.round 是類似的(猜測底層實現是一樣的,未驗證):

Math.round(17.275 * 10 ** 2)

image.png

同樣的 .5 被直接捨棄而不是進一。

隨後又出現了一些奇怪的處理方法,比如講這個數在目標位數的基礎上先擴大 10 倍再縮小 10 倍,再進行四捨五入從而規避精度丟失:

parseFloat(`${Math.round(17.275 * 10 ** 3 / 10) / 10 ** 2}`)

image.png

看上去好像挺正常的,但很可惜這只是針對 17.275 這個一個數字而已。

我們將原始數字改為 1.3335 並將目標位數改為 3,問題重新發生:

image.png

其實只要我們還用 Math.round 或者 toFixed 的方式來直接來處理四捨五入的問題,精度丟失的問題就始終無法規避。

為什麼說 直接 呢,因為其實我們可以通過一些方式處理一下數字,再給到這些方法處理,就可以達到間接處理的效果,從而規避精度丟失。

其實這個方法很粗暴,我們都知道精度丟失無非就是大了一個或者小了一個非常小的小數,所以在 0.5 這個界線上會因為這個非常小的小數而導致舍或入的判斷失準,那其實我們只需要在保留的目標位數的下一位上,一旦發現這個數是 5 就直接讓他變成 6,其他情況就把這一位後面的部分裁掉,那這個很小的小數就不會影響到舍或入的判斷了,來看程式碼:

function toFixed(number: number, decimal: number) {
  if (decimal === 0) return Math.round(number)

  let snum = String(number)
  const pointPos = snum.indexOf('.')

  if (pointPos === -1) return number

  const nums = snum.replace('.', '').split('')
  const targetPos = pointPos + decimal
  const datum = nums[targetPos]

  if (!datum) return number

  if (snum.charAt(targetPos + 1) === '5') {
    snum = snum.substring(0, targetPos + 1) + '6'
  } else {
    snum = snum.substring(0, targetPos + 2)
  }

  return parseFloat(Number(snum).toFixed(decimal))
}

image.png

方法合集

/**
 * 將任意值轉成數字,NaN 的情況將會處理成 0
 * @param value - 需要轉化的值
 */
export function toNumber(value: unknown) {
  const number = parseFloat(value as string)

  return Number.isNaN(number) ? 0 : number
}

/**
 * 講小於 10 整數 N 變成 `0N` 的字串,方法不會對入參校驗
 * @param number - 需要處理的整數
 */
export function doubleDigits(number: number) {
  return number < 10 ? `0${number}` : number.toString()
}

/**
 * 將數字格式化為三位階
 * @param number - 需要格式化的數字
 * @param segment - 分隔的位數,預設為 3
 * @param separator - 分隔的符號,預設為 ','
 */
export function segmentNumber(number: number | string, segment = 3, separator = ','): string {
  if (typeof number !== 'number') {
    number = parseFloat(number)
  }

  if (Number.isNaN(number)) return '0'

  let [integer, decimal] = String(number).split('.')

  const formatRegExp = new RegExp(`(\\d+)(\\d{${segment}})`)

  while (formatRegExp.test(integer)) {
    integer = integer.replace(formatRegExp, `$1${separator}$2`)
  }

  decimal = decimal ? `.${decimal}` : ''

  return `${integer}${decimal}`
}

/**
 * 講一個實數保留一定的小數
 * @param number - 需要處理的實數
 * @param decimal - 需要保留的小數
 */
export function toFixed(number: number, decimal: number) {
  if (decimal === 0) return Math.round(number)

  let snum = String(number)
  const pointPos = snum.indexOf('.')

  if (pointPos === -1) return number

  const nums = snum.replace('.', '').split('')
  const targetPos = pointPos + decimal
  const datum = nums[targetPos]

  if (!datum) return number

  if (snum.charAt(targetPos + 1) === '5') {
    snum = snum.substring(0, targetPos + 1) + '6'
  } else {
    snum = snum.substring(0, targetPos + 2)
  }

  return parseFloat(Number(snum).toFixed(decimal))
}

/**
 * 將一個實數擴大一定的倍數並保留一定的小數
 * @param number - 要處理的實數
 * @param multiple - 要擴大的倍數
 * @param decimal - 要保留的小數
 */
export function multipleFixed(number: number, multiple: number, decimal: number) {
  return toFixed(number * multiple, decimal)
}

/**
 * 將一個數字限定在指定的範圍內
 * @param number - 需要限定範圍的數
 * @param min - 邊界最小值,包含該值
 * @param max - 邊界最大值,包含該值
 *
 * @returns 限定了範圍後的數
 */
export function boundRange(number: number | string, min: number, max: number) {
  return Math.max(min, Math.min(max, parseFloat(number as string)))
}

往期傳送門:

【封裝小技巧】列表處理函式的封裝
【封裝小技巧】is 系列方法的封裝

最後來推薦一下我的個人開源專案 Vexip UI - GitHub

一個比較齊全的 Vue3 元件庫,支援全面的 css 變數,內建暗黑主題,全量 TypeScript 和組合式 Api,其特點是所有元件幾乎每個屬性都支援通過配置(傳一個物件)來修改其預設值,這應該是目前其他元件庫不具備的特性~

現正招募小夥伴來使用或者參與維護與發展這個專案,我一個人的力量非常有限,文件、單元測試、服務端渲染支援、周邊外掛、使用案例等等,只要你有興趣都可以從各個切入點參與進來,非常歡迎~

這幾期【封裝小技巧】的內容原始碼都包含在了 @vexip-ui/utils 包下面,GitHub,這個包也有單獨釋出,不過目前還沒有 Api 文件,可能需要直接查閱原始碼食用~