我review了一份Vibe Coding写的前端代码——能跑,但5个地方迟早要命

上周帮一个做独立项目的朋友看代码。他用Claude Code + Cursor,三天做了一个完整的React管理后台——能登录,能CRUD,能导出Excel,页面UI还不错。

他说:“你帮我看看,准备上线了。”

我打开项目,npm start跑起来,页面确实能用。然后我打开代码——看了十分钟,告诉他:“能跑,但别上线。至少改完这5个地方再说。

这不是个例。Vibe Coding现在最大的问题不是"写不出来",而是写出来的东西看起来很好,实际上全是定时炸弹。FT最近有篇报道标题就是"Who cleans up after the vibe-coding party?"。Reddit上也有人说:“The first 80% of vibe coding feels fast. The last 20% has broken me.”

以下是我在这份代码里发现的5个典型问题。如果你也在用AI写代码,对照检查一下。

问题一:所有状态都往全局塞

打开项目的状态管理,我看到一个巨大的AppContext

// ❌ AI最爱干的事:把所有状态塞进一个Context const AppContext=createContext(null);functionAppProvider({children}){const[user, setUser]=useState(null);const[theme, setTheme]=useState('light');const[sidebarOpen, setSidebarOpen]=useState(true);const[notifications, setNotifications]=useState([]);const[tableData, setTableData]=useState([]);const[filters, setFilters]=useState({});const[selectedRows, setSelectedRows]=useState([]);const[modalVisible, setModalVisible]=useState(false);const[formData, setFormData]=useState({});const[loading, setLoading]=useState(false);//...还有20多个return(<AppContext.Providervalue={{user, setUser, theme, setTheme, sidebarOpen, setSidebarOpen, //...全部暴露出去}}>{children}</AppContext.Provider>);}

40多个state,全塞在一个Provider里。结果就是:任何一个state变化,整棵组件树都重新渲染。

你在表格里勾选一行,selectedRows变了——侧边栏、导航栏、通知弹窗全部重新渲染。

AI为什么这样写?因为你告诉它"加一个XX功能",它就在已有的Context里加一个state。它不会主动说"这个state应该放到单独的Context里"。

// ✅ 按职责拆分Context const AuthContext=createContext(null);const UIContext=createContext(null);functionAuthProvider({children}){const[user, setUser]=useState(null);return(<AuthContext.Providervalue={{user, setUser}}>{children}</AuthContext.Provider>);}functionUIProvider({children}){const[theme, setTheme]=useState('light');const[sidebarOpen, setSidebarOpen]=useState(true);return(<UIContext.Providervalue={{theme, setTheme, sidebarOpen, setSidebarOpen}}>{children}</UIContext.Provider>);}// 表格页面的状态留在表格组件里,根本不需要ContextfunctionDataTable(){const[selectedRows, setSelectedRows]=useState([]);const[filters, setFilters]=useState({});//...}

判断标准:这个state是不是只有一个页面/组件用?只有一个地方用的state,别往Context里放。

问题二:每个请求都裸奔

翻了一下数据请求的代码:

// ❌ AI写的典型请求代码:只管发,不管防functionUserList(){const[users, setUsers]=useState([]);useEffect(()=>{ fetch('/api/users').then(res=>res.json()).then(data=>setUsers(data));},[]);const handleDelete=async(id)=>{await fetch(`/api/users/${id}`,{method:'DELETE'});// 删完重新拉列表 const res=await fetch('/api/users');const data=await res.json();setUsers(data);};return(<ul>{users.map(u=>(<likey={u.id}>{u.name}<buttononClick={()=>handleDelete(u.id)}>删除</button></li>))}</ul>);}

问题清单:

  • 没有loading状态——用户不知道在加载
  • 没有error处理——接口挂了页面空白
  • 没有竞态处理——快速切换页面会把旧数据覆盖新数据
  • 删除按钮没有防重复点击——连点两下发两个DELETE
  • 没有乐观更新——每次操作都要等整个列表重新加载
