资讯专栏INFORMATION COLUMN

利用babel(AST)优雅地解决0.1+0.2!=0.3的问题

张巨伟 / 3544人阅读

摘要:因此利用以及语法树在代码构建过程中重写等符号,开发时直接以这样的形式编写代码,在构建过程中编译成,从而在开发人员无感知的情况下解决计算失精的问题,提升代码的可读性。

前言

你了解过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

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/104168.html

相关文章

  • 为什么0.1+0.2不等于0.3

    摘要:又如,对于,结果其实并不是,但是最接近真实结果的数,比其它任何浮点数都更接近。许多语言也就直接显示结果为了,而不展示一个浮点数的真实结果了。小结本文主要介绍了浮点数计算问题,简单回答了为什么以及怎么办两个问题为什么不等于。 原文地址:为什么0.1+0.2不等于0.3 先看两个简单但诡异的代码: 0.1 + 0.2 > 0.3 // true 0.1 * 0.1 = 0.01000000...

    Profeel 评论0 收藏0
  • 如何解决0.1 +0.2===0.30000000000000004类问题

    摘要:方法使用定点表示法来格式化一个数,会对结果进行四舍五入。该数值在必要时进行四舍五入,另外在必要时会用来填充小数部分,以便小数部分有指定的位数。如果数值大于,该方法会简单调用并返回一个指数记数法格式的字符串。在环境中,只能是之间,测试版本为。 showImg(https://segmentfault.com/img/remote/1460000011913134?w=768&h=521)...

    yuanzhanghu 评论0 收藏0
  • JS数值

    摘要:由于浮点数不是精确的值,所以涉及小数的比较和运算要特别小心。根据标准,位浮点数的指数部分的长度是个二进制位,意味着指数部分的最大值是的次方减。也就是说,位浮点数的指数部分的值最大为。 一 前言 这篇文章主要解决以下三个问题: 问题1:浮点数计算精确度的问题 0.1 + 0.2; //0.30000000000000004 0.1 + 0.2 === 0.3; // ...

    williamwen1986 评论0 收藏0
  • JavaScript浮点运算0.2+0.1 !== 0.3

    摘要:标准二进制浮点数算法就是一个对实数进行计算机编码的标准。然后把取出的整数部分按顺序排列起来,先取的整数作为二进制小数的高位有效位,后取的整数作为低位有效位。 浮点运算JavaScript 本文主要讨论JavaScript的浮点运算,主要包括 JavaScript number基本类型 二进制表示十进制 浮点数的精度 number 数字类型 在JavaScript中,数字只有numb...

    iflove 评论0 收藏0

发表评论

0条评论

张巨伟

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<