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

栏目: JavaScript · 发布时间: 5年前

内容简介:你了解过0.1+0.2到底等于多少吗?那0.1+0.7,0.8-0.2呢?类似于这种问题现在已经有了很多的解决方案,无论引入外部库或者是自己定义计算函数最终的目的都是利用函数去代替计算。例如一个涨跌幅百分比的一个计算公式:因此利用babel以及AST语法树在代码构建过程中重写

你了解过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为遍历得到的对象。

开始

了解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:star::star::star::star::star:,有什么建议欢迎issue我。


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

JavaScript快速开发工具箱

JavaScript快速开发工具箱

Robin Nixon / 陈武、姚飞 / 清华大学出版社 / 2011-11 / 59.00元

《JavaScript快速开发工具箱:轻松解决JavaScript日常编程问题的100个插件工具》通透讲解100个现成的JavaScript插件,引导您使用这些利器得心应手地创建动态Web内容。《JavaScript快速开发工具箱:轻松解决JavaScript日常编程问题的100个插件工具》开篇讲解JavaScript、CSS和DOM,此后每章都列举一个完整示例,指导您将特定效果快速应用于网页。使......一起来看看 《JavaScript快速开发工具箱》 这本书的介绍吧!

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

MD5 加密
MD5 加密

MD5 加密工具

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器