做一个类 CAD 的图形编辑器,绕不开命令行坐标输入。老 CAD 用户不点鼠标,他们敲键盘:100,200 回车定点,@50,30 回车画相对位移,100<45 回车走极坐标,<45 回车再敲个长度确定方向加距离。同一个输入框,同一批字符,语义却各不相同。
这篇文章讲我怎么把这套输入系统从「正则加 split」重写成「逐字符压栈」,再把命令注册从「注册表单例」换成「事件收集」。两次重写解决的是两个不同的问题:前者是解析的正确性 后者是扩展的成本。
现象:一段字符串有太多种读法
先看用户能敲进来的东西有多少种。光是画直线的「指定一个点」,键盘方式就有 6 到 7 种:
100,200—— 直角坐标#100,200——#前缀强制绝对@50,30——@前缀强制相对100<45—— 裸极坐标,距离 45 度#100<45/@100<45—— 前缀 + 极坐标<45回车,再100回车 —— 角度加长度,分两步
同一段 100,200,在「第一个点」是绝对坐标,在「后续点」默认变成相对坐标。同一个 <,在 <45 里是「角度前导标记」,在 100<45 里是「极坐标分隔符」。再叠加一层:中文输入法下的全角 #@<,,用户经常忘了切回来。
这还没算鼠键混合——敲一半坐标,剩下一半用鼠标点画布补上。
输入的读法不是一个平面的枚举 而是一棵依赖上下文的决策树。这就是解析的难点。
原因:正则和 split 撑不住上下文
第一版解析器用的是正则加 split。逻辑很直接:split(',') 拿直角坐标,split('<') 拿极坐标,开头字符判前缀。几种格式时够用,格式一多就开始互相打架。
最典型的一个 bug:裸极坐标 100<45 被当成了直角坐标的单值。因为直角分支先跑,split(',') 得到 ["100<45"] 一个元素,被当成一个数字塞进去了,<45 直接丢失。
根子在于 正则和 split 是无状态的。它们看不到「这个 < 出现的时候,前面已经有过什么」。split('<') 对 <45(前导角度)和 100<45(极坐标分隔)一视同仁,可它俩语义完全相反。想区分就得在正则外面套一层又一层的 if,判断这个分隔符前面有没有数字、是不是在开头、前缀吃掉没有。判断逻辑从解析器漏到了调用方 每加一种格式就多一层嵌套。
全角是压垮它的最后一根稻草。要在正则里同时兼容 , 和 ,、< 和 <、# 和 #,字符类越写越长,且每个 format 都得自己写一遍。
方案一:逐字符压栈,让栈状态决定字符角色
新解析器不用正则,改成逐字符扫描,往一个 token 序列里压栈。这里要先纠正一个直觉:它不是多栈 只有一个 token 序列加一个字符缓冲区。
结构很小。一个 tokens 数组当输出栈,一个 buffer 字符串攒连续的数字字符。扫到分隔符或前缀,就先把 buffer 里攒的数字「flush」成一个 number token 压进去,再压入分隔符本身。
export function tokenize(rawInput: string): CoordinateToken[] {
const input = rawInput.trim()
const tokens: CoordinateToken[] = [] // 唯一的栈:输出 token 序列
let buffer = '' // 数字字符缓冲区
const flushBuffer = () => {
const value = buffer.trim()
if (value) tokens.push({ kind: 'number', value }) // 攒够一个数字就入栈
buffer = ''
}
for (const rawChar of input) {
const ch = normalizeFullwidthChar(rawChar) // 先归一化,全角问题在源头解决
if (ch === ',') {
flushBuffer()
tokens.push({ kind: 'sep', value: ',' })
} else if (ch === '<') {
flushBuffer()
// 关键:栈空时 < 是前导角度标记,栈非空时是极坐标分隔符
tokens.push({ kind: tokens.length === 0 ? 'prefix' : 'sep', value: '<' })
} else if (ch === '#' || ch === '@') {
flushBuffer()
// # @ 只在开头是前缀,出现在中间就当普通字符并入数字缓冲
tokens.length === 0 ? tokens.push({ kind: 'prefix', value: ch }) : (buffer += ch)
} else {
buffer += ch
}
}
flushBuffer()
return tokens
}
这一步的关键是那句 tokens.length === 0。同一个字符的角色 由当前栈的形态决定 而不是由固定模式串决定:
这正是正则做不到的。正则不知道自己扫到「序列的哪个位置」,压栈天然带着这个位置信息——栈的当前深度就是上下文。之前那个 100<45 丢失的 bug,在这里根本不会发生:扫到 < 时栈里已经有一个 number token,它被判成 sep,45 接着入栈,得到干净的 [number, sep, number]。
全角也顺手解决了。归一化不放在每个 format 里,而是放在扫描的第一步 normalizeFullwidthChar。原理是 Unicode 的全角字符区间 U+FF01 到 U+FF5E,整体减去偏移 0xFEE0 就是对应半角。一个公式覆盖 ,#@< 和全角数字,下游拿到的永远是半角,各 format 再不用操心全角。

