上周去面试一家做 SaaS 的中厂,二面是一位四十出头的前端负责人。
寒暄结束,他直接把笔记本转过来推到桌子中间:
"这是我们线上一个真实的报表页,10 万行数据,滚起来直接卡死。你帮我看看怎么优化。用什么工具都行。"
最后那半句他说得很平淡,我当时没多想。
结果二十分钟后,他把咖啡杯往桌上一放,问了我一个我准备了整场面试都没想到的问题。
页面代码大概长这样(我做了脱敏改写):
function ReportTable({ rows }: { rows: Row[] }) {
// rows.length ≈ 100_000
return (
<table>
<thead>...</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>{row.name}</td>
<td>{row.amount}</td>
<td>{row.status}</td>
<td>
<Select options={statusOptions} value={row.status} />
</td>
<td>
<DatePicker value={row.updatedAt} />
</td>
</tr>
))}
</tbody>
</table>
);
}
写过一年 React 的都能一眼看出问题:
<Select> <DatePicker> 这种带弹层的重组件key 没问题,但 diff 也扛不住 40 万个 DOM 节点搁在教科书里,标准优化路径也有三条:
startTransition / requestIdleCallback)三种我都懂,但每种都有细节:单元格要不要支持编辑?行高是否等高?要不要支持多选?滚动过程能不能容忍占位闪一下?
真让我在白板上写完再讲清楚三者的取舍——20 分钟够呛。
我抬头问了一句:
"您刚才说,用什么工具都行?"
面试官点头。
于是我把自己的笔记本翻开,打开 Claude Code,把这段组件连同 Row 类型、业务约束一起丢了进去,只写了一句:
"React 18 + TS。10 万行表格,每行 5 列,其中两列有 Select 和 DatePicker。产品要求可以滚动、可以点某一行进入详情、后期可能要支持行内编辑。给我三种优化方案,并解释在哪种交互需求下谁最合适。"
大概 4 秒之后,屏幕上刷出了三段方案。
import { useVirtualizer } from '@tanstack/react-virtual';
function ReportTable({ rows }: { rows: Row[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44,
overscan: 10,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
{rowVirtualizer.getVirtualItems().map((v) => {
const row = rows[v.index];
return (
<div key={row.id} style={{
position: 'absolute', top: 0, left: 0,
transform: `translateY(${v.start}px)`,
height: v.size, width: '100%',
}}>
{/* row cells */}
</div>
);
})}
</div>
</div>
);
}
Claude 的注释里写:"最通用、社区最成熟。等高行下开箱即用,行内编辑也没问题。缺点是 Select / DatePicker 这类弹层组件在滚出可视区时会被销毁,弹层里的选中态要托管到外层 state。"
function ReportTable({ rows }: { rows: Row[] }) {
const [visibleCount, setVisibleCount] = useState(50);
useEffect(() => {
if (visibleCount >= rows.length) return;
const id = requestIdleCallback(
() => setVisibleCount((c) => Math.min(c + 200, rows.length)),
{ timeout: 100 }
);
return () => cancelIdleCallback(id);
}, [visibleCount, rows.length]);
return (
<table>
<tbody>
{rows.slice(0, visibleCount).map((row) => (
<tr key={row.id}>{/* cells */}</tr>
))}
</tbody>
</table>
);
}
Claude 提示:"首屏最快,DOM 结构最简单,SEO 友好。但滚到最底部要 10+ 秒才能加载完,用户拖滚动条会看到断层。适合'看不完但不能少'的报表——比如打印导出前的预览页。"
Claude 直接建议接第三方库,并给出了两条使用理由:
"如果表格未来要支持行内编辑、多选、复制粘贴、列拖拽——自己造轮子会一路踩坑。AG Grid 的 Community 版本免费够用,缺点是包体积 500KB+,且样式覆盖成本高。"
我把屏幕转向面试官。他没说话,只是往前挪了挪坐姿。
我接着说:
"我倾向于方案一。因为您说了'后期可能要支持行内编辑'——Canvas 方案改起来太重,切片方案在编辑焦点跳出时会重排。虚拟滚动只要把弹层组件的开合状态提到外层就行。要不咱们跑一下 Performance 看看?"
我把三种方案都在他们的测试环境跑了一遍,Chrome DevTools 打开 Performance。
首屏时间从 6.8 秒压到 320 毫秒,快了 21 倍。
面试官这时候放下了咖啡杯,问了我第一个"意外"的问题:
"你怎么判断把弹层 state 提到外层就够了?"
我说:
"我没直接判断。Claude 给方案后我又追问了一次——'如果同一行的 Select 打开时用户滚出可视区,会发生什么?'它回答说组件会 unmount,弹层直接消失。所以我在方案一里加了一句:把 openId 提到父组件用 Portal 渲染弹层。这样滚出去也不会关。"
面试官盯着我看了两秒,问了第二个问题:
"你组里其他前端,也是这么用 AI 的吗?"
我如实说:不是。大部分同事要么完全不用,要么直接把 AI 的答案 Ctrl+C Ctrl+V 提交。
他"嗯"了一声,然后是第三个问题——就是那个让我离开会议室之后琢磨了一路的问题:
"我们前端组长下个月转岗。你要不要考虑一下这个位置?"
回程地铁上我一直在想:他到底看中了什么?
不是虚拟滚动。方案二方案三我都答得上来,任何一个高级前端都能。
不是 AI。他们组里肯定有人 Claude、Cursor 都开着。
想通之后我总结出三点,写在这里给同样在跳槽的朋友:
传统面试考察的是"你脑子里存了多少知识"。但当所有人脑子里都能调用同一个 AI 的时候,考察点就变成了"你能在单位时间内提出多好的问题"。
我丢给 Claude 的那条 prompt 里有五个关键要素:
如果我只写"帮我优化这个卡顿的表格",Claude 给的答案就是水平线。
面试官看到的其实是:这个人的 prompt 里,藏着五年前端经验。
我在讲方案时说过一句话:"这是 Claude 给的,但我用 Performance 面板验证过了。"
这句话之所以关键,是因为它划出了两类人:
后一种人,团队里其实不多。而这种人是能带团队的。
前端组长这个岗位在他心里对应的画像大概是:
"一个能把 AI 当放大器用的人,一个人的产出等于三个人。而且他知道什么时候不该信 AI——比如弹层脱离视口、比如包体积不能牺牲。"
我在展示解题过程里,无意间把这两点都露出来了。
如果你也在跳槽,尤其在中厂到大厂的过渡区间,几个我踩过之后的建议:
能带电脑就带电脑。 越来越多的公司允许现场用工具了。别怕面试官觉得你依赖 AI——他们在乎的是你怎么用。
练习"提问"比练习"手写"回报大。 手写 Promise、手写深拷贝、手写 EventEmitter,AI 三秒能解。但"给出三种方案 + 比较适用场景 + 结合数据量和交互约束"这种 prompt,得真练。
每一次 AI 给答案后,都强迫自己追问一次"如果 X 前提不成立,会怎样"。 例如:
这是把 AI 的天花板变成你的地板的唯一方法。
准备一段"我怎么用 AI"的故事。 不是理论,是具体的一次协作过程。这段故事的价值,比你把 Fiber 原理背下来大得多。
2026 年了,考察前端的方式已经变了。
变化不是"你会不会用 AI",而是"你能不能带着 AI,做出一个人做不完的事"。
面试官那天放下咖啡的那 2 秒,我大概能听见他脑子里在想什么:
"这人来了之后,我这个季度的排期能砍一半。"
能被这么想,比手写十道八股都值钱。