前两天面了一家公司,前两轮聊得还行,项目经历、技术选型、团队协作都答得上来。第三轮是技术深挖,面试官打开了我简历里提到的一个项目,屏幕共享让我打开某个组件的代码。
他指着一段逻辑问我:"这里为什么用 useCallback 包了一层?"
我看了一眼那段代码——确实是我项目里的,但那一瞬间我脑子完全空白。因为那个组件是我用 Claude Code 生成的,当时 prompt 写的是"写一个带搜索功能的面板组件",它给我生成了整个文件,我看了一眼能跑就提交了,从来没想过为什么要用 useCallback。
那次面试之后我做了一件事:把最近半年写的核心代码翻了一遍,对每段逻辑问自己"为什么这样写"。结果让我出了一身冷汗。
面试官问的原始代码大概长这样:
function SearchPanel({ onSearch }) {
const [keyword, setKeyword] = useState('');
const handleChange = useCallback((e) => {
setKeyword(e.target.value);
}, []);
const handleSubmit = useCallback(() => {
onSearch(keyword);
}, [keyword, onSearch]);
return (
<div>
<input value={keyword} onChange={handleChange} />
<button onClick={handleSubmit}>搜索</button>
</div>
);
}
面试官追问:"handleChange 这里用 useCallback,收益是什么?"
我当时的回答是:"防止子组件不必要的重新渲染。"
面试官又问:"input 是原生 DOM 元素,不是 React.memo 包裹的子组件——原生元素会因为父组件传入的 onChange 引用变化而重新渲染吗?"
我愣住了。
答案是不会。 原生 DOM 元素(<input>、<div>、<button>)不参与 React 的引用比较优化,它们每次父组件渲染时都会重新执行,不管你传入的 props 引用有没有变。useCallback 在这里完全没有意义——既不能减少渲染次数,还多了一层闭包和依赖数组维护的心智成本。
// ✅ 原生元素接收的回调,不需要useCallback
function SearchPanel({ onSearch }) {
const [keyword, setKeyword] = useState('');
const handleChange = (e) => {
setKeyword(e.target.value);
};
const handleSubmit = () => {
onSearch(keyword);
};
return (
<div>
<input value={keyword} onChange={handleChange} />
<button onClick={handleSubmit}>搜索</button>
</div>
);
}
useCallback 只在一个场景下有意义:传给用 React.memo 包裹的子组件,或者作为 useEffect/useMemo 的依赖时需要引用稳定。传给原生元素纯属浪费。
这个错误我犯了多少次?翻了一下项目,至少 12 处。每一处都是 AI 生成整个组件时自动带上的,我从来没质疑过。
面试进入第二个代码片段,是一个搜索联想组件:
function AutoComplete({ fetchSuggestions }) {
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState([]);
useEffect(() => {
if (query.length > 0) {
fetchSuggestions(query).then((data) => {
setSuggestions(data);
});
} else {
setSuggestions([]);
}
}, [query]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{suggestions.map((s) => <li key={s.id}>{s.text}</li>)}
</ul>
</div>
);
}
面试官:"如果用户快速输入'react'五个字母,'r'的请求比'react'的请求晚回来,会发生什么?"
我想了几秒才反应过来——显示的是'r'的搜索结果,而不是'react'的。竞态条件(Race Condition)。
面试官接着问:"你项目里这个场景是怎么处理的?"
我诚实地说我不确定。因为这段代码也是 AI 一次性生成的整个组件,"能跑"之后我就没再深想。
正确的处理方式是用 AbortController 或者忽略过期请求:
function AutoComplete({ fetchSuggestions }) {
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState([]);
useEffect(() => {
if (query.length === 0) {
setSuggestions([]);
return;
}
const controller = new AbortController();
fetchSuggestions(query, { signal: controller.signal })
.then((data) => {
setSuggestions(data);
})
.catch((err) => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort();
}, [query]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{suggestions.map((s) => <li key={s.id}>{s.text}</li>)}
</ul>
</div>
);
}
或者用一个更轻量的方案——通过请求 ID 过滤过期响应:
useEffect(() => {
if (query.length === 0) {
setSuggestions([]);
return;
}
let cancelled = false;
fetchSuggestions(query).then((data) => {
if (!cancelled) {
setSuggestions(data);
}
});
return () => { cancelled = true; };
}, [query]);
cleanup 函数里把cancelled设为true,下一次 effect 执行时,上一次的请求回来了也不会更新 state。
这个问题的要害在于:AI 生成的代码默认跑的是 happy path。 它给你一个"能工作"的版本,但不会主动替你想"如果网络慢/请求乱序/用户操作快会怎样"。而你用了半年 AI,习惯了"生成→能跑→提交"的流程,渐渐不再问自己"edge case 怎么办"。
面试官打开了我项目里一个表单页面,结构大概是这样:
// FormPage.tsx
function FormPage() {
return (
<FormContainer>
<FormHeader />
<FormBody />
<FormFooter />
</FormContainer>
);
}
// FormHeader.tsx — 只有标题和一行描述
function FormHeader() {
return (
<div>
<h2>创建项目</h2>
<p>填写以下信息创建新项目</p>
</div>
);
}
面试官:"FormHeader 只有两行静态文本,为什么要单独抽成组件?"
我又愣住了。
诚实的答案是:当时我在 Claude Code 里说"生成一个创建项目的表单页面",它自动按 Header/Body/Footer 拆分的,我觉得"看起来很整洁"就接受了。但如果真要回答"为什么"——这里没有任何合理理由。
一个组件值得被拆出去的标准:
FormHeader 一个都不满足。它既不复用,也没有状态,更不需要性能隔离。拆出去唯一的效果是多了一个文件、多了一次 import、多了一层组件调用栈——纯粹增加了项目的复杂度。
// ✅ 静态内容直接内联,不需要单独组件
function FormPage() {
return (
<FormContainer>
<div>
<h2>创建项目</h2>
<p>填写以下信息创建新项目</p>
</div>
<FormBody />
<FormFooter />
</FormContainer>
);
}
这不是一个"对错"的问题——很多人会说"拆了也没坏处"。但面试官真正在问的是:你做这个决策时有没有思考过。如果你的回答是"AI 这样生成的我就用了",等于告诉面试官:你不做设计决策,你只是 AI 的提交工具。
面试完回去我复盘了很久,问题的本质不是"我的技术退步了",而是AI 改变了我的工作方式。现在用 Claude Code 或者 Codex,一句话描述需求就能生成整个组件甚至整个页面,效率确实高了十倍——但代价是:
| 以前的工作流 | 现在的工作流 |
|---|---|
| 想清楚要写什么 → 一行行敲代码 | 一句 prompt 让 AI 生成整个组件 → 能跑 → 提交 |
| 遇到问题先想原因 | 遇到问题直接丢给 AI 重写 |
| 每行代码都是自己的决策 | 整个文件都是 AI 生成的,我只 review 了一遍 |
| Code Review 时能解释每个选择 | Code Review 时对 AI 生成的代码根本说不清"为什么" |
关键区别:以前是"先思考后编码",现在是"描述需求→AI 全盘实现→我看一眼→提交"。
AI 不会替你思考"为什么"。它给你"what"——一个能跑的完整实现。但面试官问的永远是"why"——为什么这样做而不是那样做。如果你只有 what 没有 why,在面试里就是裸奔。
面试后我给自己列了一个清单,翻项目代码时逐条对照:
5 个信号判断你是不是"AI 代理人"而不是"代码作者":
如果 5 个里面命中 3 个以上,说明你现在的状态是:你负责写 prompt 描述需求,AI 负责整个实现。 面试官只要追问两次"为什么",你就露馅。
| 审查维度 | 问自己的问题 | 常见 AI 盲区 |
|---|---|---|
| 性能决策 | 每个 useMemo/useCallback 都有必要吗? | 生成整个组件时惯性给所有函数包 useCallback |
| 边界处理 | 请求失败/超时/并发冲突怎么办? | 只实现 happy path,不主动处理 edge case |
| 组件边界 | 为什么这样拆分?有更简单的方式吗? | 按"最佳实践模板"过度抽象 |
| 类型设计 | TS 类型为什么这样定义?能收窄吗? | 大量 any 或过于宽泛的联合类型 |
| 状态管理 | 这个 state 为什么放这层?有没有可以提升/下沉的? | 默认把 state 全塞在同一个组件里 |
| 依赖关系 | useEffect 的依赖数组每一项你都知道为什么需要吗? | 按 eslint 提示全加,不思考必要性 |
核心原则:面试前把简历项目里 AI 写的代码全过一遍,对每段逻辑能回答"为什么这样做"和"如果不这样做会怎样"。 不是让你重写,是让你理解。
这次面试给我最大的教训不是"AI 不好",而是:我把 AI 当成了可以全权委托的外包,但它其实只是一个不解释理由的代码生成器。
同事帮你写一段代码,你会问"为什么这样写";AI 帮你生成整个组件,你看一眼能跑就 merge 了。这个差距在日常工作里看不出来——代码能跑,Review 能过,线上不出事。但面试官专门戳的就是这个缝隙:你到底是代码的作者,还是 AI 的提交工具?
以后我用 AI 的习惯会加一步:每次 AI 生成完代码,花 5 分钟逐段问自己"如果面试官问我为什么,我怎么回答"。答不上来的——要么搞懂再提交,要么让 AI 解释清楚再接受。
你有没有也遇到过面试官追问"为什么"的时候答不上来的瞬间?评论区聊聊你的版本。