學以致用:手把手教你擼一個工具庫並打包發布,順便解決JS小數計算精度問題


本文講解的是怎么實現一個工具庫並打包發布到npm給大家使用。本文實現的工具是一個分數計算器,大家考慮如下情況:

\[\sqrt{(((\frac{1}{3}+3.5)*\frac{2}{9}-\frac{27}{109})/\frac{889}{654})^4} \]

這是一個分數計算式,使用JS原生也是可以計算的,但是只能得到一個近視值:

Math.sqrt(Math.pow(((1/3+3.5)*2/9-27/109)/(889/654),4));   //  0.1975308641975308

因為上面好幾個分數都除不盡,所以JS計算只能算出一個近似值,如果我們需要一個精確值,就需要用分數來表示,JS原生是不支持分數計算的,本文實現的工具庫就可以進行這種分數計算,使用本文的庫計算如下:

fc('1/3')
  .plus(3.5)
  .times('2/9')
  .minus('27/109')
  .div('889/654')
  .pow(4)
  .sqrt()
  .toFraction();     // 輸出: 16/81

用我們的庫輸出的就是一個精確的分數,本庫還可以將這個分數轉化為精確的循環小數,比如上面的分數轉化成循環小數就是:

fc('16/81').toRecurringDecimal(); // "0.(197530864)"

上面計算的輸出是:0.(197530864)。其中()里面的是循環的數字,也就是說原來的小數是0.197530864197530864197530864...。本工具還可以將循環小數轉換回來:

fc('0.(197530864)').toFraction(); // 16/81

因為本工具實質上都是在進行分數計算,分子和分母都是整數,所以JS本身浮點數計算不准的問題本工具也解決了:

0.1 + 0.2;     // 0.30000000000000004
fc(0.1).plus(0.2).toNumber(); // 0.3

這個庫的名字是fraction-calculator,已經發布到npm,大家可以安裝試用:

npm install fraction-calculator --save

本工具(以下簡稱fc)代碼使用GitHub托管,歡迎大家star,有任何問題可以直接在GitHub提issue。

GitHub地址: https://github.com/dennis-jiang/fraction-calculator

GitHub上有詳細的使用說明,本文接下來的篇幅會詳細講解怎么實現功能和打包發布。

功能實現

API一覽

先來看看我們需要實現的API,心里大概有個數

image-20200308165131460

從上圖可以看出,我們的API主要分如下幾類:

  1. 構造器
  2. 計算API
  3. 比較API
  4. 輸出顯示API
  5. 靜態API
  6. 其他API
  7. 配置

下面我們分別來講講每部分怎么實現:

構造器

因為我們進行的是分數計算,JS沒有分數數據類型,我們需要一個字符串來表示分數,而且在數學中,一個大於1的分數,比如\(\frac{5}{2}\)既可以表示為這種形式,也可以表示為\(2\frac{1}{2}\),這種讀作“二又二分之一”,我們這兩種字符串都需要支持。為了方便使用,用戶直接用數字肯定也是要支持的。還有前面說過,我們支持循環小數轉分數,所以循環小數也要支持,我這里支持兩種循環小數的表示方法,使用''()來標記循環部分都可以。為了讓用戶使用更方便,最好new關鍵字也省了,像jQuery那樣,直接拿來就用。為了讓我們的庫變得更穩健,我們最好也支持傳入自己的一個實例,就可以隨便嵌套了,比如fc(fc(0.5).plus('1/3')).times(5)。最后,順便也支持下兩個參數吧,萬一有用戶喜歡呢,第一個參數表示分子,第二個表示分母。總結下來,我們的構造器的需求是:

  1. 不用new就可以直接使用
  2. 支持字符串的分數,包括有整數部分或者沒有整數部分
  3. 支持數字
  4. 支持循環小數
  5. 支持另一個實例
  6. 支持兩個數字參數

從去掉new開始構建架構

