- 前端
【免费下载链接】htmx
htmx - high power tools for HTML
本文是 htmx 项目维护者 Carson Gross 撰写的实战复盘:以 hyperscript 解析器在一次版本升级后出现的
as关键字绑定回归为例,完整还原了"AI 定位根因 → AI 给出三版修复方案 → 人类工程师最终收敛出正确方案 → AI 生成测试"的全过程。读者可以从中掌握 hyperscript 解析器"follows 机制"的底层原理,理解上下文敏感解析的取舍,并形成一套"人类在环(human-in-the-loop)+ AI 代理"的可控协作方法论。
引言:为什么值得复盘一次"平淡无奇的 Bug 修复"
作者在文中坦言,自己对 AI 的态度是"普遍矛盾"的:一方面,过去一年 AI 无疑已成为非常强大的开发工具;另一方面,它也带来诸多风险——对个人而言是智力的缓慢钝化,对集体而言是环境成本、越来越昂贵个人计算设备等。在此前发表的 Code is Cheap(er) 一文中,作者已警告过"魔法师的学徒(The Sorcerer's Apprentice)"问题:开发者过度依赖 AI,以至于无法理解和处理自己所构建系统中出现的问题。
本文的价值在于:它把一次真实的、与 AI 的交互过程完整摊开,展示 AI 的强项(调查定位、测试生成)与弱项(给出干净、贴合现有架构的解决方案),并具体演示了作者如何险些落入"魔法师的学徒"陷阱、最终又凭借对底层基础设施的熟悉而避开它。
hyperscript 解析器:一个"故意打破解析规则"的实验品
要理解这个 Bug,必须先了解 hyperscript 解析器的设计取向。
hyperscript 是一种面向 Web 的、替代性的解释型脚本语言。具有讽刺意味的是,它完全用 JavaScript 编写。在本仓库中即可找到它的完整源码:test/lib/_hyperscript.js(该文件约 6000 行,被 htmx 的测试环境直接引入,作为测试依赖运行)。
它是一个相当"奇怪"的软件:作者在编写它时故意打破了许多解析规则,把它当作一场实验,想看看结果会怎样。典型例子包括:
- 解析逻辑与语法元素(parse elements)就近放置(colocated);
- 解析器是可插拔的,语法由运行时动态定义;
- 同一个语义(如属性访问 property access)支持多种语法写法。
作者并不推荐大多数编程语言采用这种思路,但对 hyperscript 这个项目而言它运转得相当不错——这再次证明,软件世界里"杀猫不止一种方法"(there are indeed multiple ways to skin the cat)。
Bug 报告:一次版本升级引入的回归
故事的起点,是一位用户报告在升级到0.9.91版本后出现的回归:下面的表达式不再能正确解析:
fetch `{% url 'trade:get_symbol_data' %}?symbol=${symbol}` as JSON具体症状是:as JSON绑定得过紧,在字符串字面量交给fetch之前,就被当作"类型转换表达式"试图把字符串转成 JSON;而用户期望的行为(也是旧版本的行为)是:先 fetch 给定的 URL,再把响应结果当作 JSON 处理。
这种"绑定冲突(binding conflict)"是解析领域的经典问题。而由于 hyperscript 是一种 xTalk 风格的语言,继承了英语的许多歧义性,这个问题在它身上被放得更大——as这个词同时具有两种含义,是整件事的核心矛盾。
调查根因:AI 的强项
定位"为什么会出现这个回归",是作者通常会求助 AI的领域。作者使用的工具是 Claude,而 Claude 在查找根因上表现令人满意:
根因:在 0.9.91 中,作者重构go命令时过于激进,试图让go与fetch命令复用/共享逻辑。为此提取了一个公共方法parseURLOrExpression(),但在这个过程中意外地把fetch命令之后的语法从"URL 字面量"扩展成了通用的expression(表达式)。
于是冲突产生了:
- 在表达式中,
as是转换表达式(conversion expression)关键字,允许做类型转换,例如:
set x to "42" as Int- 在
fetch命令中,as又是一个修饰符,告诉命令如何转换响应,例如:
fetch https://hyperscript.org as Text(作者调侃道:"也许这个事实会让你恶心一下。很好。")
问题症结在于:重构之后,解析器在fetch关键字后开始解析一个通用表达式,而这个表达式先吞掉了as关键字,使其被当作表达式的一部分,而不再有机会成为fetch的修饰符。
借助 Claude,作者几分钟内就弄清了这一点——比独自排查快得多。
修复阶段:AI 的三版提案与逐版否决
与"定位问题"时的出色表现相反,AI 在修复问题上要弱得多。作者坦言当时自己有点偷懒,直接向 AI 索要解决方案——但接下来的"连环提案"仍然非常有信息量。
提案 1:一个 Hack
AI 的第一个建议是:先解析所谓的 "string-like"(类字符串)叶子节点,失败后再回退到完整表达式:
return this.parseElement("stringLike") || this.requireElement("expression");这个修复能立刻解决用户上报的眼前问题,但它只针对这个具体 Bug,无法覆盖一般情况——例如用变量作为 fetch 目标时:
fetch $url as JSON作者否决了它,理由是:太 hacky、不够通用。(作者自嘲:hyperscript 解析器里其实本来就长满了"自然生长"的 hack,这可能是"五十步笑百步"。)
提案 2:更聪明,但引入了不必要的复杂度
第二个提案更有意思:在解析器上新增一个noConversions标志,在解析 URL 时设置它,并让AsExpression.parse在标志生效时直接退出:
// AsExpression.parse() if (parser.noConversions) return;这会让很多解析工程师感到震惊——因为它把 hyperscript 解析器变成了上下文敏感(context-sensitive)的。但作者的反应是:"好。"因为hyperscript 解析器本来就已经是上下文敏感的了。
不过,在审视这个方案时,作者意识到:项目里已经有所需的"hacky 上下文敏感基础设施",根本不需要给解析器新增标志——而 Claude 没有发现这一点。
提案 3(转折):hyperscript 解析器中的 "follows" 机制
hyperscript 解析器中有一种"follows"概念:即被"更上层"的语法元素声明认领(claim)为 follow 令牌的 token。解析器是一个(有些奇怪的)递归下降解析器,这个机制允许某个语法元素(通常是命令)"认领"一个关键字,使表达式在解析期间不会去匹配它。
该机制在源码中的实现非常直白。在 test/lib/_hyperscript.js 中,follows 就是一个简单的栈:
var follows = []; function pushFollow(str) { follows.push(str); } function popFollow() { follows.pop(); }而它之所以能拦截匹配,关键在于matchToken的第一步检查(见 test/lib/_hyperscript.js):
function matchToken(value, type) { if (follows.indexOf(value) !== -1) { return; // disallowed token here } ... }也就是说:只要某个关键字位于 follows 栈中,任何语法元素用matchToken去匹配它都会直接失败。
一个现成的例子是when特性:它把or当作分隔符使用,而不是逻辑连接词:
<div _="when $x or $y changes put it into me"></div>(作者调侃:已经能听到很多解析工程师愤怒地关掉窗口了。"好。")
把这个机制用于本次修复:与其给解析器新增一个标志,不如pushas为 follow → 解析表达式 → pop follow。这既能阻止AsExpression解析,又仍然允许变量等大多数通用表达式正常工作。
作者把这个思路指给 Claude 后,Claude 在一阵兴奋中表示作者"绝对正确",并开始用这一技术修复 Bug——Claude 在parseURLOrExpression()中加入了正确代码,在未新增任何解析器基础设施的前提下一般性地解决了问题。看起来可以收工了。
最终修复:人类的最后一公里
然而,在审阅变更时,作者发现这个"新修复"仍然过宽:fetch和go共享同一个方法parseURLOrExpression(),但只有fetch用as表示修饰符;现有修复把go命令中完全合法的as转换表达式也一并禁用了。
于是作者亲自实现了最终修复,把特殊处理收窄到FetchCommand#parse()内部:
parser.pushFollow("as"); try { var url = parser.parseURLOrExpression(); } finally { parser.popFollow(); } if (parser.matchToken("as")) { ... }这里的关键是try/finally结构:无论解析是否抛错,follow 都会被弹栈,避免污染后续解析状态。通过把pushFollow("as")限定在 fetch 命令的 URL 解析区间内,go的解析完全不受影响。这正是作者对 Bug 的最终答案。
值得一提的对照:在本仓库 vendored 的 hyperscript 源码 test/lib/_hyperscript.js 中,fetch命令的语法元素注册依然保留了as修饰符的解析分支——json、response、html、text以及自定义转换路径dotOrColonPath——可见as作为 fetch 修饰符是该语言的长期既定语义,也印证了本次修复必须保住这条路径的正确性。
测试:AI 的另一个出色领域
在修复过程中,作者让 Claude 为各种情况生成了一些测试。hyperscript 已有相当好的测试套件,而 Claude 出色地创建了小而聚焦的测试,既展示出问题的存在,又证明修复生效。
这构成了"AI 表现良好"的第二个领域:调查与测试创建。
故事的教训:人类在环的价值
那么,这段平淡无奇的 Bug 修复故事,有什么值得玩味的?
作者认为值得注意的对照是:AI 在"调查"和"测试创建"上表现出色,但在"给出干净的解决方案"上表现不佳。
如果作者不熟悉 hyperscript 解析器及其基础设施,这次修复很容易给项目积累技术债:多一个 hacky 的解析特例,或在解析器上多一块状态。而作者断言(虽未提供证据,注释里调侃"这是在梦里被启示的"):技术债呈指数级增长,因此控制技术债极其重要。
这个故事展示:一个熟悉底层基础设施的人类,与 AI 代理协作,在控制复杂度方面远胜于放任 AI 自行其是。
具体到本案例,作者扮演的不是"盲目接受 AI 方案的魔法师的学徒",而是"魔法师"本人——他理解问题、看清了正确解法、能指挥 AI 达成目标,并借助 AI 生成的测试验证方案。这与当下某些"vibe coding"(氛围编程)形成鲜明对比——后者中,开发者(或任何人)似乎以"不理解实际发生了什么"为荣。
番外:AI 与年长开发者
作者在回顾这次经历时还有另一层感触。他是一位年长开发者(当年 50 岁)。随着年龄增长,开发者往往会在一定程度上"失去快球"(lose our fastball),具体到作者身上表现为两点:
- 记忆力不如从前;
- 无法像以前那样长时间工作。
而 AI 恰好直接缓解了这两个问题:
- 关于记忆:虽然无法记住以前能记住的一切,但借助恰当的提示,作者能很快重新理解事物。AI 很擅长帮他做到这一点,也让他能在开源项目与工作项目之间切换得更高效。
- 关于长时间工作:AI 能持续"苦干",这是即使年轻时的作者也难以跟上的。这意味着作者能为项目维护远比从前更庞大的测试套件——例如本次案例中 Claude 生成的测试,其覆盖面就超过了作者自己愿意投入精力去写的程度。
因此,AI 直击了作者作为年长开发者形成的两个相对短板。
但硬币的另一面:作者非常担心 AI 同时也在加速自己整体智力的衰退——这本来就会随年龄自然发生,而 AI 依赖可能加速这一过程。回顾这段经历,作者甚至对自己"在亲手做正确的事之前,依赖 Claude 那么久"感到有些惭愧。这是他仍在摸索的领域。
结论
作者写下这一系列交互,是因为它同时捕捉了 AI 辅助编程的好与坏:它证明了"一个还算称职的开发者 + AI 代理"的价值,也展示了盲目接受 AI 给出的第一个(或第二个)方案的危险性。
这个故事的可迁移结论可以总结为三条实操原则:
- 让 AI 干它擅长的活:问题定位/根因调查、测试用例生成,是当前 AI 的高性价比区域——本次案例中,Claude 把"找到
parseURLOrExpression重构引入回归"这一根因分析做得很出色。 - 修复方案必须由熟悉系统架构的人把关:AI 的三版提案要么过窄(仅解决单个用例的 hack)、要么过重(给解析器新增全局状态)、要么过宽(误伤
go命令)。最终修复之所以收敛,靠的是作者对 follows 机制、pushFollow/popFollow栈以及fetch/go共享方法的透彻理解。 - 用测试锚定修复:AI 生成的小而聚焦的测试,既复现了回归,也验证了修复,是"人类在环"协作中成本最低的验证手段。
希望这段真实记录,能帮助读者形成自己的 AI 代理使用策略——尤其是:在让代理动手之前,先确保自己对系统有足够理解,并始终把复杂度控制握在自己手里。
延伸阅读
- 原始文章:working-with-ai.md
- 作者对"代码便宜"的论述:Code is Cheap(er)
- 本次讨论的解析器实现:test/lib/_hyperscript.js(follows 栈见 第 463-471 行,
matchToken拦截见 第 340-348 行,asExpression注册见 第 2704-2719 行,fetch命令注册见 第 4838-4869 行)
- 前端
【免费下载链接】htmx
htmx - high power tools for HTML
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考