一、引言
在前端开发中,经常会遇到输入字段之间存在关联计算的场景。传统的处理方式可能会使代码变得复杂且难以维护,而使用抽象语法树(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
方法:用于处理数字运算,确保参与运算的参数都是数字类型。- 各种运算符处理方法(如
plus
、minus
等):使用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
实现了响应式更新,当输入字段的值发生变化时,会自动触发关联计算,并更新相关的输入框。这种方法可以应用于各种需要处理输入字段关联计算的场景,如表单验证、数据计算等。