作為項目的第一步,肯定是要想想我的API要以什么形式組織,以什么形式暴露出去。這就讓我想起了jQuery,n年前我還在用jQuery做網頁,一個$直接拿來點點點就行了,想要啥就點啥。做fc的時候就想着能不能也讓用戶用的這么爽,直接用fc點點點就行,於是就借鑒了jQuery的做法,不用new就可以直接調用。關於jQuery架構的詳細解釋可以看這篇文章。下面我們直接上成品:

// 首先創建一個fc的函數,我們最終要返回的其實就是一個fc的實例
// 但是我們又不想讓用戶new,那么麻煩
// 所以我們要在構造函數里面給他new好這個實例,直接返回
function FractionCalculator(numStr, denominator) {
  // 我們new的其實是fc.fn.init
  return new FractionCalculator.fn.init(numStr, denominator);
}

// fc.fn其實就是fc的原型,算是個簡寫,所有實例都會擁有這上面的方法
FractionCalculator.fn = FractionCalculator.prototype = {};

// 這個其實才是真正的構造函數,這個構造函數也很簡單,就是將傳入的參數轉化為分數
// 然后將轉化的分數掛載到this上,這里的this其實就是返回的實例
FractionCalculator.fn.init = function(numStr, denominator) {
  this.fraction = FractionCalculator.getFraction(numStr, denominator);
};

// 前面new的是init,其實返回的是init的實例
// 為了讓返回的實例能夠訪問到fc的方法,將init的原型指向fc的原型
FractionCalculator.fn.init.prototype = FractionCalculator.fn;

上面代碼其實就完成了我們的基礎架構,里面用到了JS面向對象的知識,如果對JS面向對象不是很了解,可以看看這篇文章。如果對上面代碼有點迷糊,強烈建議看看前面鏈接的兩篇文章,所謂學以致用,就是要先學理論然后才拿來用嘛。

有了上面的基礎架構,我們要添加實例方法和靜態方法就很簡單了:

// 添加實例方法這樣寫,下面是plus方法,注意這里是在fn上,也就是原型上
FractionCalculator.fn.plus = function() {}

// 添加靜態方法這樣寫,下面是gcd方法,注意這里沒在fn上
FractionCalculator.gcd = function() {}

前面我們在init方法里面其實將計算好的分數掛載到了this.fraction上,這里的fraction結構其實很簡單,就一個分子和分母。后面我們所有的操作其實都在玩這個對象:

let fraction = {
  numerator,      // 分子
  denominator,    // 分母
};

支持浮點數,解決JS本身精度問題

前面說了,JS本身對浮點數計算並不准,fc能夠解決這個問題,解決這個問題的方法就是當構造器接收到浮點數時,將它轉換為整數的分子和分母。可能有朋友聽說過JS將浮點數轉換成整數直接乘以10的n次方就行,n是小數位數,算完了再除以這個數就行。我最開始也是這么實現的,直到我遇到了它:0.14780.1478並不是一個什么特殊的數字,就是我測試的時候隨便輸的一個數,按照這個思路,應該將它乘以10000,然后它就會變成整數1478吧,我們來看看結果:

image-20200308155128744

結果有點出乎意料啊,看來這條路走不通了。最終我的方案是作為字符串處理,先將數字轉換為字符串,把小數點去掉,然后再轉換成數字,這樣就能得到正確的數字了。小數全程不參與運算。

然后我們構造器還要支持兩個數字,帶整數的字符串和不帶整數的字符串,這些都不難直接將拿到的參數解析成分子和分母塞到這個對象上就行了。另外我們要支持另一個實例作為參數,那就用instanceof檢查下傳入參數是不是fc的實例,如果是就將傳入參數的fraction掛載到當前實例就行了。這兩部分代碼都不難,有興趣的朋友可以去GitHub看我源碼。真正有點麻煩的是循環小數轉分數。

循環小數轉分數

