利用babel(AST)優雅地解決0.1+0.2!=0.3的問題

前言

你瞭解過0.1+0.2到底等於多少嗎?那0.1+0.7,0.8-0.2呢?
相似於這種問題如今已經有了不少的解決方案,不管引入外部庫或者是本身定義計算函數最終的目的都是利用函數去代替計算。例如一個漲跌幅百分比的一個計算公式:(現價-原價)/原價*100 + '%'實際代碼:Mul(Div(Sub(現價, 原價), 原價), 100) + '%'。本來一個很易懂的四則運算的計算公式在代碼裏面的可讀性變得不太友好,編寫起來也不太符合思考習慣。
所以利用babel以及AST語法樹在代碼構建過程當中重寫+ - * /等符號,開發時直接以0.1+0.2這樣的形式編寫代碼,在構建過程當中編譯成Add(0.1, 0.2),從而在開發人員無感知的狀況下解決計算失精的問題,提高代碼的可讀性。javascript

準備

首先了解一下爲何會出現0.1+0.2不等於0.3的狀況:java

傳送門:如何避開JavaScript浮點數計算精度問題(如0.1+0.2!==0.3)node

上面的文章講的很詳細了,我用通俗點的語言歸納一下:
咱們平常生活用的數字都是10進制的,而且10進制符合大腦思考邏輯,而計算機使用的是2進制的計數方式。可是在兩個不一樣基數的計數規則中,其中並非全部的數都能對應另一個計數規則裏有限位數的數(比較拗口,可能描述的不太準確,可是意思就是這個樣子)。webpack

在十進制中的0.1表示是10^-1也就是0.1,在二進制中的0.1表示是2^-1也就是0.5。git

例如在十進制中1/3的表現方式爲0.33333(無限循環),而在3進制中的表示爲0.1,由於3^-1就是0.3333333……
按照這種運算十進制中的0.1在二進制的表示方式爲0.000110011......0011...... (0011無限循環)github

瞭解babel

babel的工做原理實際上就是利用AST語法樹來作的靜態分析,例如let a = 100在babel處理以前翻譯成的語法樹長這樣:web

{
    "type": "VariableDeclaration",
    "declarations": [
      {
        "type": "VariableDeclarator",
        "id": {
          "type": "Identifier",
          "name": "a"
        },
        "init": {
          "type": "NumericLiteral",
          "extra": {
            "rawValue": 100,
            "raw": "100"
          },
          "value": 100
        }
      }
    ],
    "kind": "let"
  },
複製代碼

babel把一個文本格式的代碼翻譯成這樣的一個json對象從而可以經過遍歷和遞歸查找每一個不一樣的屬性,經過這樣的手段babel就能知道每一行代碼到底作了什麼。而babel插件的目的就是經過遞歸遍歷整個代碼文件的語法樹,找到須要修改的位置並替換成相應的值,而後再翻譯回代碼交由瀏覽器去執行。例如咱們把上面的代碼中的let改爲var咱們只須要執行AST.kind = "var",AST爲遍歷獲得的對象。express

在線翻譯AST傳送門
AST節點類型文檔傳送門npm

開始

瞭解babel插件的開發流程 babel-plugin-handlebookjson

咱們須要解決的問題:

  • 計算polyfill的編寫
  • 定位須要更改的代碼塊
  • 判斷當前文件須要引入的polyfill(按需引入)

polyfill的編寫

polyfill主要須要提供四個函數分別用於替換加、減、乘、除的運算,同時還須要判斷計算參數數據類型,若是數據類型不是number則採用本來的計算方式:

accAdd

function accAdd(arg1, arg2) {
    if(typeof arg1 !== 'number' || typeof arg2 !== 'number'){
        return arg1 + arg2;
    }
    var r1, r2, m, c;
    try {
        r1 = arg1.toString().split(".")[1].length;
    }
    catch (e) {
        r1 = 0;
    }
    try {
        r2 = arg2.toString().split(".")[1].length;
    }
    catch (e) {
        r2 = 0;
    }
    c = Math.abs(r1 - r2);
    m = Math.pow(10, Math.max(r1, r2));
    if (c > 0) {
        var cm = Math.pow(10, c);
        if (r1 > r2) {
            arg1 = Number(arg1.toString().replace(".", ""));
            arg2 = Number(arg2.toString().replace(".", "")) * cm;
        } else {
            arg1 = Number(arg1.toString().replace(".", "")) * cm;
            arg2 = Number(arg2.toString().replace(".", ""));
        }
    } else {
        arg1 = Number(arg1.toString().replace(".", ""));
        arg2 = Number(arg2.toString().replace(".", ""));
    }
    return (arg1 + arg2) / m;
}
複製代碼

