如何保持 AI 生成代码的模块化
Source: Dev.to
嗨,我叫 Paul,是一名专门为初创公司工作的高级软件工程师。
我把编程语言当作自助餐,无论使用哪种语言,我最看重的就是 modularization。为此,我们的 OpenAI 和 Claude 助手并不是很有帮助。它们是基于大量老旧项目训练的,这些项目里所有代码都塞进巨大的 index.js 文件。无监督的 vibe coding 往往会让每个文件产生 +1000 行代码,并且出现大量重复。
为什么这是个问题?
在初创公司,代码的寿命很短。当大量使用 vibe coding 时,AI 总能找到办法,重构的负担似乎永远不需要。但现实并非如此。需求变化迅速,新增组件或行为必须快速且无风险。AI 在评估一次改动的影响方面非常差劲。如果我给模型添加一个字段,它会对整个系统产生什么后果?我是否引入了安全风险?我是否破坏了某些功能?
当然,这就需要单元测试,但在我看来,更大的需求是 modularization。让代码保持隔离,不依赖外部,针对每个函数的单一使用场景进行编写、测试,并放在单个文件中。这种做法为我省下了大量麻烦和重构工作。虽然会让开发稍显冗长——有点像脑筋急转弯——但它让代码更稳健,也更有趣。
这篇文章的主题正是:如何强制你的 AI 工具生成模块化代码。为此我们需要严密限制模型的自由,剔除所有随意性,抹杀一切创造力。但我在 Vybe.build 工作,保持 AI 在一条短小、设计良好的“链子”上正是我们的风格。
我的技术栈
为了本文的阐述,我会使用一个我非常熟悉的技术栈,这几乎是我每次构建小型 SaaS 或 “SaaS 替代” 项目时都会选用的。它并不新奇,但与模块化思维以及 AI 辅助开发高度兼容。
- Next.js 与 App Router
- TypeScript,无处不在
- Vercel 用于部署(无服务器、零摩擦)
- Cursor 作为我的 AI 驱动编辑器(相同的思路同样适用于 Claude Code、Copilot 等)
这套技术栈之所以重要,是因为它迫使你以边界的方式思考:服务器 vs. 客户端、路由 vs. 逻辑、数据 vs. UI。边界正是 AI 往往会忽视的,除非你让它们不可逾越。
你可以在此找到一个入门项目。
特定的文件组织
这是我使用的结构:
./
├── app/ # Next.js App Router pages and routes
├── src/
│ ├── __template__/ # Template used to generate new modules
│ │ ├── api/ # Server‑side actions (DB writes, heavy logic, etc.)
│ │ ├── components/ # React components specific to the module
│ │ ├── hooks/ # React hooks
│ │ └── types/ # TypeScript types (shared client/server)
│ ├── auth/ # Authentication module
│ ├── db/ # Database module
│ └── ui/ # Shared UI library
│ ├── components/
│ ├── hooks/
│ └── globals.css
├── prisma/ # Database schema and migrations
└── public/ # Static assets
src 下的每个文件夹都是一个 模块。一个模块拥有自己的逻辑、钩子、组件和类型。不要“只这一次”去触及其他模块的内部。如果有共享的内容,就放到 ui 或专门的共享模块中。
__template__ 文件夹非常重要。它是每个新模块的蓝图。当出现新功能时,我不会直接开始写代码;我会先从该模板生成一个模块。这可以消除大量的决策工作,无论是对我本人还是对 AI 都如此。
我喜欢 api / components / hooks / types 的划分,因为它与 Next.js 的双环境特性配合得很好。服务器端代码和客户端代码被明确分离,AI 也不容易把它们混在一起。最终,模块的具体组成方式由你自行决定。
Next.js 结构
我非常严格的一条规则是:app/ 目录只包含非常特定的逻辑:
- 属性注入、页面的服务器端数据获取
- 访问保护、身份验证、路由的返回值
Next.js 对路由和执行上下文有强硬的约定,这没问题。应用路由器定义页面和 API 路由——仅此而已。
任何与业务逻辑、数据访问、转换,或甚至稍微可复用的行为相关的代码,都放在 src/ 中的模块里。
示例页面
import { getUsers } from '@/src/user/api';
import { User } from '@/src/user/types';
export default async function UserPage() {
let users: User[] = [];
let error: string | null = null;
try {
users = await getUsers();
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred';
}
if (error) {
return Error: {error};
}
return <div>{/* render users */}</div>;
}
获取数据和处理路由层面的错误属于 Next.js 的职责。其余所有内容都属于 user 模块。
示例 API 路由
import { NextResponse } from 'next/server';
import { getUsers } from '@/src/user/api/getUsers';
import { auth } from '@/src/auth';
export const GET = withAuth(async (request: Request) => {
const session = await auth.api.getSession();
if (!session) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const users = await getUsers();
return NextResponse.json(users);
});
身份验证检查和 HTTP 相关的处理留在路由中。实际业务逻辑则放在模块里。
这种分离看似枯燥、重复,却极其有效。它还能为 AI 提供一个明确且可强制遵守的边界,这正是我们保持代码库模块化、可维护所需要的。
路由将事物粘合在一起,模块完成工作
现在使用 AI
一旦这种结构建立起来,AI 就会变得既极其强大又极其危险。如果任其自行,它总是会以最懒散的方式试图提供帮助:它会在页面中编写所有代码,结果你会无端进行多次 API 调用,到处重写逻辑,等等。
目标很简单: 消除选择。如果只有一种有效的做事方式,AI 最终会遵循它。
使用 EJS 模板生成模块
第一步是让创建新模块变得轻而易举、机械化。
我使用一个小脚本(Node + EJS)从模板文件夹生成模块。从外部看,它的使用方式如下:
npm run create-module user
它会生成以下文件结构:
src/user/
├── api/
│ ├── getUser.ts
│ ├── getUsers.ts
│ ├── createUser.ts
│ └── updateUser.ts
├── components/
│ └── UserCard.tsx
├── hooks/
│ ├── useUser.ts
│ └── useUsers.ts
└── types/
└── User.ts
这里没有任何魔法。API 文件是简洁、明确的函数。hooks 是小型包装器,通常围绕 React Query。组件默认是“哑”组件。类型在服务器和客户端之间共享。
这有两个作用:
- 为人类消除摩擦。
- 为 AI 提供非常强的先验。
额外的好处:
- 没有巨大的服务文件。
- 没有 “utils.ts” 这类倾倒场。
当我让 AI “添加用户功能” 时,它不会自行发明结构,而是遵循已有的结构。如果它没有,我会重新生成或修复模块,而不是让熵逐渐渗入。
自定义 ESLint 规则作为护栏
模板处理 happy path。Linters 处理作弊。
一个具体示例:API 路由
在 Next.js 中,编写下面的代码非常容易:
export const POST = async (req: Request) => {
// do stuff
};
但在我的项目里,每个路由都必须经过一个注入上下文(session、user、permissions 等)的包装器:
export const POST = withAuth(async ({ user, req }) => {
// do stuff
});
于是我添加了一个 自定义 ESLint 规则 来强制执行此约束。如果一个路由导出 POST、GET、PUT 等,而没有正确包装,lint 就会报错。
有趣的是 AI 对此的反应。
当 AI 生成路由时忘记了包装器,lint 错误会立刻出现。AI 看到错误后,理解了模式,并通过添加缺失的包装器来修复代码。你不需要再次解释安全性或争论;只需让工具链完成教学即可。
这对许多场景都有效:
- 强制文件边界
- 防止跨模块导入
- 强制命名约定
- 阻止 “聪明” 的捷径
清晰指令
这看似显而易见,但值得重复。无论你是在使用 Cursor 规则、Claude Code 指令,还是在其他地方使用系统提示,思路都是一样的:尽早写下你的约束,并随时间进行演进。
我并不追求穷尽。我从一个简短的列表开始:
- 代码可以存放的地点
- 模块的结构方式
- 禁止的内容(单体文件、交叉导入、在路由中写业务逻辑,等等)
每当 AI 以不好的方式让我惊讶时,我不会只修复一次;我会添加一条规则。
我通常会保留一个大型指令块,解释模块结构、app/ 相关的规则,以及如何在扩展已有模块时避免创建新模式。我不会在这里粘贴完整内容,但具体细节不如养成习惯重要:把 AI 指令当作代码来对待。给它们版本号。不断改进。假设如果不维护,它们会逐渐失效。
结果
那些工具已经存在很久了,我大多一直忽视它们,直到最近才开始关注。只有当 AI 完成大部分打字工作时,它们对生产力的影响才真正让我恍然大悟。
我并不是在倡导模块化代码或“氛围编码”。你应该使用适合自己的方式。我只是分享我自己的做法,对我而言效果相当不错。
希望能给你一些灵感。
感谢阅读!