// ✅ 生产级别的请求应该这样functionUserList(){const queryClient=useQueryClient();const{data: users, isLoading, error}=useQuery({queryKey:['users'], queryFn:()=>fetch('/api/users').then(r=>r.json()),});const deleteMutation=useMutation({mutationFn:(id)=>fetch(`/api/users/${id}`,{method:'DELETE'}), onMutate: async(id)=>{await queryClient.cancelQueries({queryKey:['users']});const prev=queryClient.getQueryData(['users']);queryClient.setQueryData(['users'], old=>old.filter(u=>u.id!==id));return{prev};}, onError:(err, id, context)=>{queryClient.setQueryData(['users'], context.prev);}, onSettled:()=>{queryClient.invalidateQueries({queryKey:['users']});},});if(isLoading)return<Skeleton />;if(error)return<ErrorFallbackerror={error}/>;return(<ul>{users.map(u=>(<likey={u.id}>{u.name}<buttondisabled={deleteMutation.isPending}onClick={()=>deleteMutation.mutate(u.id)}>删除</button></li>))}</ul>);}

不是说每个请求都要写这么多。而是AI根本不会主动帮你考虑这些边界情况。它只实现了你描述的"正常流程",所有异常路径都不存在。

问题三:环境变量明文写在代码里

这是让我最紧张的一个:

// ❌ 真的在代码里看到了这个 const supabase=createClient('https://xxxxx.supabase.co','eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxxxx...');// 另一个文件里 const STRIPE_KEY='sk_live_xxxxxxxxxxxxx';

Supabase的anon key直接写在代码里。Stripe的私钥直接写在前端代码里。

我问他:“这个key是从哪来的?”

他说:“我在prompt里告诉Claude我的key,它就帮我配好了。”

AI不会主动告诉你"这个key不能放在前端代码里"。它只管让代码跑起来。如果你在prompt里给了key,它就原样写进去。

// ✅ 环境变量必须走.env // .env.local(不提交到git) //NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co //NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci... const supabase=createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY);// Stripe私钥绝不能出现在前端 // 必须放在服务端API route里 // pages/api/checkout.js // const stripe=new Stripe(process.env.STRIPE_SECRET_KEY);

检查清单:

该检查什么怎么查
代码里有没有硬编码的key全局搜索sk_eyJkeysecretpassword
.gitignore有没有忽略.env打开.gitignore看有没有.env*
前端代码有没有后端密钥前端代码里的key只能是NEXT_PUBLIC_VITE_前缀的
git历史里有没有泄露过git log --all -p -- '*.env'

问题四:权限检查只在前端

看到一个"管理员才能看到的页面",打开路由:

// ❌ AI写的"权限控制"functionAdminRoute({children}){const{user}=useAuth();if(user?.role!=='admin'){return<Navigateto="/dashboard"/>;}returnchildren;}// 路由配置<Routepath="/admin/users"element={<AdminRoute><UserManagement /></AdminRoute>}/>

看起来没问题对吧?管理员才能进管理页面。

但是——打开UserManagement组件里的API调用:

// 这个接口任何人都能调 const handleDeleteUser=async(userId)=>{await fetch(`/api/admin/users/${userId}`,{method:'DELETE',});};

前端藏了按钮,但接口没有权限验证。任何人打开浏览器DevTools,直接调/api/admin/users/123就能删用户。

这就是OWASP Top 10里的"Broken Access Control"——连续多年排第一的Web安全漏洞。

// ✅ 权限必须在后端验证(Next.js API Route示例)exportdefault asyncfunctionhandler(req, res){const session=await getServerSession(req, res, authOptions);if(!session||session.user.role!=='admin'){returnres.status(403).json({error:'Forbidden'});}if(req.method==='DELETE'){const{id}=req.query;await db.user.delete({where:{id}});returnres.status(200).json({success:true});}res.status(405).end();}