accSub

function accSub(arg1, arg2) {
    if(typeof arg1 !== 'number' || typeof arg2 !== 'number'){
        return arg1 - arg2;
    }
    var r1, r2, m, n;
    try {
        r1 = arg1.toString().split(".")[1].length;
    }
    catch (e) {
        r1 = 0;
    }
    try {
        r2 = arg2.toString().split(".")[1].length;
    }
    catch (e) {
        r2 = 0;
    }
    m = Math.pow(10, Math.max(r1, r2)); 
    n = (r1 >= r2) ? r1 : r2;
    return Number(((arg1 * m - arg2 * m) / m).toFixed(n));
}
複製代碼

accMul

function accMul(arg1, arg2) {
    if(typeof arg1 !== 'number' || typeof arg2 !== 'number'){
        return arg1 * arg2;
    }
    var m = 0, s1 = arg1.toString(), s2 = arg2.toString();
    try {
        m += s1.split(".")[1].length;
    }
    catch (e) {
    }
    try {
        m += s2.split(".")[1].length;
    }
    catch (e) {
    }
    return Number(s1.replace(".", "")) * Number(s2.replace(".", "")) / Math.pow(10, m);
}
複製代碼

accDiv

function accDiv(arg1, arg2) {
    if(typeof arg1 !== 'number' || typeof arg2 !== 'number'){
        return arg1 / arg2;
    }
    var t1 = 0, t2 = 0, r1, r2;
    try {
        t1 = arg1.toString().split(".")[1].length;
    }
    catch (e) {
    }
    try {
        t2 = arg2.toString().split(".")[1].length;
    }
    catch (e) {
    }
    r1 = Number(arg1.toString().replace(".", ""));
    r2 = Number(arg2.toString().replace(".", ""));
    return (r1 / r2) * Math.pow(10, t2 - t1);
}
複製代碼

原理:將浮點數轉換爲整數來進行計算。

定位代碼塊

瞭解babel插件的開發流程 babel-plugin-handlebook

babel的插件引入方式有兩種:

  • 經過.babelrc文件引入插件
  • 經過babel-loader的options屬性引入plugins

babel-plugin接受一個函數,函數接收一個babel參數,參數包含bable經常使用構造方法等屬性,函數的返回結果必須是如下這樣的對象:

{
    visitor: {
        //...
    }
}
複製代碼

visitor是一個AST的一個遍歷查找器,babel會嘗試以深度優先遍歷AST語法樹,visitor裏面的屬性的key爲須要操做的AST節點名如VariableDeclarationBinaryExpression等,value值可爲一個函數或者對象,完整示例以下:

{
    visitor: {
        VariableDeclaration(path){
            //doSomething
        },
        BinaryExpression: {
            enter(path){
                //doSomething
            }
            exit(path){
                //doSomething
            }
        }
    }
}
複製代碼

函數參數path包含了當前節點對象,以及經常使用節點遍歷方法等屬性。
babel遍歷AST語法樹是以深度優先,當遍歷器遍歷至某一個子葉節點(分支的最終端)的時候會進行回溯到祖先節點繼續進行遍歷操做,所以每一個節點會被遍歷到2次。當visitor的屬性的值爲函數的時候,該函數會在第一次進入該節點的時候執行,當值爲對象的時候分別接收兩個enterexit屬性(可選),分別在進入與回溯階段執行。

As we traverse down each branch of the tree we eventually hit dead ends where we need to traverse back up the tree to get to the next node. Going down the tree we enter each node, then going back up we exit each node.

在代碼中須要被替換的代碼塊爲a + b這樣的類型,所以咱們得知該類型的節點爲BinaryExpression,而咱們須要把這個類型的節點替換成accAdd(a, b),AST語法樹以下:

