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

31 阅读12分钟

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.jsx300 行代码改不动
要不要本地存储擅自加 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 行 CSSTailwind 原子类
状态手写发布订阅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 输出的第一版代码直接合入项目

下一步你可以:

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

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


掘金分类建议:

  • 分类:前端 / 人工智能
  • 标签:Vibe Coding Cursor React AI编程
  • 理由:以具体项目实例讲解 AI 辅助编程方法论,实用性强,适合前端/全栈开发者阅读