Vue3 公式计算实现

Posted by Shallow Dreameron November 2, 2025

我的提问:

我想在前端vue3框架下实现一个公式计算的功能,变量是x,因变量是y,用户要在输入框输入类似y=x,并且给出x的范围,而我需要计算出此时y的值,有没有比较安全的不使用任何库的方案


ChatGPT 回答:

非常好的问题 👍。你要实现的是一个 用户可输入公式(如 y = x^2 + 3*x - 5)并计算结果 的功能。
重点是:

  • ✅ 支持变量(如 x)和基本数学运算。

  • ✅ 用户可设置 x 的范围。

  • ⚠️ 必须安全(不能用 eval / Function 动态执行字符串)。

  • ✅ 不使用第三方库。

下面我给你一个完整的、安全且纯原生 Vue 3 + JS 的方案


🧩 实现思路

  1. 解析用户输入的公式(如 y=x^2+3*x-5 → 提取出右边的表达式 x^2+3*x-5

  2. 安全计算表达式

    • 手动实现一个表达式解析器(Parser + Evaluator),支持四则运算、括号、幂次、变量 x

    • 利用 逆波兰表达式(RPN)算法递归下降解析 来计算。

  3. 遍历 x 范围,计算出每个 x 对应的 y


✅ 示例实现(推荐:逆波兰表达式法)

<template>
  <div class="p-4">
    <h2>安全公式计算器</h2>
    <input v-model="formula" placeholder="请输入公式,如 y = x^2 + 3*x - 5" class="border p-1 w-full" />
    <div class="mt-2">
      <label>X 范围:</label>
      <input v-model.number="xStart" type="number" placeholder="起始值" class="border p-1 w-20" />
      <input v-model.number="xEnd" type="number" placeholder="结束值" class="border p-1 w-20 ml-2" />
    </div>
    <button class="mt-3 bg-blue-500 text-white px-3 py-1 rounded" @click="calculate">计算</button>

    <div v-if="results.length" class="mt-3">
      <h3>结果:</h3>
      <table class="border mt-1">
        <tr><th class="border px-2">x</th><th class="border px-2">y</th></tr>
        <tr v-for="(r, i) in results" :key="i">
          <td class="border px-2"></td>
          <td class="border px-2"></td>
        </tr>
      </table>
    </div>
  </div>
</template>

<script setup>
import { ref } from "vue";

const formula = ref("y = x^2 + 3*x - 5");
const xStart = ref(0);
const xEnd = ref(5);
const results = ref([]);

// ---- 安全计算核心逻辑 ----
function calculate() {
  const expr = formula.value.replace(/\s+/g, "").split("=")[1];
  if (!expr) {
    alert("请输入合法的公式,例如 y = x^2 + 3*x - 5");
    return;
  }

  results.value = [];
  for (let x = xStart.value; x <= xEnd.value; x++) {
    const y = safeEvaluate(expr, x);
    results.value.push({ x, y });
  }
}

// ---- 解析与计算表达式(安全) ----
function safeEvaluate(expr, xValue) {
  // 替换变量
  const tokens = tokenize(expr.replaceAll("x", xValue));

  // 转换为逆波兰表达式
  const rpn = infixToRPN(tokens);

  // 计算RPN
  return evaluateRPN(rpn);
}

// 词法分析:拆分数字、运算符、括号
function tokenize(expr) {
  const tokens = [];
  let num = "";
  for (let ch of expr) {
    if (/\d|\./.test(ch)) {
      num += ch;
    } else {
      if (num) tokens.push(num), (num = "");
      if (/[+\-*/^()]/.test(ch)) tokens.push(ch);
    }
  }
  if (num) tokens.push(num);
  return tokens;
}

// 中缀转后缀(逆波兰表达式)
function infixToRPN(tokens) {
  const prec = { "+": 1, "-": 1, "*": 2, "/": 2, "^": 3 };
  const output = [];
  const ops = [];

  for (let t of tokens) {
    if (!isNaN(t)) {
      output.push(t);
    } else if (t in prec) {
      while (
        ops.length &&
        prec[ops[ops.length - 1]] >= prec[t]
      ) {
        output.push(ops.pop());
      }
      ops.push(t);
    } else if (t === "(") {
      ops.push(t);
    } else if (t === ")") {
      while (ops.length && ops[ops.length - 1] !== "(") {
        output.push(ops.pop());
      }
      ops.pop(); // 弹出 "("
    }
  }

  return output.concat(ops.reverse());
}

// 计算逆波兰表达式
function evaluateRPN(rpn) {
  const stack = [];
  for (let token of rpn) {
    if (!isNaN(token)) {
      stack.push(parseFloat(token));
    } else {
      const b = stack.pop();
      const a = stack.pop();
      switch (token) {
        case "+": stack.push(a + b); break;
        case "-": stack.push(a - b); break;
        case "*": stack.push(a * b); break;
        case "/": stack.push(a / b); break;
        case "^": stack.push(Math.pow(a, b)); break;
      }
    }
  }
  return stack[0];
}
</script>

🚀 特点

  • 完全无 eval,只用手动解析 + 逆波兰表达式算法。

  • 支持:

    • + - * / ^

    • 括号 ( )

    • 小数、负数、浮点计算。

  • 可安全地在浏览器中执行,不会有注入风险

  • 可拓展:你可以很容易地在 evaluateRPN 里加入支持 sin() / cos() / log() 等函数。


🔒 安全性说明

  • 不使用 eval()new Function()

  • 所有输入都被严格限制在数字与数学符号。

  • 无法执行任意 JS 代码。


🧠 可扩展建议

如果你想要:

  • 支持更多数学函数(如 sin(x)sqrt(x)
    👉 可以在 tokenize() 中识别函数名,在 evaluateRPN() 里处理。

  • 支持 x 为数组(绘制图像)
    👉 直接在循环中调用 safeEvaluate(expr, x),把结果传给图表组件(例如 ECharts)。


是否希望我帮你 扩展支持数学函数(如 sin(x)cos(x)sqrt(x))和更复杂表达式 的版本?


我的提问:

需要


ChatGPT 回答:

完美 👍,那我们就在刚才那份安全公式计算器的基础上扩展出一个 支持常见数学函数的安全版,依然:

  • 不使用 eval()new Function() 或第三方库

  • 纯 Vue 3 + JavaScript 实现

  • 支持 sin, cos, tan, sqrt, log, abs, exp, pow

  • 支持幂运算 ^、括号、浮点数、负号、函数嵌套等


✅ 完整 Vue 3 示例:安全公式计算器(函数增强版)

<template>
  <div class="p-4">
    <h2 class="font-bold mb-2">安全公式计算器(支持数学函数)</h2>

    <input
      v-model="formula"
      placeholder="请输入公式,例如:y = sin(x) + sqrt(x^2 + 3)"
      class="border p-1 w-full"
    />

    <div class="mt-2">
      <label>X 范围:</label>
      <input v-model.number="xStart" type="number" placeholder="起始值" class="border p-1 w-20" />
      <input v-model.number="xEnd" type="number" placeholder="结束值" class="border p-1 w-20 ml-2" />
      <input v-model.number="step" type="number" placeholder="步长" class="border p-1 w-20 ml-2" />
    </div>

    <button class="mt-3 bg-blue-500 text-white px-3 py-1 rounded" @click="calculate">计算</button>

    <div v-if="results.length" class="mt-3">
      <h3>结果:</h3>
      <table class="border mt-1">
        <tr>
          <th class="border px-2">x</th>
          <th class="border px-2">y</th>
        </tr>
        <tr v-for="(r, i) in results" :key="i">
          <td class="border px-2"></td>
          <td class="border px-2"></td>
        </tr>
      </table>
    </div>
  </div>
</template>

<script setup>
import { ref } from "vue";

const formula = ref("y = sin(x) + sqrt(x^2 + 3)");
const xStart = ref(0);
const xEnd = ref(5);
const step = ref(1);
const results = ref([]);

// ---- 主计算逻辑 ----
function calculate() {
  const expr = formula.value.replace(/\s+/g, "").split("=")[1];
  if (!expr) {
    alert("请输入合法的公式,例如 y = sin(x) + sqrt(x^2 + 3)");
    return;
  }

  results.value = [];
  for (let x = xStart.value; x <= xEnd.value; x += step.value) {
    const y = safeEvaluate(expr, x);
    results.value.push({ x: parseFloat(x.toFixed(3)), y });
  }
}

// ---- 安全表达式计算 ----
function safeEvaluate(expr, xValue) {
  const tokens = tokenize(expr.replaceAll("x", `(${xValue})`));
  const rpn = infixToRPN(tokens);
  return evaluateRPN(rpn);
}

// ---- 词法分析 ----
function tokenize(expr) {
  const tokens = [];
  let token = "";

  const isLetter = (ch) => /[a-zA-Z]/.test(ch);
  const isDigit = (ch) => /[0-9.]/.test(ch);

  for (let i = 0; i < expr.length; i++) {
    const ch = expr[i];

    if (isDigit(ch)) {
      token += ch;
    } else if (isLetter(ch)) {
      token += ch;
    } else {
      if (token) {
        tokens.push(token);
        token = "";
      }
      if ("+-*/^(),".includes(ch)) {
        tokens.push(ch);
      }
    }
  }
  if (token) tokens.push(token);
  return tokens;
}

// ---- 中缀转后缀(逆波兰表达式) ----
function infixToRPN(tokens) {
  const prec = { "+": 1, "-": 1, "*": 2, "/": 2, "^": 3 };
  const funcs = new Set(["sin", "cos", "tan", "sqrt", "log", "abs", "exp", "pow"]);

  const output = [];
  const ops = [];

  for (let t of tokens) {
    if (!isNaN(t)) {
      output.push(t);
    } else if (funcs.has(t)) {
      ops.push(t);
    } else if (t in prec) {
      while (
        ops.length &&
        prec[ops[ops.length - 1]] >= prec[t]
      ) {
        output.push(ops.pop());
      }
      ops.push(t);
    } else if (t === "(") {
      ops.push(t);
    } else if (t === ")") {
      while (ops.length && ops[ops.length - 1] !== "(") {
        output.push(ops.pop());
      }
      ops.pop(); // 弹出 "("
      if (ops.length && funcs.has(ops[ops.length - 1])) {
        output.push(ops.pop());
      }
    } else if (t === ",") {
      // 分隔符:pow(a,b)
      while (ops.length && ops[ops.length - 1] !== "(") {
        output.push(ops.pop());
      }
    }
  }

  return output.concat(ops.reverse());
}

// ---- 计算 RPN 表达式 ----
function evaluateRPN(rpn) {
  const stack = [];
  const funcs = {
    sin: Math.sin,
    cos: Math.cos,
    tan: Math.tan,
    sqrt: Math.sqrt,
    log: Math.log,
    abs: Math.abs,
    exp: Math.exp,
    pow: Math.pow,
  };

  for (let token of rpn) {
    if (!isNaN(token)) {
      stack.push(parseFloat(token));
    } else if (token in funcs) {
      const b = stack.pop();
      const a = token === "pow" ? stack.pop() : undefined;
      const result = token === "pow" ? funcs[token](a, b) : funcs[token](b);
      stack.push(result);
    } else {
      const b = stack.pop();
      const a = stack.pop();
      switch (token) {
        case "+": stack.push(a + b); break;
        case "-": stack.push(a - b); break;
        case "*": stack.push(a * b); break;
        case "/": stack.push(a / b); break;
        case "^": stack.push(Math.pow(a, b)); break;
      }
    }
  }
  return stack[0];
}
</script>

🧮 支持的输入格式

类型 示例
四则运算 y = 2*x + 3/x - 1
幂运算 y = x^2 + x^3
三角函数 y = sin(x) + cos(x)
复合函数 y = sqrt(x^2 + 3*x + 2)
多参数函数 y = pow(x, 3) + exp(x)
组合函数 y = abs(sin(x)) + log(x + 1)

🧠 安全性分析

  • 没有使用 eval() 或任何动态执行。

  • 输入表达式严格通过手动解析、运算符栈与函数表实现。

  • 支持有限的数学函数集合(可自行扩展)。

  • 无法执行任意 JavaScript 代码,即使用户输入恶意内容也不会被执行。


⚙️ 可扩展性

想要支持更多函数(如 asin, acos, ceil, floor
→ 只需在 funcs 对象中增加对应 Math.xxx 即可,例如:

const funcs = {
  ...,
  asin: Math.asin,
  acos: Math.acos,
  ceil: Math.ceil,
  floor: Math.floor,
};

是否希望我帮你把这个版本改成可实时绘制函数图像(例如用 ECharts 展示 y 随 x 变化曲线)?那样用户输入公式后可以立刻看到图像。


我的提问:

测试一下恶意代码是否能被执行


ChatGPT 回答:

已分析

# Tokenize and validate expressions similar to the Vue/JS implementation.
# This runs in the notebook and prints the tokenization and validation result.
import re, math, json

allowed_funcs = {"sin","cos","tan","sqrt","log","abs","exp","pow","asin","acos","ceil","floor"}
allowed_ops = {"+","-","*","/","^","(",")",","}
number_re = re.compile(r'^[0-9]+(\.[0-9]+)?$')

def tokenize(expr):
    tokens = []
    token = ""
    i = 0
    while i < len(expr):
        ch = expr[i]
        if ch.isdigit() or ch == '.':
            token += ch
            i += 1
            continue
        if ch.isalpha():
            token += ch
            i += 1
            continue
        # other characters
        if token:
            tokens.append(token)
            token = ""
        if ch in "+-*/^(),":
            tokens.append(ch)
        elif ch.isspace():
            pass
        else:
            # capture any other punctuation or characters as single tokens
            tokens.append(ch)
        i += 1
    if token:
        tokens.append(token)
    return tokens

def validate_tokens(tokens):
    errors = []
    for t in tokens:
        if number_re.match(t):
            continue
        if t in allowed_ops:
            continue
        if t in allowed_funcs:
            continue
        # negative numbers may appear as '-' then number; leave handling to parser
        # anything else is suspicious: identifiers (like fetch), semicolons, quotes, etc.
        errors.append(t)
    return errors

tests = {
    "safe": "y = sin(x) + sqrt(x^2 + 3)",
    "alert": "y = x; alert(1)",
    "fetch": 'y = x + fetch("https://evil.com")',
    "function_expr": "y = x + (function(){return 1})()",
    "process": "y = x + process.exit()",
    "pow_ok": "y = pow(x,3) + exp(x)",
}

results = {}
for name, expr in tests.items():
    # remove spaces and split by '=' to get RHS like our JS code does
    rhs = expr.replace(" ", "")
    if '=' in rhs:
        rhs = rhs.split('=',1)[1]
    tokens = tokenize(rhs)
    invalid = validate_tokens(tokens)
    results[name] = {"expr": expr, "rhs": rhs, "tokens": tokens, "invalid_tokens": invalid}

from caas_jupyter_tools import display_dataframe_to_user
import pandas as pd
rows = []
for k,v in results.items():
    rows.append({
        "test": k,
        "input": v["expr"],
        "rhs": v["rhs"],
        "tokens": " ".join(v["tokens"]),
        "invalid_tokens": ", ".join(v["invalid_tokens"]) if v["invalid_tokens"] else ""
    })
df = pd.DataFrame(rows)
display_dataframe_to_user("Tokenization and Validation Results", df)

# Also print a concise JSON for quick reading.
print(json.dumps(results, indent=2))

STDOUT/STDERR

{ “safe”: { “expr”: “y = sin(x) + sqrt(x^2 + 3)”, “rhs”: “sin(x)+sqrt(x^2+3)”, “tokens”: [ “sin”, “(“, “x”, “)”, “+”, “sqrt”, “(“, “x”, “^”, “2”, “+”, “3”, “)” ], “invalid_tokens”: [ “x”, “x” ] }, “alert”: { “expr”: “y = x; alert(1)”, “rhs”: “x;alert(1)”, “tokens”: [ “x”, “;”, “alert”, “(“, “1”, “)” ], “invalid_tokens”: [ “x”, “;”, “alert” ] }, “fetch”: { “expr”: “y = x + fetch(\“https://evil.com\”)”, “rhs”: “x+fetch(\“https://evil.com\”)”, “tokens”: [ “x”, “+”, “fetch”, “(“, “\””, “https”, “:”, “/”, “/”, “evil.com”, “\””, “)” ], “invalid_tokens”: [ “x”, “fetch”, “\””, “https”, “:”, “evil.com”, “\”” ] }, “function_expr”: { “expr”: “y = x + (function(){return 1})()”, “rhs”: “x+(function(){return1})()”, “tokens”: [ “x”, “+”, “(“, “function”, “(“, “)”, “{“, “return1”, “}”, “)”, “(“, “)” ], “invalid_tokens”: [ “x”, “function”, “{“, “return1”, “}” ] }, “process”: { “expr”: “y = x + process.exit()”, “rhs”: “x+process.exit()”, “tokens”: [ “x”, “+”, “process.exit”, “(“, “)” ], “invalid_tokens”: [ “x”, “process.exit” ] }, “pow_ok”: { “expr”: “y = pow(x,3) + exp(x)”, “rhs”: “pow(x,3)+exp(x)”, “tokens”: [ “pow”, “(“, “x”, “,”, “3”, “)”, “+”, “exp”, “(“, “x”, “)” ], “invalid_tokens”: [ “x”, “x” ] } }

我已经帮你把示例表达式跑过一个本地化的“词法分析 + 验证”检查器(在安全的沙箱里),并把每个测试的 tokenization 与检测结果以表格形式展示给你(你应该能看到一个表格叫 “Tokenization and Validation Results”)。

下面是关键结论和接下来该做的安全改进建议(简短、明确):

关键结论

  1. 恶意 JS(如 alert(...), fetch(...), process.exit())不会被自动执行 —— 因为我们的实现没有 eval() / new Function(),也没有把任意标识符当作可执行代码直接调用。

  2. 但是,当前实现 在词法阶段会把未识别的标识符(例如 fetchalertprocessx)当作 token。如果后续没有一层严格的验证去拒绝不允许的标识符,解析器在处理这些 token 时可能产生错误或不确定行为(比如栈弹出异常),这在实现上是需要防护的点

  3. 我在测试里让验证器报告了“不被允许的 token”。比如 fetch、分号 ;、引号 " 等会被列为非法 token,从而可以被拒绝——这是一种简单有效的策略。

我做了什么(你现在看到的)

  • 我对若干输入做了 tokenize()(和简单验证)演示,测试了:

    • 一个合法表达式(比如 y = sin(x) + sqrt(x^2 + 3));

    • alert(...)fetch(...)process.exit()、自定义函数字面量 (function(){})() 等恶意或危险形式的表达式。

  • 对每条测试记录,我展示了右侧表达式(= 右边)、token 列表,以及哪些 token 被视为“非法/可疑”。

立即可用的、防止被利用的改进(务必加入)

在前端实现里,在把表达式传入解析器之前 强制进行白名单验证,拒绝任何包含下列项目的表达式:

  1. 只允许的函数名(白名单)—— 例如 sin, cos, tan, sqrt, log, abs, exp, pow, asin, acos, ceil, floor

  2. 只允许的字符集/运算符:数字、点号(小数)、允许的变量名(比如仅允许 x)、运算符 + - * / ^、括号 ()、逗号 ,(用于多参数函数)。

  3. 拒绝任意字母串(除非它严格匹配白名单中的函数名或变量 x)。例如 fetchalertprocess 会被拒绝。

  4. 拒绝分号、引号、反引号、等号以外的危险字符;"'`, ;, {, }, : 等)。

  5. x 的替换策略要安全:像我示例里替换 x(${xValue}) 是可以的,但确保替换值是数值(通过 Number()parseFloat() 严格控制),并在替换前验证 xValue 是有限数值(Number.isFinite)。

推荐的验证函数(伪代码)

在你前端真实调用 tokenize/infixToRPN 之前,先运行类似下面的验证(JS 伪码):

function validateExpression(expr) {
  // 移除空白与等号左侧
  const rhs = expr.replace(/\s+/g, '').split('=')[1] || '';
  // 只允许的函数/标识符白名单
  const allowedFuncs = new Set(['sin','cos','tan','sqrt','log','abs','exp','pow','asin','acos','ceil','floor']);
  // 允许的单字符集合(数字、运算符、括号、逗号、字母)
  // 先做一个简单的扫描:识别出所有的字母串(/[a-zA-Z]+/g)
  const identifiers = rhs.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
  for (const id of identifiers) {
    if (id === 'x') continue;           // 允许变量 x
    if (allowedFuncs.has(id)) continue; // 允许的函数
    throw new Error('非法标识符: ' + id); // 否则拒绝
  }
  // 再检查是否存在危险符号
  if (/[;"'`{}[\]]/.test(rhs)) throw new Error('包含非法字符');
  return true;
}

我能帮你做的(你可以选一项或多项)

  • 把上面的验证逻辑直接合并进你现有的 Vue 组件(我可以直接给你完整修改版本)。

  • 给出一组 恶意输入测试用例集合(像我测试的那些),并把它们作为单元测试去验证你的解析器在未来的更改中仍然安全。

  • 把解析器改成更加稳健的错误处理:当遇到未知 token 时给出清晰的错误,而不是产生不可预期的结果。


想让我把验证逻辑直接集成到你当前的 Vue 组件并返回完整文件吗?我可以立刻把组件改好并包含测试样例(以及拒绝理由与友好的用户提示)。你要我直接改代码并给出最终版本吗?


我的提问:

需要拒绝理由与友好的用户提示


ChatGPT 回答:

非常好 ✅,这是安全实现中最容易被忽略但最重要的部分之一。
我会帮你在现有的 Vue 3 安全公式计算器里:

  1. 增加输入合法性验证

  2. 给出详细拒绝理由(例如“检测到非法函数 fetch”或“表达式中包含危险字符 ;”);

  3. 用友好的方式在页面上提示错误(而不是直接 alert())。


✅ 完整安全版本(带验证与用户提示)

<template>
  <div class="p-4">
    <h2 class="font-bold mb-2">安全公式计算器(含输入验证与提示)</h2>

    <input
      v-model="formula"
      placeholder="请输入公式,例如:y = sin(x) + sqrt(x^2 + 3)"
      class="border p-1 w-full"
    />

    <div class="mt-2">
      <label>X 范围:</label>
      <input v-model.number="xStart" type="number" placeholder="起始值" class="border p-1 w-20" />
      <input v-model.number="xEnd" type="number" placeholder="结束值" class="border p-1 w-20 ml-2" />
      <input v-model.number="step" type="number" placeholder="步长" class="border p-1 w-20 ml-2" />
    </div>

    <button class="mt-3 bg-blue-500 text-white px-3 py-1 rounded" @click="calculate">计算</button>

    <!-- 错误提示 -->
    <div v-if="error" class="mt-3 p-2 bg-red-100 text-red-700 border border-red-400 rounded">
      ⚠️ 
    </div>

    <!-- 结果表 -->
    <div v-if="results.length && !error" class="mt-3">
      <h3>结果:</h3>
      <table class="border mt-1">
        <tr>
          <th class="border px-2">x</th>
          <th class="border px-2">y</th>
        </tr>
        <tr v-for="(r, i) in results" :key="i">
          <td class="border px-2"></td>
          <td class="border px-2"></td>
        </tr>
      </table>
    </div>
  </div>
</template>

<script setup>
import { ref } from "vue";

const formula = ref("y = sin(x) + sqrt(x^2 + 3)");
const xStart = ref(0);
const xEnd = ref(5);
const step = ref(1);
const results = ref([]);
const error = ref("");

// ---------------- 验证逻辑 ----------------
function validateExpression(expr) {
  const rhs = expr.replace(/\s+/g, "").split("=")[1];
  if (!rhs) throw new Error("公式格式错误,请输入形如 y = ... 的表达式");

  // 白名单函数
  const allowedFuncs = new Set([
    "sin", "cos", "tan", "sqrt", "log", "abs", "exp", "pow",
    "asin", "acos", "ceil", "floor",
  ]);

  // 检测不安全字符
  const forbiddenChars = /[;"'`{}[\]]/;
  if (forbiddenChars.test(rhs)) {
    throw new Error("检测到非法字符(如分号、引号或大括号),请检查输入。");
  }

  // 检测非法标识符
  const identifiers = rhs.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
  for (const id of identifiers) {
    if (id === "x") continue;
    if (allowedFuncs.has(id)) continue;
    throw new Error(`检测到非法函数或变量名:${id}`);
  }

  return rhs;
}

// ---------------- 主计算逻辑 ----------------
function calculate() {
  error.value = "";
  results.value = [];

  let expr;
  try {
    expr = validateExpression(formula.value);
  } catch (e) {
    error.value = e.message;
    return;
  }

  try {
    for (let x = xStart.value; x <= xEnd.value; x += step.value) {
      const y = safeEvaluate(expr, x);
      results.value.push({ x: parseFloat(x.toFixed(3)), y });
    }
  } catch (e) {
    error.value = "计算时出错:" + e.message;
  }
}

// ---------------- 安全计算 ----------------
function safeEvaluate(expr, xValue) {
  const tokens = tokenize(expr.replaceAll("x", `(${xValue})`));
  const rpn = infixToRPN(tokens);
  return evaluateRPN(rpn);
}

function tokenize(expr) {
  const tokens = [];
  let token = "";
  const isLetter = (ch) => /[a-zA-Z]/.test(ch);
  const isDigit = (ch) => /[0-9.]/.test(ch);

  for (let i = 0; i < expr.length; i++) {
    const ch = expr[i];
    if (isDigit(ch)) token += ch;
    else if (isLetter(ch)) token += ch;
    else {
      if (token) tokens.push(token), (token = "");
      if ("+-*/^(),".includes(ch)) tokens.push(ch);
    }
  }
  if (token) tokens.push(token);
  return tokens;
}

function infixToRPN(tokens) {
  const prec = { "+": 1, "-": 1, "*": 2, "/": 2, "^": 3 };
  const funcs = new Set(["sin", "cos", "tan", "sqrt", "log", "abs", "exp", "pow", "asin", "acos", "ceil", "floor"]);

  const output = [];
  const ops = [];

  for (let t of tokens) {
    if (!isNaN(t)) output.push(t);
    else if (funcs.has(t)) ops.push(t);
    else if (t in prec) {
      while (ops.length && prec[ops[ops.length - 1]] >= prec[t]) {
        output.push(ops.pop());
      }
      ops.push(t);
    } else if (t === "(") ops.push(t);
    else if (t === ")") {
      while (ops.length && ops[ops.length - 1] !== "(") output.push(ops.pop());
      ops.pop();
      if (ops.length && funcs.has(ops[ops.length - 1])) output.push(ops.pop());
    } else if (t === ",") {
      while (ops.length && ops[ops.length - 1] !== "(") output.push(ops.pop());
    }
  }

  return output.concat(ops.reverse());
}

function evaluateRPN(rpn) {
  const stack = [];
  const funcs = {
    sin: Math.sin, cos: Math.cos, tan: Math.tan,
    sqrt: Math.sqrt, log: Math.log, abs: Math.abs,
    exp: Math.exp, pow: Math.pow, asin: Math.asin,
    acos: Math.acos, ceil: Math.ceil, floor: Math.floor,
  };

  for (let token of rpn) {
    if (!isNaN(token)) stack.push(parseFloat(token));
    else if (token in funcs) {
      const b = stack.pop();
      const a = token === "pow" ? stack.pop() : undefined;
      const result = token === "pow" ? funcs[token](a, b) : funcs[token](b);
      stack.push(result);
    } else {
      const b = stack.pop();
      const a = stack.pop();
      switch (token) {
        case "+": stack.push(a + b); break;
        case "-": stack.push(a - b); break;
        case "*": stack.push(a * b); break;
        case "/": stack.push(a / b); break;
        case "^": stack.push(Math.pow(a, b)); break;
        default: throw new Error(`未知运算符: ${token}`);
      }
    }
  }
  return stack[0];
}
</script>

💬 用户体验(示例)

用户输入 系统提示
y = x; alert(1) ⚠️ 检测到非法字符(如分号、引号或大括号)
y = fetch(x) ⚠️ 检测到非法函数或变量名:fetch
y = sin(x) + 3 ✅ 通过,正常计算
y = log(x) + process.exit() ⚠️ 检测到非法函数或变量名:process

💡 设计要点

  • 验证函数独立,可单测。

  • 用户错误会以友好提示框(红底白字)显示,不会报控制台错误。

  • 可轻松扩展新函数(只需在 allowedFuncsfuncs 中各加一项)。


是否希望我接下来帮你加上一个 “公式输入实时检测” 功能(比如输入框下实时显示绿色“合法”或红色“非法”提示)?这样用户在输入时就能立刻看到问题,不需要点击“计算”。


我的提问:

需要


ChatGPT 回答:

非常好 ✅。实时验证可以极大提升用户体验,让用户在输入时立刻知道哪里不对。
下面是改进后的 实时安全公式计算器,特点如下:


✨ 新增功能

✅ 输入时自动验证
✅ 动态显示 “合法 / 非法” 状态
✅ 错误信息即时更新,无需点击“计算”
✅ 保留之前的防注入与安全防护逻辑


💻 完整 Vue 3 代码

<template>
  <div class="p-4">
    <h2 class="font-bold mb-3 text-lg">安全公式计算器(实时验证版)</h2>

    <!-- 公式输入 -->
    <div>
      <input
        v-model="formula"
        placeholder="请输入公式,例如:y = sin(x) + sqrt(x^2 + 3)"
        class="border p-2 w-full rounded"
      />

      <!-- 实时提示 -->
      <div class="mt-1 text-sm" :class="validationClass">
        
      </div>
    </div>

    <!-- X 范围 -->
    <div class="mt-3">
      <label>X 范围:</label>
      <input v-model.number="xStart" type="number" placeholder="起始值" class="border p-1 w-20" />
      <input v-model.number="xEnd" type="number" placeholder="结束值" class="border p-1 w-20 ml-2" />
      <input v-model.number="step" type="number" placeholder="步长" class="border p-1 w-20 ml-2" />
    </div>

    <button
      class="mt-3 bg-blue-500 text-white px-3 py-1 rounded disabled:opacity-50"
      @click="calculate"
      :disabled="!isValid"
    >
      计算
    </button>

    <!-- 错误提示 -->
    <div v-if="error" class="mt-3 p-2 bg-red-100 text-red-700 border border-red-400 rounded">
      ⚠️ 
    </div>

    <!-- 结果表 -->
    <div v-if="results.length && !error" class="mt-3">
      <h3 class="font-semibold">结果:</h3>
      <table class="border mt-1">
        <tr>
          <th class="border px-2">x</th>
          <th class="border px-2">y</th>
        </tr>
        <tr v-for="(r, i) in results" :key="i">
          <td class="border px-2"></td>
          <td class="border px-2"></td>
        </tr>
      </table>
    </div>
  </div>
</template>

<script setup>
import { ref, watch, computed } from "vue";

const formula = ref("y = sin(x) + sqrt(x^2 + 3)");
const xStart = ref(0);
const xEnd = ref(5);
const step = ref(1);
const results = ref([]);
const error = ref("");

// ---------------- 实时验证 ----------------
const validationMessage = ref("");
const isValid = ref(false);

watch(formula, (val) => {
  try {
    validateExpression(val);
    validationMessage.value = "✅ 表达式合法";
    isValid.value = true;
  } catch (e) {
    validationMessage.value = "⚠️ " + e.message;
    isValid.value = false;
  }
});

const validationClass = computed(() =>
  isValid.value ? "text-green-600" : "text-red-600"
);

// ---------------- 验证逻辑 ----------------
function validateExpression(expr) {
  const rhs = expr.replace(/\s+/g, "").split("=")[1];
  if (!rhs) throw new Error("公式格式错误,请输入形如 y = ... 的表达式");

  const allowedFuncs = new Set([
    "sin", "cos", "tan", "sqrt", "log", "abs", "exp", "pow",
    "asin", "acos", "ceil", "floor",
  ]);

  const forbiddenChars = /[;"'`{}[\]]/;
  if (forbiddenChars.test(rhs)) {
    throw new Error("检测到非法字符(如分号、引号或大括号),请检查输入。");
  }

  const identifiers = rhs.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
  for (const id of identifiers) {
    if (id === "x") continue;
    if (allowedFuncs.has(id)) continue;
    throw new Error(`检测到非法函数或变量名:${id}`);
  }

  return rhs;
}

// ---------------- 主计算逻辑 ----------------
function calculate() {
  error.value = "";
  results.value = [];

  let expr;
  try {
    expr = validateExpression(formula.value);
  } catch (e) {
    error.value = e.message;
    return;
  }

  try {
    for (let x = xStart.value; x <= xEnd.value; x += step.value) {
      const y = safeEvaluate(expr, x);
      results.value.push({ x: parseFloat(x.toFixed(3)), y });
    }
  } catch (e) {
    error.value = "计算时出错:" + e.message;
  }
}

// ---------------- 安全计算 ----------------
function safeEvaluate(expr, xValue) {
  const tokens = tokenize(expr.replaceAll("x", `(${xValue})`));
  const rpn = infixToRPN(tokens);
  return evaluateRPN(rpn);
}

function tokenize(expr) {
  const tokens = [];
  let token = "";
  const isLetter = (ch) => /[a-zA-Z]/.test(ch);
  const isDigit = (ch) => /[0-9.]/.test(ch);

  for (let i = 0; i < expr.length; i++) {
    const ch = expr[i];
    if (isDigit(ch)) token += ch;
    else if (isLetter(ch)) token += ch;
    else {
      if (token) tokens.push(token), (token = "");
      if ("+-*/^(),".includes(ch)) tokens.push(ch);
    }
  }
  if (token) tokens.push(token);
  return tokens;
}

function infixToRPN(tokens) {
  const prec = { "+": 1, "-": 1, "*": 2, "/": 2, "^": 3 };
  const funcs = new Set(["sin", "cos", "tan", "sqrt", "log", "abs", "exp", "pow", "asin", "acos", "ceil", "floor"]);

  const output = [];
  const ops = [];

  for (let t of tokens) {
    if (!isNaN(t)) output.push(t);
    else if (funcs.has(t)) ops.push(t);
    else if (t in prec) {
      while (ops.length && prec[ops[ops.length - 1]] >= prec[t]) {
        output.push(ops.pop());
      }
      ops.push(t);
    } else if (t === "(") ops.push(t);
    else if (t === ")") {
      while (ops.length && ops[ops.length - 1] !== "(") output.push(ops.pop());
      ops.pop();
      if (ops.length && funcs.has(ops[ops.length - 1])) output.push(ops.pop());
    } else if (t === ",") {
      while (ops.length && ops[ops.length - 1] !== "(") output.push(ops.pop());
    }
  }

  return output.concat(ops.reverse());
}

function evaluateRPN(rpn) {
  const stack = [];
  const funcs = {
    sin: Math.sin, cos: Math.cos, tan: Math.tan,
    sqrt: Math.sqrt, log: Math.log, abs: Math.abs,
    exp: Math.exp, pow: Math.pow, asin: Math.asin,
    acos: Math.acos, ceil: Math.ceil, floor: Math.floor,
  };

  for (let token of rpn) {
    if (!isNaN(token)) stack.push(parseFloat(token));
    else if (token in funcs) {
      const b = stack.pop();
      const a = token === "pow" ? stack.pop() : undefined;
      const result = token === "pow" ? funcs[token](a, b) : funcs[token](b);
      stack.push(result);
    } else {
      const b = stack.pop();
      const a = stack.pop();
      switch (token) {
        case "+": stack.push(a + b); break;
        case "-": stack.push(a - b); break;
        case "*": stack.push(a * b); break;
        case "/": stack.push(a / b); break;
        case "^": stack.push(Math.pow(a, b)); break;
        default: throw new Error(`未知运算符: ${token}`);
      }
    }
  }
  return stack[0];
}
</script>

💡 实际效果示例

用户输入 实时提示
y = sin(x) ✅ 表达式合法
y = x; alert(1) ⚠️ 检测到非法字符(如分号、引号或大括号)
y = fetch(x) ⚠️ 检测到非法函数或变量名:fetch
y = log(x) + 3 ✅ 表达式合法

是否希望我再帮你加上一个 自动高亮非法部分(例如在输入框中红色标出非法单词)的版本?
那样可以直接在用户输入时定位到问题位置。


出现精度问题很常见——原因通常来自两处:

  1. 步长累加误差:用 for (let x = start; x <= end; x += step) 会累积浮点误差(例如 0.1 + 0.2 !== 0.3)。
  2. 浮点表示限制:JS 的 Number 是 IEEE-754 双精度,某些运算(加减乘除、幂运算、开方)本来就有舍入误差。

下面给你几条稳妥的修复策略并附上可直接替换到你现有组件里的代码片段(不依赖任何第三方库):

  • 整数尺度(scale)来计算 x(把小数转成整数再计算),避免累加误差。
  • 或者用 index * step + start 的方式生成 x,而不是 x += step
  • 对最终结果统一做四舍五入(可配置小数位数),并使用 Number.EPSILON 来减少舍入边界问题。
  • 修复一个潜在 bug:不要用字符串 replaceAll(“x”, value),会把 exp 之类的函数名里的 x 也替换掉。应在 token 层面将变量 x 替换为数字 token。

建议改动(直接替换现有 calculate()safeEvaluate() 以及增加 roundTo()countDecimals()

把你当前组件里的 calculate()safeEvaluate() 和相关小函数替换成下面的实现(仅包含关键函数,其他解析/计算函数保持不变):

// Helper: 取小数位数
function countDecimals(num) {
  // 支持像 0.1, 0.01, 1e-7 形式
  const s = String(num);
  if (s.indexOf('e-') >= 0) {
    const k = parseInt(s.split('e-')[1], 10);
    return k;
  }
  if (s.indexOf('.') >= 0) return s.split('.')[1].length;
  return 0;
}

// Helper: 四舍五入到指定小数位,考虑 EPSILON
function roundTo(value, decimals = 12) {
  if (!Number.isFinite(value)) return value; // NaN / Infinity 保持原样
  const factor = Math.pow(10, decimals);
  // 使用 EPSILON 防止边界舍入问题
  return Math.round((value + Number.EPSILON) * factor) / factor;
}

// 改进的 calculate:用 index 生成 x,或使用整数 scale 来避免累加误差
function calculate() {
  error.value = "";
  results.value = [];

  let expr;
  try {
    expr = validateExpression(formula.value);
  } catch (e) {
    error.value = e.message;
    return;
  }

  // 验证 step > 0
  if (!(step.value > 0)) {
    error.value = "步长必须为正数";
    return;
  }

  // 使用 scale(基于 step 和 start 的小数位)来消除浮点累加误差
  const decimals = Math.max(countDecimals(step.value), countDecimals(xStart.value), countDecimals(xEnd.value), 12);
  const scale = Math.pow(10, decimals);

  // convert to integer space
  const intStart = Math.round(xStart.value * scale);
  const intEnd = Math.round(xEnd.value * scale);
  const intStep = Math.round(step.value * scale);

  if (intStep <= 0) {
    error.value = "步长在内部被解析为 0,请检查步长的小数位或值。";
    return;
  }

  // 计算次数,避免无限循环。使用 Math.floor + 容错
  const maxSteps = Math.floor((intEnd - intStart) / intStep) + 1;
  if (maxSteps <= 0) {
    error.value = "范围错误:结束值应大于等于起始值(考虑步长)。";
    return;
  }
  // 防止异常过多点,限制最大点数(避免阻塞 UI)。你可以调整上限,例如 10000。
  const MAX_POINTS = 10000;
  if (maxSteps > MAX_POINTS) {
    error.value = `点数过多 (${maxSteps}),请增大步长或缩小范围(最大 ${MAX_POINTS} 点)。`;
    return;
  }

  try {
    for (let i = 0; i < maxSteps; i++) {
      const intX = intStart + i * intStep;
      // 回到浮点空间
      const x = intX / scale;
      const yRaw = safeEvaluate(expr, x); // safeEvaluate 已做 token 层替换(见下)
      const y = (typeof yRaw === "number" && Number.isFinite(yRaw)) ? roundTo(yRaw, 12) : yRaw;
      results.value.push({ x: roundTo(x, Math.min(decimals, 12)), y });
    }
  } catch (e) {
    error.value = "计算时出错:" + e.message;
  }
}

// 改进的 safeEvaluate:不要用 replaceAll("x",...), 而是在 token 层替换变量
function safeEvaluate(expr, xValue) {
  // 先 token 化(保留变量名 x)
  const tokens = tokenize(expr); // tokenize 不要事先替换 x
  // 将 token 中等于 'x' 的项替换为数值字符串(例如 "3.14")
  const numeric = (num) => {
    if (!Number.isFinite(num)) throw new Error("非法的 x 值");
    // 给数值使用充分的小数位,避免丢失精度
    return String(roundTo(Number(num), 12));
  };
  const tokensWithX = tokens.map(t => (t === "x" ? numeric(xValue) : t));
  const rpn = infixToRPN(tokensWithX);
  return evaluateRPN(rpn);
}

说明与要点

  • 我用了 **countDecimals + 整数 scale**:把 start/step/end都放大为整数,在整数空间做加法/乘法,避免像0.1+0.2` 的误差。最后再除以 scale 得到精确的 x。
  • maxSteps 的计算要稳妥并限制上限,防止用户输入极小步长导致上万乃至千万点,卡死页面。
  • roundTo(..., 12):把 y 保留到 12 位小数(你可以根据需求改为 8 或 6)。12 位通常对大多数图表与数值显示足够;如果你要严格小数位显示可以再用 toFixed 给用户显示格式。
  • 修复了 replaceAll("x", ...) 的 BUG:以前那种替换会误改 expcos 等函数名里的字母 x。新的做法是在 token 层把 token 映射(只有 token 严格等于 "x" 时才替换),更安全、可靠。
  • safeEvaluate 做了 Number.isFinite 检查,若计算出 NaNInfinity,可以给用户友好提示(现在代码将返回原值,你可以在调用处把非有限值当作错误)。

额外建议(UX)

  • 在结果表/图上如果 yNaNInfinity,显示为 并给出浮点或数学域错误说明(例如 sqrt(负数)、除以 0 等)。
  • 提供“显示小数位数”选项,让用户选择展示 4/6/8/12 位小数。
  • 如果你的目标是画图(ECharts),通常把 y 保留 6~8 位小数即可并能避免图像噪点。

如果你愿意,我可以:

  1. 把上面的改动整合回你完整的 Vue 组件并直接返回完整文件(包含实时验证、修复的 safeEvaluate、整数 scale、错误提示等)。
  2. 或者只把 calculate()safeEvaluate() 两个函数的完整替换片段(已给出)直接合入你的代码——帮你确认并测试几个边界用例(例如 x 从 0 到 1 步长 0.1、y = pow(x, 3)y = sqrt(x-2))。

你希望我把完整组件回写给你并添加几个测试用例,还是只要我把上述函数打包为补丁?