铁律:前端的权限检查是UX优化(不给用户看到没权限的按钮),后端的权限检查才是安全防线。两个都要有,但后端那个不能省。

问题五:组件巨大无比,一个文件500行

这是AI写代码最显眼的特征——所有逻辑都塞在一个组件里:

// ❌ AI的经典作品:一个500行的"完整"组件functionOrderManagement(){//20个useState const[orders, setOrders]=useState([]);const[filters, setFilters]=useState({});const[selectedOrder, setSelectedOrder]=useState(null);const[isEditing, setIsEditing]=useState(false);//... //10个handler函数 const handleSearch=()=>{/*30行 */};const handleFilter=()=>{/*25行 */};const handleEdit=()=>{/*40行 */};const handleDelete=()=>{/*20行 */};const handleExport=()=>{/*50行 */};//... //200行JSXreturn(<div>{/* 搜索栏 */}<div>{/*50行搜索表单 */}</div>{/* 筛选器 */}<div>{/*40行筛选条件 */}</div>{/* 表格 */}<table>{/*80行表格渲染 */}</table>{/* 编辑弹窗 */}{isEditing&&<div>{/*60行编辑表单 */}</div>}{/* 分页 */}<div>{/*30行分页器 */}</div></div>);}

为什么AI喜欢写成这样?因为你说"做一个订单管理页面",它就在一个文件里把所有东西都实现了。它的目标是"让你的需求跑起来",不是"让代码可维护"。

当你要改一个筛选器的bug时,你要在500行代码里找到那20行。当你要给表格加一列时,你要理解这500行的所有状态依赖关系。

// ✅ 按职责拆分 // components/OrderFilters.jsxfunctionOrderFilters({value, onChange}){return(/* 筛选器UI,只管筛选 */);}// components/OrderTable.jsxfunctionOrderTable({data, onEdit, onDelete}){return(/* 表格UI,只管展示 */);}// components/OrderEditModal.jsxfunctionOrderEditModal({order, onSave, onClose}){return(/* 编辑弹窗,只管编辑 */);}// hooks/useOrders.jsfunctionuseOrders(filters){returnuseQuery({queryKey:['orders', filters], queryFn:()=>fetchOrders(filters),});}// pages/OrderManagement.jsx — 组装层,不超过50行functionOrderManagement(){const[filters, setFilters]=useState({});const[editingOrder, setEditingOrder]=useState(null);const{data: orders, isLoading}=useOrders(filters);return(<div><OrderFiltersvalue={filters}onChange={setFilters}/><OrderTabledata={orders}onEdit={setEditingOrder}onDelete={handleDelete}/>{editingOrder&&(<OrderEditModalorder={editingOrder}onSave={handleSave}onClose={()=>setEditingOrder(null)}/>)}</div>);}

Review速查表

检查项怎么查AI代码常见问题
全局状态搜Context/Provider所有state塞一个Context
请求处理搜fetch/axios没有loading/error/竞态处理
密钥泄露搜sk_/eyJ/secret/password硬编码在前端代码里
权限控制看API路由有没有auth检查前端藏按钮但接口裸奔
组件大小看文件行数单文件500行+,逻辑全混在一起

Vibe Coding不是问题,不Review才是

我不反对Vibe Coding。三天能跑起来一个完整的管理后台,这在两年前不可想象。

AI生成的代码和人写的代码需要同一套审查标准。你不会让一个实习生提交的代码不经过review就直接上线,AI写的代码也不应该。

区别在于:实习生的代码你一看就知道哪里不对,AI写的代码看起来很专业——命名规范,结构清晰,注释齐全。但那5个问题就藏在这些"看起来很对"的代码里。

如果你正在Vibe Coding一个准备上线的项目,至少跑一遍上面的速查表。

你review过AI写的代码吗?发现过什么让你冒冷汗的问题?