做這個需求的時候,我的數學知識報警了,雖然是中學知識,但是這么多年沒用,還是忘記了,趕緊回去翻翻課本才搞定。下面一起來復習下中學數學知識:循環小數轉分數。

題目:請將循環小數5.45(689)轉換成分數,其中括號里面的是循環部分。

解這個題之前先來復習一個概念,循環小數分為純循環小數和混循環小數兩種:

純循環小數:小數部分全部循環,比如0.(689)

混循環小數:小數部分前面有幾位不參與循環,后面的才是循環部分,比如0.234(689)

再來復習一個定理:

任何純循環小數都可以轉換為,分母為n個9的分數,n為循環小數的循環位數。而分子就是循環節本身。

舉個例子,0.(689)是純循環小數,他的循環部分為689,總共三位,所以他轉換為分數的分母就是三個9,分子就是689。轉換成分數就是\(\frac{689}{999}\)

有了這個定理,前面的題目就可以求解了:

5.45(689)

= 5 + 0.45 + 0.00(689)

= 5 + \(\frac{45}{100}\) + (0.(689)/100)

= 5 + \(\frac{45}{100}\) + (\(\frac{689}{999}\)/100)

= 5 + \(\frac{45}{100}\) + \(\frac{689}{99900}\)

算到這一步其實就可以了,我們已經將它轉化成了分數的加法,只要我們實現了fc的加法,然后直接調用就行了。所以我這里代碼的思路是先用正則將循環小數分成,整數,非循環部分,循環部分,然后用這個計算方法分別轉換成分數,然后加起來就行了。具體的代碼我就不貼了,有興趣的朋友還是去我GitHub看源碼吧,哈哈。

計算API

計算API是最多的一類API,我們需要支持加,減,乘,除,取余,次方,開方,絕對值,取反,取倒數,上取整,下取整,四舍五入。同時用戶在計算的時候可能是連續計算的,可能加減乘除都有,我們還需要支持鏈式調用。下面我們先講講鏈式調用:

鏈式調用

鏈式調用在JS的世界里很常見,比如jQuery,可以隨意點點點,那這個是怎么實現的呢?比如如下代碼:

fc(1.5).plus('1/3').times(5).toNumber();
  1. 前面講了fc(1.5)返回的是一個fc的實例,為了能夠讓他調到plus,所以plus肯定得是一個實例方法
  2. plus的返回值還能調到times方法,那plus的返回值到底是什么呢?答案還是fc實例,我們plus還得返回一個fc實例,times也是一個實例方法,所以plus的返回值能訪問。
  3. plus怎么返回一個fc實例呢?其實很簡單,他自己就是實例方法,是被fc實例調用的,所以這個方法里面的this就指向了調用者,也就是fc實例。所以要實現鏈式調用,就要在對應的實例方法里面返回this。如果你對this指向還不是很熟悉,請看這篇文章。

下面來看一段鏈式調用的示例代碼:

function fc() {}

fc.prototype.func1 = function() { return this;}
fc.prototype.func2 = function() { return this;}

// 因為實例方法func1和func2都返回了this,所以可以一直點點點
const instance = new fc();
instance.func1().func2().func2().func1();

上述代碼只是一個鏈式調用演示,並沒有具體功能,大家可以根據自己需要添加功能。

約分和通分

我們的計算API看似有很多,其實核心的就是加法和乘法。因為減法就是加一個符號相反的數,除法就是乘一個倒數。其他的計算API基本都可以用這兩個核心方法來算。

下面來看看加法,我們再來回憶下中學數學知識,分數加法的計算:先通分,將分母變成一樣的,然后分子進行相加,然后將最后結果進行約分。看個例子:

\(\frac{1}{2} + \frac{1}{3}\)

= \(\frac{3}{6} + \frac{2}{6}\)

=\(\frac{5}{6}\)

