likes
comments
collection
share

bable插件开发实战--修复toFixed()

作者站长头像
站长
· 阅读数 36

问题

在金融app上展示金钱数字时一般都需要保留两位小数,一般使用 number.toFixed(2) 就能解决问题,但是 toFixed() 并不是严格意义上的四舍五入,而是四舍六入五成双也即“4舍6入5凑偶”

这里“四”是指≤4 时舍去,"六"是指≥6时进上,"五"指的是根据5后面的数字来定,当5后有有效数字(不为0)时,舍5入1;当5后无有效数字时,需要分两种情况来讲:①5前为奇数,舍5入1;②5前为偶数,舍5不进。(0是偶数)。

// 当保留2位小数时的判断

const a1 = 0.046; // 第3位小数为6
const a2 = 0.044; // 第3位小数为4
const a3 = 0.0451 // 第3位小数为5, 5后为 1-有效数字
const a4 = 0.0450; // 第3位小数为5, 5后为 0-无效数字,5前为偶数
const a5 = 0.04503; //第3位小数为5,  5后为 03-有效数字
const a6 = 0.045; // 第3位小数为5, 5后无有效数字,5前为偶数
const a7 = 0.035; // 第3位小数为5, 5后无有效数字,5前为奇数


console.log(a1.toFixed(2)); // 0.05
console.log(a2.toFixed(2)); // 0.04
console.log(a3.toFixed(2)); // 0.05
console.log(a4.toFixed(2)); // 0.04
console.log(a5.toFixed(2)); // 0.05
console.log(a6.toFixed(2)); // 0.04
console.log(a7.toFixed(2)); // 0.04

解决思路也比较简单,可以使用 accounting.js 库,这是一个用于货币和货币格式JavaScript库。或者自己将数字修改后再使用 toFixed() 方法。

function fixtoFixed(target, _n) {
    var n = _n || 0;
    return Number(Math.round(target + 'e' + n) + 'e-' + n).toFixed(n);
}

但是有个问题,当早期代码里直接调用 toFixed() 来做四舍五入,且在代码里的很多地方都是这样使用的,且多个工程的使用都是如此。现在需要修复这个问题应该怎么做?一个一个地方去改显然不是一个好方法,这个时候 babel 就派上用场了。

babel是什么

babel是一个JavaScript编译器,提供了一套工具链,可以将 ES 2015+ 语法编写的代码转换为向后兼容的JavaScript语法。

主要功能有:

  1. 语法转换(es2015+ => es5), 主要是parser做的事情 举例:const => var, () => {} => function() {}, JSX 等。
  2. polyfill 添加目标环境缺失特性(配合core-js) Array.from(), new Set([])。
  3. 源码转换,以plugin的方式处理 n.toFixed(m) => anotherToFixed(n, m)。

用 babel 做什么

  1. 一个toFixed的代码转换插件
  2. 为什么要做,Number.toFixed 存在一些问题,就是文章开头介绍的问题,我们要修复它。
  3. 做出来的效果是什么,有两个方向,一个是直接使用 accounting.js 库,用accounting.toFixed 替换,另一个则是就地实现。

如何写?拆解babel转换原理

一句话总结,就是先拆开成AST(抽象语法树),加加减减,再拼回去。

  1. 解析步骤

原始js代码,经过词法分析和语法分析,组成抽象语法树。打个贴近日常的比喻就是,我们看到一句话:你是一个好孩子。我们大脑也进行了词法分析:你,是,一个,好,孩子(好比查字典,匹配已定义的词汇),然后是语法分析,先组成短语:好孩子,一个好孩子,再根据主谓宾结构组成:你是一个好孩子。对标js, const you = 'a good boy'; 按照词法拆分成 const, you, =, 'a good boy',按照我们学习过的编程语言知识,我们知道这是一个变量声明并初始化赋值的结构,其中 you 是变量名,'a good boy' 是个字符串常量, = 号是一个赋值操作符。

bable插件开发实战--修复toFixed()

bable插件开发实战--修复toFixed()

  1. AST explorer

babel插件的处理过程,其实就是对AST的操作过程,通过一种叫做visitor的模式,递归地按需地处理节点。这种visitor的模式,在babel遍历AST的过程中,每个节点都会被访问两次,enter和exit,插件就可以根据需要,对节点做出对应的修改,最后再根据修改过的AST生成目标代码。AST Explorer 可以让我们直观的看到每个AST节点,还可以选择不同的parser,调试转换代码等。

bable插件开发实战--修复toFixed()

bable插件开发实战--修复toFixed()

  1. 学习各个术语

