前两天面了一家公司,前两轮聊得还行,项目经历、技术选型、团队协作都答得上来。第三轮是技术深挖,面试官打开了我简历里提到的一个项目,屏幕共享让我打开某个组件的代码。
他指着一段逻辑问我:"这里为什么用useCallback包了一层?"
我看了一眼那段代码------确实是我项目里的,但那一瞬间我脑子完全空白。因为那个组件是我用Claude Code生成的,当时prompt写的是"写一个带搜索功能的面板组件",它给我生成了整个文件,我看了一眼能跑就提交了,从来没想过为什么要用useCallback。
那次面试之后我做了一件事:把最近半年写的核心代码翻了一遍,对每段逻辑问自己"为什么这样写"。结果让我出了一身冷汗。
第一个说不出来的问题:"这个useCallback是必要的吗?"
面试官问的原始代码大概长这样:
tsx
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在这里完全没有意义------既不能减少渲染次数,还多了一层闭包和依赖数组维护的心智成本。
tsx
// ✅ 原生元素接收的回调,不需要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生成整个组件时自动带上的,我从来没质疑过。
第二个说不出来的问题:"并发请求怎么处理?"
面试进入第二个代码片段,是一个搜索联想组件:
tsx
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或者忽略过期请求:
tsx
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过滤过期响应:
tsx
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怎么办"。
第三个说不出来的问题:"为什么拆成三个组件?"
面试官打开了我项目里一个表单页面,结构大概是这样:
tsx
// 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拆分的,我觉得"看起来很整洁"就接受了。但如果真要回答"为什么"------这里没有任何合理理由。
一个组件值得被拆出去的标准:
- 需要复用------其他地方也用同样的结构
- 有自己的状态或逻辑------内部管理着独立的state或effect
- 是性能隔离边界------用React.memo包裹防止父组件渲染波及
FormHeader一个都不满足。它既不复用,也没有状态,更不需要性能隔离。拆出去唯一的效果是多了一个文件、多了一次import、多了一层组件调用栈------纯粹增加了项目的复杂度。
tsx
// ✅ 静态内容直接内联,不需要单独组件
function FormPage() {
return (
<FormContainer>
<div>
<h2>创建项目</h2>
<p>填写以下信息创建新项目</p>
</div>
<FormBody />
<FormFooter />
</FormContainer>
);
}
这不是一个"对错"的问题------很多人会说"拆了也没坏处"。但面试官真正在问的是:你做这个决策时有没有思考过。如果你的回答是"AI这样生成的我就用了",等于告诉面试官:你不做设计决策,你只是AI的提交工具。
为什么AI用久了会出现这种状态
面试完回去我复盘了很久,问题的本质不是"我的技术退步了",而是AI改变了我的工作方式。现在用Claude Code或者Codex,一句话描述需求就能生成整个组件甚至整个页面,效率确实高了十倍------但代价是:
| 以前的工作流 | 现在的工作流 |
|---|---|
| 想清楚要写什么 → 一行行敲代码 | 一句prompt让AI生成整个组件 → 能跑 → 提交 |
| 遇到问题先想原因 | 遇到问题直接丢给AI重写 |
| 每行代码都是自己的决策 | 整个文件都是AI生成的,我只review了一遍 |
| Code Review时能解释每个选择 | Code Review时对AI生成的代码根本说不清"为什么" |
关键区别:以前是"先思考后编码",现在是"描述需求→AI全盘实现→我看一眼→提交"。
AI不会替你思考"为什么"。它给你"what"------一个能跑的完整实现。但面试官问的永远是"why"------为什么这样做而不是那样做。如果你只有what没有why,在面试里就是裸奔。
面试前自检:你的代码你能解释吗?
面试后我给自己列了一个清单,翻项目代码时逐条对照:
5个信号判断你是不是"AI代理人"而不是"代码作者":
- 打开三个月前写的组件,能在30秒内说出"为什么用这个Hook而不是另一个"吗? 说不出来=你没做过这个决策
- 项目里有多少个useCallback/useMemo?去掉它们会有可测量的性能差别吗? 回答"不确定"=大概率是AI生成时惯性添加的
- 搜索项目里所有的useEffect,每一个的cleanup函数你都知道为什么要那样写吗? 不知道=你没思考过生命周期边界
- 随机打开一个你"写"的工具函数,能不看代码画出它的执行流程吗? 画不出来=那是AI写的,你只是点了accept
- 把你最近一次PR里的代码打印出来,不看电脑,能对着纸回答同事的追问吗? 不能=这段代码你只"拥有"但不"理解"
如果5个里面命中3个以上,说明你现在的状态是:你负责写prompt描述需求,AI负责整个实现。 面试官只要追问两次"为什么",你就露馅。
面试前3天AI代码自审清单
| 审查维度 | 问自己的问题 | 常见AI盲区 |
|---|---|---|
| 性能决策 | 每个useMemo/useCallback都有必要吗? | 生成整个组件时惯性给所有函数包useCallback |
| 边界处理 | 请求失败/超时/并发冲突怎么办? | 只实现happy path,不主动处理edge case |
| 组件边界 | 为什么这样拆分?有更简单的方式吗? | 按"最佳实践模板"过度抽象 |
| 类型设计 | TS类型为什么这样定义?能收窄吗? | 大量any或过于宽泛的联合类型 |
| 状态管理 | 这个state为什么放这层?有没有可以提升/下沉的? | 默认把state全塞在同一个组件里 |
| 依赖关系 | useEffect的依赖数组每一项你都知道为什么需要吗? | 按eslint提示全加,不思考必要性 |
核心原则:面试前把简历项目里AI写的代码全过一遍,对每段逻辑能回答"为什么这样做"和"如果不这样做会怎样"。 不是让你重写,是让你理解。
不是不用AI,是用了之后多问一个"为什么"
这次面试给我最大的教训不是"AI不好",而是:我把AI当成了可以全权委托的外包,但它其实只是一个不解释理由的代码生成器。
同事帮你写一段代码,你会问"为什么这样写";AI帮你生成整个组件,你看一眼能跑就merge了。这个差距在日常工作里看不出来------代码能跑,Review能过,线上不出事。但面试官专门戳的就是这个缝隙:你到底是代码的作者,还是AI的提交工具?
以后我用AI的习惯会加一步:每次AI生成完代码,花5分钟逐段问自己"如果面试官问我为什么,我怎么回答"。答不上来的------要么搞懂再提交,要么让AI解释清楚再接受。
你有没有也遇到过面试官追问"为什么"的时候答不上来的瞬间?评论区聊聊你的版本。