使用 AST 处理输入字段的关联计算问题

一、引言

在前端开发中,经常会遇到输入字段之间存在关联计算的场景。传统的处理方式可能会使代码变得复杂且难以维护,而使用抽象语法树(AST)来处理这些关联计算可以提供一种更清晰、更灵活的解决方案。本文将结合一个具体的 HTML 文件示例,详细介绍如何使用 AST 处理输入字段的关联计算。

二、文件概述

2.1 整体功能

该 HTML 文件实现了一个输入字段关联计算的功能,系统会根据预设的计算规则自动计算出相关的合计金额等结果,并实时更新到对应的输入框中。

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <!-- 引入javascript解析器 -->
  <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/acorn/8.7.0/acorn.js" type="application/javascript"></script>
  <!-- 引入 bignumber 处理+-*/运算 -->
  <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/bignumber.js/9.0.2/bignumber.js" type="application/javascript"></script>
  <title>Document</title>
</head>
<body>
  <div class="input-container"></div>
  <!-- ... -->
</body>
</html>

2.2 主要模块

  • 运算符处理模块(Operator 类) :定义了各种运算符的操作,如加、减、乘、除、比较等,并通过 operatorMap 进行管理。
  • AST 解析模块(AcornParser 类) :使用 acorn 库解析模板字符串,生成抽象语法树(AST),并提供了一些方法来遍历和收集特定类型的节点。
  • 字段处理模块(FieldHandler 类):负责处理输入字段的关联计算,包括生成依赖属性、执行赋值表达式、收集依赖等。

三、运算符处理模块(Operator 类)

3.1 功能概述

Operator 类主要用于处理各种运算符的操作。它通过一个静态的 operatorMap 来存储运算符和对应的处理函数,提供了一个统一的接口来执行不同的运算。

3.2 代码实现

javascript 复制代码
class Operator {
  static operatorMap = new Map();

  static run(operatorSignal, ...args) {
    if (!Operator.operatorMap.has(operatorSignal)) {
      throw new Error(`不支持的运算符: ${operatorSignal}`);
    }
    return Operator.operatorMap.get(operatorSignal)(...args);
  }

  // 公共方法,用于处理数字运算
  static calculateNumbers(initialValue, args, operation) {
    let result = initialValue;
    for (let i = 1; i < args.length; ++i) {
      if (typeof result === 'number' && typeof args[i] === 'number') {
        result = operation(result, args[i]);
        continue;
      }
      throw new Error('不支持非数字类型的运算');
    }
    return result;
  }

  static plus(...args) {
    return Operator.calculateNumbers(args[0], args, (a, b) => new BigNumber(a).plus(new BigNumber(b)).toNumber());
  }

  static minus(...args) {
    return Operator.calculateNumbers(args[0], args, (a, b) => new BigNumber(a).minus(new BigNumber(b)).toNumber());
  }

  static mul(...args) {
    return Operator.calculateNumbers(args[0], args, (a, b) => new BigNumber(a).times(new BigNumber(b)).toNumber());
  }

  static slash(...args) {
    return Operator.calculateNumbers(args[0], args, (a, b) => new BigNumber(a).dividedBy(new BigNumber(b)).toNumber());
  }

  static bigger(...args) {
    return Operator.calculateNumbers(args[0], args, (a, b) => new BigNumber(a).isGreaterThan(new BigNumber(b)));
  }
}
Operator.operatorMap.set('+', Operator.plus);
Operator.operatorMap.set('-', Operator.minus);
Operator.operatorMap.set('*', Operator.mul);
Operator.operatorMap.set('/', Operator.slash);
Operator.operatorMap.set('>', Operator.bigger);
Operator.operatorMap.set('&&', (a, b) => a && b);
Operator.operatorMap.set('||', (a, b) => a || b);
Operator.operatorMap.set('==', (a, b) => a== b);
Operator.operatorMap.set('!=', (a, b) => a != b);
Operator.operatorMap.set('Number', Number);
Operator.operatorMap.set('Date', Date);

3.3 代码解释

  • operatorMap:一个 Map 对象,用于存储运算符和对应的处理函数。
  • run 方法:根据运算符信号从 operatorMap 中获取对应的处理函数,并执行该函数。
  • calculateNumbers 方法:用于处理数字运算,确保参与运算的参数都是数字类型。
  • 各种运算符处理方法(如 plusminus 等):使用 BigNumber 库进行精确的数字运算。

四、AST 解析模块(AcornParser 类)

4.1 功能概述

