Vibe Coding 翻车实录:同样是 AI 写代码,为什么别人的能跑你的改不动?
开篇:两种 Prompt,两种人生
❌ Vibe 新手(一条指令梭哈):
帮我写一个 React 待办清单页面,支持新增、删除任务。
AI 开始噼里啪啦输出。你复制粘贴,npm run dev——报错。改了 3 个 undefined,又报了 2 个类型错误。终于跑起来了,但代码全塞在一个 300 行的 App.jsx 里,想加个拖拽排序不知道从哪下手。
✅ Vibe 老手(先规划再编码):
遵守胶水编程思维:优先使用成熟方案,避免凭空造逻辑。
第一个阶段:只做规划,禁止输出任何代码。
1. 确认技术栈:React 19 + Tailwind CSS + TypeScript
2. 梳理功能边界:
- 新增待办、删除待办、切换完成状态
- 不做本地持久化、筛选、拖拽功能
3. 拆分模块(乐高组件):
输入框组件、待办条目组件、列表容器组件
4. 定义数据流:
useState 存储 task 数组,数据结构 { id, text, completed }
5. 输出这份完整规划,等待我确认无误后,再分段实现代码
AI 输出一份清晰的规划文档。你扫一眼发现"不对,拖拽排序我要做",在规划里加上。AI 重新规划。你再确认。然后 AI 才开始写代码——这一次,它输出的代码组件边界清晰、数据结构预先约定、每个文件不超过 50 行。
这篇文章就是用第二种方式,带你走完一个完整的 React TodoList 项目。读完你就能把同样的方法套用到自己的项目中。
核心概念:Vibe Coding 不是"躺着让 AI 写"
Vibe Coding 的本质是人做决策,AI 做执行。你负责"做什么"和"为什么",AI 负责"怎么写"。
但大多数人的问题是——把"做什么"也交给了 AI。AI 不知道你的项目要不要本地存储、不知道你想用哪个拖拽库、不知道你偏好 Composition 还是 Render Props。它只能猜。猜错了,就是你的 Bug。
这就引出本文的核心方法论——三步法:
第一步:先画图(规划先行)→ 划定边界,防止 AI 自由发挥
第二步:再砌墙(胶水编程)→ 只粘合成熟组件,不手搓底层逻辑
第三步:让 AI 进化(元方法论)→ 让 AI 帮你优化提示词
用一个比喻来理解前两步:
你不会让新来的同事直接上手写代码。你会先给他看技术文档、划定需求边界、告诉他哪些轮子已经造好了直接用。AI 也一样——把它当新同事,而不是算命先生。
下面用 React TodoList 项目,把前两步走一遍。
Step 1:规划先行——5 分钟画图,省 2 小时改 Bug
为什么这一步是"必选"而不是"可选"
AI 擅长的不是"决定做什么",而是"把已决定的事做出来"。当你跳过规划直接让它写代码时,它会在以下环节自行发挥:
| 你在 Prompt 里没说 | AI 可能会 | 后果 |
|---|---|---|
| 数据结构长什么样 | 自己编字段名 title vs name vs text | 组件间传参全炸 |
| 拆不拆组件 | 全塞 App.jsx | 300 行代码改不动 |
| 要不要本地存储 | 擅自加 localStorage | 凭空多出逻辑你没预期 |
| 用哪个拖拽库 | 手写一套拖拽 | bug 比功能多 |
规划的本质是用文档对 AI 的输出做约束。 就像给函数加 TypeScript 类型——类型定了,参数就不会乱传。
一份可以直接抄的规划 Prompt 模板
遵守胶水编程思维:优先使用成熟方案,避免凭空造逻辑。
第一个阶段:只做规划,禁止输出任何代码。
1. 确认技术栈:[填你的技术栈,如 React 19 + Tailwind CSS + TypeScript]
2. 梳理功能边界:
- [要做的功能 1]、[要做的功能 2]
- 明确不做:[不需要的功能],避免 AI 擅自添加
3. 拆分模块(乐高组件):
[列出你预期的组件树]
4. 定义数据流:
- 状态管理方案:[useState / useReducer / Zustand]
- 核心数据结构:[interface/type 定义]
5. 输出这份完整规划,等待我确认无误后,再分段实现代码。
实际跑一遍:TodoList 的规划输出
把上面的模板填入 TodoList 的需求,AI 输出了这份规划:
技术栈:React 19 + Tailwind CSS 4 + TypeScript + Vite
功能边界:
✅ 新增待办、删除待办、切换完成状态
❌ 不做本地持久化、不做筛选、不做拖拽(第一版)
模块拆分:
TodoInput → 输入框 + 添加按钮
TodoItem → 单条待办(勾选 + 删除)
TodoList → 列表容器(遍历 + 状态管理)
TodoEmpty → 空状态占位
数据流:
useState<Todo[]> 存储在 App.tsx
Todo = { id: number; text: string; completed: boolean }
⚠️ 这里有个反直觉的点:规划阶段就要定义数据结构。
{ id, text, completed }这三个字段是你定的,不是 AI 猜的。如果你不先定,AI 可能写成{ title, isDone },然后你在子组件里用todo.text当场 undefined。
确认规划无误后,进入第二步。
Step 2:胶水编程——代码是粘出来的,不是造出来的
一句话定义
胶水编程 = 能抄不写,能连不造。 AI 不创造零件,只负责把现成零件粘在一起。你只写衔接、调用、流转的粘合代码。
对比两种思路:
| 手搓零件(❌) | 胶水编程(✅) | |
|---|---|---|
| 拖拽排序 | 手写 mousedown/mousemove/mouseup + 坐标计算 + 排序算法 | pnpm add @hello-pangea/dnd,粘合 DragDropContext |
| 样式 | 手写 200 行 CSS | Tailwind 原子类 |
| 状态 | 手写发布订阅 | React useState |
核心原则:你写的那部分代码,应该是把一个成熟组件和另一个成熟组件连起来的"胶水",而不是组件本身。
下面按搭建顺序,逐步构建 TodoList。
Step 2.1:搭骨架——类型定义 + App 入口
先定义数据结构。这是"胶水"的接口规范——所有组件围绕这个接口协作:
// src/types.ts
// 🔑 关键:数据结构在规划阶段就定好了,这里是落地
export interface Todo {
id: number
text: string
completed: boolean
}
App 主组件——状态提升到顶层,子组件只负责渲染和回调:
// src/App.tsx
import { useState } from 'react'
import type { Todo } from './types'
import TodoInput from './components/TodoInput'
import TodoList from './components/TodoList'
function App() {
const [todos, setTodos] = useState<Todo[]>([])
// 🔑 关键:所有状态操作都在 App 层定义,子组件只管"通知"
const handleAdd = (text: string) => {
const newTodo: Todo = { id: Date.now(), text, completed: false }
setTodos((prev) => [newTodo, ...prev])
// ⚠️ 这里用函数式更新 (prev) => [] 而不是 setTodos([...todos, newTodo])
// 因为 setTodos([...todos, ...]) 在连续快速添加时可能读到过期闭包
}
const handleToggle = (id: number) => {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
)
}
const handleDelete = (id: number) => {
setTodos((prev) => prev.filter((t) => t.id !== id))
}
return (
<div className="min-h-screen bg-gray-50 flex justify-center px-4 pt-16">
<div className="w-full max-w-lg">
<h1 className="text-3xl font-bold text-center text-gray-800 mb-8">
📋 待办清单
</h1>
<TodoInput onAdd={handleAdd} />
<TodoList
todos={todos}
onToggle={handleToggle}
onDelete={handleDelete}
onReorder={handleReorder}
/>
</div>
</div>
)
}
这就叫"胶水"——App.tsx 自己没写任何 UI 交互逻辑。它只做了三件事:定义数据、定义操作函数、把函数传给子组件。渲染细节全交给子组件。
Step 2.2:粘第一个组件——TodoInput
// src/components/TodoInput.tsx
import { useState, useRef } from 'react'
interface Props {
onAdd: (text: string) => void
}
export default function TodoInput({ onAdd }: Props) {
const [value, setValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const handleSubmit = () => {
const trimmed = value.trim()
if (!trimmed) return // 🔑 空白不提交,用户体验细节
onAdd(trimmed)
setValue('')
inputRef.current?.focus() // 🔑 提交后自动聚焦,连续添加不打断心流
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleSubmit()
}
return (
<div className="flex gap-2 mb-6">
<input
ref={inputRef}
type="text"
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-indigo-400
focus:border-transparent text-gray-700 placeholder-gray-400"
placeholder="输入新任务,按 Enter 添加..."
value={value}
onChange={(e) => setValue(e.target.value)}
// ⚠️ e.target 的类型是 EventTarget,不是 HTMLInputElement
// 但在 React 中 onChange 的泛型已正确推断,这里可以直接用 e.target.value
onKeyDown={handleKeyDown}
autoFocus
/>
<button
className="px-6 py-3 bg-indigo-500 text-white font-medium rounded-lg
hover:bg-indigo-600 active:scale-95 transition-all
disabled:opacity-40 disabled:cursor-not-allowed"
onClick={handleSubmit}
disabled={!value.trim()} // 🔑 空白时禁用按钮,防御性 UI
>
添加
</button>
</div>
)
}
这个组件的"胶水量"很低——它只粘合了 <input> 和 <button> 两个原生 HTML 元素,加上受控状态的样板代码。没造任何轮子。
Step 2.3:粘空状态——TodoEmpty
单独抽一个空状态组件,而不是在 TodoList 里写 {todos.length === 0 && <p>暂无</p>}:
// src/components/TodoEmpty.tsx
export default function TodoEmpty() {
return (
<div className="text-center py-12 text-gray-400">
<p className="text-5xl mb-4">📝</p>
<p className="text-lg">暂无任务,去添加一个吧 🎯</p>
</div>
)
}
为什么 8 行也要单独抽组件? 因为"列表为空时显示什么"和"列表不为空时怎么渲染"是两个独立关注点。混在一起,将来想换空状态文案或样式,得在一大坨逻辑里找。胶水编程提倡"每个组件只关心一件事"。
Step 2.4:粘列表容器 + 拖拽——胶水编程的精华
这是最能体现胶水思维的一步。需求是"给待办列表增加拖拽排序"。
❌ 错误示范:"帮我写 React 待办清单的拖拽排序功能"——AI 很可能手写一套 mousedown/mousemove/mouseup + 坐标监听 + 排序算法。手写拖拽的边界 case 极多,纯属给自己埋雷。
✅ 胶水做法:
遵守胶水编程原则:绝不从零自研底层逻辑,优先选择社区长期验证的成熟开源组件。
1. 先调研:React 生态成熟的拖拽库,优先选用 @hello-pangea/dnd
(react-beautiful-dnd 的活跃 fork,业内广泛使用)
pnpm add @hello-pangea/dnd
2. 不要写任何拖拽底层代码,只做粘合工作:
把现有的 TodoList 组件和 @hello-pangea/dnd 粘合起来
3. 只写模块之间的适配、数据流转的粘合代码
AI 给出的代码就是纯粹的"胶水":
// src/components/TodoList.tsx
import type { DropResult } from '@hello-pangea/dnd'
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'
import type { Todo } from '../types'
import TodoItem from './TodoItem'
import TodoEmpty from './TodoEmpty'
interface Props {
todos: Todo[]
onToggle: (id: number) => void
onDelete: (id: number) => void
onReorder: (result: DropResult) => void // 🔑 拖拽事件传给 App 层处理
}
export default function TodoList({ todos, onToggle, onDelete, onReorder }: Props) {
if (todos.length === 0) {
return <TodoEmpty />
}
return (
// 🔑 DragDropContext:整个拖拽系统的"电源开关"
<DragDropContext onDragEnd={onReorder}>
{/* 🔑 Droppable:拖拽的"目的地",一块可以往里放东西的区域 */}
<Droppable droppableId="todos">
{(provided) => (
<ul
ref={provided.innerRef}
// ⚠️ 这里必须展开 provided.droppableProps,否则拖拽检测不到放置区域
{...provided.droppableProps}
className="flex flex-col gap-2"
>
{todos.map((todo, index) => (
// 🔑 Draggable:每个可以被拖拽的"零件"
<Draggable key={todo.id} draggableId={todo.id.toString()} index={index}>
{(provided, snapshot) => (
<TodoItem
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
isDragging={snapshot.isDragging}
// 🔑 以下三个 props 就是"胶水"——把 dnd 库的能力传递给纯展示组件
dragHandleProps={provided.dragHandleProps}
innerRef={provided.innerRef}
draggableProps={provided.draggableProps}
/>
)}
</Draggable>
))}
{provided.placeholder}
{/* ⚠️ 别忘了 placeholder!否则拖拽时列表高度会塌陷 */}
</ul>
)}
</Droppable>
</DragDropContext>
)
}
你没有写一行拖拽逻辑。 坐标计算、动画、排序——全是 @hello-pangea/dnd 的事。你写的只是把 DragDropContext、Droppable、Draggable 和自己的 TodoItem 连起来。这就是胶水编程。
Step 2.5:粘最后一个组件——TodoItem
// src/components/TodoItem.tsx
import type {
DraggableProvidedDragHandleProps,
DraggableProvidedDraggableProps,
} from '@hello-pangea/dnd'
import type { Todo } from '../types'
interface Props {
todo: Todo
onToggle: (id: number) => void
onDelete: (id: number) => void
// 🔑 以下三个由上层 Draggable 注入,TodoItem 只负责"粘合"
isDragging: boolean
dragHandleProps: DraggableProvidedDragHandleProps | null
innerRef: (element: HTMLElement | null) => void
draggableProps: DraggableProvidedDraggableProps
}
export default function TodoItem({
todo, onToggle, onDelete,
isDragging, dragHandleProps, innerRef, draggableProps,
}: Props) {
return (
<li
ref={innerRef}
// ⚠️ 展开顺序:先 draggableProps,再 dragHandleProps
// 反过来会导致拖拽手柄失效——dragHandleProps 需要覆盖 draggableProps 的部分行为
{...draggableProps}
{...dragHandleProps}
className={`flex items-center gap-3 px-4 py-3 rounded-lg border
transition-all group
${isDragging ? 'shadow-lg ring-2 ring-indigo-300' : ''}
${todo.completed
? 'bg-gray-100 border-gray-200'
: 'bg-white border-gray-200 hover:border-indigo-300 hover:shadow-sm'
}`}
>
{/* 勾选按钮 */}
<button
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center
flex-shrink-0 transition-colors cursor-pointer
${todo.completed
? 'bg-indigo-500 border-indigo-500'
: 'border-gray-300 hover:border-indigo-400'
}`}
onClick={() => onToggle(todo.id)}
>
{todo.completed && (
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<path d="M11.5 3.5L5.5 10.5L2.5 7.5"
stroke="white" strokeWidth="2" fill="none"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
{/* 任务文本 */}
<span
className={`flex-1 cursor-pointer select-none
${todo.completed ? 'line-through text-gray-400' : 'text-gray-700'}`}
onClick={() => onToggle(todo.id)}
>
{todo.text}
</span>
{/* 删除按钮——hover 时才显示 */}
<button
className="text-gray-300 hover:text-red-500 transition-colors
opacity-0 group-hover:opacity-100 cursor-pointer"
onClick={() => onDelete(todo.id)}
>
✕
</button>
</li>
)
}
⚠️
{...draggableProps}和{...dragHandleProps}的展开顺序很重要。dragHandleProps会覆盖部分拖拽行为,让"整行可拖"变成"整行都可做拖拽手柄"。如果顺序反了,拖拽可能完全不生效。
Step 2.6:App 层补充拖拽回调
回到 App.tsx,加上最后一块胶水:
// 在 App 组件中追加
import type { DropResult } from '@hello-pangea/dnd'
const handleReorder = (result: DropResult) => {
// ⚠️ 必须判空:拖到列表外时 destination 为 null
if (!result.destination) return
const items = Array.from(todos) // 🔑 先拷贝再操作,不可变原则
const [moved] = items.splice(result.source.index, 1)
items.splice(result.destination.index, 0, moved)
setTodos(items)
}
// 传给 TodoList:
<TodoList
todos={todos}
onToggle={handleToggle}
onDelete={handleDelete}
onReorder={handleReorder} // ← 新增
/>
完成。完整的项目结构——7 个文件,每个不超过 55 行:
src/
├── types.ts ← 数据结构(规划阶段定的)
├── App.tsx ← 胶水中枢,状态 + 回调
├── main.tsx ← ReactDOM 入口
├── index.css ← Tailwind 引入(就一行 @import "tailwindcss")
└── components/
├── TodoInput.tsx ← 输入框粘合
├── TodoList.tsx ← 列表 + 拖拽胶水
├── TodoItem.tsx ← 单条渲染
└── TodoEmpty.tsx ← 空状态
总结:下次 Vibe Coding,拿这张清单抄
回到开篇的痛点:同样是 AI 写代码,为什么你的改不动? 因为你把所有决策权交给了 AI。AI 猜对了 90%,剩下的 10% 就是你改不动的根源。
三步法清单——每次让 AI 写代码前过一遍:
| 步骤 | 做什么 | 一句话口诀 |
|---|---|---|
| ① 规划先行 | 技术栈 → 功能边界 → 模块拆分 → 数据结构 | "先画图再砌墙" |
| ② 胶水编程 | 能抄不写,只写组件间的粘合代码 | "你不是造轮子的,你是和泥的" |
| ③ 元方法论 | 让 AI 优化你的提示词 | "教会 AI 怎么教你" |
速查表(可截图保存):
✅ 规划阶段就定义 interface/type,不要让 AI 猜字段名
✅ 功能边界写清楚"做什么"和"不做什么"
✅ 每装一个三方库,先确认社区验证过(下载量、Star、维护状态)
✅ setState 优先用函数式更新 (prev) => newState
✅ 拖拽库只做粘合——DragDropContext → Droppable → Draggable → 你的组件
✅ 组件拆分原则:一个组件只关心一件事
✅ {...draggableProps} 写在 {...dragHandleProps} 前面
❌ 不要让 AI 手写拖拽、表单验证、动画等底层逻辑
❌ 不要把 AI 输出的第一版代码直接合入项目
下一步你可以:
- 把本文的 Prompt 模板套到你自己的项目中,先让 AI 出一份规划看看
- 试着用胶水思维重构一个已有的"屎山组件"——先问自己"这段逻辑有没有现成的库可以替代?"
- 进阶:研究"元方法论"——用 AI 去优化你的 AI Coding 提示词(这个话题够写另一篇了)
一个开放问题: 规划先行解决了"AI 不知道要做什么"的问题,但如果你自己也不知道技术上该怎么拆呢?这时候是该先问 AI"怎么做规划"吗?欢迎评论区聊聊你的做法。
掘金分类建议:
- 分类:前端 / 人工智能
- 标签:
Vibe CodingCursorReactAI编程 - 理由:以具体项目实例讲解 AI 辅助编程方法论,实用性强,适合前端/全栈开发者阅读