为什么需要在前端算公式
场景是这样的:用户在网页上生成一张表格,里面有 =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 表格预览里,够用。