AcornParser 类使用 acorn 库解析模板字符串,生成抽象语法树(AST),并提供了一些方法来遍历和收集特定类型的节点,如赋值表达式节点、标识符节点等。

4.2 代码实现

javascript 复制代码
class AcornParser {
  constructor() {
    this.parser = acorn;
    // https://262.ecma-international.org/12.0/#sec-global-object
    // 挂在在全局对象上的部分属性
    const globalObjectWords = 'eval isFinite isNaN parseFloat parseInt encodeURI decodeURI encodeURIComponent decodeURIComponent Array ArrayBuffer BigInt BigInt64Array BigUint64Array Boolean DataView Date Error EvalError FinalizationRegistry Float32Array Float64Array Function Int8Array Int16Array Int32Array Map Number Object Promise Proxy RangeError ReferenceError RegExp Set SharedArrayBuffer String Symbol SyntaxError TypeError Uint8Array Uint8ClampedArray Uint16Array Uint32Array URIError WeakMap WeakRef WeakSet Atomics JSON Math Reflect';
    this.globalObjectReg = new RegExp("^(?:" + globalObjectWords.replace(/ /g, "|") + ")$");
    this.nodeTypes = {
      AssignmentExpression: 'AssignmentExpression', // 赋值表达式
      Identifier: 'Identifier', // 标识符
      Literal: 'Literal', // 字面量
      BinaryExpression: 'BinaryExpression', // 二元表达式
      CallExpression: 'CallExpression', // 调用表达式
      NewExpression: 'NewExpression', // 新建表达式
      MemberExpression: 'MemberExpression', // 成员表达式
      ConditionalExpression: 'ConditionalExpression', // 条件表达式
      LogicalExpression: 'LogicalExpression', // 逻辑表达式
    };
  }

  parse(template) {
    return this.parser.parse(template, { ecmaVersion: 5 });
  }

  isGlobalObjectProperty(word) {
    return this.globalObjectReg.test(word);
  }

  // 返回赋值表达式列表
  getAssignmentExpressionList(ast) {
    return this.traverseAndCollect(ast, this.nodeTypes.AssignmentExpression);
  }

  // 返回 Identifier 节点列表
  getIdentifierList(ast) {
    return this.traverseAndCollect(ast, this.nodeTypes.Identifier, (node) =>!this.globalObjectReg.test(node.name));
  }

  traverseAndCollect(node, findNodeType, filter = () => true) {
    const result = [];
    this.traverse(node, findNodeType, (n) => {
      if (filter(n)) {
        result.push(n);
      }
    });
    return result;
  }

  traverse(node, findNodeType, callback) {
    if (!node) return;

    if (node.type === findNodeType) {
      callback(node);
      return;
    }

    for (const key in node) {
      if (typeof node[key] === 'object' && node[key]!== null) {
        if (Array.isArray(node[key])) {
          node[key].forEach((n) => {
            this.traverse(n, findNodeType, callback);
          });
        } else {
          this.traverse(node[key], findNodeType, callback);
        }
      }
    }
  }
}

4.3 代码解释

  • constructor 方法:初始化 acorn 解析器,定义全局对象属性的正则表达式和节点类型。
  • parse 方法:使用 acorn 解析模板字符串,生成 AST。
  • isGlobalObjectProperty 方法:判断一个单词是否是全局对象的属性。
  • getAssignmentExpressionList 方法:遍历 AST,收集所有的赋值表达式节点。
  • getIdentifierList 方法:遍历 AST,收集所有的标识符节点,并排除全局对象的属性。
  • traverseAndCollect 方法:遍历 AST,根据指定的节点类型和过滤条件收集节点。
  • traverse 方法:递归遍历 AST,对符合条件的节点执行回调函数。

五、字段处理模块(FieldHandler 类)

5.1 功能概述

FieldHandler 类负责处理输入字段的关联计算,包括生成依赖属性、执行赋值表达式、收集依赖等。

5.2 代码实现

javascript 复制代码
let activeEffect = null;
function effect(fn) {
  if (!!activeEffect) throw new Error('不允许嵌套effect');
  activeEffect = fn;
  fn();
  activeEffect = null;
}

class FieldHandler {
  /**
   * @param {string} template 模板字符串
   * @param {object} parser 解析器
   */
  constructor(template, parser, fieldInitValues) {
    this.parser = new AcornParser();
    this.isRawUpdate = false;
    this.reactiveObject = {};
    // 避免嵌套更新
    this.currentEffectFieldCode = {};
    this.afterGetExpressionValue = ((v) => v);
    this.assignCallback = (() => undefined);
    this.init(template, fieldInitValues);
  }

