我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

tsx 复制代码
// ❌ AI最爱干的事:把所有状态塞进一个Context
const AppContext = createContext(null);

function AppProvider({ 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.Provider value={{
      user, setUser, theme, setTheme,
      sidebarOpen, setSidebarOpen,
      // ...全部暴露出去
    }}>
      {children}
    </AppContext.Provider>
  );
}

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

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

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

tsx 复制代码
// ✅ 按职责拆分Context
const AuthContext = createContext(null);
const UIContext = createContext(null);

function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  return (
    <AuthContext.Provider value={{ user, setUser }}>
      {children}
    </AuthContext.Provider>
  );
}

function UIProvider({ children }) {
  const [theme, setTheme] = useState('light');
  const [sidebarOpen, setSidebarOpen] = useState(true);
  return (
    <UIContext.Provider value={{ theme, setTheme, sidebarOpen, setSidebarOpen }}>
      {children}
    </UIContext.Provider>
  );
}

// 表格页面的状态留在表格组件里,根本不需要Context
function DataTable() {
  const [selectedRows, setSelectedRows] = useState([]);
  const [filters, setFilters] = useState({});
  // ...
}

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

问题二:每个请求都裸奔

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

tsx 复制代码
// ❌ AI写的典型请求代码:只管发,不管防
function UserList() {
  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 => (
        <li key={u.id}>
          {u.name}
          <button onClick={() => handleDelete(u.id)}>删除</button>
        </li>
      ))}
    </ul>
  );
}

问题清单:

  • 没有loading状态------用户不知道在加载
  • 没有error处理------接口挂了页面空白
  • 没有竞态处理------快速切换页面会把旧数据覆盖新数据
  • 删除按钮没有防重复点击------连点两下发两个DELETE
  • 没有乐观更新------每次操作都要等整个列表重新加载
tsx 复制代码
// ✅ 生产级别的请求应该这样
function UserList() {
  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 <ErrorFallback error={error} />;

  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>
          {u.name}
          <button
            disabled={deleteMutation.isPending}
            onClick={() => deleteMutation.mutate(u.id)}
          >
            删除
          </button>
        </li>
      ))}
    </ul>
  );
}

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

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

这是让我最紧张的一个:

tsx 复制代码
// ❌ 真的在代码里看到了这个
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,它就原样写进去。

tsx 复制代码
// ✅ 环境变量必须走.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'

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

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

tsx 复制代码
// ❌ AI写的"权限控制"
function AdminRoute({ children }) {
  const { user } = useAuth();

  if (user?.role !== 'admin') {
    return <Navigate to="/dashboard" />;
  }

  return children;
}

// 路由配置
<Route path="/admin/users" element={
  <AdminRoute>
    <UserManagement />
  </AdminRoute>
} />

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

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

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

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

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

tsx 复制代码
// ✅ 权限必须在后端验证(Next.js API Route示例)
export default async function handler(req, res) {
  const session = await getServerSession(req, res, authOptions);

  if (!session || session.user.role !== 'admin') {
    return res.status(403).json({ error: 'Forbidden' });
  }

  if (req.method === 'DELETE') {
    const { id } = req.query;
    await db.user.delete({ where: { id } });
    return res.status(200).json({ success: true });
  }

  res.status(405).end();
}

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

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

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

tsx 复制代码
// ❌ AI的经典作品:一个500行的"完整"组件
function OrderManagement() {
  // 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行JSX
  return (
    <div>
      {/* 搜索栏 */}
      <div>{/* 50行搜索表单 */}</div>
      {/* 筛选器 */}
      <div>{/* 40行筛选条件 */}</div>
      {/* 表格 */}
      <table>{/* 80行表格渲染 */}</table>
      {/* 编辑弹窗 */}
      {isEditing && <div>{/* 60行编辑表单 */}</div>}
      {/* 分页 */}
      <div>{/* 30行分页器 */}</div>
    </div>
  );
}

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

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

tsx 复制代码
// ✅ 按职责拆分
// components/OrderFilters.jsx
function OrderFilters({ value, onChange }) {
  return (/* 筛选器UI,只管筛选 */);
}

// components/OrderTable.jsx
function OrderTable({ data, onEdit, onDelete }) {
  return (/* 表格UI,只管展示 */);
}

// components/OrderEditModal.jsx
function OrderEditModal({ order, onSave, onClose }) {
  return (/* 编辑弹窗,只管编辑 */);
}

// hooks/useOrders.js
function useOrders(filters) {
  return useQuery({
    queryKey: ['orders', filters],
    queryFn: () => fetchOrders(filters),
  });
}

// pages/OrderManagement.jsx --- 组装层,不超过50行
function OrderManagement() {
  const [filters, setFilters] = useState({});
  const [editingOrder, setEditingOrder] = useState(null);
  const { data: orders, isLoading } = useOrders(filters);

  return (
    <div>
      <OrderFilters value={filters} onChange={setFilters} />
      <OrderTable
        data={orders}
        onEdit={setEditingOrder}
        onDelete={handleDelete}
      />
      {editingOrder && (
        <OrderEditModal
          order={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写的代码吗?发现过什么让你冒冷汗的问题?

相关推荐
东小西1 小时前
第12篇:《AI的幻觉差点让我背锅:于是我给它开了一场"开卷考试"》
openai·ai编程
REDcker1 小时前
Cesium三维WebGIS入门详解
前端·gis·web·cesium·webgis
0暗影流光02 小时前
十倍效能提升——Web 基础研发体系的建立
前端
前端一课2 小时前
用Agent Skill重构代码:300行缩至30行的实战教程
前端
腻害兔2 小时前
【若依项目-产品经理视角】RuoYi-Vue-Pro 源码拆解:IM 即时通讯模块,一个被低估的「全功能聊天系统」
java·前端·vue.js·产品经理·ai编程
奎叔2 小时前
Flutter 异常处理体系:从捕获、转换到恢复
前端
心念枕惊2 小时前
.NET CORE 授权进阶-角色、策略与动态权限实现
java·前端·.netcore
小徐_23332 小时前
寓言故事一则:狗猛酒酸
前端·ai编程
ServBay3 小时前
AI 时代的供应商锁定风险,开发者和企业如何保持主动权
aigc·ai编程