🔥手撸JS系列:Babel精度计算插件

1,949 阅读9分钟

一、实际业务场景考虑插件作用

在日常开发中,我经常面临着如下的问题:

如上图所示: 一个销售单总共有2个产品,一个单价为43.3111,而另一个单价为52.1233。假设这个销售单分为2个发货单发货。按照正常逻辑来说,这个销售单总价应是43.3111+52.1233=93.4344,但是在F12控制台你会发现并非如此。

但是在业务中你需要判断俩个发货单之和是否等于销售单总额来判断销售单的三个状态:全部发货、部分发货、未发货。但是你在控制台上也可以得到:
所以就算2个发货单全部发完,金额并不相等,所以也得不出全部发货这个状态。(以上仅为举例,数值可能有差,实际工作中极为常见)。你有可能会说JS真坑。但是在JAVA\PHP中同样有这样的问题。在JS高级教程中有这样一句话:

关于浮点数值计算会产生舍入误差的问题,有一点需要明确:这是使用基于IEEE754数值的浮点计算的通病,ECMAScript并非独此一家;其他使用相同数值格式的语言也存在这个问题。

那么如何解决呢?

现实中有很多种解决方法,最简单的方法无非是将进行计算的数值转化为整数再进行计算,因为整数形不会有舍入误差的问题。

function calcDecimalLength(decimal){//计算数值的方法
    var splitArray=decimal.toString().split(".");
    if(splitArray.length===1){
        return 0;
    }
    return splitArray[1].length;
}
function calcMax(a,b){//计算相乘的倍数
    let aLength=calcDecimalLength(a);
    let bLength=calcDecimalLength(b);
    let max=Math.pow(10,Math.max(aLength,bLength));
    return max;
}
function addCalc(a,b){//加法计算
    if(typeof a!=="number" || typeof b!=="number"){
        return a+b;
    }
    let max=calcMax(a,b);
    return (a*max+b*max)/max;
}
function minusCalc(a,b){//减法计算
    if(typeof a!=="number" || typeof b!=="number"){
        return a-b;
    }
    let max=calcMax(a,b);
    return (a*max-b*max)/max;
}
function multCalc(a,b){//乘法计算
    if(typeof a!=="number" || typeof b!=="number"){
        return a*b;
    }
    let max=calcMax(a,b);
    return ((a*max)*(b*max))/max;
}
function diviCalc(a,b){//除法计算
    if(typeof a!=="number" || typeof b!=="number"){
        return a/b;
    }
    let max=calcMax(a,b);
    return ((a*max)/(b*max))/max;
}

只需要在精度计算的时候进行引入相应的函数。

//导出上面的函数
module.exports={
    addCalc,
    minusCalc,
    multCalc,
    diviCalc
}
//需要使用的文件引入
var {
    addCalc,
    minusCalc,
    multCalc,
    diviCalc
}=require("导入计算函数文件");
//精确计算
addCalc(43.3111+52.1233);//93.4344

但是你会不会觉得很麻烦呢,需要在每个文件引入这样一个函数。不说繁琐,而且也更容易出错。

二、babel相关概念概述

1.Babel运行阶段

首先来了解Babel转码的过程分三个阶段:分析(parse)、转换(transform)、生成(generate)。 其中,分析、生成阶段由Babel核心完成,而转换阶段,则由Babel插件完成,这也是本文的重点

分析(@babel/core核心处理,无需考虑)

Babel读入源代码,经过词法分析、语法分析后,生成抽象语法树(AST)。

parse(sourceCode) => AST

转换(自己写)

经过前一阶段的代码分析,Babel得到了AST。在原始AST的基础上,Babel通过插件,对其进行修改,比如新增、删除、修改后,得到新的AST。

transform(AST, BabelPlugins) => newAST

生成(@babel/core核心处理,无需考虑)

通过前一阶段的转换,Babel得到了新的AST,然后就可以逆向操作,生成新的代码。

generate(newAST) => newSourceCode

2.插件基础格式

//其中types和template为@babel/core中的相对应的解析包(@babel/template|@babel/types),也可以单独引入进行使用
export default function({ types: babelTypes,template:babelTemplate }) {
  return {
    visitor: {
      Identifier(path, state) {},
      ASTNodeTypeHere(path, state) {}
    }
  };
};
  • babelType:类似lodash那样的工具集,主要用来操作AST节点,比如创建、校验、转变等。举例:判断某个节点是不是标识符(identifier)。
  • path:AST中有很多节点,每个节点可能有不同的属性,并且节点之间可能存在关联。path是个对象,它代表了两个节点之间的关联。你可以在path上访问到节点的属性,也可以通过path来访问到关联的节点(比如父节点、兄弟节点等)
  • state:代表了插件的状态,你可以通过state来访问插件的配置项。
  • visitor:Babel采取递归的方式访问AST的每个节点,之所以叫做visitor,只是因为有个类似的设计模式叫做访问者模式,不用在意背后的细节。
  • Identifier、ASTNodeTypeHere:AST的每个节点,都有对应的节点类型,比如标识符(Identifier)、函数声明(FunctionDeclaration)等,可以在visitor上声明同名的属性,当Babel遍历到相应类型的节点,属性对应的方法就会被调用,传入的参数就是path、state。

