1. 为什么“老实”反而成了AI前端面试的减分项
九月这波AI前端岗位的面试,我前前后后跟了十几场,有自己面的,也有帮朋友做模拟面试官的。一个很明显的感受是:那些规规矩矩、问什么答什么、像背书一样把八股文倒出来的候选人,反而最容易在二面被刷掉。不是他们基础差,恰恰相反,很多人TypeScript类型体操写得比我还溜,但一到“AI交互逻辑怎么封装”这种开放题就卡壳了。
这里说的“不用太老实”,不是让你去编简历、吹项目,而是指答题策略要跳出传统前端面试的套路。传统前端面试考的是“你知道不知道”,AI前端面试考的是“你能不能把大模型这个不确定的东西,稳稳当当地塞进浏览器里”。这两件事的评判标准完全不一样。
我拿一个真实场景举例。面试官问:“如果让你做一个AI聊天页面,流式输出怎么实现?”老实人的回答是:“用SSE,EventSource接收,然后append到DOM。”这个答案没错,但只能拿60分。因为面试官接下来会问:“用户点停止生成怎么办?”“网络断了怎么续?”“Markdown流式渲染怎么处理半截代码块?”这三个问题一出来,只背过八股文的人就露馅了。
所以这篇内容,我想把AI前端面试里真正拉开差距的几个核心点拆开讲清楚。包括SSE流式输出的完整封装逻辑、AbortController的中断与恢复、TypeScript在AI交互层的类型设计,以及面试官最爱追问的边界场景。适合正在准备九月、十月AI前端岗位的朋友,也适合已经入职但想把手头AI功能重构得更稳的开发者。
提示:下面涉及的所有代码和方案,都是基于我在实际项目中跑通的逻辑整理的,不是伪代码。你可以直接拿去改吧改吧用在面试白板或者实际项目里。
2. AI前端面试的核心考察逻辑拆解
2.1 面试官到底想看你什么能力
传统前端面试的评分表大概是这样的:HTML/CSS占20%,JavaScript基础占30%,框架原理占30%,工程化占20%。但AI前端岗位的评分表变了,我根据几次面试反馈和同行交流,大致画了一个权重分布:
| 能力维度 | 权重 | 具体考察点 |
|---|---|---|
| AI交互逻辑封装 | 35% | SSE/WebSocket选型、流式渲染、中断恢复 |
| TypeScript类型设计 | 25% | 泛型约束、联合类型、类型守卫在AI场景的应用 |
| 边界与异常处理 | 20% | 网络抖动、超时、并发请求、内存泄漏 |
| 传统前端基础 | 15% | 框架原理、性能优化、工程化 |
| 软技能 | 5% | 沟通表达、问题拆解 |
你看,传统八股文只占15%了。但很多候选人还在用80%的时间背八股,这就是“太老实”的第一个表现。
我面过一个候选人,问他“SSE和WebSocket在AI场景怎么选”,他背了一大段两者区别,从协议层讲到应用层,很标准。但我追问:“如果我要做多轮对话,每轮都要带上下文,你用SSE怎么传?”他愣了一下说:“SSE只能服务端推,客户端传参得用URL参数吧。”这个回答就暴露了他没实际做过AI对话项目。因为真实场景里,上下文可能几千token,URL根本放不下,正确做法是用POST发请求,然后服务端以SSE格式流式返回。
2.2 “不用太老实”的三层含义
第一层,别只答结论,要答决策过程。面试官问“为什么用SSE不用WebSocket”,你不要只背区别,要说:“因为这个场景是单向流式输出,SSE基于HTTP,实现成本低,浏览器原生EventSource支持自动重连。WebSocket虽然全双工,但需要额外维护连接状态,对于纯文本生成场景属于过度设计。不过如果要做实时协作编辑,我会选WebSocket。”
第二层,主动暴露边界问题。答完主逻辑后,主动补一句:“这里有个坑,EventSource不支持自定义header,所以如果要做鉴权,我得用fetch加ReadableStream来手动解析SSE格式。”这句话一出来,面试官就知道你真做过。
第三层,把TypeScript当成设计工具,不是语法练习。很多人写TS就是给变量加个类型,但AI场景里,类型系统要能约束“流式数据块”的结构。比如定义一个StreamChunk类型,让它在不同状态下有不同的字段,这样调用方就不会传错。
2.3 一个反常识的观察
我发现在AI前端面试里,面试官对“不知道”的容忍度反而更高。因为AI领域变化太快,没人什么都懂。但面试官对“不懂装懂”是零容忍的。有一次我问一个候选人:“你项目里SSE断线重连怎么做的?”他说:“EventSource自动重连。”我追问:“那重连之后之前的内容会重复吗?”他想了想说:“这个我没注意,应该不会吧。”其实会重复,因为服务端不知道客户端收到哪了。但他如果老实说“这块我没处理,如果要做的话我会加一个lastEventId来标记”,反而加分。
所以“不用太老实”的核心是:展示你的思考路径,而不是伪装成什么都懂。
3. SSE流式输出在AI交互中的完整封装
3.1 为什么AI场景偏爱SSE而不是WebSocket
先把这个选型问题说透。SSE全称Server-Sent Events,本质上是服务器向浏览器单向推送文本流。它的协议格式很简单,就是data: xxx\n\n这样的文本块。浏览器原生提供EventSourceAPI来接收。
AI对话场景为什么适合SSE?三个原因:
第一,交互模式是单向的。用户发一条消息,AI流式返回一段回答。这个过程中,客户端不需要持续向服务端推数据。WebSocket的全双工能力用不上。
第二,基于HTTP,基础设施友好。SSE走的是标准HTTP请求,Nginx、网关、负载均衡都不用特殊配置。WebSocket需要协议升级,有些代理层会拦截。
第三,自动重连和事件ID机制。EventSource内置了断线重连,并且支持Last-Event-ID头,服务端可以根据这个ID决定从哪继续推。虽然实际项目里我很少直接用EventSource,但这个设计思路值得借鉴。
但SSE也有硬伤:原生EventSource不支持POST请求,也不支持自定义header。这意味着你没法在header里放Authorization token,也没法在body里放长上下文。所以实际项目里,我都是用fetch加ReadableStream来手动解析SSE流。
// 手动解析SSE流的核心逻辑 async function fetchSSE(url: string, body: object, onChunk: (text: string) => void, signal: AbortSignal) { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(body), signal }); const reader = response.body?.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader!.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6); if (data === '[DONE]') return; try { const parsed = JSON.parse(data); onChunk(parsed.content); } catch (e) { // 忽略解析错误,继续处理下一块 } } } } }这段代码有几个关键点。decoder.decode(value, { stream: true })里的stream: true很重要,因为一个UTF-8字符可能被拆到两个chunk里,不加这个参数会出现乱码。buffer的作用是处理“半截行”,因为网络传输不保证每次read都刚好读到完整的一行。
3.2 流式渲染的三种方案与选型
拿到文本块之后,怎么渲染到页面上?我试过三种方案:
方案一:直接append到DOM。最简单,但每次append都会触发重排,长文本下性能很差。而且没法做Markdown渲染。
方案二:维护一个完整字符串,每次更新整个内容区。用React的useState存完整文本,每次chunk来了就setText(prev => prev + chunk)。这个方案在短文本下没问题,但文本长了之后,每次setState都会导致整个Markdown重新解析,CPU直接拉满。
方案三:分块渲染加虚拟化。把文本按段落切分,只重新渲染最后一个段落。这个方案实现复杂,但性能最好。
我实际项目里用的是方案二的变体:用useRef存完整文本,用requestAnimationFrame节流更新。因为AI返回速度大概是每秒20-50个token,如果每个token都触发一次React渲染,一秒就是几十次重渲染。用rAF合并到每帧一次,性能就稳了。
const textRef = useRef(''); const [displayText, setDisplayText] = useState(''); const rafRef = useRef<number>(); function appendChunk(chunk: string) { textRef.current += chunk; if (!rafRef.current) { rafRef.current = requestAnimationFrame(() => { setDisplayText(textRef.current); rafRef.current = undefined; }); } }注意:用rAF节流时,组件卸载前一定要
cancelAnimationFrame(rafRef.current),否则会内存泄漏。这个坑我在两个项目里都踩过。
3.3 Markdown流式渲染的半截代码块问题
AI返回的内容通常是Markdown格式,包含代码块、列表、表格。流式渲染时最头疼的是:代码块可能只返回了一半。比如```javascript刚出来,后面的代码还没到,Markdown解析器就会把后面的内容当成代码块内容,渲染出一堆乱码。
我的解决方案是:在渲染前对文本做“闭合补全”。检测未闭合的代码块标记,临时补上一个闭合标记,渲染完再移除。
function closeUnclosedMarkdown(text: string): string { const codeBlockCount = (text.match(/```/g) || []).length; if (codeBlockCount % 2 !== 0) { return text + '\n```'; } return text; }这个逻辑很简单,但效果很好。面试的时候如果你能主动提到这个细节,面试官基本会认定你做过真实项目。
4. AbortController与中断恢复的工程化处理
4.1 用户点“停止生成”背后的完整链路
用户点停止按钮,表面上是“不显示了”,但背后要做四件事:
- 中断fetch请求:用
AbortController.abort(),这会触发fetch的reject,抛出AbortError。 - 保留已生成内容:不能把已经显示的文字清空,用户可能想复制。
- 更新UI状态:把“生成中”改成“已停止”,按钮从“停止”变回“发送”。
- 通知服务端:如果服务端支持,发一个中断信号,让服务端停止推理,节省算力。
第三和第四点很多人会忽略。尤其是第四点,如果不通知服务端,服务端会继续生成直到完成,浪费资源。虽然前端面试不要求你写服务端代码,但你能说出这个点,说明你有全局思维。
const abortRef = useRef<AbortController | null>(null); async function sendMessage(text: string) { abortRef.current = new AbortController(); setStatus('generating'); try { await fetchSSE('/api/chat', { message: text }, appendChunk, abortRef.current.signal); setStatus('done'); } catch (e) { if (e.name === 'AbortError') { setStatus('stopped'); // 可选:通知服务端 fetch('/api/chat/abort', { method: 'POST', body: JSON.stringify({ requestId }) }); } else { setStatus('error'); } } } function stopGeneration() { abortRef.current?.abort(); }4.2 中断后的“继续生成”怎么做
有些产品支持“继续生成”,就是用户点了停止之后,再点一下继续,从断点接着输出。这个功能实现起来比想象中复杂。
核心问题是:服务端不知道客户端收到了多少内容。解决方案有两种:
第一种,客户端把已生成的内容传回服务端,服务端把这段内容作为上下文,继续生成。缺点是浪费token,因为已生成的内容要重新传一遍。
第二种,用event ID标记。服务端每发一个chunk带一个递增ID,客户端中断时记录最后收到的ID。继续时把ID传回去,服务端从下一个ID开始推。这个方案更优雅,但需要服务端配合。
我面试的时候如果被问到这个问题,会先讲第二种方案,然后补一句:“如果服务端不支持event ID,我会用第一种方案兜底,虽然浪费一点带宽,但实现成本低。”
4.3 并发请求与竞态问题
AI对话场景里,用户可能快速发多条消息。如果不管控,会出现“后发的请求先返回”的竞态问题,导致界面内容错乱。
我的处理方式是:每次发新请求前,先abort上一个请求。这样保证同一时间只有一个活跃的流。
function sendMessage(text: string) { // 先中断上一个 abortRef.current?.abort(); abortRef.current = new AbortController(); // ... 发新请求 }但这里有个细节:abort上一个请求时,会触发上一个请求的catch,如果把catch里的逻辑写成“显示错误提示”,就会闪一下错误。所以要在catch里判断:如果是主动abort的,不显示错误。
catch (e) { if (e.name === 'AbortError') { // 主动中断,不处理 return; } // 真正的错误才处理 }提示:这个细节在面试里非常加分。我面过的候选人里,十个有八个没考虑过竞态问题。
5. TypeScript在AI交互层的类型设计实战
5.1 用联合类型约束流式数据块
AI流式返回的数据块不是只有一种形态。可能有内容块、可能有错误块、可能有结束标记。用TypeScript的联合类型可以精确描述:
type StreamChunk = | { type: 'content'; content: string; index: number } | { type: 'error'; code: string; message: string } | { type: 'done'; totalTokens: number }; function handleChunk(chunk: StreamChunk) { switch (chunk.type) { case 'content': appendChunk(chunk.content); break; case 'error': showError(chunk.message); break; case 'done': setStatus('done'); break; } }这样写的好处是,switch语句里TypeScript会自动收窄类型,访问chunk.content时不会报错。如果不用联合类型,就得写一堆类型断言,代码又丑又不安全。
5.2 泛型在AI请求封装中的应用
不同AI接口的请求参数和返回结构不一样,但流式处理的逻辑是一样的。用泛型可以把通用逻辑抽出来:
interface ChatRequest { message: string; history: Array<{ role: string; content: string }>; } interface ChatResponse { content: string; finishReason: 'stop' | 'length' | 'abort'; } async function streamRequest<TReq, TRes>( url: string, body: TReq, parser: (raw: string) => TRes, onData: (data: TRes) => void, signal: AbortSignal ): Promise<void> { // 通用流式处理逻辑 }这个泛型封装在实际项目里非常实用,因为一个产品可能接多个AI服务商,每个服务商的返回格式不同,但流式读取的逻辑可以复用。
5.3 类型守卫处理未知的JSON解析结果
JSON.parse的返回类型是any,直接访问属性很容易运行时出错。用类型守卫可以安全地处理:
function isContentChunk(data: unknown): data is { content: string } { return typeof data === 'object' && data !== null && 'content' in data && typeof (data as any).content === 'string'; } // 使用 const parsed = JSON.parse(line); if (isContentChunk(parsed)) { onChunk(parsed.content); }面试的时候如果面试官问“你怎么处理服务端返回的不确定数据”,把这个类型守卫的写法说出来,比说“用try catch”高一个档次。
5.4 关于TypeScript 7.0的弃用警告
最近TypeScript 7.0的预览版出来之后,有几个配置项被标记为弃用,比如moduleResolution=node10和baseUrl。面试里如果聊到工程化,可以提一嘴:“我最近在升级TS 7.0,发现baseUrl被弃用了,现在推荐用paths配合moduleResolution: bundler。”这句话能体现你关注技术动态。
但注意,不要花太多时间聊这个,因为面试官更关心你的AI交互逻辑能力。工程化配置只是加分项,不是核心项。
6. 高频追问与避坑指南
6.1 面试官最爱追问的五个问题
根据我自己的面试经历和同行反馈,AI前端面试里出现频率最高的追问是这几个:
| 追问问题 | 考察点 | 回答要点 |
|---|---|---|
| SSE和WebSocket怎么选 | 技术选型能力 | 单向用SSE,双向用WebSocket,AI场景优先SSE |
| 用户中断后怎么恢复 | 边界处理能力 | event ID方案优先,客户端传上下文兜底 |
| 流式Markdown怎么渲染 | 工程细节 | 闭合补全、rAF节流、分块渲染 |
| 并发请求怎么管控 | 竞态处理 | abort上一个、请求队列、状态机 |
| 类型怎么约束流式数据 | TS设计能力 | 联合类型、类型守卫、泛型封装 |
6.2 三个我踩过的坑
坑一:EventSource的自动重连导致内容重复。早期项目我直接用EventSource,断线重连后发现内容重复了。原因是服务端不知道客户端收到哪了,从头开始推。后来改成手动fetch加ReadableStream,自己控制重连逻辑。
坑二:TextDecoder没加stream参数导致中文乱码。这个坑很隐蔽,因为英文没问题,只有中文会偶尔出现乱码。原因是UTF-8中文字符占3个字节,可能被拆到两个chunk里。加{ stream: true }就好了。
坑三:组件卸载后setState导致内存泄漏。流式请求还没结束,用户就切走了页面,这时候setState会报warning。解决方案是用一个isMounted标志位,或者在cleanup函数里abort请求。
useEffect(() => { return () => { abortRef.current?.abort(); }; }, []);6.3 面试时的表达技巧
最后说几个表达上的技巧。第一,先给结论再给理由。面试官问“怎么选”,你先说“我选SSE”,然后再解释为什么。不要绕半天才说结论。
第二,主动画图。如果面试是白板或者共享屏幕,画一个简单的数据流图:用户输入 -> fetch POST -> 服务端流式返回 -> ReadableStream解析 -> rAF节流 -> 渲染。图比文字直观十倍。
第三,留一个钩子。答完一个问题后,主动说“这里还有个细节,如果要做XX的话,需要YY”。引导面试官追问你准备好的内容。
提示:面试不是考试,是交流。你不需要每个问题都答对,但你需要让面试官觉得“这个人做过真实项目,遇到问题能自己解决”。
7. 从面试到落地:AI前端能力的长期积累
7.1 面试只是起点,落地才是考验
我见过很多人面试过了,入职之后发现公司的AI功能代码写得一团糟。流式输出用setInterval轮询,中断逻辑直接刷新页面,类型定义全是any。所以面试准备的过程,其实也是你建立正确工程习惯的过程。
我建议你在准备面试的同时,自己动手写一个最小的AI聊天demo。不需要接真实的大模型,用一个mock服务端模拟流式返回就行。把SSE解析、中断恢复、Markdown渲染、类型定义都跑一遍。这个过程比背一百道八股文都有用。
7.2 值得持续关注的技术点
AI前端这个方向变化很快,有几个点值得持续关注:Web Streams API的浏览器兼容性、React Server Components在AI场景的应用、WebGPU做端侧推理、AI Agent的前端交互模式。这些不一定马上用到,但面试里聊到能体现你的技术视野。
7.3 一个实用的学习路径
如果你现在基础还比较薄弱,我建议按这个顺序补:先搞定TypeScript的联合类型和泛型,这是AI交互层类型设计的基础。然后手写一个SSE解析器,理解流式数据的处理逻辑。接着做一个带中断和恢复的聊天demo。最后研究Markdown流式渲染的性能优化。这个路径走下来,大概两到三周,你就能在面试里跟面试官聊得有来有回。
我个人在实际操作中的体会是,AI前端面试最看重的不是你背了多少,而是你能不能把“不确定的流式数据”稳稳当当地管住。这个能力一旦建立起来,不管是面试还是实际工作,都会轻松很多。