  init(template, fieldInitValues) {
    this.reactiveObject = {};
    const ast = this.parser.parse(template, { ecmaVersion: 5 });
    const exprList = this.parser.getAssignmentExpressionList(ast);

    // 生成依赖属性
    this.generateDependencies(exprList, fieldInitValues);

    // 执行赋值表达式,收集依赖属性值
    exprList.forEach(assignmentExpression => {
      this.executeAssignmentExpression(assignmentExpression);
    });
  }

  generateDependencies(exprList, fieldInitValues = {}) {
    exprList.forEach(assignmentExpression => {
      const { right } = assignmentExpression;
      const identifierList = this.parser.getIdentifierList(right);
      identifierList.forEach(identifier => {
        this.definedProperty(identifier.name, fieldInitValues[identifier.name]);
      });
    });
  }

  registerAfterGetExpressionValue(callback) {
    this.afterGetExpressionValue = callback || ((v) => v);
  }

  registerAssignCallback(callback) {
    this.assignCallback = callback || (() => undefined);
  }

  // 执行赋值表达式,收集依赖fieldCode
  executeAssignmentExpression(assignmentExpression) {
    const { left, right } = assignmentExpression;
    const targetFieldCode = this.getTargetFieldCode(left);
    if (!targetFieldCode) return;

    let isOnlyCollect = true;
    //
    effect(() => {
      let rightValue = this.getAssignmentExpressionValue(right);
      if (isOnlyCollect) {
        isOnlyCollect = false;
        return;
      }
      // 遇到嵌套循环,优先执行前面的表达式
      /**
       * 例如:更新 d 时,先执行 a = b + d + 1; 再执行 b = a + d + 1; a 不会再更新
       * a = b + d + 1;
       * b = a + d + 1;
       */
      if (!!this.currentEffectFieldCode[targetFieldCode]) return
      rightValue = this.afterGetExpressionValue(rightValue, targetFieldCode);
      this.assignCallback(rightValue, targetFieldCode);
      this.reactiveObject[targetFieldCode] = rightValue;
    });
  }

  getTargetFieldCode(left) {
    const identifiers = this.parser.getIdentifierList(left);
    return identifiers.length > 0? identifiers[0].name : '';
  }

  // 获取赋值表达式右侧值
  getAssignmentExpressionValue(assignmentExpressionRight) {
    const command = (node, isProp) => {
      if (!node) return;
      switch (node.type) {
        case this.parser.nodeTypes.BinaryExpression:
          return Operator.run(node.operator, command(node.left), command(node.right));
        case this.parser.nodeTypes.CallExpression:
          const fn = command(node.callee);
          if (!fn) throw new Error(`不支持的函数调用: ${JSON.stringify(node.callee)}`);
          return fn(...this.removeLastUndefined(Array.from(node.arguments || []).map(_node => command(_node))));
        case this.parser.nodeTypes.MemberExpression:
          const object = command(node.object)
          const property = command(node.property, true)
          if (typeof object === 'object' && object !== null) {
            const result = object[property]
            if (typeof result === 'function') {
              return result.bind(object)
            }
            return result
          }
          throw new Error(`不支持的成员表达式:${JSON.stringify(node)}`)
        case this.parser.nodeTypes.NewExpression:
          const Constructor = command(node.callee)
          if (!Constructor) throw new Error(`不支持的构造函数调用: ${JSON.stringify(node.callee)}`);
          return new Constructor(...this.removeLastUndefined(Array.from(node.arguments || []).map(_node => command(_node))));
        case this.parser.nodeTypes.ConditionalExpression:
          const test = command(node.test)
          const consequent = command(node.consequent)
          const alternate = command(node.alternate)
          return test ? consequent : alternate;
        case this.parser.nodeTypes.LogicalExpression:
          const left = command(node.left)
          const right = command(node.right)
          return Operator.run(node.operator, left, right);
        case this.parser.nodeTypes.Identifier:
          if (isProp) return node.name;
          if (this.parser.isGlobalObjectProperty(node.name)) {
            return Operator.operatorMap.get(node.name);
          }
          return this.reactiveObject[node.name];
        case this.parser.nodeTypes.Literal:
          return node.value;
        default:
          throw new Error(`不支持的节点类型:${node.type}`);
      }
    };
    return command(assignmentExpressionRight);
  }

