我用 Claude Code 一天搭了个高扩展性的 Web 3D 编辑器 SDK,但最有价值的不是代码 🔥

0 阅读13分钟

前言

你盼世界,我盼望你无 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。

我选了 standardwinky-standard-plugin —— 标准插件,语义精确。

💡 命名看着是小事,但它决定了后面每个开发者第一次看到这个包名时的理解成本。好的命名就是最好的文档。

案例 6:design.md 爆炸——AI 不会主动帮你管理复杂度

讨论到中期,design.md 已经膨胀到 645 行了。我意识到这是个问题:

好的,你会不会觉得 design.md 文件有点太大了,你能否做下拆分,这样后面你在实际编写代码的时候,不至于一下读 design.md 由于太大而导致上下文模糊

这个问题很有意思——AI 的上下文窗口是有限的,一次性读 645 行的设计文档,很容易"遗忘"前面的内容。这不只是代码组织问题,对 AI 来说这是个工作效率问题。

AI 把 design.md 拆成了索引文件 + 7 个子文档:monorepo.mdecs.mdplugin-system.mdsdk-services.mdworkbench-ui.mddata-flow.mdmock-server.md。后面实现阶段按需读取,效果好了很多。

🚨 又一个 AI 不会主动做的事: 管理自己的上下文复杂度。文档太长它不会跟你说"我可能记不住前面的内容",它只会默默地"忘记"。你得替 AI 管理它的工作环境。

三、设计定了,开干——像带团队一样带 AI

3 个多小时的设计讨论结束后,每个模块的方案都敲定了。接下来就是实现阶段。

我给 AI 下了一个指令:

在实施 Phase3 及之后的内容,使用 subAgents 并行执行任务,每个代理独立负责自己的文件,绝对不修改其他代理的文件,互不冲突。

然后就像一个 Tech Lead 分配任务一样,AI 把后续工作拆成了多个独立任务,分配给了 多个并行 Agent

Agent负责内容
Agent 1ECS 核心实现 + 14 个单元测试
Agent 2standard-plugin(Components/Systems/MeshCreatorRegistry)
Agent 3SDK Services(EditorSDK/SceneService/SelectionService)
Agent 4Mock Server(Express + JSON 存储)
Agent 5Pages 应用脚手架(Webpack + React Router)
Agent 6Workbench 基础组件库
Agent 7首页(项目列表/创建/删除)
Agent 8编辑器布局 + 场景树面板
Agent 9Toolbar + 3D Viewport
Agent 10PropertyPanel(属性面板)

最高峰时 5 个 Agent 同时在跑,各自负责自己的文件,互不冲突。

web3d-3.png

这个阶段体感非常爽——前面花了大量时间把设计抠清楚了,实现阶段 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

我会不定时的更新一些前端方面的知识内容以及自己的原创文章🎉。

你的鼓励就是我持续创作的主要动力 😊。