學以致用:手把手教你擼一個工具庫並打包釋出,順便解決JS浮點數計算精度問題
阿新 • • 發佈:2020-03-09
本文講解的是怎麼實現一個工具庫並打包釋出到npm給大家使用。本文實現的工具是一個分數計算器,大家考慮如下情況:
$$
\sqrt{(((\frac{1}{3}+3.5)*\frac{2}{9}-\frac{27}{109})/\frac{889}{654})^4}
$$
這是一個分數計算式,使用JS原生也是可以計算的,但是隻能得到一個近視值:
```javascript
Math.sqrt(Math.pow(((1/3+3.5)*2/9-27/109)/(889/654),4)); // 0.1975308641975308
```
因為上面好幾個分數都除不盡,所以JS計算只能算出一個近似值,如果我們需要一個精確值,就需要用分數來表示,JS原生是不支援分數計算的,本文實現的工具庫就可以進行這種分數計算,使用本文的庫計算如下:
```javascript
fc('1/3')
.plus(3.5)
.times('2/9')
.minus('27/109')
.div('889/654')
.pow(4)
.sqrt()
.toFraction(); // 輸出: 16/81
```
用我們的庫輸出的就是一個精確的分數,本庫還可以將這個分數轉化為精確的迴圈小數,比如上面的分數轉化成迴圈小數就是:
```javascript
fc('16/81').toRecurringDecimal(); // "0.(197530864)"
```
上面計算的輸出是:`0.(197530864)`。其中`()`裡面的是迴圈的數字,也就是說原來的小數是`0.197530864197530864197530864...`。本工具還可以將迴圈小數轉換回來:
```javascript
fc('0.(197530864)').toFraction(); // 16/81
```
**因為本工具實質上都是在進行分數計算,分子和分母都是整數,所以JS本身浮點數計算不準的問題本工具也解決了:**
```javascript
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](http://dennisgo.cn/images/Projects/FractionCalculation/image-20200308165131460.png)
從上圖可以看出,我們的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架構的詳細解釋可以看這篇文章。](https://juejin.im/post/5e549c4d6fb9a07cd614d268)下面我們直接上成品:
```javascript
// 首先建立一個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面向物件不是很瞭解,可以看看這篇文章。](https://juejin.im/post/5e50e5b16fb9a07c9a1959af#comment)如果對上面程式碼有點迷糊,強烈建議看看前面連結的兩篇文章,所謂學以致用,就是要先學理論然後才拿來用嘛。
有了上面的基礎架構,我們要新增例項方法和靜態方法就很簡單了:
```javascript
// 新增例項方法這樣寫,下面是plus方法,注意這裡是在fn上,也就是原型上
FractionCalculator.fn.plus = function() {}
// 新增靜態方法這樣寫,下面是gcd方法,注意這裡沒在fn上
FractionCalculator.gcd = function() {}
```
前面我們在init方法裡面其實將計算好的分數掛載到了`this.fraction`上,這裡的`fraction`結構其實很簡單,就一個分子和分母。後面我們所有的操作其實都在玩這個物件:
```javascript
let fraction = {
numerator, // 分子
denominator, // 分母
};
```
#### 支援浮點數,解決JS本身精度問題
前面說了,JS本身對浮點數計算並不準,fc能夠解決這個問題,解決這個問題的方法就是當構造器接收到浮點數時,將它轉換為整數的分子和分母。可能有朋友聽說過JS將浮點數轉換成整數直接乘以10的n次方就行,n是小數位數,算完了再除以這個數就行。我最開始也是這麼實現的,直到我遇到了它:`0.1478`。`0.1478`並不是一個什麼特殊的數字,就是我測試的時候隨便輸的一個數,按照這個思路,應該將它乘以10000,然後它就會變成整數1478吧,我們來看看結果:
![image-20200308155128744](http://dennisgo.cn/images/Projects/FractionCalculation/image-20200308155128744.png)
結果有點出乎意料啊,看來這條路走不通了。最終我的方案是作為字串處理,先將數字轉換為字串,把小數點去掉,然後再轉換成數字,這樣就能得到正確的數字了。小數全程不參與運算。
然後我們構造器還要支援兩個數字,帶整數的字串和不帶整數的字串,這些都不難直接將拿到的引數解析成分子和分母塞到這個物件上就行了。另外我們要支援另一個例項作為引數,那就用`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,可以隨意點點點,那這個是怎麼實現的呢?比如如下程式碼:
```javascript
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指向還不是很熟悉,請看這篇文章。](https://juejin.im/post/5e59e35ce51d4526e651c338)
下面來看一段鏈式呼叫的示例程式碼:
```javascript
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迴圈就搞定了:
```javascript
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](http://dennisgo.cn/images/Projects/FractionCalculation/image-20200308172003024.png)
這條路走不通,只有換條路走,讓我們從這種“高階”演算法中回來,回到我們質樸的小學數學。我們學習除法的時候遇到除不盡的時候,都是將餘數乘以10,然後繼續算,那我們程式也這樣算就好了,那怎麼才算有迴圈了呢?**有迴圈的判斷其實就是出現了同樣的餘數。**因為出現了同樣的餘數,你後面再用這個數字去乘以10計算,肯定跟之前同樣的那個餘數得到了同樣的結果,這就**迴圈了。**想通了這個質樸的道理,我們只需要將每次計算的餘數存下來,下次計算的時候檢查一下這個餘數是不是存在了,如果已經存在了,那迴圈節就找到了。這個餘數第一次出現的位置就是迴圈節開始的位置,第二次出現的前一個位置就是迴圈節結束的位置。貼個示例程式碼吧,為了加快每次查詢的速度,我這裡用的是一個物件來儲存餘數:
```javascript
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,`gcd`和`lcm`,這其實就是我們前面計算用到的最大公約數和最小公倍數,既然都寫出來了,為啥不順便暴露給使用者用呢?
### 其他API
剩下就是`clone`了,這其實為為了方便使用者想繼續操作,但是又不想修改當前值的時候用。另外還有一個配置,預設輸出分數的時候會約分,加了個開關,可以輸出不約分的分數。
到這裡,我們的功能就講完了,下面會說說工程相關的。
## 單元測試
單元測試是很重要的,尤其是對於這種計算庫,我寫完一個功能,需要測試下他功能正常不,就需要單元測試。更重要的是可以保證重構的正確性,實現過程中,我多次踩坑,進行了多次重構。如果沒有單元測試,重構完我心裡是沒譜的,不知道之前的功能有沒有搞壞。有了單元測試,重構完,直接把單元測試拿來跑一遍就行。我這裡單元測試的框架用的[Jest](https://jestjs.io/),具體使用大家可以看官方文件,也可以看我原始碼當例子,我這裡不再贅述,下面貼一個例子:
```javascript
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很強大,我們只要加一點簡單的配置,就能支援這三種了:
```javascript
{
...
library: 'fc', // 庫名字,也是script引入時掛載到window的物件名字
libraryTarget: 'umd', // 支援的引入方式,umd是包括ES6, node, 瀏覽器,AMD等
libraryExport: 'default', // 預設匯出的路徑,我用export default匯出的就寫'default'
...
}
```
另外fc開發的時候用了一些ES6的特性,老瀏覽器是不支援的,所以我還用了babel翻譯下,babel配置也很簡單:
```javascript
{
...
"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
作者博文GitHub專案地址: https://github.com/dennis-jiang/Front-End-Knowledges
作者掘金文章彙總:https://juejin.im/post/5e3ffc8551882549