  // 移除数组最后一个undefined元素,可修复 Number(undefined)/new Date(undefined) 等情况
  removeLastUndefined(arr) {
    for (let i = arr.length - 1; i >= 0; i--) {
      if (arr[i] === undefined) {
        arr.pop();
      } else {
        break;
      }
    }
    return arr;
  }

  // 为 this.reactiveObject 定义属性
  definedProperty(fieldCode, initialValue) {
    if (this.reactiveObject.hasOwnProperty(fieldCode)) return;
    console.log('definedProperty', fieldCode, initialValue);

    let value = initialValue;
    let dependencies = [];
    Object.defineProperty(this.reactiveObject, fieldCode, {
      configurable: true,
      enumerable: true,
      get: () => {
        if (!!activeEffect) {
          dependencies.push(activeEffect);
        }
        return value;
      },
      set: (nextValue) => {
        value = nextValue;
        if (this.isRawUpdate) return;
        this.currentEffectFieldCode[fieldCode] = true
        dependencies.forEach(dependency => {
          dependency();
        });
        this.currentEffectFieldCode[fieldCode] = false
      }
    });
  }

  // 更新值
  updateValue(fieldCode, value) {
    this.isRawUpdate = false;
    this.reactiveObject[fieldCode] = value;
  }

  // 更新值,不触发 effect
  updateValueWithoutEffect(fieldCode, value) {
    this.isRawUpdate = true;
    this.reactiveObject[fieldCode] = value;
    this.isRawUpdate = false;
  }
}

5.3 代码解释

  • constructor 方法:初始化 FieldHandler 实例,解析模板字符串,生成依赖属性,并执行赋值表达式。
  • init 方法:解析模板字符串,生成 AST,收集赋值表达式节点,生成依赖属性,并执行赋值表达式。
  • generateDependencies 方法:根据赋值表达式的右侧节点,生成依赖属性。
  • registerAfterGetExpressionValue 方法:注册一个回调函数,用于处理赋值表达式的结果。
  • registerAssignCallback 方法:注册一个回调函数,用于处理赋值操作。
  • executeAssignmentExpression 方法:执行赋值表达式,收集依赖属性值,并更新 reactiveObject
  • getTargetFieldCode 方法:获取赋值表达式左侧的目标字段代码。
  • getAssignmentExpressionValue 方法:根据 AST 节点类型,递归计算赋值表达式右侧的值。
  • removeLastUndefined 方法:移除数组最后一个 undefined 元素。
  • definedProperty 方法:为 reactiveObject 定义属性,实现响应式更新。
  • updateValue 方法:更新字段值,并触发依赖更新。
  • updateValueWithoutEffect 方法:更新字段值,不触发依赖更新。

六、输入字段生成与事件处理

6.1 输入字段生成

根据 inputDataList 生成输入框,并将其添加到页面中。

javascript 复制代码
// 定义输入字段数据
const inputDataList = [
  { fieldCode: 'A1', label: 'Ax费用' },
  { fieldCode: 'A2', label: 'A费用' },
  { fieldCode: 'B1', label: 'Bx费用' },
  { fieldCode: 'B2', label: 'B费用' },
  { fieldCode: 'NS', label: '费用合计' },
  { fieldCode: 'A3', label: 'A+点%' },
  { fieldCode: 'A4', label: 'A点%' },
  { fieldCode: 'FA', label: 'A金额' },
  { fieldCode: 'B3', label: 'B点%' },
  { fieldCode: 'FB', label: 'B金额' },
  { fieldCode: 'FS', label: '金额合计' },
  { fieldCode: 'T1', label: 'D1', inputType: 'date' },
  { fieldCode: 'T2', label: 'D2', inputType: 'date' },
  { fieldCode: 'TR', label: 'D1大于D2', inputType: 'radio', name: 'time', disabled: true },
  { fieldCode: 'TR', label: 'D1小于等于D2', inputType: 'radio', name: 'time', disabled: true }
];

