上周帮一个做独立项目的朋友看代码。他用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 AppContextcreateContext(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 AuthContextcreateContext(null);const UIContextcreateContext(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(resres.json()).then(datasetUsers(data));},[]);const handleDeleteasync(id){await fetch(/api/users/${id},{method:DELETE});// 删完重新拉列表 const resawait fetch(/api/users);const dataawait 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 queryClientuseQueryClient();const{data: users, isLoading, error}useQuery({queryKey:[users], queryFn:()fetch(/api/users).then(rr.json()),});const deleteMutationuseMutation({mutationFn:(id)fetch(/api/users/${id},{method:DELETE}), onMutate: async(id){await queryClient.cancelQueries({queryKey:[users]});const prevqueryClient.getQueryData([users]);queryClient.setQueryData([users], oldold.filter(uu.id!id));return{prev};}, onError:(err, id, context){queryClient.setQueryData([users], context.prev);}, onSettled:(){queryClient.invalidateQueries({queryKey:[users]});},});if(isLoading)returnSkeleton /;if(error)returnErrorFallbackerror{error}/;return(ul{users.map(u(likey{u.id}{u.name}buttondisabled{deleteMutation.isPending}onClick{()deleteMutation.mutate(u.id)}删除/button/li))}/ul);}不是说每个请求都要写这么多。而是AI根本不会主动帮你考虑这些边界情况。它只实现了你描述的正常流程所有异常路径都不存在。问题三环境变量明文写在代码里这是让我最紧张的一个// ❌ 真的在代码里看到了这个 const supabasecreateClient(https://xxxxx.supabase.co,eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxxxx...);// 另一个文件里 const STRIPE_KEYsk_live_xxxxxxxxxxxxx;Supabase的anon key直接写在代码里。Stripe的私钥直接写在前端代码里。我问他“这个key是从哪来的”他说“我在prompt里告诉Claude我的key它就帮我配好了。”AI不会主动告诉你这个key不能放在前端代码里。它只管让代码跑起来。如果你在prompt里给了key它就原样写进去。// ✅ 环境变量必须走.env // .env.local不提交到git //NEXT_PUBLIC_SUPABASE_URLhttps://xxxxx.supabase.co //NEXT_PUBLIC_SUPABASE_ANON_KEYeyJhbGci... const supabasecreateClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY);// Stripe私钥绝不能出现在前端 // 必须放在服务端API route里 // pages/api/checkout.js // const stripenew Stripe(process.env.STRIPE_SECRET_KEY);检查清单该检查什么怎么查代码里有没有硬编码的key全局搜索sk_、eyJ、key、secret、password.gitignore有没有忽略.env打开.gitignore看有没有.env*前端代码有没有后端密钥前端代码里的key只能是NEXT_PUBLIC_或VITE_前缀的git历史里有没有泄露过git log --all -p -- *.env问题四权限检查只在前端看到一个管理员才能看到的页面打开路由// ❌ AI写的权限控制functionAdminRoute({children}){const{user}useAuth();if(user?.role!admin){returnNavigateto/dashboard/;}returnchildren;}// 路由配置Routepath/admin/userselement{AdminRouteUserManagement //AdminRoute}/看起来没问题对吧管理员才能进管理页面。但是——打开UserManagement组件里的API调用// 这个接口任何人都能调 const handleDeleteUserasync(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 sessionawait getServerSession(req, res, authOptions);if(!session||session.user.role!admin){returnres.status(403).json({error:Forbidden});}if(req.methodDELETE){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{/* 编辑弹窗 */}{isEditingdiv{/*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(divOrderFiltersvalue{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写的代码吗发现过什么让你冒冷汗的问题
我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 AppContextcreateContext(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 AuthContextcreateContext(null);const UIContextcreateContext(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(resres.json()).then(datasetUsers(data));},[]);const handleDeleteasync(id){await fetch(/api/users/${id},{method:DELETE});// 删完重新拉列表 const resawait fetch(/api/users);const dataawait 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 queryClientuseQueryClient();const{data: users, isLoading, error}useQuery({queryKey:[users], queryFn:()fetch(/api/users).then(rr.json()),});const deleteMutationuseMutation({mutationFn:(id)fetch(/api/users/${id},{method:DELETE}), onMutate: async(id){await queryClient.cancelQueries({queryKey:[users]});const prevqueryClient.getQueryData([users]);queryClient.setQueryData([users], oldold.filter(uu.id!id));return{prev};}, onError:(err, id, context){queryClient.setQueryData([users], context.prev);}, onSettled:(){queryClient.invalidateQueries({queryKey:[users]});},});if(isLoading)returnSkeleton /;if(error)returnErrorFallbackerror{error}/;return(ul{users.map(u(likey{u.id}{u.name}buttondisabled{deleteMutation.isPending}onClick{()deleteMutation.mutate(u.id)}删除/button/li))}/ul);}不是说每个请求都要写这么多。而是AI根本不会主动帮你考虑这些边界情况。它只实现了你描述的正常流程所有异常路径都不存在。问题三环境变量明文写在代码里这是让我最紧张的一个// ❌ 真的在代码里看到了这个 const supabasecreateClient(https://xxxxx.supabase.co,eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxxxx...);// 另一个文件里 const STRIPE_KEYsk_live_xxxxxxxxxxxxx;Supabase的anon key直接写在代码里。Stripe的私钥直接写在前端代码里。我问他“这个key是从哪来的”他说“我在prompt里告诉Claude我的key它就帮我配好了。”AI不会主动告诉你这个key不能放在前端代码里。它只管让代码跑起来。如果你在prompt里给了key它就原样写进去。// ✅ 环境变量必须走.env // .env.local不提交到git //NEXT_PUBLIC_SUPABASE_URLhttps://xxxxx.supabase.co //NEXT_PUBLIC_SUPABASE_ANON_KEYeyJhbGci... const supabasecreateClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY);// Stripe私钥绝不能出现在前端 // 必须放在服务端API route里 // pages/api/checkout.js // const stripenew Stripe(process.env.STRIPE_SECRET_KEY);检查清单该检查什么怎么查代码里有没有硬编码的key全局搜索sk_、eyJ、key、secret、password.gitignore有没有忽略.env打开.gitignore看有没有.env*前端代码有没有后端密钥前端代码里的key只能是NEXT_PUBLIC_或VITE_前缀的git历史里有没有泄露过git log --all -p -- *.env问题四权限检查只在前端看到一个管理员才能看到的页面打开路由// ❌ AI写的权限控制functionAdminRoute({children}){const{user}useAuth();if(user?.role!admin){returnNavigateto/dashboard/;}returnchildren;}// 路由配置Routepath/admin/userselement{AdminRouteUserManagement //AdminRoute}/看起来没问题对吧管理员才能进管理页面。但是——打开UserManagement组件里的API调用// 这个接口任何人都能调 const handleDeleteUserasync(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 sessionawait getServerSession(req, res, authOptions);if(!session||session.user.role!admin){returnres.status(403).json({error:Forbidden});}if(req.methodDELETE){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{/* 编辑弹窗 */}{isEditingdiv{/*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(divOrderFiltersvalue{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写的代码吗发现过什么让你冒冷汗的问题