在浏览器里写一个“够用就好“的 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 + 1B1 = 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
}

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

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

这个方案的局限

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

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

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


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

相关推荐
晨枫阳1 小时前
Client authentication(客户端授权)开关与OAuth2 PKCE的深刻理解
前端·安全
志尊宝1 小时前
Vue3 零基础每日笔记(027):组件上的 v-model——让自定义组件也能双向绑定
前端·javascript·vue.js·笔记·html5
云浪1 小时前
从 0 手写一个 MCP Server:让 Copilot 调用 Tool 完成四则运算
javascript·node.js·mcp
萧行之3 小时前
可视化技术复盘:从图形语法看 D3、Vega-Lite、ECharts、Observable Plot
前端·学习·数据可视化
尾善爱看海11 小时前
Vue 面试收官篇:SSR、性能优化落地、30 道高频面试题精讲(附标准答案)
前端·javascript·vue.js·面试·vue
驳是11 小时前
一个组件,提升 react-router 开发的幸福感
前端·preact
GreenTea12 小时前
OpenAI Agents API 上手实测:一次调用把整个 agent loop 甩给 OpenAI
前端·后端·算法
星云API技术支持12 小时前
企业微信二次开发:群权限设置、成员管理与群资料维护的接口组合实践
java·前端·企业微信
京东云开发者12 小时前
81.8 秒的视频,我们改了 15 个版本:一次纯 Codex 驱动的 AI-native 视频实践
前端·aigc