要通分就要計算他們的最小公倍數(lowest common multiple,以下簡稱LCM),要計算最小公倍數其實需要先算最大公約數(greatest common divisor,以下簡稱GCD)。我們以前算最大公約數,都是將目標數分解成質因數,然后將公共的質因數相乘,就是最大公約數,這個方法比較繁瑣,還要先拆解質因數。我們這里不用這個方法,而用歐幾里得算法,上定理:

歐幾里得算法:對於兩個數a, b的最大公約數gcd(a, b)有:

gcd(a, b) = gcd(b, a %b )

仔細看這個公式,你會發現他其實是可以迭代的,舉個例子:

gcd(150, 270)

= gcd(270, 150)

= gcd(150, 120)

= gcd(120, 30)

= gcd(30, 0)

迭代到最終的模為0,其實這時候的"a"就是最終的GCD,我們這里就是30,30是150和270的GCD。對於這種可以迭代的公式,我們直接一個while循環就搞定了:

function getGCD(a, b) {
  // get greatest common divisor(GCD)
  // GCD(a, b) = GCD(b, a % b)
  a = Math.abs(a);
  b = Math.abs(b);
  let mod = a % b;

  while (mod !== 0) {
    a = b;
    b = mod;
    mod = a % b;
  }

  return b;
}

拿到了GCD我們就可以約分了,也可以用來算LCM,來看看怎么算LCM:

對於兩個數a, b, 如果gcd是他們的最大公約數,那么存在另外兩個互質的數字x, y:

a = x * gcd

b = y * gcd

所以他們的最小公倍數就是 x * y * gcd,也就是

(x * gcd) * (y * gcd) / gcd

= a * b / gcd

有了LCM,我們的分數加減法就沒有問題了。另外乘法直接分子乘分子,分母乘分母就行了,這里不展開說了。

取余和取模

還有個需要注意的概念是取余和取模,也就是我們計算API里面的mod方法。我們先來看看取余和取模的區別:

對於兩個正數來說,取余和取模是沒有區別的,他們的區別在於一個是正數,一個是負數的時候,對於商的取舍上有區別。

取余: 取余時,如果除不盡,商往0的方向取整

取模: 取模時,如果除不盡,商往負無窮的方向取整

舉個例子: -7 對 4取余和取模

  1. 先算商-1.75
  2. 取余,商往0方向取整,也就是-1,然后算 -7 - (-1) * 4 = -3
  3. 取模,商往負無窮方向取整,也就是-2, 然后算 -7 - (-2) * 4 = 1

JS的%其實是取余計算,所以fc的mod方法跟他保持了一致,是取余運算,算法跟前面的例子是一樣的,計算過程中用到了我們前面實現的減法和乘法。

其他幾個計算API都比較簡單,有些還是基於Math實現的,比如pow, ceil...我這里就不展開講了,有興趣的朋友還是去看我GitHub源碼,哈哈~

比較API

這幾個比較API都很簡單,直接用原本的數減去目標數就行,減法前面已經實現了。最后將結果跟0比較,可以輕松得出是大於,小於還是等於。

顯示API

顯示API有4個,可以以小數,固定位數小數,循環小數和分數的形式展示。其中toFraction, toFixed, toNumber都比較簡單,toNumber直接用分子除以分母就行, toFixed再這個基礎上調一下JS本身的toFixed就行,toFraction就是將分子和分母用字符串形式輸出就行,輸出前記得約分。真正有點麻煩的是輸出成循環小數。

輸出成循環小數

將分數轉換成循環小數的方法不止一種,我們先來說說理論上正確,但是實現起來是坑的方法。

前面循環小數化分數的時候我們已經講了,對於0.(456)轉化成分數就是\(\frac{456}{999}\)。那反過來說,只要我將一個分數的分母轉換成n個9的形式,分子不就是循環部分了嗎?那我們就可以從一個9開始遍歷,然后到n個9,找到一個能除進的就行,比如:

