前言
你盼世界,我盼望你无 bug。Hello 大家好,我是霖呆呆!
先说结论:一个人 + AI,5 小时,从产品需求文档到浏览器跑通一个高扩展性的 Web 3D 编辑器 SDK。
ECS 架构、插件系统(tapable hooks)、轻量依赖注入、MobX 状态管理、pnpm monorepo……该有的都有,不是一个"能跑就行"的 demo。
但这篇文章想聊的不是速度。
我发现了一个很多人用 AI 编程时会忽略的问题:如果你不和 AI 抠设计细节,它默认会用最快最简单的方式去实现,而不会考虑扩展性和可维护性。 最终你得到的就是一坨"能跑但不能维护"的代码。
所以这篇文章的重点是:我和 AI "掰扯"设计方案的那些时刻 —— 每一次追问,都让最终产物从"凑合能用"变成了"真正可用"。
一、AI 的第一版方案:看着挺完整,但经不起追问
故事从一句话开始:
如果我想要 claudeCode 帮我实现一个 WEB 3D 编辑器应用,一份好的产品需求文档应该如何写
AI 很快给了一份 PRD 模板框架,我也在 prd/ 目录写了初版需求文档。然后让 AI 帮我完善。
说实话,AI 完善后的 PRD 看着挺像回事的:功能模块列了一堆,技术选型也写了,ECS 架构也提了。
但仔细一看,问题来了:
- 功能描述偏抽象,缺少交互细节
- 没有分期交付计划,一股脑全堆在一起
- ECS 架构只有个概念,没有设计约束
- 后端方案完全是空的
这就好比让实习生写了个方案,PPT 做得很漂亮,但你追问一句"这个具体怎么实现",他就支支吾吾了 😅
于是我和 AI 确认了几个关键决策:用 React SPA + Webpack,协同功能放 P2 期,先用 Mock API 方案。AI 重新把 PRD 整理成了三期:P0(MVP 核心编辑)→ P1(完善体验)→ P2(协作平台)。
PRD 理清之后,我用了一个叫 OpenSpec 的工作流来驱动后续的设计和实施。简单来说,OpenSpec 是一套结构化的 AI 协作工作流,它会帮你生成三个关键文件:proposal.md(提案概述)、design.md(技术设计)、tasks.md(任务拆分)。
为什么要用它?因为直接让 AI "开始写代码"是最大的陷阱。 你需要一个流程来保证 AI 先把设计想清楚,再动手实现。OpenSpec 的 propose → apply → archive 三步走,正好把"设计"和"实现"强制分开了。
AI 输出了第一版 design.md。同样的问题 —— 看着挺完整,但细节经不起推敲。
我意识到,如果我就这样让 AI 开始写代码,它大概率会按照"最短路径"去实现每一个模块。能跑?能跑。能维护?呵呵。
所以接下来,我做了整个项目中最有价值的事情 —— 和 AI 逐个模块地"抠"设计细节。
二、和 AI 一起「抠」设计——这才是一天里最有价值的几个小时
这个阶段大概花了 3 个多小时,占了整个项目 5 小时的大头。但我认为这是值得的,因为每一次追问,都让架构方案有了质的提升。
下面挑几个最有代表性的案例。
案例 1:RenderSystem —— switch-case 还是注册表?
3D 编辑器的核心问题之一:谁来负责创建 Babylon.js 的 3D 元素? 比如用户点击"添加立方体",这个立方体是谁创建的,逻辑写在哪里?
我直接问 AI:
RenderSystem 的逻辑你会怎么设计,并且我想要知道具体创建某个 Babylon 3D 元素的逻辑你会写在哪里?请告诉我你的思考过程,并提供几种可选的方案
AI 给了三个方案:
方案 A:RenderSystem 内部 switch-case(大而全)
方案 B:工厂方法模式(每种类型一个工厂函数)
方案 C:每个 3D 元素有自己的 System, 负责处理对应类型的创建更新删除
方案 A 是什么样的呢?大概长这样 👇
// ❌ 方案A:switch-case,每加一种类型就要改这里
class RenderSystem {
update(entity: Entity) {
const mesh = entity.getComponent(MeshCompt);
switch (mesh.type) {
case 'box': return MeshBuilder.CreateBox(...);
case 'sphere': return MeshBuilder.CreateSphere(...);
case 'cylinder': return MeshBuilder.CreateCylinder(...);
// 以后每加一种类型,都要来这里加 case...
}
}
}
这不纯纯违反开闭原则吗 😤 以后要加个"甜甜圈"类型的 mesh,还得来改 RenderSystem 的代码。
而方案 C 就优雅多了:
// ✅ 方案C:每个 3D 元素有自己的 System, 负责处理对应类型的创建更新删除
- systems
- CameraSystem
- LightSystem
- TransformSystem
// 在 plugin 中可以通过 context 拿到 world, 并注册 System
world.registerSystem(CameraSystem);
world.registerSystem(LightSystem);
world.registerSystem(TransformSystem);
这样的话, 外部插件也可以通过 world 注册自己的有关 3D 元素渲染的 System,完全不需要修改核心代码。这才是一个"可扩展"的设计。
AI 最终也推荐了方案 C,但说实话 —— 如果我不主动要求"提供几种可选方案"和"告诉我你的思考过程",AI 大概率会直接给方案 A,因为它最快最简单。
这次讨论之后,我给 AI 立了个规矩:
非常好,你的建议就是我想要的,我希望你后面在设计方案的时候,都能像现在这样思考,应该要考虑到未来的维护性和扩展性,而不是想着用最简单最快的实现方案来实现,你明白了吗
AI 把这条写进了它的记忆里。从那以后,后续的设计讨论明显更有深度了。
💡 划重点: 不要期望 AI 一上来就给你最优解。你需要主动引导它思考多种方案,然后用你的工程经验做判断。AI 是一个强大的"方案生成器",但"方案选择器"得是你自己。
案例 2:World 类膨胀 —— 提前拆分,还是等它爆炸?
ECS 架构的核心是 World 类——管理所有的 Entity、Component、System。AI 第一版设计把所有逻辑都塞在 World 里:创建实体、添加组件、注册系统、脏标记管理……
我一看,这不行:
World 类太大了,建议拆分一下,例如可以拆分 ComponentManager、EntityManager 等等
AI 立刻给出了拆分方案:EntityManager、ComponentManager、SystemManager、DirtyManager 四个 Manager,World 变成一个薄薄的门面层。
但这里有个细节值得注意 —— Manager 之间有交叉依赖怎么办?比如删除一个 Entity 的时候,需要同时清理它的 Component 和脏标记。
AI 提了两种方案:
方案 1:Manager 之间直接互相引用
方案 2:Manager 零依赖,通过 World 协调
我选了方案 2。原因很简单:Manager 之间零依赖,测试的时候可以单独实例化任何一个 Manager,不需要 mock 其他 Manager。这个决定也让后面的 14 个单元测试写起来非常顺畅。
// ✅ World 只做协调,Manager 之间零依赖
class World {
private entityManager = new EntityManager();
private componentManager = new ComponentManager();
private dirtyManager = new DirtyManager();
private systemManager = new SystemManager();
removeEntity(entityId: string) {
// World 协调各个 Manager
this.componentManager.removeAllComponents(entityId);
this.dirtyManager.clearEntity(entityId);
this.entityManager.removeEntity(entityId);
}
}
🚨 这里有个教训: AI 不会主动告诉你"这个类太大了,需要拆分"。它只会按照你的要求往里面加功能,直到这个类变成一个几百行的怪物。架构直觉是你的事,不是 AI 的事。
案例 3:Babylon.js 节点引用——一个"聪明"方案的陷阱
3D 编辑器里,ECS 的 Component 存的是数据(比如 position: {x:0, y:0, z:0}),但 Babylon.js 运行时需要真实的 mesh 对象。那 System 创建的 Babylon.js 节点,存在哪里?
AI 给了三个方案:
方案 A:写回 Component 字段(如 _babylonMesh)
方案 B:独立的 RuntimeRef Map
方案 C:统一的 BabylonRefCompt 组件
方案 C 听起来最"干净"——搞一个专门的 BabylonRefCompt,统一存所有运行时引用。但我马上发现了问题:
C 有一个问题是,那就是后面每增加一种 Component,涉及到新增了与 Babylon.js 元素相关的 Component 是不是都要改动到 BabylonRefCompt,并且有可能业务通过 plugin 的方式自己实现了一些 Component,此时它们是没有办法修改 BabylonRefCompt 的
看到没?这个"统一管理"的方案,反而变成了扩展性的瓶颈。外部插件添加了自定义 Component,根本没法往 BabylonRefCompt 里塞字段。
最终采用改进版方案 A:每个 Component 自持运行时引用,用 _ 前缀标识。简单、直接、对插件友好。
案例 4:Component 序列化——约定 vs 显式
Component 里的 _babylonMesh 这种运行时字段,序列化的时候不应该被保存。怎么跳过它们?
我提了一个方案:
方案 A 是否可以这么设计呢?就是在 Component 的基类中增加一个 public 的方法,用于来决定哪些字段可能不需要序列化的,例如 _light。然后各个 Component 重写这个方法
AI 也给了它的方案:_ 前缀约定——序列化时自动跳过所有以 _ 开头的字段。零代码,零配置。
两种方案各有优劣:
_ 前缀约定 | 重写方法显式声明 | |
|---|---|---|
| 优点 | 零代码,零心智负担 | 显式,一目了然 |
| 缺点 | 是个"约定",新人可能不知道 | 容易忘记重写 |
最终我们选了折中方案:_ 前缀自动跳过 + transientFields() 可选重写。双层保护,既有约定的简洁,又有显式声明的安全网。
class Component {
// 默认:_ 前缀字段自动跳过序列化
// 可选:重写此方法声明额外的非序列化字段
transientFields(): string[] {
return [];
}
}
class LightCompt extends Component {
type = 'point';
intensity = 1.0;
_light: any = null; // 自动跳过 ✅
}
案例 5:插件粒度和命名——细节里的设计品味
AI 第一版设计用了 winky-core-plugin 这个包名来装标准功能。我觉得不对:
我倾向于选 B,但是可以把 B 中 winky-core-plugin 的名称换一下,因为我理解的它做的事应该是类似于一些通用元素和功能,而用 core 这个词并不合适
"core" 这个词给人的感觉是底层基础设施(像 React 的 react-core),但这个包装的是"标准编辑器功能"——基础几何体、灯光、相机这些。AI 列了四个候选名:builtin、essentials、common、standard。
我选了 standard。winky-standard-plugin —— 标准插件,语义精确。
💡 命名看着是小事,但它决定了后面每个开发者第一次看到这个包名时的理解成本。好的命名就是最好的文档。
案例 6:design.md 爆炸——AI 不会主动帮你管理复杂度
讨论到中期,design.md 已经膨胀到 645 行了。我意识到这是个问题:
好的,你会不会觉得 design.md 文件有点太大了,你能否做下拆分,这样后面你在实际编写代码的时候,不至于一下读 design.md 由于太大而导致上下文模糊
这个问题很有意思——AI 的上下文窗口是有限的,一次性读 645 行的设计文档,很容易"遗忘"前面的内容。这不只是代码组织问题,对 AI 来说这是个工作效率问题。
AI 把 design.md 拆成了索引文件 + 7 个子文档:monorepo.md、ecs.md、plugin-system.md、sdk-services.md、workbench-ui.md、data-flow.md、mock-server.md。后面实现阶段按需读取,效果好了很多。
🚨 又一个 AI 不会主动做的事: 管理自己的上下文复杂度。文档太长它不会跟你说"我可能记不住前面的内容",它只会默默地"忘记"。你得替 AI 管理它的工作环境。
三、设计定了,开干——像带团队一样带 AI
3 个多小时的设计讨论结束后,每个模块的方案都敲定了。接下来就是实现阶段。
我给 AI 下了一个指令:
在实施 Phase3 及之后的内容,使用 subAgents 并行执行任务,每个代理独立负责自己的文件,绝对不修改其他代理的文件,互不冲突。
然后就像一个 Tech Lead 分配任务一样,AI 把后续工作拆成了多个独立任务,分配给了 多个并行 Agent:
| Agent | 负责内容 |
|---|---|
| Agent 1 | ECS 核心实现 + 14 个单元测试 |
| Agent 2 | standard-plugin(Components/Systems/MeshCreatorRegistry) |
| Agent 3 | SDK Services(EditorSDK/SceneService/SelectionService) |
| Agent 4 | Mock Server(Express + JSON 存储) |
| Agent 5 | Pages 应用脚手架(Webpack + React Router) |
| Agent 6 | Workbench 基础组件库 |
| Agent 7 | 首页(项目列表/创建/删除) |
| Agent 8 | 编辑器布局 + 场景树面板 |
| Agent 9 | Toolbar + 3D Viewport |
| Agent 10 | PropertyPanel(属性面板) |
最高峰时 5 个 Agent 同时在跑,各自负责自己的文件,互不冲突。
这个阶段体感非常爽——前面花了大量时间把设计抠清楚了,实现阶段 AI 几乎不需要你额外指导。每个 Agent 都知道自己要实现什么,因为设计文档里写得清清楚楚。
💡 这就是"设计先行"的回报: 设计越清晰,AI 实现时需要的人工干预就越少。反过来,如果你跳过设计直接让 AI 写代码,后面会花 10 倍的时间来修修补补。
四、合在一起跑不通?正常的
多 个 Agent 各自完成了自己的模块,接下来就是集成阶段——也是最真实的阶段。
pnpm build,一堆红色报错。这很正常 😂
几个典型的问题:
Bug 1:TypeScript 类型过严
ComponentRegistry 的 register() 方法期望无参构造函数,但实际的 Component 子类都有构造参数。类型不匹配,编译失败。
Bug 2:Monorepo 的 paths 互相污染
tsconfig.base.json 里的 paths 配置把其他包的源码拉进了 library 包的编译范围,导致 rootDir 报错。解决方案:每个 library 包的 tsconfig 中用 "paths": {} 覆盖 base 的 paths。
Bug 3:浏览器里打开编辑器,控制台报 No camera defined
根因:StandardPlugin 注册了 CameraSystem,但忘了给它传 scene 和 canvas,也没有在初始化时创建默认相机实体。
这些 bug 都不难修,但它们说明了一个事实:AI 写的代码跟人写的代码一样,集成时一定会有问题。 不要指望 AI 一次性给你一个完美运行的项目。
修完之后,pnpm build 全量通过,14 个单元测试全绿,浏览器打开成功进入编辑器页面 ✅
学习成绩单
一天下来,我搭出了一个完整的 Web 3D 编辑器 SDK:
- winky-ecs:自研 ECS 核心,4 个 Manager 分层,14 个单元测试
- winky-sdk:EditorSDK + PluginService(tapable hooks)+ 轻量 DI 容器
- winky-standard-plugin:6 个标准 Component + 对应 System + MeshCreatorRegistry
- winky-workbench:Input/NumberInput/Select/ColorPicker/Tree 等基础组件
- pages:React SPA,首页 + 编辑器页面
- mock-server:Express Mock API
但回过头看,整个过程中最有价值的不是 AI 写出的那些代码,而是我和 AI 一起"掰扯"设计的那 3 个多小时。
每一次追问"还有没有更好的方案",每一次指出"这样设计对插件不友好",每一次坚持"要考虑扩展性"——这些才是让最终产物从"能跑的 demo"变成"高扩展性的 SDK"的关键。
AI 会放大你的能力边界。 一个有架构能力的人用 AI,一天能做出一个人一周才能做出的东西,而且质量不打折。但如果你没有设计能力,AI 只会更快地帮你写出一堆难以维护的代码。
后语
知识无价,支持原创!这篇文章就介绍到这里。
在 AI 时代,设计判断力才是开发者真正的护城河。
喜欢霖呆呆的小伙伴还希望可以关注霖呆呆的公众号 LinDaiDai
我会不定时的更新一些前端方面的知识内容以及自己的原创文章🎉。
你的鼓励就是我持续创作的主要动力 😊。