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

JachinZou發表於2019-05-06

前言

你瞭解過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),從而在開發人員無感知的情況下解決計算失精的問題,提升程式碼的可讀性。

準備

首先了解一下為什麼會出現0.1+0.2不等於0.3的情況:

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

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

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

例如在十進位制中1/3的表現方式為0.33333(無限迴圈),而在3進位制中的表示為0.1,因為3^-1就是0.3333333……
按照這種運算十進位制中的0.1在二進位制的表示方式為0.000110011......0011...... (0011無限迴圈)

瞭解babel

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

{
    "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為遍歷得到的物件。

線上翻譯AST傳送門
AST節點型別文件傳送門

開始

瞭解babel外掛的開發流程 babel-plugin-handlebook

我們需要解決的問題:

  • 計算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

相關文章