\(\frac{5}{3}\)

= \(\frac{15}{9}\)

= \(1 + \frac{6}{9}\)

= 1.6666666666...

但是需要注意的是,有些分母的質因子含有2和5,這種一輩子都轉換不成n個9,對於這種分數,我們需要對分子乘以10,然后約分,來去掉分母的2和5質因子,如果還去不掉,就再乘10。不要擔心這里乘以的10,這里乘了多少10,最后把小數點往左移動多少位就行了。來個例子:

\(\frac{3}{28}\) // 分母含質因子2,調整分子乘以10

-> \(\frac{30}{28}\)

= \(\frac{15}{14}\) // 分母含質因子2,調整分子乘以10

-> \(\frac{150}{14}\)

= \(\frac{75}{7}\)

= \(10 + \frac{5}{7}\)

= \(10 + \frac{714285}{999999}\)

= 10.714285714285714285714285714285

-> 0.10(714285) // 前面乘了兩個10,小數點左移兩位

上面這個算法理論上來說是正確的,我最開始也是按照這個算法實現的,吭哧吭哧寫了半天代碼,測試的時候遇到了很多詭異的情況。調試的時候發現,原因是在計算過程中,可能需要很多個9的分母,但是JS對於超過20位的數字,直接就四舍五入用科學計數法表示了,后面的計算基於這個肯定就不准了:

image-20200308172003024

這條路走不通,只有換條路走,讓我們從這種“高級”算法中回來,回到我們質朴的小學數學。我們學習除法的時候遇到除不盡的時候,都是將余數乘以10,然后繼續算,那我們程序也這樣算就好了,那怎么才算有循環了呢?有循環的判斷其實就是出現了同樣的余數。因為出現了同樣的余數,你后面再用這個數字去乘以10計算,肯定跟之前同樣的那個余數得到了同樣的結果,這就循環了。想通了這個質朴的道理,我們只需要將每次計算的余數存下來,下次計算的時候檢查一下這個余數是不是存在了,如果已經存在了,那循環節就找到了。這個余數第一次出現的位置就是循環節開始的位置,第二次出現的前一個位置就是循環節結束的位置。貼個示例代碼吧,為了加快每次查找的速度,我這里用的是一個對象來存儲余數:

function getDecimalsFromFraction(numerator, denominator) {
  // make sure numerator is less than denominator
  const modObj = {};
  const quotientArray = [];

  let mod;
  let index = 0;

  while (true) {
    mod = numerator % denominator;

    if (mod === 0) {
      return quotientArray.join('');
    }

    let existIndex = modObj[mod];
    if (existIndex >= 0) {
      let quotientLength = quotientArray.length;
      quotientArray.splice(existIndex, 0, '(');
      quotientArray.splice(quotientLength + 1, 0, ')');

      return quotientArray.join('');
    }

    modObj[mod] = index;
    index++;
    numerator = mod * 10;

    let quotient = parseInt(numerator / denominator);
    quotientArray.push(quotient);

    if (index >= 3000) {
      // Recurring part can be very long, we only handle first 3000 numbers
      return quotientArray.join('');
    }
  }
}

這么計算的問題是一個分數化循環小數的循環節可能非常長,這個最大長度,理論值是分母-1,因為任何數除以分母,余數可能是1到分母減1之間的任何一個數,運氣不好的時候,可能全部輪一遍。當他非常長的時候,計算很慢,而且沒有必要,所以我這里只搜索前面3000位小數,如果3000位還沒搜索到,就直接把已有的商返回了。

靜態API

fc有兩個靜態API,gcdlcm,這其實就是我們前面計算用到的最大公約數和最小公倍數,既然都寫出來了,為啥不順便暴露給用戶用呢?

其他API

剩下就是clone了,這其實為為了方便用戶想繼續操作,但是又不想修改當前值的時候用。另外還有一個配置,默認輸出分數的時候會約分,加了個開關,可以輸出不約分的分數。

