(奶妈级教程,万字长文) 手把手教你制作前端编译插件系列(2) - 如何制作一个js数字精度转换插件

22 阅读13分钟

阅读提示

文章以babel-loader plugins制作插件为例

对babel-loader或者babel-plugins不熟悉的,可以先查阅前置知识: babel-loade基础知识

需求背景

众所周知在 JavaScript 里,0.1 + 0.2 != 0.3 主要是因为 JavaScript 采用 IEEE 754 双精度 64 位浮点数表示法来存储和处理小数导致的

console.log(0.1 + 0.2); // 输出结果为 0.30000000000000004

由于存储位数有限,对于无法精确表示的二进制小数,只能进行近似存储。当对 0.1 和 0.2 进行存储时,它们实际上存储的是近似值。当对这两个近似值进行加法运算时,得到的结果也是一个近似值,而不是精确的 0.3。

IEEE 754标准其实源自于国际性组织电子工程师协会 (Institute of Electrical and Electronics Engineers), 业界地位可能类似于前端之ECMA吧

除了js,java,python和C++都用了这套规范,所以以后也不要在吐槽js精度垃圾啦, 吃的都是"大锅饭"

解决办法

toFixed

对于最一般的情况我们可以用tofixed做转换

const result = (0.1 + 0.2).toFixed(1);
console.log(result === '0.3'); 
// 输出结果为 true

但是toFixed方法的缺点是只对结果进行四舍五入,如果运算链比较长,或者对数字精度要求比较高的情况下toFixed等于失效

引入第三方精度计算库

一般有decimal.js , big.js 等 , 原理和使用方法其实都大差不差,都是通过将数字转换成字符串后在通过内置的运算算法跳过了 IEEE 754 的计算标准, 以decimal.js 举例, 使用方式如下:

const Decimal = require('decimal.js');
const num1 = new Decimal('0.1');
const num2 = new Decimal('0.2');
const result = num1.plus(num2);
console.log(result.toString()); // 输出 0.3

但是这个方法的缺点就是进行数字运算的时候代码不太直观,可读性没那么好,比如:

1 + (0.1 * 2) * 2 / n 
//转换成第三方库的形式
Decimal(1).plus(Decimal(0.1).times(Decimal(0.2))).times(Decimal(2)).div(Decimal(n))

这样精度提高了,眼神也不好使了.

那么有没有办法既保留可读性又让js运算精度提高呢?

答案是有的,那就是利用编译工具将js的数学运算自动转换成第三方库链式调用的计算形式

比如,假设我们有这样一个babel插件:

{
           loader: 'babel-loader',
           options: {
              targets: {
                esmodules: true
              },
              plugins: [
              //参数的含义等下说
              numericalPrecision({ 
              	defaultPrecision:4,
              	tool:'Decimal',
              	importPath:'Decimal' 
              })
              ],
            }
}

这是项目里的js代码

 1 + (0.1 * 2) * 2 / n 

使用插件后,期望的编译结果:

Decimal(1).plus(Decimal(0.1).times(Decimal(0.2))).times(Decimal(2)).div(Decimal(n))

好了,到了这里我们的需求背景大概就已经明确拉!

开刀阔斧

现在就开始动手改造吧!

api 设计

根据刚才的需求这个插件我们至少需要三个传入参数分别是defaultPrecision, toolimportPath.

那为什么呢? 接着往下面看!

首先新建一个 numericalPrecision.js 的文件

module.exports = ({ defaultPrecision = 2, tool, importPath = tool } = {}) =>
  function ({ types: t }) {
    return {
      visitor: {
        Program(path, state) {
        },
      }
    }
  }

在用webpack导入

{
           loader: 'babel-loader',
           options: {
              targets: {
                esmodules: true
              },
              plugins: [
             		numericalPrecision({ 
              			defaultPrecision:4,
              			tool:'Decimal',
              			importPath:'Decimal' 
             		 })
              ],
            }
}

在这里参数 tool 就是你想用的第三方库工具,比如 Decimal, 因为我们不知道用户是用 Decimal.js 还是 big.js 所以我们通过传入参数去设计这样一个解耦策略, importPath 就是文件导入路径, defaultPrecision 是精度, 比如下面代码:

 1 + (0.1 * 2) * 2 / n 

根据现有配置,期望翻译成如下代码:

import Decimal from 'Decimal'
Decimal(1).plus(Decimal(0.1).times(Decimal(0.2))).times(Decimal(2)).div(Decimal(n)).toFixed(4)