{
        "type": "ExpressionStatement",
        },
        "expression": {
          "type": "CallExpression",
          },
          "callee": {
            "type": "Identifier",
            "name": "accAdd"
          },
          "arguments": [
            {
              "type": "Identifier",
              "name": "a"
            },
            {
              "type": "Identifier",
              "name": "b"
            }
          ]
        }
      }
複製代碼

所以只須要將這個語法樹構建出來並替換節點就好了,babel提供了簡便的構建方法,利用babel.template能夠方便的構建出你想要的任何節點。這個函數接收一個代碼字符串參數,代碼字符串中採用大寫字符做爲代碼佔位符,該函數返回一個替換函數,接收一個對象做爲參數用於替換代碼佔位符。

var preOperationAST = babel.template('FUN_NAME(ARGS)');
var AST = preOperationAST({
    FUN_NAME: babel.types.identifier(replaceOperator), //方法名
    ARGS: [path.node.left, path.node.right] //參數
})
複製代碼

AST就是最終須要替換的語法樹,babel.types是一個節點建立方法的集合,裏面包含了各個節點的建立方法。

最後利用path.replaceWith替換節點

BinaryExpression: {
    exit: function(path){
        path.replaceWith(
            preOperationAST({
                FUN_NAME: t.identifier(replaceOperator),
                ARGS: [path.node.left, path.node.right]
            })
        );
    }
},
複製代碼

判斷須要引入的方法

在節點遍歷完畢以後,我須要知道該文件一共須要引入幾個方法,所以須要定義一個數組來緩存當前文件使用到的方法,在節點遍歷命中的時候向裏面添加元素。

var needRequireCache = [];
...
    return {
        visitor: {
            BinaryExpression: {
                exit(path){
                    needRequireCache.push(path.node.operator)
                    //根據path.node.operator判斷向needRequireCache添加元素
                    ...
                }
            }
        }
    }
...
複製代碼

AST遍歷完畢最後退出的節點確定是Programexit方法,所以能夠在這個方法裏面對polyfill進行引用。
一樣也能夠利用babel.template構建節點插入引用:

var requireAST = template('var PROPERTIES = require(SOURCE)');
...
    function preObjectExpressionAST(keys){
        var properties = keys.map(function(key){
            return babel.types.objectProperty(t.identifier(key),t.identifier(key), false, true);
        });
        return t.ObjectPattern(properties);
    }
...
    Program: {
        exit: function(path){
            path.unshiftContainer('body', requireAST({
                PROPERTIES: preObjectExpressionAST(needRequireCache),
                SOURCE: t.stringLiteral("babel-plugin-arithmetic/src/calc.js")
            }));
            needRequireCache = [];
        }
    },
...
複製代碼

path.unshiftContainer的做用就是在當前語法樹插入節點,因此最後的效果就是這個樣子:

var a = 0.1 + 0.2;
//0.30000000000000004
	↓ ↓ ↓ ↓ ↓ ↓
var { accAdd } = require('babel-plugin-arithmetic/src/calc.js');
var a = accAdd(0.1, 0.2);
//0.3
複製代碼
var a = 0.1 + 0.2;
var b = 0.8 - 0.2;
//0.30000000000000004
//0.6000000000000001
	↓ ↓ ↓ ↓ ↓ ↓
var { accAdd, accSub } = require('babel-plugin-arithmetic/src/calc.js');
var a = accAdd(0.1, 0.2);
var a = accSub(0.8, 0.2);
//0.3
//0.6
複製代碼

完整代碼示例

Github項目地址

使用方法:

npm install babel-plugin-arithmetic --save-dev
複製代碼

添加插件
/.babelrc

{
	"plugins": ["arithmetic"]
}
複製代碼

或者

/webpack.config.js

...
{
	test: /\.js$/,
	loader: 'babel-loader',
	option: {
		plugins: [
			require('babel-plugin-arithmetic')
		]
	},
},
...
複製代碼

歡迎各位小夥伴給我star⭐⭐⭐⭐⭐,有什麼建議歡迎issue我。

參考文檔

如何避開JavaScript浮點數計算精度問題(如0.1+0.2!==0.3)
AST explorer
@babel/types
babel-plugin-handlebook

相關文章
相關標籤/搜索