工程层:改一个页面,不该惊动另外十个
这是五层约束系列的第四篇。母篇《从能跑到可交付:一套前端 AI 协作系统的设计笔记 》把五层一起讲了,这个系列逐层拆开:语言层 管代码的形状合不合法,样式层 管视觉只许从固定的调色板里选,状态层 管数据从哪来、只有一份。这一篇讲工程层:代码的目录、引用和体积由谁说了算。我用的系统是开源的 frontend-harness-cli ,文中所有规则都能在仓库里找到对应实现。
一个文件是怎么长到 1,400 行的
先看一个具体的场景。
一个列表页文件,第一次生成时是 200 行:取数据、渲染表格、带一个筛选框,干净利落。三个月后你再打开它,它有 1,400 行。
没有人故意把它写大。每一次改动单独看都合理:加一个导出按钮,往文件末尾追加四十行;加一个批量操作,再追加六十行;修一个边界情况,在中间塞一个分支。写代码的 AI 换了十几个上下文,每一个都做了当下最便宜的选择——追加永远比拆分便宜:追加只需要看懂文件末尾,拆分要看懂整个文件,还要承担拆错的风险。
直到某一天,你让它改表格里一列的排序规则。它得先把 1,400 行全读进来——那一列的逻辑和导出、批量操作、两个弹窗共用着同一批变量。排序改好了,导出坏了。不是它粗心,是这个文件已经长成了一个所有东西互相缠绕的整体,动任何一处都要全身陪绑。
为什么要有工程层:它管的是修改的成本
前三层管的都是"这一版代码对不对":形状合不合法、样式合不合规范、数据准不准。工程层管的是另一件事:下一次修改,代价有多大。
代码一定会被改。问题只在于,一次修改是留在一个文件里,还是顺着引用关系传染到十个文件里。这件事不取决于改代码的人小不小心——在这套系统里,读代码、改代码的都是上下文全新的 AI,它对它没读到的文件谈不上小心。一次修改是局部的还是传染的,在写下引用关系的那一刻就已经决定了,比任何人的细心都早。
母篇里给这一层的比喻是交通规则加红绿灯:不是建议你别闯,而是路口的设计让你闯不过去。工程层的规则——谁可以引用谁、文件能长多大、什么样的代码有资格交付——全部要落成机器能执行的检查,而不是写进文档里等谁自觉。
设计一:引用方向是法律,不是默契
栈包的布局一节(harness init 装进项目的 standards 文件)写着三条:
- 路由不许引用另一个路由的文件:
/orders页面里的组件,只能被 orders 自己用; - 共享代码不许反向引用路由:被大家共用的东西,层级只能更低,不能回头依赖某个具体页面;
- 路径统一走别名
@/(指向src/),不用一串../../../把目录深度写进代码——文件一挪窝,相对路径就全断,别名不会。
为什么第一条要这么严?设想报表页缺一个表格组件,订单页目录里正好有一个长得一模一样的。引用过来,五分钟搞定。两周后订单页要改版式,表格改成紧凑布局——报表页跟着变了样,而且没人知道为什么,因为这条依赖藏在一个路由对另一个路由的引用里,不在任何"共享组件"的清单上。报表页出问题时,你不会想到去查订单页。
规则的处理方式不是"引用时小心点",而是把共享这件事提前:如果两个路由都要用,先把它抽出来,放进共享目录、抽成 hook、或者沉到 lib 里,让依赖关系明着摆在目录结构上。扫描发现跨路由引用时,报告里给的建议正是这一句:把共用的那块抽到共享目录、hooks 或 lib 中去。
引用方向还有一层作用,是给读代码的人的。依赖只朝一个方向走(页面 → 共享 → 基础库),读任何一个文件时,顺着引用往下走是一棵会收尾的树;方向可以任意时,读懂一个文件可能需要读懂整个仓库。对上下文有限的 AI 读者来说,这决定了它能不能在一次任务里把该看的都看完。
设计二:循环引用,直接禁止
循环引用是引用关系里最阴的一种:模块 A 引用 B,B 又引用回 A。
它阴在平时不发作。开发时跑得好好的,某次构建顺序、打包方式或者初始化时机变了,其中的一方先加载,拿到对方一个还没初始化完的空壳,运行时报一个 undefined 错误——而报错的位置离真正的病因隔着两个模块。
对 AI 读者,循环还有一个更直接的伤害:没有任何一个文件能单独读懂。读 A 要先读 B,读 B 又回到 A,“入口"不存在了。让一个上下文全新的 AI 去改循环里的任何一环,它都得把整个环装进上下文,还得自己猜初始化顺序——猜错就是上面那种运行时错误。
所以这条没有商量余地:扫描在引用图里查到环,直接按最重的一档处理。它的判语是:“循环让模块的初始化顺序变得依赖加载次序,还会让 tree-shaking 失效。”
设计三:体积上限——关键不是 300,是必须有上限
栈包里还有两个数字:单个文件不超过 300 行,单个函数不超过 50–100 行,超了就拆——拆成组件、拆成 hooks。
先把最容易误解的地方说清:300 这个数字本身不重要。这两个上限在项目配置里都可调(rules.maxFileLines 等配置项),你的项目觉得 400 合适,改成 400,系统照样运转。重要的是上限必须存在。
因为没有上限时,文件的生长是单向的——就是开头那个场景。每一任 AI 都面对同一个选择:追加几十行,立刻完成任务;或者停下来拆分,读完整个文件、承担拆错的风险。在它那一次任务的账本上,追加永远划算。拆分的收益归未来的读者,成本归当下的作者,而作者和读者永远不是同一个上下文。靠"自觉"解决不了这种账本错位,只能靠一条外部的硬线:长到这条线,必须停下来拆,拆分从"可选的优化"变成"继续写代码的前置条件”。
时机也由上限决定。文件 300 行时拆,是把一个还看得完的整体切成三块;文件 1,400 行时拆,是在一团缠绕里动手术。上限把拆分这件事逼到它还便宜的时候发生。
函数同理。一个 300 行的函数,没有任何读者能在一次阅读里同时看清开头立的规矩和结尾的分支——bug 就藏在两半之间的相互作用里。扫描给超长函数的判语是:“生成的长函数,是 bug 藏身的地方。”
设计四:检查要串在交付的必经之路上
规则再对,如果只躺在文档里被"建议遵守",迟早被绕过。这一层的检查被串在三个位置上:
- 提交前钩子:代码离开本机之前,先过一遍;
- CI:把类型检查、Lint、构建、测试串成一条流水线,红了就不许合并;
- 交付门禁:在这套系统里,项目自己的这组门禁命令同时就是 Done 阶段的交付门禁——流水线红着,功能就不许被标记为"已交付"。
第三个位置是关键。钩子和 CI 管的是"代码能不能进仓库",交付门禁管的是"这件事能不能算做完"。检查不在必经之路上,就只是建议;串在必经之路上,才是路口本身——红灯不是罚款通知,是闸机没开。
机器怎么验这一层
这一层主要靠 harness scan 的结构维度。它从源码构建引用图,再逐项量文件的行数和函数的长度。扣分口径是公开的,值得注意的是轻重排序:
- 引用分层违规(
import-layer),扣 10 分; - 跨路由引用(
cross-route-import),扣 10 分,报告附带抽取建议——把共用的部分挪到共享目录、hooks 或 lib; - 循环引用(
import-cycle),扣 10 分; - 文件超长(
file-too-long),扣 8 分,报告附带拆分建议; - 函数超长(
function-too-long),扣 6 分。
引用类的问题全部压在最重的一档,体积类在后面。这个排序是刻意的:文件太长,疼的是改这个文件的人;引用错了,疼的是所有被它牵连的人。 局部的痛定价低,传染的痛定价高。再加上设计四里的门禁命令——项目配置的 gate 命令在交付时真跑,退出码说了算。
这一层管不了什么
工程层保证的是"修改可以被关在一个局部",不是"修改是对的"。
一个仓库可以引用方向全对、没有一个环、每个文件都在 300 行以内,同时把功能做错、数据拿错——那是状态层和评测阶段的辖区。反过来,这一层也保证不了拆分的质量:把一个 900 行的文件随手切成三个 300 行文件,每刀都切在逻辑中间,指标全过,代码比原来更难读。切在哪、什么该放在一起,仍然是设计判断;上限只负责逼这个问题在文件还小的时候被问出来,它不替你回答。
接力到这里:代码形状合法(语言层)、视觉只从调色板里选(样式层)、数据只有一份且来路清楚(状态层)、修改不互相传染(工程层)。但这一切都发生在代码里,用户看到的不是代码,是页面。最后一层,美学层,管的是前面四层都看不见的那件事:功能全对,页面为什么还是可以不对。