面试官让我用 AI 重构一个 8 年陈的 React 组件——他说他不看代码,只看我会不会拆

面试官让我用 AI 重构一个 8 年陈的 React 组件------他说他不看代码,只看我会不会拆

他把笔记本推过来,屏幕上趴着一个 2017 年写的 Class 组件,props 17 个,state 里塞了 5 个布尔开关,生命周期里挂着 4 个异步请求,像一具裹满胶带的木乃伊。旁边 Cursor 闪闪发亮,像个跃跃欲试的殡葬师。

"25 分钟,用 AI 重构。"他说,"我不看代码,我只看你敢不敢拆。"

我当时的内心 OS:这不就是考 Prompt 工程吗?我会。我啪嗒啪嗒敲了一串"把这个 Class 组件改成函数式,优化性能",Cursor 也很争气,30 秒产出了一份看起来像 2026 年的代码:useEffect、useState、箭头函数,整整齐齐。

面试官扫了一眼,问:"原来的 bug 在哪?"

我愣住了。AI 把 bug 包上了一层现代语法糖,而我连糖纸都没撕开。

那一刻我懂了:他要的不是 AI 写得多漂亮,而是我能不能指着这堆陈年垃圾说"这里烂、那里毒、这块要切掉"。AI 是手术刀,但握刀的人得先知道病人哪根血管不能碰。

下面是我复盘出来的四步拆法,以及一张我现在逢重构必用的检查清单。

第一步:先别动代码,先画依赖图

老组件最恶心的不是行数,是它把关系藏得像前任的聊天记录。一个 props 从爷爷组件传下来,在 componentDidMount 里变成 state,在 shouldComponentUpdate 里决定生死,最后在某个回调里反向写回去------你改一行,三个地方一起中风。

AI 读这种代码,会彬彬有礼地把它"翻译"成新语法,但不会告诉你:"嘿,这里有个 props 和 state 互相代理的癌变。"

所以我的规矩是:改第一行代码之前,先画三张图。

  • 数据流图:谁进来、谁出去、谁被谁改
  • 副作用图:请求、定时器、事件监听、DOM 操作,一个都不能漏
  • 渲染决策图:什么条件下渲染、什么条件下重渲染

面试现场我用白板,平时我用 Miro 或者直接写 markdown。别嫌麻烦,不画这三张图就动手重构,和闭着眼睛拆炸弹没区别。

画完我才知道,那个 8 年老组件真正的癌不是 Class,是 props 和 state 互相代理。A 从 props 来,B 从 A 派生,C 又在回调里写回 A。AI 重写一遍,这种代理关系只会藏得更深,Bug 从明面上的一颗痘,变成皮下的一颗瘤。

第二步:把"隐藏假设"全部揪出来

老代码里有一种 bug,堪称幽灵:代码本身没错,是它成立的前提已经烂透了。

比如这段:

jsx 复制代码
componentDidMount() {
  if (this.props.userId) {
    fetchUser(this.props.userId).then(user => this.setState({ user }));
  }
}

看着人畜无害对吧?2017 年写的时候,userId 是数字,非空,上游不会发疯。现在呢?上游可能传字符串、传空对象、传 undefined 的远房亲戚。代码没崩,是因为你的测试数据比真实世界仁慈。

我让 Cursor 做了一件它不爱做的事:别重写,先把所有默认成立但没写下来的条件列出来。

它列出了 11 条,挑几条毒的给你看:

  • userId 是数字且非空
  • fetchUser 一定返回对象而不是 null
  • 组件卸载前请求一定已经完成
  • onSave 回调一定存在
  • isAdmin 是布尔值而不是字符串 "true"

这些就是隐藏假设。重构之前不把她们写成断言、类型、或者显式分支,等于把地雷从毛坯房搬到精装修房,爆炸的时候更贵。

第三步:小步拆,别搞"全量重写"的葬礼

面试官后来直言,他最烦看到候选人让 AI 一键"全量重写"。生产环境里没人能一次验证 800 行新代码,除非你想凌晨三点接 P0 电话。

我的策略是:按副作用边界拆,而不是按视觉边界拆。