假如你想用其他库,比如 big.js, 修改配置

	numericalPrecision({ 
         defaultPrecision:4,
         tool:'big',
         importPath:'bigjs' 
       })

根据配置翻译后会成为:

import big from 'bigjs'
big(1).plus(big(0.1).times(big(0.2))).times(big(2)).div(big(n)).toFixed(4)

当然你也可以使用自己的计算库:

	numericalPrecision({ 
         defaultPrecision:2, //修改下精度
         tool:'mynum',
         importPath:'@src/utils/mynum' 
       })

根据配置翻译后会成为:

import mynum from '@src/utils/mynum'
mynum(1).plus(mynum(0.1).times(mynum(0.2))).times(mynum(2)).div(mynum(n)).toFixed(2)

到了这里整体功能已经设计完毕了,但是还缺少一些细节

比如有时候不同模块对精度的取舍不一致,有时候需要小数点2位,有时候需要4位,而插件的配置项是静态的,那该怎么办呢?

第一种方法,修改配置:

rules:[
//a模块精度4
{
           loader: 'babel-loader',
           options: {
              targets: {
                esmodules: true
              },
              plugins: [
              numericalPrecision({ 
              	defaultPrecision:4,
              	tool:'Decimal',
              	importPath:'Decimal' 
              })
              ],
            },
            includes:['a-modules']
},
//b模块精度2
{
           loader: 'babel-loader',
           options: {
              targets: {
                esmodules: true
              },
              plugins: [
              numericalPrecision({ 
              	defaultPrecision:2,
              	tool:'Decimal',
              	importPath:'Decimal' 
              })
              ],
            },
            includes:['b-modules']
}
]

虽然问题解决了,但是这种方法显得比较臃肿,可能会导致配置爆炸.

思索了一下,我们可以借用 预处理器指令 这种编译技巧去告诉编译器当下应该完成何种任务:

 /* precision=3 */ 10 / 3 

这里的注释 / precision=3 / 就是预处理器指令, 通过一段注释告诉当下编译器去完成更具体的任务, 条件编译也是这种思路

所以这段代码,被期望翻译成:

import Decimal from 'Decimal'
Decimal(10).div(Decimal(3).toFixed(3)

到了这里, 大概就是我们这个插件的总体设计目标了

实现思路

  1. 检查文件是否有第三方库导入, 如果导入了就跳过, 如果没有就自动导入

  2. 获取预处理器指令, 根据指令生成正确编译配置

  3. 遍历所有BinaryExpression(数字运算AST节点)表达式,匹配 +.-,x,/ 将对应的表达式转换成 plus,minus,times,div方法(这一部分有点难度) , 这样整段表达式就变换成链式调用的形式了

着手实践

  1. 首先完成动态导入
module.exports = ({ defaultPrecision = 2, tool, importPath = tool } = {}) =>
  function ({ types: t }) {
    if (!tool) {
      console.error('numericalPrecision缺少tool参数')
      return
    }
    return {
      visitor: {
        Program(path, state) {
          // 检查是否已经有 Decimal 的导入
          let hasDecimalImport = false
          path.traverse({
            ImportDeclaration(path) {
              if (path.node.source.value === importPath) {
                hasDecimalImport = true
                path.stop()
              }
            }
          })

          if (!hasDecimalImport) {
            const importDeclaration = t.importDeclaration(
              [t.importDefaultSpecifier(t.identifier(tool))],
              t.stringLiteral(importPath)
            )
            path.node.body.unshift(importDeclaration)
          }
        },
      }
    }
  }

核心的替换语句如下:

			const importDeclaration = t.importDeclaration(
              [t.importDefaultSpecifier(t.identifier(tool))],
              t.stringLiteral(importPath)
            )

其中:

  1. t.importDeclaration(specifiers, source): 生成一个 import 声明节点,用于表示模块导入语句。参数 specifiers 是一个数组,包含了导入的成员(比如默认导入、具名导入、命名空间导入等),参数 source 是一个字符串,表示导入的模块路径。通俗点说 import 语法就是一个 importDeclaration

  2. t.importDefaultSpecifier(local): 生成一个默认导入的标识符节点,表示导入模块的默认导出成员。参数 local 是一个标识符节点,代表导入的默认成员的名称。

  3. t.identifier(name): 生成一个标识符节点,表示一个标识符(变量名,函数名等)。参数 name 是一个字符串,表示标识符的名称。

  4. t.stringLiteral(value): 生成一个字符串字面量节点,表示一个字符串字面量值。参数 value 是一个字符串,表示该字符串字面量的值。通俗点说类似"string"就是一个stringLiteral

我们尝试逐渐翻译一下这个过程吧

  1. 先从 t.importDeclaration 开始:
import t.identifier(tool) from t.stringLiteral(importPath)
  1. 由于 t.identifier(tool) 注册时是 Decimal, 所以:
import Decimal from t.stringLiteral(importPath)
  1. t.stringLiteral(importPath) 注册时也是 Decimal, 最后:
import Decimal from 'Decimal'

好啦, 大概是这几个核心api的作用啦, 当然真实的转换过程和顺序跟上面是不一样的,比如t.stringLiteral(importPath)应该是最先执行的, 在这里只是为了让大伙更直观的理解.

第2步: 获取预处理器指令

return {
      visitor: {
        Program(path, state) {
          // 检查是否已经有 Decimal 的导入
          let hasDecimalImport = false
          path.traverse({
            ImportDeclaration(path) {
              if (path.node.source.value === importPath) {
                hasDecimalImport = true
                path.stop()
              }
            }
          })

          if (!hasDecimalImport) {
            const importDeclaration = t.importDeclaration(
              [t.importDefaultSpecifier(t.identifier(tool))],
              t.stringLiteral(importPath)
            )
            path.node.body.unshift(importDeclaration)
          }
        },
        BinaryExpression(path) {
          const { node } = path
          const [_, p_set = defaultPrecision] =
              node.leadingComments
                ?.find(({ type, value }) => type === 'CommentBlock' && value.trim().includes('precision='))
                ?.value?.split('=') ?? []
        }
      }
    }

注释类型一般有两种一个是行内注释,一个是块级注释 CommentBlock 就是我们要取得注释类型

这里的 BinaryExpression visitor 就是诸如 + - / * 语法的AST入口, 学名叫做二元表达式

第3步: 替换BinaryExpression节点 (重点)

BinaryExpression节点的特点是它分为两个部分一部分是left , 一部分是right, left就是操作符左边部分,right就是右边部分, 典型的一段BE表达式

1 + 1 

被AST解读出来后, 数据结构大致就是这样(这里删除了不重要的部分)

  "expression": {
          "type": "BinaryExpression",
		// 左边
          "left": {
            "type": "NumericLiteral",
            "start": 0,
            "end": 1,
            "value": 1
          },
          "operator": "+",
          //右边
          "right": {
            "type": "NumericLiteral",
            "start": 4,
            "end": 5,
            "value": 1
          }
        }

可通过在线AST验证

既然知道了BE的AST数据结构特点, 那么思路也就有了, 只要不断遍历BE表达式把右边的操作符号替换成 memberExpression 节点就好了(memberExpression可以理解成诸如 array.map 这样的代码形式, 因为我们要把代码替换成链式调用的形式,所以要用到 memberExpression )

首先写个工具函数去匹配对应的操作符

 const operators = ['+', '-', '*', '/']
 function getDecimalMethod(operator) {
      switch (operator) {
        case '+':
          return 'plus'
        case '-':
          return 'minus'
        case '*':
          return 'times'
        case '/':
          return 'div'
        default:
          return null
      }
    }

在加个替换表达式的工具函数:

function getNewExpression(operator){
//callExpression 是什么呢?
 const transformedExpression = t.callExpression(
            t.memberExpression(left, t.identifier(getDecimalMethod(operator))),
            [right]
          )
return transformedExpression
}

api解释:

  1. callExpression 表示函数调用表达式,通常由函数名、参数列表和括号组成。例如:myFunction(arg1, arg2)

  2. memberExpression 表示成员访问表达式,通常由对象名和属性名(或方法名)组成。例如:myObject.propertymyObject.method() 都是 memberExpression

先不考虑变量的具体值,假设这是一段加法转译,上面的代码大概就翻译成 left.plus(right)

下面是最核心的部分:

  BinaryExpression(path) {
          const { node } = path
          //匹配正确的操作符
          
          if (!getDecimalMethod(node.operator)) return
          
          try {
          
           const [_, p_set = defaultPrecision] =
              node.leadingComments
                ?.find(({ type, value }) => type === 'CommentBlock' && value.trim().includes('precision='))
                ?.value?.split('=') ?? []
                
            //将操作符right部分换成方法调用
            const newExpression = getNewExpression(node.expression.operator)
            
            //path.skip 是指跳过当前节点
            if (!newExpression) return path.skip()
            //生成换成的链式表达式
            const newExp = t.callExpression(t.memberExpression(newExpression, t.identifier('toFixed')), [
              t.numericLiteral(+p_set)
            ])
            
            //replaceWith将旧的AST替换成新的
            path.replaceWith(newExp)
            path.skip()
          } catch (error) {
            path.skip()
            error.message && console.log(error.message)
          }
        }

好了, 那么到了这里最核心的功能我们就算实现了, 那看起来也没难度对吗? 现在我们就算完成了这个插件了吗? 其实还远远没有呢?

正所谓知易行难, 大家都知道挣500万只要在淘宝卖1000件商品每件商品挣10元利润,坚持500天大概一年半的样子就可以挣到了,但是实际操作起来却是无比困难的.

比如选品问题,利润10块钱的商品不难找,找一个"好卖"的商品就显得极为困难,普通人没有经营经验和思路,往往都是踩着石头过河,很多时候石头被洪水冲走了,河还没有过去.

就算商品选好了, 供应链可能也是非常不稳定的, 比如厂家看你卖的比较好了就坐地起价抬高成本断你货源, 店铺流量和推广成本总是成本高流量低,指不定什么时候冒出一大堆水军把店铺一个月的推广费用一天全部烧掉了.

从这个例子出发大到电商平台政策调整,国家法律法规调整; 小到正确打包商品,面对竞争对手恶意拍单发货;也处处都是学问那....

好吧,有点扯远了, 这是一段小插曲, 重新回到正题这里; 再来谈谈实现操作符代码转译的会碰到的一些实际难题.

首先JS是一种非常灵活的语言,只有不理解的语法, 没有想不到的语法.

比如说:

 1 + 'ss';
 
 'ss' + 'bb'

碰到这种表达式你就需要告诉编译器跳过编译, 因为数字加字符串和字符串加字符串跟数学计算无关,但是它们同样是BinaryExpression节点,

再比如说,计算中碰到变量的情况

1 + n + b

由于你不知道n和b到底是字符串还是数字, 所以你不能硬编译这段代码, 需要在编译过程中增加适当的提示,或者通过预处理器指令告诉编译器是否可以安全编译(参考文章前面预处理器指令部分)

// force-precision 是一段编译时预处理声明, 告诉编译器可以正确转译
/* force-precision */  1 + n + b

再比如对于复杂的语句,还需要针对AST节点做深度处理,例如这段代码 :

f().a + 3 + f.a.b

理想中它应该需要被编译成:

// f().a + 3 + f.a.b
Decimal(f().a).plus(Decimal(3)).plus(f.a.b)

但是在处理不当的情况会被编译成:

Decimal(f()).a.plus(Decimal(3)).plus(f).a.b

???

是否感觉很诧异呢? 但是其实是符合AST结构特点的

我们先来回顾一下1+1的AST结构

  "expression": {
          "type": "BinaryExpression",
		// 左边
          "left": {
            "type": "NumericLiteral",
            "start": 0,
            "end": 1,
            "value": 1
          },
          "operator": "+",
          //右边
          "right": {
            "type": "NumericLiteral",
            "start": 4,
            "end": 5,
            "value": 1
          }
        }

可以看到这个 BinaryExpression 的 left 和 right 节点都是 NumericLiteral, NumericLiteral 就是数字字面量,它的AST结构相对简单.

但是 f.a.b + 1 的结构长这样的 (为了避免眼花缭乱, 我删除了一些不必要的成分)

        "expression": {
          "type": "BinaryExpression",
          "start": 0,
          "end": 9,
          },
          "left": {
            "type": "MemberExpression",
            "start": 0,
            "end": 5,
            "object": {
              "type": "MemberExpression",
              "start": 0,
              "end": 3,
              "object": {
                "type": "Identifier",
                "start": 0,
                "end": 1,
                "name": "f"
              },
              "computed": false,
              "property": {
                "type": "Identifier",
                "start": 2,
                "end": 3,
                "loc": {
                 
                  "identifierName": "a"
                },
                "name": "a"
              }
            },
            "computed": false,
            "property": {
              "type": "Identifier",
              "start": 4,
              "end": 5,
              "loc": {
                "start": {
                  "line": 1,
                  "column": 4,
                  "index": 4
                },
                "end": {
                  "line": 1,
                  "column": 5,
                  "index": 5
                },
                "identifierName": "b"
              },
              "name": "b"
            }
          },
          "operator": "+",
          "right": {
            "type": "NumericLiteral",
            "start": 8,
            "end": 9,
            "loc": {
              "start": {
                "line": 1,
                "column": 8,
                "index": 8
              },
              "end": {
                "line": 1,
                "column": 9,
                "index": 9
              }
            },
            "extra": {
              "rawValue": 1,
              "raw": "1"
            },
            "value": 1
          }
        }
      }