Identifier, Literal, Expression, ImportDeclaration... enter/exit/path

  1. 注意事项

visitor中访问对象是path,节点通过path.node访问,注意类型的差异、 变量引用与作用域的绑定关系,防止意外修改作用域之外的代码。

  1. 工具箱
  • babylon:解析器,将代码解析成 AST
  • babel-traverse:遍历和维护整棵树的状态,并且负责替换、移除和添加节点
  • babel-types:AST 节点的 Lodash 式工具库,包含了构造、验证以及变换 AST 节点的方法
  • babel-generator:Babel 的代码生成器,它读取AST并将其转换为代码和源码映射
  • babel-template:能让你编写字符串形式且带有占位符的代码来代替手动编码,尤其是生成的大规模 AST的时候

实现过程

配套仓库 github.com/zhanghaosys…

依赖安装(@babel/core)

安装完成,已包含 babel-parser, babel-traverse, babel-types等,配合nodemon等工具可以实时查看结果

bable插件开发实战--修复toFixed()

找到待替换的目标

待替换的目标为 Number.toFixed, 那么如何识别呢?

123.456.toFixed(2), 对应的AST如下:

bable插件开发实战--修复toFixed()

按照js的语法,初步得到一些信息,这是一个函数调用,调用方是一个成员函数,名为 toFixed,带有一些参数等; 再对比多几种形式,比如说Number(1.23).toFixed(2),对应 AST 如下:

bable插件开发实战--修复toFixed()

形式和上面的123.456.toFixed(2)是一样的,只不过调用者Number(1.23)又是一个函数调用。至于一些更复杂的变形,a.b.c.toFixed(),最终也会是一个MemberExpression,但至于它是不是Number类似,我们是区分不出来的,毕竟这个只是静态分析,无法判断运行时的类型。

但toFixed(2) 明显不是我们的目标,因为我们不会这么调用

bable插件开发实战--修复toFixed()

最后确认下我们的目标,CallExpression中的MemberExpression, 其中Identifier为toFixed。visitor中访问到的节点,其实是path,而不是node,path用于连接不同的node。这个概念会影响到对节点操作的理解。

accounting.js 库实现

实现方式有两种,一是用 accounting.js 库,二是不额外引入一个accounting库,就地实现。先说第一种方式,把 Number.toFixed 替换为 accounting.toFixed

123.456.toFixed(2) => accounting.toFixed(123.456, 2) 除了替换名字,还要移动参数位置,各种参数的类型要匹配。大致两种类型的替换操作,直接字符串替换,或者,创建节点做替换。

直接字符串替换(简易方式)

简易方式实现比较简单,需要获取 AST 中的 object 和 arguments,然后作为参数传给 accounting.toFixed()。

bable插件开发实战--修复toFixed()

代码实现如下:

// accounting_simple.js
module.exports = function aBabelPlugin() {

  return {
    visitor: {
      CallExpression(path) {
        const callee = path.get('callee');
        const property = callee.get('property');
        const isToFixedCall = callee.isMemberExpression()
        && property.isIdentifier({ name: 'toFixed' });
        if (isToFixedCall) {
          const number = callee.get('object');
          const args = path.get('arguments');
          // number.toFixed() 没有第二个参数,跳过这种情况
          if (args.length > 1) {
              return;
          }
          const accountingFixedArgs = args.length === 0 ? '' : ',' + args[0];
          path.replaceWithSourceString(`accounting.toFixed(${number} ${accountingFixedArgs})`);
  
        }
        path.skip(); // 不对子节点继续访问
      } 
    }
  }
}

验证

将 accounting_simple.js 文件以插件的形式引入

const babel = require('@babel/core');

const code = `
123.456.toFixed(2)
Number(n).toFixed()
toFixed(2)
`;
const output = babel.transformSync(code, {
  plugins: [
    ['./accounting_simple.js']
  ]
})

console.log('============ generated code start ============')
console.log(output.code);
console.log('============ generated code end ============')

输出结果

bable插件开发实战--修复toFixed()

创建节点替换(复杂模式)

先从AST Explorer 直观看到我们的目标节点,然后通过babel-types 一步步创建。。。 需要了解每个节点的类型,创建要求,比如说 t.callExpression, t.memberExpression, t.identifier 等。

bable插件开发实战--修复toFixed()


const t = require('@babel/types');

