利用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)
,从而在开发人员无感知的情况下解决计算失精的问题,提升代码的可读性。
准备
首先了解一下为什么会出现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节点名如VariableDeclaration
、BinaryExpression
等,value值可为一个函数或者对象,完整示例如下:
{ visitor: { VariableDeclaration(path){ //doSomething }, BinaryExpression: { enter(path){ //doSomething } exit(path){ //doSomething } } } }
函数参数path包含了当前节点对象,以及常用节点遍历方法等属性。
babel遍历AST语法树是以深度优先,当遍历器遍历至某一个子叶节点(分支的最终端)的时候会进行回溯到祖先节点继续进行遍历操作,因此每个节点会被遍历到2次。当visitor的属性的值为函数的时候,该函数会在第一次进入该节点的时候执行,当值为对象的时候分别接收两个enter
,exit
属性(可选),分别在进入与回溯阶段执行。
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遍历完毕最后退出的节点肯定是Program
的exit
方法,因此可以在这个方法里面对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