产物是三类 token:prefix sep number。解析到此为止不做语义判断,只做形状。语义交给各个 format 自己认领。
format 做的是形状匹配
拿到 token 序列后,每个坐标格式只干一件事:看这个形状是不是我的。裸极坐标要求恰好三个 token,中间是 <:
// 裸极坐标 100<45:严格要求 [number, sep('<'), number]
canParse(input) {
const tokens = tokenize(input)
if (tokens[0]?.kind === 'prefix') return false // 排除 # @ < 前缀开头
return tokens.some(t => t.kind === 'sep' && t.value === '<')
}
逗号类格式则宽松一些,用一个 filterNumberTokens 只挑数字 token,不在乎逗号有几个、位置对不对,100,,200 也能救回来。严格的格式做严格的形状检查 宽松的格式只数数字,各自表达自己的容错边界,互不干扰。
坐标语义靠上下文,不靠输入本身
压栈解决了「这段字符是什么形状」,但没解决「100,200 到底是绝对还是相对」。因为这个答案不在输入里——它取决于你在画第几个点。
第一个点裸输入是绝对坐标,后续点裸输入默认变成相对坐标,这是 CAD 的老约定。所以解析必须带一个上下文参数。我用一个 PointSequenceTracker 单例订阅绘制生命周期事件,维护两个布尔:isFirstPoint 和 isContinuous,谁要解析就问它拿一份快照。
parse(input, ctx) {
// ...得到距离和角度...
const isAbsolute = ctx.isFirstPoint && !ctx.isContinuous // 语义在这里定
return { coordinates, isRelative: !isAbsolute, isAbsolute, formatId: 'barePolar' }
}
format 是纯的 上下文是外部注入的。同一个 barePolar 格式,第一个点返回绝对、后续点返回相对,靠的是 ctx 而不是格式内部记状态。这样解析器可以被两个入口共用——画布上跟随光标的浮层输入,和底部常驻的命令行——两边坐标语义完全一致,因为它们问的是同一个 tracker。
两步输入与鼠键混合:状态机接住半截输入
有两种输入天生是「半截」的。
一是角度加长度:<45 回车先定方向,再 100 回车定距离。第一次回车时坐标还凑不齐,得挂起来等第二个值。我用一个状态机管这段等待,两个状态 idle 和 awaitingLength。第一个回车把角度存进状态、切到 awaitingLength,第二个回车拿长度合成坐标、切回 idle。
二是鼠键混合:键盘敲一半,鼠标点画布补另一半。敲 50, 然后鼠标点一下,X 用键盘的 50,Y 用鼠标点击位置。这类混合有六到八种,同样需要一个待定态挂住键盘那一半,等鼠标点击来补。
鼠键混合踩过一个隐蔽的坑。绘制工具本身也监听画布点击(正常画图就靠它),于是键盘挂起后鼠标那一下点击,被工具的手势识别先吃掉了,混合输入的兜底通道永远收不到。问题不在解析 在手势仲裁。修法是让工具的 onPress 回调在「有待定的混合输入」时返回 false,主动让出这次点击,画布的兜底通道才能把它派发给等待中的混合控制器。这种 bug 单测测不出来,因为它发生在真实点击的手势竞争里,得靠端到端的真实点击才能复现。
现象:注册新语法要改一堆共享文件
解析这条线理顺了,注册这条线又冒出来。
系统里有三类东西要「注册」:坐标格式(有哪些格式可用)、非工具命令(缩放 平移 撤销 等)、模式命令(圆的两点 三点 三切等子模式)。第一版全用命令式注册表——一个单例,谁有货就 registry.register(x),用的时候 registry.getAll()。
问题是 注册表是双向耦合的。贡献方要 import 注册表单例才能塞东西,注册表要被调用方 import 才能取。更糟的是模式命令,圆专属的模式别名映射表 MODE_NAME_MAP 硬编码在共享的提交逻辑里。想给圆加一个 TTR 简写,得改那张共享的表;想加个新工具的模式,还是改那张共享的表。一个工具的语法扩展 摊派到了公共文件上。
这张共享表还藏了个 bug:它漏了圆的第五种模式,导致 CIRCLE:三切 一直不生效,没人发现。这正是硬编码集中表的通病——它离真正的数据源太远,加东西时容易漏。
方案二:把注册表换成事件收集
新方案借了同一个代码库里磁吸系统的现成模式:不用注册表,用一个「收集事件」。收集方广播一个空篮子,谁有货往里放,广播返回时篮子已经满了。
底层就是事件总线的一个同步方法 emitSync——它和普通 emit 一样同步跑完所有监听器,区别只是把事件实例本身返回:
emitSync<T extends Event>(event: T): T {
this.emit(event) // emit 内部是同步 for 循环,按优先级逐个调监听器
return event // 监听器都跑完了,返回同一个实例
}
配上一个可变的 candidates 数组当篮子,收集和贡献就彻底解耦了:
// 贡献方(各格式文件自己声明,我不知道谁在收)
globalEventBus.on(CollectCoordinateFormatCandidatesEvent, (event) => {
event.context.candidates.push(cartesianFormat, absoluteFormat, relativeFormat, ...)
})
// 收集方(解析入口,我不知道谁在贡)
const formats = globalEventBus
.emitSync(new CollectCoordinateFormatCandidatesEvent({ toolType, candidates: [] }))
.context.candidates // emitSync 返回时,数组已被所有监听器填满
因为 emit 是纯同步的 for 循环,没有 await,emitSync 一返回,candidates 就是所有监听器 push 完的聚合结果。语义上等价于函数调用,只是调用双方互不认识,只共享一个事件类 不共享任何单例。