原来的组件你乍一看能拆成 Header、Form、Footer 三块,拆完发现它们三个还在抢同一个 state,拆了个寂寞。真正该这么拆:

  1. 把数据请求抽出去 :让 hook 或数据层接管 fetchUser / saveData
  2. 把渲染决策抽出去 :5 个布尔开关合并成 1-2 个有限状态(idle | loading | error | success
  3. 把 UI 表现抽出去:Header / Form / Footer 这时候才真正独立
  4. 最后才把 Class 转成函数式:这反而是最不重要的一步

下面这个示例展示什么叫"先把数据请求和 UI 切开"。

原始组件(已精简,但还能闻到 2017 年的味道):

jsx 复制代码
import React, { Component } from 'react';

class LegacyUserCard extends Component {
  constructor(props) {
    super(props);
    this.state = { user: null, loading: false, error: null, editing: false };
  }

  componentDidMount() {
    this.loadUser();
  }

  loadUser = () => {
    this.setState({ loading: true });
    fetchUser(this.props.userId)
      .then(user => this.setState({ user, loading: false }))
      .catch(error => this.setState({ error, loading: false }));
  };

  toggleEdit = () => this.setState(s => ({ editing: !s.editing }));

  render() {
    const { user, loading, error, editing } = this.state;
    if (loading) return <p>加载中...</p>;
    if (error) return <p>出错了:{error.message}</p>;
    if (!user) return null;

    return (
      <div className="user-card">
        <h3>{user.name}</h3>
        {editing ? (
          <input defaultValue={user.name} />
        ) : (
          <p>{user.bio}</p>
        )}
        <button onClick={this.toggleEdit}>
          {editing ? '保存' : '编辑'}
        </button>
      </div>
    );
  }
}

拆完第一步,数据请求滚出 UI:

jsx 复制代码
import { useState, useEffect } from 'react';

// 副作用边界先封死
function useUser(userId) {
  const [state, setState] = useState({ status: 'idle', user: null, error: null });

  useEffect(() => {
    if (!userId) return;
    setState(s => ({ ...s, status: 'loading' }));
    fetchUser(userId)
      .then(user => setState({ status: 'success', user, error: null }))
      .catch(error => setState({ status: 'error', user: null, error }));
  }, [userId]);

  return state;
}

// UI 只负责展示,别再碰请求
function UserCard({ userId }) {
  const { status, user, error } = useUser(userId);
  const [editing, setEditing] = useState(false);

  if (status === 'loading') return <p>加载中...</p>;
  if (status === 'error') return <p>出错了:{error.message}</p>;
  if (status !== 'success' || !user) return null;

  return (
    <div className="user-card">
      <h3>{user.name}</h3>
      {editing ? <input defaultValue={user.name} /> : <p>{user.bio}</p>}
      <button onClick={() => setEditing(e => !e)}>
        {editing ? '保存' : '编辑'}
      </button>
    </div>
  );
}

这一步没解决所有问题,但已经做到两件事:

  • 5 个布尔变量收敛成 1 个有限状态机
  • UI 不再直接碰数据层,后面换 GraphQL、TanStack Query、RTK,都只改 hook

第四步:给 AI 下"可验收"的指令

面试中我最大的转变,是 Prompt 从"帮我重构"变成"完成这个可验证的小任务"。

别这样写:

"帮我把这个 Class 组件改成函数组件,优化性能。"

AI 听完会给你一个"看起来对"的答案,然后你在凌晨三点发现它把 componentWillUnmount 里的清理逻辑吃掉了。

要这样写:

"这个组件用了 5 个 state 变量控制请求/编辑/错误状态。请把它们合并成 1 个 status 状态机(idle/loading/success/error),并保证:1)原来所有渲染分支都不丢失;2)卸载时不会 setState;3)给出 3 个单元测试用例覆盖状态转换。"

差别在哪?后者有验收标准。 AI 写出来对不对,你扫一眼就知道。

我把这套话术整理成了一张检查清单,每次重构老组件之前都先让 AI 按这个顺序来:

AI 重构老组件检查清单(建议收藏)

步骤 要 AI 做的事 验收标准
1. 依赖梳理 列出所有 props / state / 副作用 / 外部依赖 输出 markdown 表格,不许写"等等"
2. 假设暴露 找出默认成立但没有断言的条件 列出 ≥5 条,标风险等级
3. 状态机收敛 把多个布尔控制变量合并成有限状态 状态数 ≤3 个,覆盖所有渲染分支
4. 副作用边界 把请求/定时器/监听抽到 hook 或工具函数 UI 组件不再直接调用 fetch/setInterval
5. 最小可测步 每次只重构一个边界,生成对应测试 每步改动 ≤80 行,有 1-2 个测试
6. 行为回归 对比重构前后输入输出 列出 3 组相同输入下的预期相同输出
7. 性能断言 只优化有测量数据的性能问题 必须给出 before/after 的 render 次数或耗时
8. 删除而非隐藏 确认删除的代码真的没人用 全局搜索被删除的函数/变量名

这张表不是用来让 AI 更聪明的,是用来防止你被 AI 唬住的。面试官说的"看你会不会拆",翻译过来就是:你能不能给出清晰的边界和验收标准,而不是对着 AI 的输出点头。

面试最后,他问了我一个灵魂问题

"如果 AI 给的答案看起来很完美,但你不知道它为什么对,你敢不敢用?"

我说不敢。

他点头:"那就可以。重构不是重写,是把不懂的东西拆成懂的。AI 能写代码,但拆代码的责任只能是人。"

我拿到了 offer。更实在的是,我把这套四步法带进了真实项目。上周刚拆了一个 2019 年的表单组件,没有一次大提交,全是小步验证,rollback 成本接近零。

最后送你一句话:AI 可以帮你写得更快,但只有你知道烂在哪里,才不会在事故复盘会上被骂得更快。


上面的检查清单建议收藏。你最近一次重构老组件,是先画依赖图,还是直接让 AI 全量重写了?


相关推荐
计算机魔术师17 分钟前
700吉瓦「幽灵用电」:美国AI数据中心的抢电闹剧正在失控
前端
IT_陈寒25 分钟前
JavaScript数组排序踩的坑,差点让我加班到凌晨
前端·人工智能·后端
风骏时光牛马27 分钟前
提示词工程:大模型交互的核心构建艺术
前端
今日无bug1 小时前
大模型的回答是怎么一个字一个字蹦出来的?前端流式输出解析
前端·llm·agent
计算机魔术师1 小时前
Anthropic 复盘 Claude 模型越权访问真实系统事件并改进对齐与安全措施
前端
掘金者阿豪1 小时前
Seedance 2.0/2.5 虚拟素材能跨 Key 共用吗?一次讲清 Asset ID、账号隔离与 SaaS 素材架构
前端·后端
黄敬峰1 小时前
一文搞懂 Agent Memory 管理:从临时记忆到长期记忆
面试
Jacky19991 小时前
Electron + Vue 3 桌面打字游戏实战:从 VSCode 扩展到独立应用的架构改造
javascript
用户50093768390391 小时前
热敏小票 Web 打印:我在 58/80mm 上折腾的两周
前端·vue.js