看吧,代码就修改了一点点但数据结构多了不止一串串啊. 类似的代码还有

 //虽然看着差不多的代码,但解决起来很费力
 f.a().b + 1 
 f().b + 1

这类问题的一个解决思路就是需要进行一次节点递归,针对该节点做局部替换,否则就很容易出现表达式嵌套或者重复编译的情况.

babel-loader 团队目前已经为这个项目贡献了十余年了, 编译是一门很深奥的学问, 篇幅问题在这里我也只能浅显的分享一下自己的经验, 如果有需要分享更多细节会新开一遍.

在完成这个插件之前也经历了许多困难 , 实现源码可以访问github: numericalPrecision

最后贴上我通过的测试用例:

function ddd() {
  b =
    Decimal(1 + 3 + d)
      .plus(2 + 3 + d)
      .plus(2 + 3 + d)
      .plus(2 + 3 + d)
      .plus(4) + 1
}

for (let i = 0; i < length; i++) {
  var a = /* ignore-precision */ a + b + c
  result += characters.charAt(Math.floor(Math.random() * charactersLength))
}

export default class Clazz {
  say() {}
  render() {
    const b = [1, 2, 3, 4, 6].reduce((p, t) => p + t, 0)
    return <div>{[1 + A(), A() + 1]}</div>
  }
}

