1. 这不是鸡汤,是9月AI前端面试现场的真实切口
“最后提醒一次,9月的AI前端面试不用太老实”——这句话刚在几个前端技术群刷屏时,我正蹲在客户现场调试一个WebSocket心跳超时导致的AI推理结果截断问题。没有PPT,没有“大模型赋能”,就盯着Chrome DevTools里Network面板里那条被截断的SSE流,反复比对后端返回的data:字段和前端EventSource.onmessage回调里的实际payload。那一刻我突然明白:今年9月的AI前端面试,考的早不是“你会不会调API”,而是“你敢不敢在TypeScript类型系统里动刀子,在流式传输的毛细血管里做手术”。
核心关键词已经非常清晰:AI前端、TypeScript、流式处理、SSE、WebSocket。这不是一个泛泛而谈的“AI+前端”概念题,而是一道带血丝的实操题——它直指当前真实业务场景中三个正在剧烈摩擦的断层:第一,LLM服务端输出天然具备流式特性(token-by-token),但传统HTTP请求模型是“请求-响应”原子操作;第二,TypeScript的静态类型系统默认假设数据是完整、确定、一次性到达的,而流式数据是分片、异步、可能中断的;第三,前端工程师面对Electron打包、Vue-TSC校验、TS 5.3+新语法这些工具链升级,常把“能跑通”当成“懂原理”,结果一问SSE重连策略或WebSocket subprotocol协商机制就卡壳。
适合谁看?如果你正准备9月前后的大厂/中厂AI方向前端岗面试,尤其是JD里写了“熟悉AI应用开发”“有流式交互经验”“掌握TypeScript高级类型”这类字眼,那你不是来学理论的,你是来抢时间的。如果你已经用过Vite + Vue3 + TS写过Chat UI,但没亲手改过vue-tsc的--noEmit行为、没在EventSource断开时手动清理过AbortController、没给WebSocket连接写过带指数退避的重连队列——那这篇就是你的临阵磨枪。它不讲“AI如何改变前端”,只拆解“当你在TypeScript里接住第一个AI流式token时,代码里到底发生了什么”。
2. 面试官真正想撕开的三层皮:流式、类型、运行时
2.1 流式处理不是“加个onmessage就行”,而是重构数据生命周期
很多候选人一听到SSE或WebSocket,第一反应是翻文档抄一段new EventSource()或new WebSocket(),然后在onmessage里console.log(data)。这就像医生只记住“发烧吃退烧药”,却不知道体温调节中枢在下丘脑。面试官要撕开的第一层皮,就是看你是否理解流式数据彻底颠覆了前端数据消费模型。
传统HTTP请求的数据生命周期是线性的:发起请求 → 等待响应 → 解析JSON → 渲染DOM → 完结。而SSE/WS的数据生命周期是并发的、持续的、状态化的:连接建立 → 持续接收分片 → 实时解析并增量渲染 → 处理连接中断 → 自动重连 → 恢复上下文 → 继续接收。这里面藏着三个致命陷阱:
分片边界模糊性:SSE的
data:字段以\n\n分隔,但AI服务端返回的token可能是中文标点、emoji、甚至base64编码的图片片段,它们本身可能含换行符。如果前端用split('\n\n')粗暴切割,会直接把一个完整的token切成两半,导致后续JSON.parse失败或文本错乱。我见过最典型的案例,是某大厂面试者用event.data.split('\n').filter(Boolean)处理SSE,结果遇到"data: {\"text\":\"你好,\n世界!\"}\n\n"时,split('\n')把"你好,\n世界!"拆成["{\"text\":\"你好,", "世界!\"}"],再JSON.parse必然报错。连接状态不可信性:WebSocket的
readyState只有0-3四个值,但网络抖动时它可能卡在OPEN(1)却收不到任何消息,或者短暂变成CLOSING(2)又自动恢复。单纯监听onopen/onclose事件远远不够。真实业务中,我们要求每个连接必须配对心跳包(ping/pong)和业务级超时检测——比如后端每30秒发一个{"type":"heartbeat"},前端启动一个setTimeout,若60秒内未收到任何消息(包括heartbeat),则主动关闭并触发重连。这个逻辑不能依赖浏览器原生机制,必须手写。流式渲染的DOM性能墙:每秒接收20个token,意味着每秒要执行20次DOM插入。如果每次
el.innerHTML += token,浏览器会反复重排重绘,页面直接卡死。正确做法是攒批:用requestIdleCallback或setTimeout(..., 0)将多个token合并为一次DOM操作;更优解是用document.createDocumentFragment()批量创建节点,最后一次性appendChild。我在某AI写作工具项目里实测过,单token插入100次耗时380ms,而攒10个token批量插入10次仅耗时42ms——性能差9倍。
提示:面试时如果被问“SSE和WebSocket怎么选”,别背教科书答案。直接说:“SSE适合单向推送(如AI生成进度、日志流),优势是HTTP兼容、自动重连、服务端实现简单;WebSocket适合双向实时交互(如AI多轮对话、协同编辑),但需要自己管重连、心跳、subprotocol协商。我们上个项目选SSE,因为后端是Python FastAPI,用
StreamingResponse推token比维护WebSocket长连接更稳。”
2.2 TypeScript不是“加个interface就完事”,而是对抗流式不确定性的防御工事
第二层皮,是TypeScript在流式场景下的失效与重生。很多人以为interface AIResponse { text: string; }加上const res: AIResponse = JSON.parse(data)就完成了类型安全。错。流式数据让TypeScript的静态类型系统面临三重冲击:
数据完整性缺失:SSE的
event.data永远是字符串,且可能只是"data: {\"text\":\"Hello\"}"的一部分。JSON.parse可能抛出SyntaxError,而TypeScript编译期根本无法捕获这种运行时错误。更糟的是,AI服务端可能返回结构化错误:"data: {\"error\":\"rate_limit_exceeded\"}",此时text字段根本不存在。类型动态漂移:同一个SSE连接,前10个event可能是
{"text":"a"},第11个突然变成{"progress":50,"status":"thinking"},第12个又变回{"text":"b"}。用固定interface描述这种多态流,要么写一堆| undefined,要么用any自欺欺人。工具链版本撕裂:热词里提到的
"vue-tsc": "^1.8.27"和"typescript": "^5.3.3"就是典型。Vue-TSC 1.8.x基于TS 4.9构建,而TS 5.3引入了const type parameters和satisfies操作符。当你在.vue文件里写const config = { api: 'http://ai' } satisfies { api: string };,Vue-TSC会直接报错“Cannot find name 'satisfies'”,因为它的类型检查器不认识TS 5.3的新语法。这不是代码问题,是工具链版本错配的灾难。
破局的关键,是把TypeScript从“类型声明者”升级为“类型守门人”。具体怎么做?
首先,放弃JSON.parse,拥抱safeParse。我团队的标准做法是封装一个parseSSEData<T>(raw: string): Result<T, ParseError>函数,内部用try/catch包裹JSON.parse,并预设常见错误模式(如空字符串、非JSON格式、字段缺失)。返回值用Result类型(类似Rust的Result<T,E>)明确区分成功与失败路径,强制调用方处理错误分支。这样,类型安全就从“编译期假设”变成了“运行时契约”。
其次,用联合类型+类型守卫驯服多态流。针对AI返回的混合事件,我们定义:
type SSEEvent = | { type: 'token'; text: string } | { type: 'progress'; progress: number; status: 'thinking' | 'generating' } | { type: 'error'; code: string; message: string } | { type: 'done'; finalText: string }; function isTokenEvent(event: SSEEvent): event is Extract<SSEEvent, { type: 'token' }> { return event.type === 'token'; }这样在onmessage里就能安全地if (isTokenEvent(event)) { /* 处理token */ },TypeScript会自动缩小类型范围,避免event.text访问undefined。
最后,用declare global修补工具链裂缝。当Vue-TSC不认TS 5.3语法时,我们在shims-vue.d.ts里加:
// 兼容TS 5.3+的satisfies操作符 declare global { interface ObjectConstructor { // 此处不添加实际实现,仅让Vue-TSC通过语法检查 } }同时在vite.config.ts里配置esbuild的target: 'es2020',确保生成代码兼容性。这是实战中不得不做的妥协——类型安全不能输在工具链门口。
注意:面试官如果问“TS 5.3的
satisfies和as const区别”,千万别只答“satisfies不改变类型,as const会”。要举例子:const obj = { a: 1 } satisfies { a: number },此时obj.a类型仍是number;而const obj = { a: 1 } as const,obj.a类型变成1。在AI配置对象里,我们常用satisfies保证结构正确,同时保留字段的可变性(比如apiUrl可能后续被环境变量覆盖)。
2.3 运行时不是“写完就扔”,而是Electron打包、Chrome兼容、TS校验的三方绞杀
第三层皮,是代码从开发环境到生产环境的残酷穿越。标题里提到的electron 打包、chrome 109 websocket 不行、vue 类型工具与现有 typescript 7 不兼容,全是真实踩过的坑。这层皮撕开后,你会发现面试本质是考你能否在工具链的夹缝中保持代码健壮性。
先看Electron打包。很多AI前端应用要打包成桌面客户端(比如本地部署的AI代码助手),这时WebSocket的协议头会暴露问题。Electron 22+默认启用nodeIntegration: false,但某些AI SDK(如LangChain.js)内部用require('net')建TCP连接,打包后直接报Cannot find module 'net'。解决方案不是开nodeIntegration(有安全风险),而是用contextBridge暴露一个精简的ipcRenderer通道,让主进程代为建立WebSocket连接,再通过IPC转发消息。我在一个Electron+Vue3项目里,主进程用new WebSocket('ws://localhost:3000'),渲染进程用window.electronAPI.sendToAI({ type: 'message', data: 'hello' }),既安全又可控。
再看Chrome兼容性。热词里“chrome 109 websocket 不行”指向一个真实bug:Chrome 109-112在某些HTTPS环境下,WebSocket握手时Sec-WebSocket-Protocol头被截断,导致subprotocol协商失败。后端SpringBoot配置@EnableWebSocket时,若指定了setAllowedOrigins("*"),Chrome会拒绝连接。修复方案是后端显式设置setAllowedOrigins(Arrays.asList("https://yourdomain.com")),前端连接时传入正确的subprotocol数组:new WebSocket('wss://api.ai.com', ['json.v1'])。这个细节,90%的面试者根本没碰过。
最后是TS校验冲突。vue-tsc和typescript版本不匹配,会导致volar插件在VS Code里报红,但tsc --noEmit却能通过。根因是vue-tsc有自己的类型检查器副本,它不读取tsconfig.json里的compilerOptions.lib。解决方案是统一锁死版本:在package.json里写"devDependencies": { "typescript": "5.3.3", "vue-tsc": "1.8.27" },然后用pnpm update --interactive确保所有依赖树里TS版本一致。更狠的一招,是在CI脚本里加npx tsc --version && npx vue-tsc --version双校验,版本不一致直接fail。
3. 实操拆解:从零搭建一个抗压AI流式前端(含完整代码)
3.1 基础骨架:Vite + Vue3 + TS 5.3,绕过所有已知坑
我们不从Create Vue开始,因为官方模板默认用@vue/ts-plugin,它和TS 5.3.3有兼容问题。正确姿势是手动初始化:
npm create vite@latest ai-stream-demo -- --template vue-ts cd ai-stream-demo pnpm install # 关键:降级vue-tsc到1.8.27,锁定TS版本 pnpm add -D typescript@5.3.3 vue-tsc@1.8.27然后修改tsconfig.json,重点配置:
{ "compilerOptions": { "target": "ES2020", "lib": ["ES2020", "DOM", "DOM.Iterable", "ScriptHost"], "module": "ESNext", "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitAny": true, "esModuleInterop": true, "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ { "name": "@volar/vue-language-core" } ] }, "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], "exclude": ["node_modules"] }这里"lib": ["ES2020", "DOM", ...]是关键——TS 5.3默认用ES2022,但Chrome 109不支持Array.prototype.at()等新API,必须降级。"skipLibCheck": true暂时跳过第三方库类型检查,避免vue-tsc报一堆无关错误。
接着配置vite.config.ts,解决Electron打包预备:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], // 关键:为Electron预留接口 define: { __ELECTRON__: process.env.NODE_ENV === 'electron' ? 'true' : 'false' }, build: { target: 'es2020', // 与tsconfig保持一致 rollupOptions: { external: ['electron'] // 打包时排除electron模块 } } })现在运行pnpm dev,应该能正常启动。如果VS Code里.vue文件还报红,重启Volar插件或执行Developer: Restart TS Server。
3.2 核心流式连接:SSE重连+类型安全解析(含防抖、断点续传)
我们不写一个简单的EventSource,而是造一个工业级AIStreamClient类。它要解决三个问题:自动重连、类型安全解析、断点续传(resume from last ID)。
// src/lib/ai-stream-client.ts interface SSEEvent<T> { id: string; data: T; event: string; } interface StreamError { code: 'PARSE_ERROR' | 'NETWORK_ERROR' | 'TIMEOUT'; message: string; } class AIStreamClient<T> { private source: EventSource | null = null; private url: string; private onMessage: (data: T) => void; private onError: (error: StreamError) => void; private lastEventId: string = ''; private retryCount = 0; private maxRetry = 5; private retryDelay = 1000; constructor(url: string, onMessage: (data: T) => void, onError: (error: StreamError) => void) { this.url = url; this.onMessage = onMessage; this.onError = onError; } connect() { // 关键:带last-event-id的重连 const headers = new Headers(); if (this.lastEventId) { headers.set('Last-Event-ID', this.lastEventId); } // EventSource不支持headers,所以用URL参数模拟 const urlWithId = `${this.url}?last_event_id=${this.lastEventId}`; this.source = new EventSource(urlWithId, { withCredentials: true // 若需cookie认证 }); this.source.onopen = () => { console.log('[AIStream] Connected'); this.retryCount = 0; }; this.source.onmessage = (event) => { try { // 安全解析:先trim,再检查是否为空 const raw = event.data.trim(); if (!raw) return; // 尝试解析JSON const parsed = JSON.parse(raw) as T; this.lastEventId = event.id || this.lastEventId; this.onMessage(parsed); } catch (e) { this.onError({ code: 'PARSE_ERROR', message: `Failed to parse SSE data: ${e instanceof Error ? e.message : String(e)}` }); } }; this.source.onerror = (error) => { console.error('[AIStream] Error:', error); this.onError({ code: 'NETWORK_ERROR', message: 'SSE connection failed' }); // 指数退避重连 if (this.retryCount < this.maxRetry) { setTimeout(() => { this.retryCount++; this.disconnect(); this.connect(); }, Math.min(this.retryDelay * Math.pow(2, this.retryCount), 30000)); } }; } disconnect() { if (this.source) { this.source.close(); this.source = null; } } // 断点续传:外部可调用此方法恢复ID resumeFromId(id: string) { this.lastEventId = id; } } export function createAIStreamClient<T>( url: string, onMessage: (data: T) => void, onError: (error: StreamError) => void ): AIStreamClient<T> { return new AIStreamClient(url, onMessage, onError); }使用示例(在Vue组件setup里):
<script setup lang="ts"> import { ref, onMounted, onUnmounted } from 'vue' import { createAIStreamClient } from '@/lib/ai-stream-client' const messages = ref<string[]>([]) const isLoading = ref(false) const error = ref<string | null>(null) const client = createAIStreamClient<{ text: string }>( '/api/ai/stream', (data) => { messages.value.push(data.text) }, (err) => { error.value = err.message // 可在此触发告警或用户提示 } ) onMounted(() => { isLoading.value = true client.connect() }) onUnmounted(() => { client.disconnect() }) </script>这个实现比网上90%的教程强在哪?
- 断点续传:通过
Last-Event-ID参数,服务端可从指定ID继续推送,避免重连后重复内容; - 指数退避:重连间隔从1s→2s→4s→8s→16s,防止雪崩;
- 类型安全:泛型
<T>确保onMessage回调参数类型由调用方决定,createAIStreamClient返回精确类型; - 错误隔离:
PARSE_ERROR和NETWORK_ERROR分开处理,前端可针对性展示“解析失败,请联系管理员”或“网络不稳定,正在重连”。
3.3 WebSocket增强版:心跳保活+subprotocol协商+二进制帧支持
当业务需要双向交互(比如用户中断AI生成、发送文件),就得上WebSocket。但原生API太裸,我们封装AIWebSocketClient:
// src/lib/ai-websocket-client.ts interface WSMessage<T> { type: 'request' | 'response' | 'heartbeat'; payload: T; timestamp: number; } class AIWebSocketClient<T> { private socket: WebSocket | null = null; private url: string; private protocols: string[] = []; private onMessage: (data: T) => void; private onError: (error: string) => void; private onOpen: () => void; private onClose: () => void; private heartbeatInterval: NodeJS.Timeout | null = null; private pingTimeout: NodeJS.Timeout | null = null; private isReconnecting = false; constructor( url: string, protocols: string[], onMessage: (data: T) => void, onError: (error: string) => void, onOpen: () => void, onClose: () => void ) { this.url = url; this.protocols = protocols; this.onMessage = onMessage; this.onError = onError; this.onOpen = onOpen; this.onClose = onClose; } connect() { try { // 关键:传入subprotocol数组 this.socket = new WebSocket(this.url, this.protocols); this.socket.onopen = () => { this.isReconnecting = false; this.onOpen(); this.startHeartbeat(); }; this.socket.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { // 处理二进制帧(如AI返回的音频流) const uint8Array = new Uint8Array(event.data); this.handleBinary(uint8Array); } else { try { const parsed = JSON.parse(event.data) as WSMessage<T>; if (parsed.type === 'heartbeat') return; // 心跳不透传 this.onMessage(parsed.payload); } catch (e) { this.onError(`Failed to parse WS message: ${e}`); } } }; this.socket.onerror = (error) => { this.onError(`WebSocket error: ${error}`); }; this.socket.onclose = (event) => { this.onClose(); if (event.code !== 1000) { // 正常关闭不重连 this.reconnect(); } }; } catch (e) { this.onError(`Failed to create WebSocket: ${e}`); } } private startHeartbeat() { if (this.heartbeatInterval) clearInterval(this.heartbeatInterval); this.heartbeatInterval = setInterval(() => { if (this.socket?.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify({ type: 'heartbeat', timestamp: Date.now() })); // 启动超时检测 if (this.pingTimeout) clearTimeout(this.pingTimeout); this.pingTimeout = setTimeout(() => { if (this.socket?.readyState === WebSocket.OPEN) { console.warn('[AIWebSocket] No pong received, closing connection'); this.socket?.close(4000, 'Ping timeout'); } }, 30000); } }, 25000); } private reconnect() { if (this.isReconnecting) return; this.isReconnecting = true; setTimeout(() => { console.log('[AIWebSocket] Reconnecting...'); this.disconnect(); this.connect(); }, Math.min(1000 * Math.pow(2, Math.min(5, 1)), 30000)); } send(payload: T) { if (this.socket?.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify({ type: 'request', payload, timestamp: Date.now() })); } else { this.onError('WebSocket not ready'); } } disconnect() { if (this.heartbeatInterval) clearInterval(this.heartbeatInterval); if (this.pingTimeout) clearTimeout(this.pingTimeout); if (this.socket) { this.socket.close(); this.socket = null; } } private handleBinary(data: Uint8Array) { // 示例:处理AI返回的WAV音频流 const blob = new Blob([data], { type: 'audio/wav' }); const url = URL.createObjectURL(blob); // 触发播放或下载 } } export function createAIWebSocketClient<T>( url: string, protocols: string[], onMessage: (data: T) => void, onError: (error: string) => void, onOpen: () => void, onClose: () => void ): AIWebSocketClient<T> { return new AIWebSocketClient(url, protocols, onMessage, onError, onOpen, onClose); }使用要点:
- subprotocol协商:后端SpringBoot需配置
registry.addHandler(new AIWebSocketHandler(), "/ws").setAllowedOrigins("*").addInterceptors(new WebSocketHandshakeInterceptor());,并在拦截器里设置headers.set("Sec-WebSocket-Protocol", "json.v1");; - 二进制支持:AI返回语音/图像时,服务端用
session.binaryMessage(new ByteBuffer(...)),前端onmessage里判断instanceof ArrayBuffer; - 心跳保活:
startHeartbeat每25秒发ping,30秒没收到pong就关连接,避免假死。
3.4 Electron打包实战:解决Node API、跨域、打包体积三座大山
假设我们要把上述AI流式前端打包成Electron桌面应用。package.json关键配置:
{ "name": "ai-stream-app", "version": "1.0.0", "main": "electron/main.js", "build": { "appId": "com.yourcompany.ai-stream", "productName": "AI Stream Studio", "files": [ "!node_modules/**/*", "!src/**/*", "!electron/**/*", "!package-lock.json", "!yarn.lock", "!pnpm-lock.yaml" ], "win": { "target": "nsis" }, "mac": { "target": "dmg" } } }electron/main.js核心逻辑:
const { app, BrowserWindow, ipcMain } = require('electron') const path = require('path') function createWindow() { const win = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, // 关键:禁用nodeIntegration sandbox: true } }) // 开发时加载Vite dev server,生产时加载dist if (process.env.NODE_ENV === 'development') { win.loadURL('http://localhost:5173') } else { win.loadFile(path.join(__dirname, '../dist/index.html')) } } app.whenReady().then(createWindow) // 关键:IPC通道暴露AI能力 ipcMain.handle('ai:stream-sse', async (event, url) => { // 主进程用node-fetch或axios调用SSE,避免渲染进程跨域 const response = await fetch(url, { headers: { 'Origin': 'file://' } }) return response.body // 返回ReadableStream }) ipcMain.handle('ai:send-message', async (event, message) => { // 主进程建WebSocket,转发消息 const ws = new WebSocket('ws://localhost:3000') ws.send(JSON.stringify(message)) })electron/preload.js暴露安全API:
const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('electronAPI', { streamSSE: (url) => ipcRenderer.invoke('ai:stream-sse', url), sendMessage: (message) => ipcRenderer.invoke('ai:send-message', message) })这样,渲染进程只需调用window.electronAPI.streamSSE('/api/ai'),所有跨域、Node API调用都由主进程代理,既安全又灵活。打包体积控制上,用electron-builder的extraResources把AI模型权重文件单独放resources/目录,不在asar包里,避免启动慢。
4. 面试高频问题与真实排查记录(附速查表)
4.1 “stream disconnected before completion: idle timeout waiting for sse” —— 不是后端问题,是前端没喂饱
这个错误在Postman或curl里测试SSE时经常出现,但很多人误以为是后端配置问题。真相是:SSE连接要求服务端必须持续发送:keep-alive\n\n或数据帧,否则浏览器认为连接空闲超时断开。
排查步骤:
- 用curl验证后端:
curl -N http://localhost:3000/api/stream,观察是否持续输出; - 如果curl也断开,检查后端代码——FastAPI需用
StreamingResponse且yield不能停顿超过30秒; - 如果curl正常但浏览器断开,检查前端EventSource是否被GC:
source = new EventSource(...)后,若source变量被重新赋值或作用域结束,连接会被关闭; - 最隐蔽的坑:Vue组件
onUnmounted里source.close()写错了位置,导致组件销毁时连接已断,但错误日志还在。
解决方案:
- 后端每25秒发一次
data: \n\n(空数据帧); - 前端
EventSource实例必须挂载到组件外(如const source = ref<EventSource | null>(null)),避免GC; - 在
onerror里打印event.target.readyState,0表示未连接,0之后立即重连。
实操心得:我在某项目里发现,Chrome对SSE空闲超时是45秒,Firefox是60秒,Safari是30秒。所以后端keep-alive间隔必须≤25秒,留足缓冲。
4.2 “Postman WebSocket连接失败” —— 协议头、CORS、SSL证书三重门
Postman连不上WebSocket,90%是这三个原因:
- 协议头缺失:Postman WebSocket请求必须手动加
Sec-WebSocket-Protocol: json.v1,否则SpringBoot拒绝; - CORS未放开:SpringBoot的
@CrossOrigin(origins = "*")对WebSocket无效,必须在WebSocketConfiguration里配registry.addHandler(...).setAllowedOrigins("*"); - SSL证书问题:Postman默认不信任自签名证书,需在Settings → General → SSL certificate verification关掉。
速查表:
| 现象 | 可能原因 | 检查命令 | 修复方案 |
|---|---|---|---|
Postman显示Connecting...后无响应 | 后端未开启WebSocket | curl -i -N http://localhost:3000/ws | 检查SpringBoot@EnableWebSocket是否生效 |
| 连接后立即断开(code 1006) | Sec-WebSocket-Protocol不匹配 | Postman Headers里看是否含该头 | 后端WebSocketHandler里handshake方法打印headers.get("Sec-WebSocket-Protocol") |
Chrome控制台报WebSocket connection to 'wss://...' failed | SSL证书不被信任 | openssl s_client -connect yourdomain.com:443 | 用Let's Encrypt证书,或Chrome访问chrome://flags/#unsafely-treat-insecure-origin-as-secure |
4.3 “Vue类型工具与TypeScript 7不兼容” —— 版本锁死与CI双校验
这个错误本质是vue-tsc和typescript版本不一致。vue-tsc1.8.x基于TS 4.9,而TS 7是未来版本(目前最新是5.4),标题里“typescript 7”应为笔误,实指TS 5.x系列。
排查流程:
pnpm list typescript查看全量依赖树,确认是否有多个TS版本;npx vue-tsc --version和npx tsc --version输出是否一致;- VS Code里打开
Help → Toggle Developer Tools,看Console是否有TypeError: Cannot read property 'createProgram' of undefined——这是TS版本错配的典型报错。
修复方案:
pnpm remove typescript && pnpm add -D typescript@5.3.3强制统一;- 在
package.json的scripts里加"type-check": "tsc --noEmit && vue-tsc --noEmit"; - CI脚本里加:
# 检查TS版本一致性 TSC_VERSION=$(npx tsc --version | cut -d' ' -f2) VUE_TSC_VERSION=$(npx vue-tsc --version | cut -d' ' -f2) if [ "$TSC_VERSION" != "$VUE_TSC_VERSION" ]; then echo "TS version mismatch: tsc=$TSC_VERSION, vue-tsc=$VUE_TSC_VERSION" exit 1 fi注意:不要用
pnpm update typescript,它会升级到最新版,可能破坏vue-tsc兼容性。永远用pnpm add -D typescript@x.x.x精确锁定。
4.4 “Time流的方式来开发代码” —— 不是玄学,是RxJS+Web Worker的组合拳
热词里“时间流的方式来开发代码”,实指用响应式编程处理流式数据。但直接上RxJS有学习成本,我们用更轻量的方案:
- 核心思想:把SSE/WS事件流当作Observable,用
fromEvent创建,再用debounceTime、switchMap等操作符处理; - 为什么需要:用户快速输入时,多次请求要取消前序,只保留最后一次结果;
- 实操代码:
import { fromEvent, switchMap, debounceTime, map } from 'rxjs' // 监听输入框,500ms防抖后触发AI请求 fromEvent(inputEl,