在浏览器里写一个“够用就好“的 Excel 公式求值器

为什么需要在前端算公式

场景是这样的:用户在网页上生成一张表格,里面有 =SUM(B3:B14) 这样的公式,最后下载成 .xlsx。

下载的文件里必须存公式原文,不能存算好的数字------否则用户在 Excel 里改一个数,合计不会跟着变,那这表就废了。

但网页上的预览不能直接显示 =SUM(B3:B14),得显示算出来的结果。

于是就需要在浏览器里自己算一遍。引 SheetJS 那种完整实现?为了预览这点事引一个大依赖不划算。最后写了个两百多行的求值器,只覆盖实际会用到的子集。

记录一下其中几个值得说的点。

词法:正则数组按顺序匹配

js 复制代码
const TOKEN_PATTERNS = [
  ['ws',     /^\s+/],
  ['number', /^\d+(\.\d+)?([eE][+-]?\d+)?/],
  ['string', /^"([^"]*)"/],
  // 'a b'!B3 / Sheet1!$B$3
  ['ref',    /^(?:'([^']+)'|([A-Za-z一-龥_][\w一-龥]*))!(\$?[A-Za-z]{1,3}\$?\d+)/],
  ['ref',    /^\$?[A-Za-z]{1,3}\$?\d+(?![\w(])/],
  ['func',   /^[A-Za-z][A-Za-z0-9.]*(?=\s*\()/],
  ['op',     /^(<>|>=|<=|[+\-*/^&<>=%])/],
  ['punct',  /^[(),:]/]
]

有几个细节是踩出来的:

带工作表名的引用必须排在普通引用前面。 否则 Sheet1!B3 会被先匹配成函数名或者别的东西。正则数组的顺序就是优先级,这一点必须固定住。

工作表名要允许中文。 国内用户建的表基本都叫「销售明细」「1月」这种名字,[A-Za-z一-龥_][\w一-龥]* 这段就是为此加的。带空格的表名 Excel 会用单引号包起来,所以还要单独处理 'a b'!B3 这种形式。

op 里多字符运算符必须放前面。 <>、>=、<= 要排在单字符 [<>=] 之前,否则 >= 会被拆成 > 和 = 两个 token。这是写词法器的经典坑。

单元格引用后面不能跟单词字符或左括号 ,即 (?![\w(])。没有这个否定断言的话,SUM(...) 里的 SUM 开头会被当成列引用 S。

求值:单元格引用要能递归

麻烦的地方在于:一个单元格的公式可能引用另一个单元格,而那个单元格里也是公式。

所以取值不能只返回原始内容,得判断它是不是公式,是的话就递归算:

js 复制代码
function resolve(sheetName, colIndex, rowNum, trail) {
  const sheet = sheetName ? byName.get(sheetName) : sheets[0]
  if (!sheet) throw new FormulaError(`找不到工作表 ${sheetName}`)
  if (colIndex < 1 || colIndex > sheet.columns.length) return null

  const dataEnd = DATA_START_ROW + sheet.rows.length - 1
  let raw = null
  if (rowNum >= DATA_START_ROW && rowNum <= dataEnd) {
    raw = sheet.rows[rowNum - DATA_START_ROW][colIndex - 1]
  } else if (rowNum === dataEnd + 1 && sheet.totalRow) {
    raw = sheet.totalRow[colIndex - 1]   // 合计行
  } else {
    return null
  }

  if (!isFormula(raw)) return raw
  return evaluateCell(raw, sheet.name, `${sheet.name}!${colIndex}:${rowNum}`, trail)
}

递归一旦出现,就必须处理循环引用。

循环引用:一个 Set 就够

A1 = B1 + 1,B1 = A1 + 1------不拦住会直接爆栈。

做法很简单,求值路径上维护一个 trail 集合,进入时加入、退出时删除:

js 复制代码
function evaluateCell(formula, sheetName, cacheKey, trail) {
  if (trail.has(cacheKey)) throw new FormulaError('公式存在循环引用')
  if (cache.has(cacheKey)) return cache.get(cacheKey)

  trail.add(cacheKey)
  try {
    const value = parse(tokenize(formula.slice(1)), resolve, sheetName, trail)
    cache.set(cacheKey, value)
    return value
  } finally {
    trail.delete(cacheKey)
  }
}

两个点:

trail.delete 必须放在 finally 里。抛错的时候如果不清理,这个 key 会永远留在集合里,后面所有引用到它的正常公式都会被误判成循环引用。

trail(当前路径)和 cache(已算结果)是两个不同的东西,别合并。trail 是栈语义、进出成对;cache 是记忆化、只增不减。合在一起就错了。

边界:算不出来就退回去

最关键的一个设计决定是------求值失败不抛给调用方:

js 复制代码
return function evaluate(formula, sheetName) {
  if (!isFormula(formula)) return { ok: true, value: formula }
  try {
    return { ok: true, value: parse(tokenize(formula.slice(1)), resolve, sheetName, new Set()) }
  } catch {
    return { ok: false, value: formula }   // 原样返回公式文本
  }
}

因为这个求值器注定是不完整的。Excel 有几百个函数,我实现的聚合函数只有六个:

js 复制代码
const AGGREGATORS = {
  SUM, PRODUCT, AVERAGE, MAX, MIN, MEDIAN
}

遇到 VLOOKUP、IFERROR 或者任何没实现的写法,直接抛错,外层捕获后原样显示 =VLOOKUP(...)。

这个降级很重要:预览算不出来是小事,下载的文件里公式是对的就行------用 Excel 打开一样能算。如果为了预览好看而把不认识的公式算成 0 或者 N/A,那才是真的坑人。

这个方案的局限

只覆盖子集,而且是刻意的。 上面那六个函数加四则运算、比较、字符串连接,就是全部。要支持更多只能一个个加。如果你的场景公式复杂度不可控,别走这条路,老老实实上完整实现。

没有类型系统。 Excel 区分数字、文本、日期、错误值,这里基本当数字处理,日期和文本参与运算的行为跟 Excel 不完全一致。

cache 不失效。 用户改了一个单元格,得把整个 evaluator 重建。数据量大的时候这是浪费,但实现增量失效的复杂度远超收益,没做。


写这类东西最容易犯的错,是想做成通用库。实际上明确自己只服务一个场景、并且把不支持的情况优雅降级,两百行就能解决问题。这套跑在 forxi.cn 的 AI 表格预览里,够用。

相关推荐
计算机魔术师14 小时前
DeepSeek 据报道接近完成至少 800 亿元融资,腾讯与宁德时代参与
前端
可乐ea14 小时前
多模型编排的三层:框架、模型路由与提供商路由
前端·网络·人工智能·ai智能体·多智能体协作·多模型编排·大模型路由
青柠之夏cc16 小时前
前端拖拽功能原生实现,不引入拖拽库完成业务
开发语言·前端·python
liangshanbo121516 小时前
前端大文件切片上传怎么做?
前端
Sand(ContextGate)17 小时前
Python Agent 测试实战:测试与评估,让 Agent 像传统软件一样可交付
前端·javascript·python·microsoft·ai
广州华水科技17 小时前
单北斗GNSS变形监测系统在城市安全与地质灾害中的应用前景
前端
颜进强17 小时前
20 · NestJs循环依赖与 forwardRef:容器为什么在环面前会死,"占位再补齐"怎么救
前端·后端·ai编程
颜进强17 小时前
19 · NestJs @Global 落地账:装饰器与 `isGlobal` 参数,各在什么场景上岗
前端·后端·ai编程
码艺-Alimjan17 小时前
Tauri 网站To桌面应用实战总结
前端·javascript·vue
颜进强17 小时前
18 · NestJS动态模块与 forRoot:`imports: [ConfigModule.forRoot({...})]` 到底在 import 什么
前端·后端·ai编程