function bs() {
  return /* skip-precision */ b + a - p + 1
}

function c(a) {
  let b = 0
  function bs(p) {
    return /* ignore-precision */ b + a - p + 1
  }
  b = 2 + 3 + bs(2)
  return b
}

b + 'c' + 1 - 1
function s() {
  return b + 'c' + 1 - 1
}

'c' + b + 2 + a
function s() {
  return 'c' + b + 2 + a
}

function a() {
  return /* skip-precision */ b + c
}

var a = /*precision=4*/ /* ignore-precision */ b + c + d + 1 + c + d
function aa() {
  return /*precision=4*/ /* ignore-precision */ b + c + d + 1 + c + d
}

1 + b + 2 + b + b + b
function aa() {
  return 1 + b + 2 + b + b + b
}

function c() {
  return Decimal(1 + 3 + d) + 1
}

f(1 + 3 + d)
b = f(1 + 3 + d)
function c() {
  b = f(1 + 3 + d)
  return f(1 + 3 + d)
}

b1 = Decimal(f() + 3).plus(4)

b1 = Decimal(f() + f()).plus(4)

f().a + 3

b1 = Decimal(f().a + 3 + f.a.b).plus(4)

b1 = Decimal(1 + 3).plus(4)

b2 = Decimal(1 + 3 + d) + 1
//.plus(4) + 1

b2 = D(1 + 3 + d).plus(4) + 1

b2 = Decimal.obj.ca(1).plus(a.b + 1) + 1

a.b + a.b.d

a.b() + a.b.d

a.d.d + a.b

a.d.d + a.b + b().plus()

b2 = Decimal.obj.ca(a.b + a.b.d).plus(1 + a.b)

a.c(1).plus(1)

b3 = Decimal(1 + 3 + d) + 1

b4 = f() + 1

export default class Clazz {
  say() {}
  render() {
    const b = [1, 2, 3, 4, 6].reduce((p, t) => p + t, 0)
    return <div>{[1 + A(), A() + 1]}</div>
  }
}

function func() {
  return 1 + 2 - 3 + 4
}

function func() {
  return 1 + 2 - 3 + b
}

function func2() {
  return 1 + '2' - 3 + func()
}

function func2(a = 1 + func() + func()) {
  return 1 + '2'
}

function f() {
  return f1(1 + 2 + 3) + f2(math(1)) + f3() + 1
}

function func2() {
  return '1' + '2'
}

function func2() {
  return '1' + 2
}

var a = 1 + 2 - 3 + 4
1 + 2 - 3 + 4

for (const iterator of object) {
  var a = 1 + 2 - 3 + 4
}

if (true) {
  var a = 1 + 2 - 3 + 4
}

function A() {
  return 1 + 2 + 3 + 4
}