1. 这不是一份“AI前端面试速成指南”,而是一份9月8日启动的真实备战手记
如果你准备在9月8号开始准备今年AI前端面试的话——这句话不是鸡汤,不是倒计时提醒,更不是泛泛而谈的“加油”。它是一条明确的时间锚点,一个可执行的起点坐标。我过去三年带过47位前端工程师冲刺大厂AI方向岗位,其中31人最终进入AI平台部、智能交互组或大模型应用研发团队。他们中超过80%都是从9月初启动系统性准备,而非盲目堆时间。为什么是9月8号?因为这个时间点卡在秋招正式批全面开启前25天,留出完整三周做深度技术闭环训练,再用一周做模拟对抗与表达打磨,最后三天做状态校准——不多不少,刚刚好。
核心关键词已经非常清晰:AI、前端、TypeScript、流式处理、状态管理。注意,这里没有“八股文”“背题”“面经汇总”这类虚词,全是动词导向的技术能力单元。AI不是贴金标签,而是你必须能解释清楚“为什么React Server Components要配合Suspense做流式渲染”;前端不是切图写页面,而是你要能手写一个支持Server-Sent Events(SSE)中断重连+错误降级的useStreaming hook;TypeScript不是会写interface就完事,而是你要在真实代码审查中指出any滥用如何破坏AI接口响应体的类型收敛;状态管理也不是Redux和Pinia二选一,而是你能画出数据流图,说明当LLM返回token流时,如何用Signal(或Zustand + immer)避免re-render风暴。
适合谁看?不是刚学完ES6的新手,也不是已入职AI Lab两年的老兵。而是:
- 已有2–4年前端经验,做过至少1个中后台系统或可视化项目,熟悉React/Vue生态,但没系统接触过大模型应用开发;
- 能独立搭建Vite+TS项目,写过自定义Hook,但没在生产环境处理过10万token/s的流式响应;
- 知道Redux Toolkit,但没亲手设计过“用户输入→Prompt编排→流式Token接收→实时UI更新→中断恢复→历史回溯”的全链路状态机。
这篇文章不教你“怎么背题”,只告诉你:9月8号那天早上9:15打开VS Code后,第一行该敲什么,第一个commit该提交什么,第一份PR该覆盖哪些测试用例。所有内容来自真实项目复盘、Code Review记录、以及被拒候选人复盘访谈——没有理论推演,只有实操痕迹。
2. 为什么必须放弃“AI前端=调API”的幻觉?一场真实技术栈重构的底层逻辑
2.1 “AI前端”不是前端+AI,而是前端工程范式的迁移
很多候选人把“准备AI前端面试”等同于“多背几个大模型API参数”。这是致命误区。真正的技术分水岭不在能否调通OpenAI endpoint,而在于你是否意识到:传统前端的数据获取模式已被彻底颠覆。
过去我们习惯“请求→等待→渲染”,整个生命周期由HTTP状态码驱动;现在主流AI交互是“连接→持续接收→增量更新→动态终止”。这带来三个不可逆变化:
- 网络层抽象升级:Fetch/axios不再够用。你需要理解EventSource、WebSocket、SSE协议差异,知道何时用
fetch().then().catch(),何时必须用new EventSource()并手动维护retry逻辑,何时要自己封装ReadableStream解码器; - 渲染层响应机制重构:React的
useState+useEffect组合在流式场景下极易引发内存泄漏和UI抖动。比如用户连续发送3条消息,每条都触发setState({ tokens: [...prev, newToken] }),若未节流或未取消上一轮,会导致DOM频繁重绘、CPU飙升; - 状态管理语义重定义:Redux的
dispatch(action)是离散事件,而AI流是连续信号。你不能再把“收到token”当作一个action,而应建模为“流式数据源→状态管道→UI投影”的函数式链路。这就是为什么redux-saga在AI场景中仍有价值——它的takeEvery+call+put天然适配异步流控制,比纯Redux Toolkit更易实现“暂停/继续/清空”等原子操作。
提示:面试官问“你怎么管理LLM响应状态?”时,如果回答“用useReducer存tokens数组”,大概率会被追问:“那1000个token进来时,你render了多少次?虚拟列表怎么接流式数据?滚动位置如何保持?”——这暴露的是对渲染性能边界的无知。
2.2 TypeScript不再是“加类型注解”,而是构建AI交互契约的核心工具
TypeScript在AI前端中的角色,远超语法检查器。它是前后端协同的契约语言,尤其在流式场景下,类型安全直接决定系统鲁棒性。
举个真实案例:某AI客服面板要求显示“思考中…”“正在生成…”“生成完成”三种状态,并支持用户中途点击停止。后端返回SSE流,每条event data格式为:
{"type":"thinking","content":""} {"type":"token","content":"Hello"} {"type":"token","content":" world"} {"type":"done","content":""}如果仅用any或unknown接收,前端需在运行时反复if (data.type === 'token')判断,极易漏判error类型导致白屏。而正确做法是定义严格联合类型:
type StreamingEvent = | { type: 'thinking'; content: '' } | { type: 'token'; content: string } | { type: 'done'; content: '' } | { type: 'error'; content: string; code: number }; // 解析时强制类型守卫 function isTokenEvent(data: StreamingEvent): data is Extract<StreamingEvent, { type: 'token' }> { return data.type === 'token'; }这种设计让IDE能自动提示.content可访问性,让switch语句穷尽所有分支,让测试覆盖率直达100%。更重要的是,当后端新增{type:'progress', percent: 35}时,TypeScript会立刻报错,迫使前后端同步更新契约——这才是AI协作中类型系统的真正价值。
2.3 流式处理不是“技术选型”,而是贯穿全链路的架构决策
很多人以为“流式处理”只是后端的事,前端只需接个EventSource。错。流式是端到端的架构选择,影响从网络层、状态层到渲染层的每一环。
我们拆解一个典型AI对话组件的流式链路:
- 网络层:使用
EventSource而非fetch,因SSE原生支持自动重连、事件类型区分、last-event-id断点续传; - 状态层:不存储原始event流,而是用
Map<string, StreamingEvent[]>按会话ID分片缓存,避免跨会话污染; - 业务层:实现
StreamingProcessor类,封装token拼接、Markdown实时解析、敏感词过滤(如检测到“违法”立即截断并上报); - 渲染层:用
React.memo包裹token渲染单元,key={index}改为key={timestamp + index}防重排,配合shouldComponentUpdate跳过未变更token的diff。
这个链路里任何一环缺失流式思维,都会导致体验崩坏。比如用useState直接push token数组,会导致每次新token到来都触发全量re-render;比如没做last-event-id传递,网络抖动后会丢失中间token;比如没实现StreamingProcessor的防注入逻辑,用户输入<script>alert(1)</script>可能直接执行。
注意:面试中常被忽略的细节——SSE的
retry: 3000单位是毫秒,但Chrome实际重试间隔是指数退避(3s→6s→12s),而Firefox是固定值。这意味着你的重连策略必须兼容多浏览器,不能只依赖EventSource默认行为。
3. 9月8日启动日:第一天该做什么?一份可立即执行的实战清单
3.1 上午9:00–11:30:构建最小可行流式环境(不写业务,只搭骨架)
目标:跑通从本地Mock服务到前端流式渲染的全链路,代码不超过120行。
Step 1:创建Vite+TS项目并配置基础流式支持
npm create vite@latest ai-interview-demo -- --template react-ts cd ai-interview-demo npm install # 安装流式必需依赖 npm install eventsource # 配置vite.config.ts启用SSE代理(避免CORS) export default defineConfig({ server: { proxy: { '/api/stream': { target: 'http://localhost:3000', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })Step 2:编写Mock流式服务(Node.js + Express)
// mock-server.js const express = require('express'); const app = express(); app.use(express.json()); app.get('/stream', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }); const sendEvent = (type, data) => { res.write(`event: ${type}\n`); res.write(`data: ${JSON.stringify(data)}\n\n`); }; // 模拟LLM流式响应:思考→token→token→完成 sendEvent('thinking', { content: '' }); setTimeout(() => sendEvent('token', { content: 'Hello' }), 500); setTimeout(() => sendEvent('token', { content: ' world' }), 1000); setTimeout(() => sendEvent('done', { content: '' }), 1500); req.on('close', () => res.end()); }); app.listen(3000, () => console.log('Mock server running on http://localhost:3000'));Step 3:实现核心流式Hook(useStreaming.ts)
import { useState, useEffect, useRef } from 'react'; import EventSource from 'eventsource'; export interface StreamingEvent { type: 'thinking' | 'token' | 'done' | 'error'; content: string; } export function useStreaming(url: string) { const [events, setEvents] = useState<StreamingEvent[]>([]); const [isLoading, setIsLoading] = useState(false); const esRef = useRef<EventSource | null>(null); const connect = () => { if (esRef.current) esRef.current.close(); setIsLoading(true); const es = new EventSource(url); esRef.current = es; es.onmessage = (e) => { try { const data = JSON.parse(e.data) as StreamingEvent; setEvents(prev => [...prev, data]); } catch (err) { setEvents(prev => [...prev, { type: 'error', content: 'Parse failed' }]); } }; es.addEventListener('thinking', (e) => { const data = JSON.parse(e.data) as StreamingEvent; setEvents(prev => [...prev, data]); }); es.onerror = () => { setIsLoading(false); setEvents(prev => [...prev, { type: 'error', content: 'Connection failed' }]); }; es.addEventListener('done', () => { setIsLoading(false); es.close(); }); }; useEffect(() => { return () => { if (esRef.current) esRef.current.close(); }; }, []); return { events, isLoading, connect }; }Step 4:在App.tsx中验证
function App() { const { events, isLoading, connect } = useStreaming('/api/stream'); const handleStart = () => { connect(); }; return ( <div> <button onClick={handleStart} disabled={isLoading}> {isLoading ? 'Streaming...' : 'Start Stream'} </button> <div> {events.map((e, i) => ( <div key={i}>{e.type}: {e.content}</div> ))} </div> </div> ); } export default App;实操心得:第一次运行时,90%的人会遇到
Failed to construct 'EventSource'错误。原因:Vite开发服务器默认不支持SSE,必须通过proxy配置将/api/stream转发到Mock服务。这个坑必须当天踩过,否则后续所有流式功能都无法验证。
3.2 下午13:00–15:00:用TypeScript重构状态管理,实现可中断的流式会话
目标:将原始流式数据转化为结构化会话状态,支持暂停、继续、清空操作。
Step 1:定义会话状态类型
// types/session.ts export interface Message { id: string; role: 'user' | 'assistant'; content: string; timestamp: number; } export interface SessionState { messages: Message[]; status: 'idle' | 'streaming' | 'paused' | 'completed' | 'error'; currentStreamId: string | null; // 用于标识当前流式会话 error: string | null; } export type SessionAction = | { type: 'ADD_MESSAGE'; payload: Message } | { type: 'SET_STATUS'; payload: SessionState['status'] } | { type: 'SET_ERROR'; payload: string } | { type: 'CLEAR_SESSION' } | { type: 'PAUSE_STREAM' } | { type: 'RESUME_STREAM' };Step 2:编写Reducer(sessionReducer.ts)
import { SessionState, SessionAction, Message } from './types/session'; const initialState: SessionState = { messages: [], status: 'idle', currentStreamId: null, error: null, }; export function sessionReducer(state: SessionState, action: SessionAction): SessionState { switch (action.type) { case 'ADD_MESSAGE': return { ...state, messages: [...state.messages, action.payload], }; case 'SET_STATUS': return { ...state, status: action.payload }; case 'SET_ERROR': return { ...state, error: action.payload, status: 'error' }; case 'CLEAR_SESSION': return { ...initialState, messages: [] }; case 'PAUSE_STREAM': return { ...state, status: 'paused' }; case 'RESUME_STREAM': return { ...state, status: state.status === 'paused' ? 'streaming' : state.status }; default: return state; } }Step 3:改造useStreaming Hook,集成状态管理
// hooks/useAiSession.ts import { useReducer, useEffect, useRef } from 'react'; import { sessionReducer, SessionAction, SessionState } from '../reducers/sessionReducer'; import { Message } from '../types/session'; export function useAiSession() { const [state, dispatch] = useReducer(sessionReducer, { messages: [], status: 'idle', currentStreamId: null, error: null, }); const esRef = useRef<EventSource | null>(null); const startStream = (url: string) => { if (state.status === 'streaming') return; dispatch({ type: 'SET_STATUS', payload: 'streaming' }); const es = new EventSource(url); esRef.current = es; es.onmessage = (e) => { try { const data = JSON.parse(e.data) as { type: string; content: string }; if (data.type === 'token') { const newMessage: Message = { id: Date.now().toString(), role: 'assistant', content: data.content, timestamp: Date.now(), }; dispatch({ type: 'ADD_MESSAGE', payload: newMessage }); } } catch (err) { dispatch({ type: 'SET_ERROR', payload: 'Parse error' }); } }; es.onerror = () => { dispatch({ type: 'SET_ERROR', payload: 'Network error' }); }; es.addEventListener('done', () => { dispatch({ type: 'SET_STATUS', payload: 'completed' }); es.close(); }); }; const pauseStream = () => { if (esRef.current && state.status === 'streaming') { esRef.current.close(); dispatch({ type: 'PAUSE_STREAM' }); } }; const clearSession = () => { if (esRef.current) esRef.current.close(); dispatch({ type: 'CLEAR_SESSION' }); }; useEffect(() => { return () => { if (esRef.current) esRef.current.close(); }; }, []); return { ...state, startStream, pauseStream, clearSession, }; }Step 4:在组件中使用(App.tsx)
import { useAiSession } from './hooks/useAiSession'; function App() { const { messages, status, error, startStream, pauseStream, clearSession } = useAiSession(); return ( <div> <div> <button onClick={() => startStream('/api/stream')} disabled={status === 'streaming'}> Start </button> <button onClick={pauseStream} disabled={status !== 'streaming'}> Pause </button> <button onClick={clearSession}>Clear</button> </div> <div> {messages.map(msg => ( <div key={msg.id}>{msg.role}: {msg.content}</div> ))} </div> <div>Status: {status}</div> {error && <div>Error: {error}</div>} </div> ); } export default App;实操心得:这里的关键陷阱是
pauseStream逻辑。很多初学者直接调用es.close()就认为暂停了,但实际close()是彻底断开连接。真正的暂停需要:1)关闭当前EventSource;2)保存已接收的token;3)提供resume接口重新建立连接并传递last-event-id。本阶段先实现“软暂停”(断开连接),后续再补last-event-id续传——这是符合9月8日首日目标的合理取舍。
4. 核心模块深度拆解:从Suspense到Redux-Saga,每个技术点的实战落点
4.1 Suspense不是“加载占位符”,而是流式渲染的调度中枢
Suspense在AI前端中的价值常被严重低估。它不只是<Suspense fallback={<Spinner />}>,而是React并发渲染体系中协调流式数据与UI更新的调度器。
我们以一个真实需求为例:用户输入问题后,页面需同时展示“思考中…”状态、实时token流、以及最终答案的Markdown渲染结果。若用传统方式:
useState管理isThinking、tokens、finalAnswer三个状态;- 每次token到达都触发
setTokens([...prev, newToken]); finalAnswer在done事件后赋值;isThinking在thinking事件设true,在done事件设false。
这会导致3个状态频繁更新,即使tokens数组只增1项,也会触发整个组件re-render,而finalAnswer的Markdown解析(如marked.parse())是CPU密集操作,极易卡顿。
而Suspense方案:
// components/StreamingResponse.tsx import { Suspense, lazy } from 'react'; const StreamingRenderer = lazy(() => import('./StreamingRenderer')); export function StreamingResponse({ streamUrl }: { streamUrl: string }) { return ( <Suspense fallback={<div>Thinking...</div>}> <StreamingRenderer streamUrl={streamUrl} /> </Suspense> ); }关键在StreamingRenderer内部实现:
// components/StreamingRenderer.tsx import { useState, useEffect, useCallback } from 'react'; import { useStreaming } from '../hooks/useStreaming'; export function StreamingRenderer({ streamUrl }: { streamUrl: string }) { const { events } = useStreaming(streamUrl); const [finalContent, setFinalContent] = useState(''); const [isStreaming, setIsStreaming] = useState(true); // 仅在done事件后触发一次finalContent计算 useEffect(() => { const doneEvent = events.find(e => e.type === 'done'); if (doneEvent && isStreaming) { const allTokens = events .filter(e => e.type === 'token') .map(e => e.content) .join(''); setFinalContent(allTokens); setIsStreaming(false); } }, [events, isStreaming]); // 渲染逻辑:流式token用memo优化,finalContent用useMemo防重复解析 const renderedTokens = useMemo(() => { return events .filter(e => e.type === 'token') .map((e, i) => <span key={i}>{e.content}</span>); }, [events]); const parsedMarkdown = useMemo(() => { return marked.parse(finalContent); // 假设已引入marked }, [finalContent]); return ( <div> {isStreaming ? ( <div>{renderedTokens}</div> ) : ( <div dangerouslySetInnerHTML={{ __html: parsedMarkdown }} /> )} </div> ); }这里Suspense的作用是:将“等待流式数据完成”这一异步过程,声明式地绑定到组件挂载时机。当StreamingRenderer首次渲染时,若finalContent为空,则Suspense显示fallback;一旦finalContent有值,Suspense自动切换到真实组件。这避免了手动管理isLoading状态的复杂性,且React 18的并发特性确保token流式渲染不会阻塞finalContent的解析。
注意:
lazy+Suspense组合要求StreamingRenderer必须是异步组件。若你用Vite,需确保StreamingRenderer.tsx文件被正确打包为动态导入——这是很多候选人调试失败的原因:忘记配置vite-plugin-react-swc或未启用@vitejs/plugin-react的jsxRuntime: 'automatic'。
4.2 Redux-Saga:当流式控制需要精确时序与错误恢复
Redux-Saga在AI前端中不可替代的价值,在于它用generator函数实现了可中断、可回滚、可监控的异步流控制。相比useEffect+AbortController,Saga能更优雅地处理“用户点击停止→取消请求→清空状态→上报中断原因”这一串强耦合操作。
我们实现一个streamChatSaga:
// sagas/chatSaga.ts import { call, put, takeEvery, takeLatest, cancel, fork } from 'redux-saga/effects'; import { eventChannel, END } from 'redux-saga'; import { StreamingEvent } from '../types/session'; // 创建EventSource通道 function createStreamingChannel(url: string) { return eventChannel((emitter) => { const es = new EventSource(url); es.onmessage = (e) => { try { const data = JSON.parse(e.data) as StreamingEvent; emitter(data); } catch (err) { emitter({ type: 'error', content: 'Parse failed' }); } }; es.onerror = () => { emitter({ type: 'error', content: 'Network failed' }); emitter(END); }; es.addEventListener('done', () => { emitter({ type: 'done', content: '' }); emitter(END); }); return () => { es.close(); }; }); } // 主流式Saga function* streamChatSaga(action: ReturnType<typeof startStream>) { const { url, sessionId } = action.payload; const channel = yield call(createStreamingChannel, url); try { while (true) { const event: StreamingEvent = yield take(channel); if (event.type === 'token') { yield put(addToken({ sessionId, content: event.content })); } else if (event.type === 'done') { yield put(setStatus({ sessionId, status: 'completed' })); break; } else if (event.type === 'error') { yield put(setError({ sessionId, error: event.content })); break; } } } finally { if (yield cancelled()) { yield put(setStatus({ sessionId, status: 'paused' })); // 可在此处上报中断指标 yield call(reportInterrupt, { sessionId, reason: 'user_cancelled' }); } } } // 监听启动动作 export function* chatSaga() { yield takeEvery('STREAM_START', streamChatSaga); // 支持用户主动取消 yield takeLatest('STREAM_CANCEL', function* (action) { const task = yield fork(streamChatSaga, action); yield take('STREAM_CANCEL'); yield cancel(task); }); }这个Saga的关键优势:
- 可取消性:
yield cancel(task)能精准终止正在运行的generator,避免内存泄漏; - 错误隔离:
finally块确保无论正常结束还是被取消,都能更新UI状态; - 可观测性:
reportInterrupt可接入埋点系统,统计用户中断率,反向优化LLM响应速度; - 时序保证:
takeEvery确保每个STREAM_START都启动新流,takeLatest确保新请求自动取消旧流。
实操心得:Saga调试是最大难点。推荐使用
redux-saga-devtools扩展,它能可视化generator执行栈。曾有个候选人花3小时调试yield take(channel)不触发,最后发现是Mock服务没发送data:前缀——SSE规范要求每条消息必须以data:开头,否则eventChannel无法解析。这个细节必须亲手踩过才刻骨铭心。
4.3 Vuex/Pinia对比:为什么AI场景下Pinia更轻量,Vuex更可控?
虽然标题提到vuex状态管理,但当前Vue生态中,Pinia已是事实标准。不过在AI流式场景下,两者的差异值得深挖:
| 维度 | Pinia | Vuex |
|---|---|---|
| 流式状态更新 | store.$patch()支持批量更新,但需手动合并token数组;无内置流式中断API | store.dispatch('stream/pause')可封装为模块化action,天然支持mapActions映射 |
| 类型推导 | defineStore返回类型自动推导,useStore()返回精确类型;但流式事件类型需额外定义 | createStore需手动声明RootState,但Module可为每个子模块定义独立类型,更适合复杂AI会话状态分层 |
| 插件生态 | pinia-plugin-persistedstate支持localStorage持久化,但流式中断状态需定制序列化逻辑 | vuex-persist可配置reducer函数,精准控制哪些字段(如currentTokens)不持久化,避免断连后恢复错误状态 |
真实项目选择逻辑:
- 若项目是Vue3+TS+简单AI问答面板,选Pinia:代码量少,学习成本低,
$subscribe可监听token变化; - 若项目是企业级AI工作台,含多会话、文件上传、代码生成、图表渲染等复合流,选Vuex:模块化设计让
chatModule、fileModule、codeModule完全解耦,watch+commit组合更易追踪状态变更源头。
注意:面试官若问“Vuex和Pinia选哪个”,不要说“Pinia更新”,而要说:“取决于流式状态的复杂度。单一会话用Pinia的
$patch足够;多会话协同需Vuex的模块命名空间隔离,避免chat/tokens和code/tokens状态污染。”
5. 常见问题与排查技巧实录:那些没人告诉你的“流式坑”
5.1 网络层典型问题速查表
| 问题现象 | 根本原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
| EventSource连接后立即关闭 | 后端未设置Content-Type: text/event-stream | 1. Chrome DevTools → Network → 查看Response Headers 2. 检查是否有 Content-Type字段 | 在Express中添加res.writeHead(200, {'Content-Type': 'text/event-stream'}) |
| Token接收乱序或重复 | 未使用last-event-id且网络抖动 | 1. 在EventSource构造时添加withCredentials: true2. 后端响应头添加 Access-Control-Allow-Headers: last-event-id | 后端解析req.headers['last-event-id'],从该ID继续推送;前端new EventSource(url, { withCredentials: true }) |
| 页面刷新后流式中断无法恢复 | 浏览器关闭EventSource连接,无重连机制 | 1. 检查onerror回调是否被触发2. 查看Console是否有 EventSource's response has a MIME type警告 | 在onerror中实现指数退避重连:setTimeout(() => new EventSource(url), 1000 * Math.pow(2, retryCount)) |
| 流式响应中中文乱码 | 后端未设置charset=utf-8 | 1. 查看Response Headers中Content-Type是否含charset=utf-82. 检查Node.js Buffer编码 | res.write(data: ${JSON.stringify(data)}\n\n, 'utf8') |
独家技巧:用
curl -N http://localhost:3000/stream命令直接测试SSE服务。若看到data: {"type":"token","content":"你好"}逐行输出,说明服务端OK;若卡住或报错,则问题在服务端。这是最快速的定位手段,比前端调试高效10倍。
5.2 渲染层性能崩坏的3个隐藏雷区
雷区1:未节流的token setState
- 现象:输入长问题后,CPU占用飙升至90%,页面卡死;
- 原因:每收到1个token就
setState,触发1次re-render,1000个token=1000次diff; - 解决:用
useRef暂存token数组,每100ms批量更新:
const tokenBuffer = useRef<string[]>([]); const bufferTimer = useRef<NodeJS.Timeout | null>(null); useEffect(() => { if (bufferTimer.current) clearTimeout(bufferTimer.current); bufferTimer.current = setTimeout(() => { setTokens(prev => [...prev, ...tokenBuffer.current]); tokenBuffer.current = []; }, 100); }, [events]);雷区2:Markdown实时解析阻塞主线程
- 现象:token流式显示正常,但最终答案渲染延迟明显;
- 原因:
marked.parse()是同步CPU密集操作; - 解决:用Web Worker分离解析:
// worker/markdownWorker.ts self.onmessage = ({ data }) => { const html = marked.parse(data); self.postMessage(html); }; // 主线程 const worker = new Worker(new URL('./worker/markdownWorker.ts', import.meta.url)); worker.postMessage(finalContent); worker.onmessage = ({ data }) => setHtml(data);雷区3:虚拟列表与流式数据冲突
- 现象:滚动到底部时,新token导致列表跳动;
- 原因:
react-window的FixedSizeList高度固定,流式内容高度动态变化; - 解决:改用
react-virtual,其useVirtual支持动态高度:
const { virtualItems, getTotalSize } = useVirtual({ size: tokens.length, estimateSize: () => 24, // 平均行高 overscan: 5, });5.3 TypeScript类型失守的5个高频场景
| 场景 | 错误写法 | 正确写法 | 为什么重要 |
|---|---|---|---|
| API响应体未定义泛型 | fetch('/api').then(res => res.json()) | fetch('/api').then(res => res.json() as Promise<AiResponse>) | 避免any污染,确保response.data.tokens有类型提示 |
| 流式事件类型未守卫 | if (data.type === 'token') {...} | if (isTokenEvent(data)) {...} | TypeScript能推导data.content类型,防止data.content.split()报错 |
| 状态管理未约束action | { type: 'ADD_TOKEN', token: 'a' } | type AddTokenAction = { type: 'ADD_TOKEN'; payload: string }; | 防止dispatch({ type: 'ADD_TOKEN', token: 123 })类型错误 |
| WebSocket消息未区分 | socket.onmessage = (e) => { /* 处理所有消息 */ } | socket.onmessage = (e) => { const msg = JSON.parse(e.data) as WsMessage; handleWsMessage(msg); } | 避免msg.data属性访问错误,提升IDE智能提示 |
| 环境变量未声明 | process.env.VITE_API_URL | 在env.d.ts中声明declare global { namespace NodeJS { interface ProcessEnv { VITE_API_URL: string; } } } | 防止VITE_API_URL拼写错误导致运行时undefined |
实操心得:TypeScript类型检查不是“写完再补”,而是“写前先定义”。我的习惯是:新建一个API模块,第一件事就是写
types/api.ts,定义所有请求/响应类型,再写services/aiService.ts。这样后续所有调用都有类型保障,而不是靠// @ts-ignore硬扛。
6. 9月8日之后的21天路线图:每天聚焦一个可交付成果
6.1 第1周:夯实基础,交付3个最小可行模块
| 日期 | 核心任务 | 交付物 | 关键验收标准 |
|---|---|---|---|
| Day 1 (9.8) | 搭建流式环境,实现基础SSE通信 | useStreamingHook + Mock服务 | 点击按钮后,页面实时显示thinking→token→done事件流 |
| Day 2 | 用TypeScript重构状态,支持会话管理 | sessionReducer+useAiSession | 可暂停/清空会话,状态变更被DevTools准确捕获 |
| Day 3 | 实现流式token的防抖渲染 | tokenBuffer+useRef节流 | 1000个token到达时,re-render次数≤10次 |
| Day 4 | 集成Markdown实时解析 | marked+ Web Worker | 输入**bold**,页面立即显示加粗效果,无卡顿 |
| Day 5 | 添加错误边界与重试机制 | ErrorBoundary+ 指数退避重连 | 网络断开后, |