三、本例解析

打开这个AST解析网址

{
  "type": "Program",
  "start": 0,
  "end": 15,
  "body": [
    {
      "type": "ExpressionStatement",
      "start": 0,
      "end": 15,
      "expression": {//加号表达式
        "type": "BinaryExpression",
        "start": 0,
        "end": 15,
        "left": {//加号左边
          "type": "Literal",
          "start": 0,
          "end": 7,
          "value": 43.3111,
          "raw": "43.3111"
        },
        "operator": "+",
        "right": {//加号右边
          "type": "Literal",
          "start": 8,
          "end": 15,
          "value": 52.1233,
          "raw": "52.1233"
        }
      }
    }
  ],
  "sourceType": "module"
}

这次本例我们解析的是二进制表达式(BinaryExpression)。对于BinaryExpression,BabelType的定义如下:

defineType("BinaryExpression", {
  builder: ["operator", "left", "right"],
  fields: {
    operator: {
      validate: assertOneOf(...BINARY_OPERATORS),
    },
    left: {
      validate: assertNodeType("Expression"),
    },
    right: {
      validate: assertNodeType("Expression"),
    },
  },
  visitor: ["left", "right"],
  aliases: ["Binary", "Expression"],
});

BinaryExpression 主要是由 operator、left、right 组成的。对于本例子来说,operator 是对应的+号,left是对应的加号左边,right是对应的加号右边。

四、插件实战

1.运算函数编写

即上面的运算方法

function addCalc(a,b){
    if(typeof a!=="number" || typeof b!=="number"){
        return a+b;
    }
    let max=calcMax(a,b);
    return (a*max+b*max)/max;
    
}
function minusCalc(a,b){
    if(typeof a!=="number" || typeof b!=="number"){
        return a-b;
    }
    let max=calcMax(a,b);
    return (a*max-b*max)/max;
}

function multCalc(a,b){
    if(typeof a!=="number" || typeof b!=="number"){
        return a*b;
    }
    let max=calcMax(a,b);
    return ((a*max)*(b*max))/max;
}

function diviCalc(a,b){
    if(typeof a!=="number" || typeof b!=="number"){
        return a/b;
    }
    let max=calcMax(a,b);
    return ((a*max)/(b*max))/max;
}

function calcDecimalLength(decimal){
    var splitArray=decimal.toString().split(".");
    if(splitArray.length===1){
        return 0;
    }
    return splitArray[1].length;
}

function calcMax(a,b){
    let aLength=calcDecimalLength(a);
    let bLength=calcDecimalLength(b);
    let max=Math.pow(10,Math.max(aLength,bLength));
    return max;
}


module.exports={
    addCalc,
    minusCalc,
    multCalc,
    diviCalc
}

2.插件核心部分讲解

了解babel插件的开发流程 babel-plugin-handlebook

babel的插件引入方式有两种:

  • 通过.babelrc文件引入插件
  • 通过babel-loader的options属性引入plugins

babel-plugin接受一个函数,函数接收一个babel参数,参数包含bable常用构造方法等属性,函数的返回结果必须是以下这样的对象:

{
    visitor: {
        //...
    }
}

visitor是一个AST的一个遍历查找器,babel会尝试以深度优先遍历AST语法树,visitor里面的属性的key为需要操作的AST节点名如BinaryExpression等,value值可为一个函数或者对象,完整示例如下:

{
    visitor: {
        BinaryExpression: {
            enter(path){
                //doSomething
            }
            exit(path){
                //doSomething
            }
        }
    }
}

函数参数path包含了当前节点对象,以及常用节点遍历方法等属性。 babel遍历AST语法树是以深度优先,当遍历器遍历至某一个子叶节点(分支的最终端)的时候会进行回溯到祖先节点继续进行遍历操作,因此每个节点会被遍历到2次。当visitor的属性的值为函数的时候,该函数会在第一次进入该节点的时候执行,当值为对象的时候分别接收两个enter,exit属性(可选),分别在进入与回溯阶段执行。

在代码中需要被替换的代码块为a + b这样的类型,因此我们得知该类型的节点为BinaryExpression,而我们需要把这个类型的节点替换成accAdd(a, b)。 利用babel.template可以方便的构建出你想要的任何节点。这个函数接收一个代码字符串参数,代码字符串中采用大写字符作为代码占位符,该函数返回一个替换函数,接收一个对象作为参数用于替换代码占位符。

