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 辅助编程方法论,实用性强,适合前端/全栈开发者阅读
相关推荐
cooldream20092 小时前
AI 编程系列之 11:AI Coding 工程师的能力模型——5 年后的护城河
ai编程·vibe coding·claude code
太平洋月光2 小时前
AI 快捷指令:Cursor Rules · Commands · Skills
前端·ai编程
唐老板2 小时前
AI 编程的保密底线:企业代码不能这么漏
ai编程
武子康2 小时前
Pi vs Claude Code vs Codex 正确读法:6 组同模型匹配 + 2.08×/1.46×/1.20×/1.54×/1.22×/1.44× 成
人工智能·ai编程·claude
太平洋月光2 小时前
stagewise如何结合cursor开发
前端·ai编程
光影少年2 小时前
react navite 页面跳转、传参、路由监听、导航栏自定义
前端·react native·react.js
码哥字节2 小时前
Superpowers 6.0 的 SDD 重写,我扒了源码才知道:token 砍半不是优化,是设计哲学的转向
ai编程·claude
怕浪猫3 小时前
第6章 检索增强生成:打造知识库驱动型Agent
openai·agent·ai编程
梦想的颜色4 小时前
2026 VibeCoding 工具链精选|IDE + 大模型成套组合推荐,按场景分级收录
ide·trae·ai 编程·vibecoding·国产海外 ai 编程方案·氛围编程成套配置·副业 ai 开发工具栈