AI 写代码总翻车?试试"先画图再砌墙"的 Vibe Coding 三步法

Vibe Coding 翻车实录:同样是 AI 写代码,为什么别人的能跑你的改不动?


开篇:两种 Prompt,两种人生

❌ Vibe 新手(一条指令梭哈):

复制代码
帮我写一个 React 待办清单页面,支持新增、删除任务。

AI 开始噼里啪啦输出。你复制粘贴,npm run dev------报错。改了 3 个 undefined,又报了 2 个类型错误。终于跑起来了,但代码全塞在一个 300 行的 App.jsx 里,想加个拖拽排序不知道从哪下手。

✅ Vibe 老手(先规划再编码):

markdown 复制代码
遵守胶水编程思维:优先使用成熟方案,避免凭空造逻辑。

第一个阶段:只做规划,禁止输出任何代码。

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 模板

markdown 复制代码
遵守胶水编程思维:优先使用成熟方案,避免凭空造逻辑。

第一个阶段:只做规划,禁止输出任何代码。

1. 确认技术栈:[填你的技术栈,如 React 19 + Tailwind CSS + TypeScript]

2. 梳理功能边界:
   - [要做的功能 1]、[要做的功能 2]
   - 明确不做:[不需要的功能],避免 AI 擅自添加

3. 拆分模块(乐高组件):
   [列出你预期的组件树]

4. 定义数据流:
   - 状态管理方案:[useState / useReducer / Zustand]
   - 核心数据结构:[interface/type 定义]

5. 输出这份完整规划,等待我确认无误后,再分段实现代码。

实际跑一遍:TodoList 的规划输出

把上面的模板填入 TodoList 的需求,AI 输出了这份规划:

css 复制代码
技术栈: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 入口

先定义数据结构。这是"胶水"的接口规范------所有组件围绕这个接口协作:

typescript 复制代码
// src/types.ts
// 🔑 关键:数据结构在规划阶段就定好了,这里是落地
export interface Todo {
  id: number
  text: string
  completed: boolean
}

App 主组件------状态提升到顶层,子组件只负责渲染和回调:

typescript 复制代码
// 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

typescript 复制代码
// 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>}

typescript 复制代码
// 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 极多,纯属给自己埋雷。

胶水做法

perl 复制代码
遵守胶水编程原则:绝不从零自研底层逻辑,优先选择社区长期验证的成熟开源组件。

1. 先调研:React 生态成熟的拖拽库,优先选用 @hello-pangea/dnd
   (react-beautiful-dnd 的活跃 fork,业内广泛使用)
   pnpm add @hello-pangea/dnd

2. 不要写任何拖拽底层代码,只做粘合工作:
   把现有的 TodoList 组件和 @hello-pangea/dnd 粘合起来

3. 只写模块之间的适配、数据流转的粘合代码

AI 给出的代码就是纯粹的"胶水":

typescript 复制代码
// 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

typescript 复制代码
// 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,加上最后一块胶水:

typescript 复制代码
// 在 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 行

css 复制代码
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 怎么教你"

速查表(可截图保存):

go 复制代码
✅ 规划阶段就定义 interface/type,不要让 AI 猜字段名
✅ 功能边界写清楚"做什么"和"不做什么"
✅ 每装一个三方库,先确认社区验证过(下载量、Star、维护状态)
✅ setState 优先用函数式更新 (prev) => newState
✅ 拖拽库只做粘合------DragDropContext → Droppable → Draggable → 你的组件
✅ 组件拆分原则:一个组件只关心一件事
✅ {...draggableProps} 写在 {...dragHandleProps} 前面
❌ 不要让 AI 手写拖拽、表单验证、动画等底层逻辑
❌ 不要把 AI 输出的第一版代码直接合入项目

下一步你可以:

  1. 把本文的 Prompt 模板套到你自己的项目中,先让 AI 出一份规划看看
  2. 试着用胶水思维重构一个已有的"屎山组件"------先问自己"这段逻辑有没有现成的库可以替代?"
  3. 进阶:研究"元方法论"------用 AI 去优化你的 AI Coding 提示词(这个话题够写另一篇了)

一个开放问题: 规划先行解决了"AI 不知道要做什么"的问题,但如果你自己也不知道技术上该怎么拆呢?这时候是该先问 AI"怎么做规划"吗?欢迎评论区聊聊你的做法。


掘金分类建议:

  • 分类:前端 / 人工智能
  • 标签:Vibe Coding Cursor React AI编程
  • 理由:以具体项目实例讲解 AI 辅助编程方法论,实用性强,适合前端/全栈开发者阅读
相关推荐
ZacJi12 小时前
我为 DeepSeek Harness 写了一个 dsh-apple-mode 插件
ios·deepseek·vibecoding
wangruofeng13 小时前
连 agent loop 自己都是插件,拆开 DeepSeek 新开源的 harness 底座
aigc·ai编程
程序员黑豆13 小时前
Java字符串详解
java·前端·ai编程
小虎AI生活15 小时前
从写代码到被出版社认作"科普推广人":一场新书发布会给我的触动
ai编程
Goboy17 小时前
那份让我加班3天的竞品调研报告,我用 TRAE Work 4小时搞定了
ai编程·trae
答案—answer18 小时前
VibeCoding 能做到什么程度?我用它做了一座 3D 数字博物馆
3d·ai编程·threejs·vibecoding
libokaifa19 小时前
从零手搓一个 GPT:只用 Python 标准库,写一个会起名的字符级模型
ai编程
wangyue_msn_8619 小时前
Agent知识学习笔记——03 RAG
笔记·学习·ai编程
火云牌神19 小时前
前后端分离:约束 AI 分工,避免接口耦合与职责错乱
人工智能·系统架构·ai编程·前后端分离·vibecoding
刘立军20 小时前
领域驱动设计:给 AI 划定上下文边界,告别“大泥球”代码
架构·ai编程·领域驱动设计