module.exports = function aBabelPlugin() {

    return {
      visitor: {
        CallExpression(path) {
          const callee = path.get('callee');
          const property = callee.get('property');
          const isToFixedCall = callee.isMemberExpression()
          && property.isIdentifier({ name: 'toFixed' });
          if (isToFixedCall) {
            const number = callee.get('object');
            const accountingArgs = [number.node];
            const args = path.get('arguments');
            // number.toFixed() 没有第二个参数,跳过这种情况
            if (args.length > 1) {
                return;
            }
            if (args[0]) {
                accountingArgs.push(args[0].node);
            }
            // 创建 memberExpression 节点
            const accountingToFixed = t.memberExpression(t.identifier('accounting'), t.identifier('toFixed'));
            // 创建 callExpression 节点
            const callExp = t.callExpression(accountingToFixed, accountingArgs);
            path.replaceWith(callExp);
    
          }
          path.skip(); // 不对子节点继续访问
        } 
      }
    }
  }

验证方式和简易模式一样,输出结果也一样。

优化

accounting 作用域的判断,require 和 import 两种方式。

目前为止,我们是直接假设 accounting 这个变量存在,理应提前进行判断,按需引入。一般引入的操作是import 或者 require,只要在visitor中设置对应的勾子,就能实现相应的配置,这里再给出一种基于作用域(path.scope)判断的方式。

babel转换代码的时候是按模块或者文件进行的,我们要在该作用域内查找,我们只要保证最外层作用域存在accounting即可。在visitor中,最外层的节点是Program。path的数据结构中,有个bindings属性,表示当前作用域中涉及到的变量引用(binding 就是指引用与作用域的一种关联关系)。

在简易模式(复杂模式也一样)的基础上实现,代码如下:

Program(path) {
        const accountingRefName = 'accounting';
        // 没有引入 accounting
        console.log(path.scope.hasBinding(accountingRefName), Object.keys(path.scope.bindings));
        if (!path.scope.hasBinding(accountingRefName)){
          const requireDefine = {
            id: t.identifier(accountingRefName),
            init: t.callExpression(t.identifier('require'), [t.stringLiteral('accounting')]),
          };
          path.scope.push(requireDefine);
        }
        // 已引入 accounting
        console.log(path.scope.hasBinding(accountingRefName), Object.keys(path.scope.bindings));
      },

测试效果如下

bable插件开发实战--修复toFixed()

就地实现

如果不想额外引入一个accounting库,可以就地实现一个toFixed函数,只不过代码行数增多,我们可以使用 babel-template 这个工具,以及最后要把函数实现放到当前作用域中。

const t = require('@babel/types');

module.exports = function aBabelPlugin({ template }) {

  return {
    visitor: {
      Program(path) {
        const fnBuilder = template(`
          var FNNAME = function FNNAME(target, _n) {
            var n = _n || 0;
            return Number(Math.round(target + 'e' + n) + 'e-' + n).toFixed(n);
          }
        `);
        const builtFn = fnBuilder({ FNNAME: t.identifier('fixedToFixed') });
        // 将函数放在当前作用域中
        path.scope.push(builtFn.declarations[0]);
      },
      CallExpression(path) {
        const callee = path.get('callee');

        const property = callee.get('property');
        const isToFixedCall = callee.isMemberExpression()
        && property.isIdentifier({ name: 'toFixed' });
        if (!isToFixedCall) return;
        
        const number = callee.get('object');
        const args = path.get('arguments');
        if (args.length > 1) return; // Number.toFixed没有第二个参数,跳过这种情况
        
        const accountingArgs = [number.node];
        if (args[0]) accountingArgs.push(args[0].node);

        const callExp = t.callExpression(t.identifier('fixedToFixed'), accountingArgs);
        path.replaceWith(callExp);

        path.skip(); // 不对子节点继续访问
      } 
    }
  }
}

测试效果如下:

你会发现有点不大对劲,这个代码出现了递归死循环: bable插件开发实战--修复toFixed()

因为该函数的实现中,最后也调用了 Number.toFixed。

bable插件开发实战--修复toFixed()

所以只有想办法,识别出这处特殊调用即可。也就是在识别过程中跳过fixedToFixed函数即可,在代码中加入如下两行

bable插件开发实战--修复toFixed()

测试效果如下:

bable插件开发实战--修复toFixed()

项目中使用

开箱即用,直接引用babel插件所在路径,或者发布到npm仓库,随便举个例子

bable插件开发实战--修复toFixed()

bable插件开发实战--修复toFixed()

配置参数的使用

我们可以通过插件的选项,在访问节点的state.opts参数可以获取该值

bable插件开发实战--修复toFixed()

bable插件开发实战--修复toFixed()

单元测试

使用babel-plugin-tester可以方便很多,支持snapshot, 独立文件等

bable插件开发实战--修复toFixed()

bable插件开发实战--修复toFixed()

转载自:https://juejin.cn/post/7176128634627817530
评论
请登录