var preOperationAST=template("FUN_NAME(ARGS)");//将0.1+0.2转化为addCalc的模板

BinaryExpression回溯阶段查找并替换

BinaryExpression:{ 
    exit:function(path){
        if(path.node.operator==="+"){
            path.replaceWith(
                preOperationAST({
                    FUN_NAME:t.identifier("addCalc"),
                    ARGS:[path.node.left,path.node.right]
                }))
        }
        if(path.node.operator==="-"){
            path.replaceWith(
                preOperationAST({
                    FUN_NAME:t.identifier("minusCalc"),
                    ARGS:[path.node.left,path.node.right]
                }))
        }
        if(path.node.operator==="*"){
            path.replaceWith(
                preOperationAST({
                    FUN_NAME:t.identifier("multCalc"),
                    ARGS:[path.node.left,path.node.right]
                }))
        }
        if(path.node.operator==="/"){
            path.replaceWith(
                preOperationAST({
                    FUN_NAME:t.identifier("diviCalc"),
                    ARGS:[path.node.left,path.node.right]
                }))
        }
    }
}

AST遍历完毕最后退出的节点肯定是Programexit方法,因此可以在这个方法里面对计算方法进行引用。

//path.unshiftContainer的作用就是在当前语法树插入节点,即这段代码的意思大概就是
Program:{
        exit:function(path){
            path.unshiftContainer("body",requireAST({
                PROPERTIES:t.ObjectPattern([
                    t.objectProperty(t.identifier("addCalc"),t.identifier("addCalc"), false, true),
                    t.objectProperty(t.identifier("minusCalc"),t.identifier("minusCalc"), false, true),
                    t.objectProperty(t.identifier("multCalc"),t.identifier("multCalc"), false, true),
                    t.objectProperty(t.identifier("diviCalc"),t.identifier("diviCalc"), false, true),
                    ]),
                SOURCE: t.stringLiteral("calc/calc.js")
            }))
        }
},

最后在搭建的简易webpack环境demo中进行测试demo地址,亲测有效。(如果不会搭建webpack开发环境可以看我的另一篇文章webpack环境搭建

五、代码精简

虽然说上面的代码可行,但是你有没有觉得有很多重复性的代码呢:

if(path.node.operator==="+"){
    ...  
}
if(path.node.operator==="-"){
    ...
}
if(path.node.operator==="*"){
   ...
}
if(path.node.operator==="/"){
   ...
}

上面的代码中,四册运算就要写四个if 那么如果有很多个运算公式呢?而且由于不知道到底运用了何种运算符,每个页面都会把所有函数全部都导入过来,所以我们可以在BinaryExpression中将运算符保存起来,然后在Program中根据保存的值将需要导入的函数导入进来,可以造成不必要的资源浪费。

BinaryExpression:{ 
                exit:function(path){
                    var replaceOperator = pushCache(path.node.operator);

                    replaceOperator!=="none" && path.replaceWith(
                        preOperationAST({
                            FUN_NAME:t.identifier(replaceOperator),
                            ARGS:[path.node.left,path.node.right]
                        })
                    )
                }
}
var needRequireCache = [];//需要引入的数组

function pushCache(operation){//将操作符传化为对应的函数并且将函数push进数组里面
    var operationFun;
    switch(operation){
        case '+':
            operationFun = 'addCalc';
            break;
        case '-':
            operationFun = 'minusCalc';
            break;
        case '*':
            operationFun = 'multCalc';
            break;
        case '/':
            operationFun = 'diviCalc';
            break;
        default: 
            operationFun = 'none';
    }
    if(needRequireCache.indexOf(operationFun)>=0) return operationFun;
    operationFun !== 'none' && needRequireCache.push(operationFun);
    return operationFun;
}
function preObjectExpressAST(keys){//传入的keys为require的数组
        var properties=keys.map(function(){
            return t.objectProperty(t.identifier(key),t.identifier(key),false,true)
        });
        return t.ObjectPattern(properties);
}
Program:{//注意在引入需要的函数后需要将数组置空
                exit:function(path){
                    path.unshiftContainer("body",requireAST({
                        PROPERTIES:preObjectExpressAST(needRequireCache),
                        SOURCE: t.stringLiteral("calc/calc.js")
                    }));
                    needRequireCache = [];
                }
},

可见,代码数量减少了数行。

注意:Program中的路径需要是node-modules里面的包,在正式项目中需要替换。

六、发布到线上去

最高逼格当然是发布到线上去将包,具体可以看我掘金的另一篇文章npm上线发布

新建文件夹并经过一系列操作,发布到线上已经。

亲测,正常安装在项目中可用!哈哈哈!大功告成!那么本篇文章就告一段落了!有不足的地方请大神多指教!