落到三类注册,就是三个收集事件、三个「一文件一监听器」的贡献点:
收益直接体现在「加一条新语法」的成本上。新工具要支持自己的模式命令,只在它自己的命令文件里加一行 globalEventBus.on(...),判断 toolType 是自己才处理,不碰任何公共文件。别名也不再是旁挂映射表,而是声明式地挂在模式定义上:
{ mode: 'tangentTangentRadius', name: '切线切线半径', shortcut: '4', aliases: ['TTR'] }
监听器直接从 aliases 字段反查。当初那个漏掉第五种模式的 bug,也随着「从元数据反查」一起消失了——因为数据源只有一处,加模式就是往数组里加一项,无处可漏。要么让扩展点回到数据本身 要么就永远在维护一张离数据很远的映射表。
编排层:把两条线串起来
压栈和事件收集是两个正交的机制,它们在解析入口汇合。事件收集回答「当前有哪些格式可用」,是结构性查询;压栈回答「这段输入是什么形状」,在每个格式内部跑。中间的编排层只做排序和委托:
export function parseCoordinateInput(rawInput, formats, ctx) {
const sorted = [...formats].sort((a, b) => b.priority - a.priority)
for (const format of sorted) {
if (format.canParse(trimmed, ctx)) { // format 内部 tokenize 做形状匹配
const result = format.parse(trimmed, ctx) // 命中就解析出坐标
if (result) return { ...result, rawInput }
}
}
return { isRelative: false, isAbsolute: false, rawInput }
}
优先级排序解决格式之间的抢食——带前缀的绝对/相对格式优先级高于裸格式,裸极坐标高于裸直角,最后才轮到无前缀的直角坐标兜底。

再往上一层是整个提交判断链。用户按下回车,一段输入要依次问过:是不是两步输入的第二步 -> 是不是「撤销上一点」-> 是不是坐标 -> 是不是模式命令 -> 是不是工具命令 -> 是不是非工具命令 -> 空回车是不是要完成绘制。每一环都是独立的判断,命中就返回,不命中就往下传。提交链是策略的顺序编排 解析和收集只是其中被调用的两环。
收束
这套系统两次重写,方向不同但内核是一回事。压栈把「上下文」从调用方的一堆 if 收回到栈的深度里,事件收集把「注册」从公共单例摊到各自的文件里。两者都是在削减一种耦合:解析不再依赖调用顺序 注册不再依赖共享单例。
写解析器容易只盯着「能不能解析对」,写注册容易只盯着「能不能注册上」。但真正决定这套东西好不好维护的,是加第十种格式、第二十条命令时要动几个文件。

参与讨论
(Participate in the discussion)
参与讨论