到這里,我們的功能就講完了,下面會說說工程相關的。

單元測試

單元測試是很重要的,尤其是對於這種計算庫,我寫完一個功能,需要測試下他功能正常不,就需要單元測試。更重要的是可以保證重構的正確性,實現過程中,我多次踩坑,進行了多次重構。如果沒有單元測試,重構完我心里是沒譜的,不知道之前的功能有沒有搞壞。有了單元測試,重構完,直接把單元測試拿來跑一遍就行。我這里單元測試的框架用的Jest,具體使用大家可以看官方文檔,也可以看我源碼當例子,我這里不再贅述,下面貼一個例子:

describe('FractionCalculator instance', () => {
  it('can support integer', () => {
    const instance = fc(4);

    expect(instance.fraction).toEqual({
      numerator: 4,
      denominator: 1,
    });
  });
 });

打包發布

做了一個工具庫,當然是希望給大家用,造福社會了~打包之前我們要知道我們需要一個什么樣的包,我們的用戶環境可能是什么樣的,根據具體需求配置打包策略。我這里的需求是:

  1. 流行的ES6,node.js要支持
  2. 瀏覽器要支持
  3. 老的瀏覽器,比如IE,盡量支持

根據需求,我們需要支持import, require, script標簽三種引入方式。好在webpack很強大,我們只要加一點簡單的配置,就能支持這三種了:

{
  ...
  library: 'fc',                 // 庫名字,也是script引入時掛載到window的對象名字
  libraryTarget: 'umd',          // 支持的引入方式,umd是包括ES6, node, 瀏覽器,AMD等
  libraryExport: 'default',      // 默認導出的路徑,我用export default導出的就寫'default'
  ...
}

另外fc開發的時候用了一些ES6的特性,老瀏覽器是不支持的,所以我還用了babel翻譯下,babel配置也很簡單:

{
  ...
  "useBuiltIns": "usage"     // 關鍵就是這個配置,這個只會添加用到了的polyfill
  ...
}

最終我打了三個包出來:

  1. fraction-calculator.js沒有壓縮,沒有polyfill的版本,供ES6和node使用,package.json里面的main也指向的這個包,這樣用戶npm安裝之后,import或者require的就是這個文件
  2. fraction-calculator.min.js壓縮版的fraction-calculator.js,供高級瀏覽器使用,比如火狐,Chrome,高級瀏覽器自己支持ES6,就不用polyfill了,這個文件體積也最小,只有7kb
  3. fraction-calculator.polyfill.min.js加了polyfill的fraction-calculator.min.js,體積會稍微大一點,供IE之類的使用。

這些都弄好后就npm publish吧,這個命令會將這個庫推送到npm去,然后別人就可以下載安裝了。

總結

做這個工具起源於偶然間看到的歐幾里得算法,看到這個算法可以約分,能約分就能計算分數了,那我也寫個分數的加減乘除玩玩。做完這個功能之后,想到還有小數,循環小數呢,於是慢慢加了些功能,就成現在這樣了。最開始的初衷其實不是解決JS浮點數精度問題,做完之后才發現,我靠,這樣一來JS浮點數精度問題不是也解決了嗎,算是意外驚喜了~文中只講了核心方法,其他方法並沒有展開講,大家有興趣的可以看我源碼哦,順便當幫我code review了,哈哈~

文章的最后,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支持是作者持續創作的動力。本工具剛剛發布,可能還有一些小bug,如果你在使用中遇到任何問題,可以直接在GitHub提issue哦

fc項目GitHub地址: https://github.com/dennis-jiang/fraction-calculator

歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~

“前端進階知識”系列文章:https://juejin.im/post/5e3ffc85518825494e2772fd

“前端進階知識”系列文章源碼GitHub地址: https://github.com/dennis-jiang/Front-End-Knowledges

QR1270


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2026 CODEPRJ.COM