const fieldHandler = new FieldHandler(`
  A2 = Number(A1) / 1.5
  B2 = Number(B1) / 1.5
  NS = Number(A2) + Number(B2)

  // 计算值都存在再计算,如果已经存在值,则不更改
  // 因为按照百分比输入,所以/100
  FA = ((A4 != null && A4 != '') || (A3 != null && A3 != ''))
    && (A2 != null && A2 != '')
      ? Number(A2) * (Number(A3) + Number(A4)) / 100
      : FA

  // 计算值都存在再计算,如果已经存在值,则不更改
  // 因为按照百分比展示,所以*100
  A4 = (FA != null && FA != '') && (A2 != null && A2 != '')
    ? Number(FA) / Number(A2) * 100 - Number(A3)
    : A4

  // 计算值都存在再计算,如果已经存在值,则不更改
  // 因为按照百分比输入,所以/100
  FB = (B3 != null && B3 != '') && (B2 != null && B2 != '')
    ? Number(B2) * Number(B3) / 100
    : FB

  // 计算值都存在再计算,如果已经存在值,则不更改
  // 因为按照百分比展示,所以*100
  B3 = (FB != null && FB != '') && (B2 != null && B2 != '')
    ? Number(FB) / Number(B2) * 100
    : ''

  // 两个都是空串,则置空
  FS = (FA != null && FA != '') || (FB != null && FB != '')
    ? Number(FA) + Number(FB)
    : ''

  TR = (new Date(T1)).getTime() > (new Date(T2)).getTime()
`, new AcornParser(), {
  T1: (new Date()).toLocaleDateString(),
  T2: (new Date()).toLocaleDateString(),
});

fieldHandler.registerAfterGetExpressionValue((v) => {
  if (typeof v === 'number') {
    if (!Number.isFinite(v)) return 0;
  }
  return v;
});

fieldHandler.registerAssignCallback((value, assignFieldCode) => {
  const input = document.querySelector(`input[data-field-code="${assignFieldCode}"]`);
  if (input) {
    if (assignFieldCode === 'TR') {
      const radioInputs = document.querySelectorAll(`input[data-field-code="${assignFieldCode}"]`);
      if (radioInputs.length === 0) return;
      radioInputs[0].checked = value;
      radioInputs[1].checked = !value;
    } else {
      input.value = value;
    }
  }
});

// 根据 inputDataList 生成输入框
const inputContainer = document.querySelector('.input-container');
const createInputElement = (inputData) => {
  const wrapper = document.createElement('div');
  const label = document.createElement('label');
  label.innerHTML = inputData.label;
  const input = document.createElement('input');
  input.setAttribute('type', inputData.inputType || 'number');
  if (inputData.inputType === 'radio') {
    input.setAttribute('name', inputData.name);
  }
  input.setAttribute('data-field-code', inputData.fieldCode);
  if (!!inputData.disabled) {
    input.setAttribute('disabled', true);
  }
  wrapper.appendChild(label);
  wrapper.appendChild(input);
  return wrapper;
};

const fragment = document.createDocumentFragment();
inputDataList.forEach(inputData => {
  const inputElement = createInputElement(inputData);
  fragment.appendChild(inputElement);
});
inputContainer.appendChild(fragment);

6.2 事件处理

当输入框失去焦点时,更新对应字段的值,并触发关联计算。

javascript 复制代码
// 当输入框失去焦点时,更新对应字段的值
inputContainer.addEventListener('blur', (e) => {
  if (e.target.matches('input[data-field-code]')) {
    const input = e.target;
    const fieldCode = input.getAttribute('data-field-code');
    const value = input.value;
    console.log(fieldCode, value);
    fieldHandler.updateValue(fieldCode, value);
  }
}, true);

七、总结

通过使用 AST 处理输入字段的关联计算,我们可以将复杂的计算逻辑与 UI 分离,提高代码的可维护性和可扩展性。在这个示例中,我们使用 acorn 库解析模板字符串,生成 AST,然后根据 AST 节点类型递归计算赋值表达式的值。同时,通过 Object.defineProperty 实现了响应式更新,当输入字段的值发生变化时,会自动触发关联计算,并更新相关的输入框。这种方法可以应用于各种需要处理输入字段关联计算的场景,如表单验证、数据计算等。

相关推荐
嘉琪coder5 分钟前
显示器报废,win笔记本远程连接mac mini4 3种方法实测
前端·windows·mac
hrrrrb32 分钟前
【CSS3】筑基篇
前端·css·css3
boy快快长大35 分钟前
【VUE】day01-vue基本使用、调试工具、指令与过滤器
前端·javascript·vue.js
三原39 分钟前
五年使用vue2、vue3经验,我直接上手react
前端·javascript·react.js
嘉琪coder44 分钟前
React的两种状态哲学:受控与非受控模式
前端·react.js
木胭脂沾染了灰1 小时前
策略设计模式-下单
java·前端·设计模式
Eric_见嘉1 小时前
当敦煌壁画遇上 VS Code:我用古风色系开发了编程主题
前端·产品·visual studio code
青红光硫化黑1 小时前
React基础之项目创建
开发语言·javascript·ecmascript