字节前端一面:WebSocket、Electron与流式渲染
- 轮次
- 一面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- WebSocket 的连接是如何建立和管理的?
- WebSocket 通信过程中,如果发生网络抖动和丢包,WebSocket 和 TCP 分别会发生什么变化?
- TCP Server 如何知道发生了丢包?
- ping 一个目标主机的完整过程是什么?使用了什么协议?
- Electron 的多窗口架构是什么样的?
- Electron 的 Main Process 主要负责什么逻辑?Node 相关逻辑负责什么?大计算量任务应该放在哪里?如何避免 Main Process 卡顿?
- 你刚刚提到了 Worker,可以展开讲讲吗?
- 如果 Electron 的 Main Process 发生卡顿,对 Renderer Process 有什么影响?
- AI 对话通过 SSE 进行 Token 流式输出,如果每收到一个 Token 都执行一次
setState,会有什么影响? - 在 React 中怎么解决高频 Token 更新导致的性能问题?
- 了解 requestAnimationFrame 吗?能不能使用它解决 Token 高频刷新的问题?
- AI Coding:实现 AI 流式问答的前端会话状态管理。
- 你平常开发过程中,AI 生成代码的比例大概是多少?剩余部分主要由你完成什么?为什么这些工作不继续交给 AI?
- 你刚刚提到会修改 AI 生成代码的风格,如何约束 AI,使其生成的代码符合项目的代码风格和开发规范?
- 当 Context 越来越长,AI 的注意力开始分散甚至忽略之前定义的规范时,应该怎么办?
- 你刚刚提到了 Memory 和 AGENTS.md,把规范放在这些地方就能确保 AI 不会遗忘吗?还有其他解决方法吗?
- 你提到了 Spec,Spec 能解决这个问题吗?Spec 本身又存在哪些问题?
- 有了解过 Multi-Agent 吗?Sub-Agent 能解决这个问题吗?
- 在 Rules、Memory、AGENTS.md、Spec、Sub-Agent 之外,还有没有更进一步的方法?
《参考解析》
WebSocket 的建立与管理:握手阶段复用 HTTP/1.1 的 Upgrade 机制:客户端发 GET 请求带 Connection: Upgrade、Upgrade: websocket、Sec-WebSocket-Key(16 字节随机数 base64)和 Sec-WebSocket-Version: 13;服务端返回 101 Switching Protocols,带 Sec-WebSocket-Accept(对 key 拼上固定 GUID 做 SHA-1 再 base64),之后这条 TCP 连接升级为全双工帧协议,不再有 HTTP 请求头开销。连接管理要做四件事:心跳保活(客户端定时发 ping,服务端回 pong,或用协议层的 ping/pong 帧,间隔建议 20~30 秒,因为中间 LB 的空闲超时通常 60 秒);断线重连(指数退避 + 随机抖动,避免服务端重启时全体客户端同时重连造成惊群);连接鉴权与续期(握手时带 token,token 过期要有重新鉴权或强制重连机制);优雅关闭(用 close(1000) 并携带原因码,服务端做 draining)。另外要注意:HTTP/1.1 下同域 WebSocket 连接数受浏览器限制、Sec-WebSocket-Key 与 Origin 校验用于防跨站劫持,以及部署时 LB 必须支持 Upgrade 并放大 proxy_read_timeout。
丢包时 WebSocket 与 TCP 各自发生什么:WebSocket 建立在 TCP 之上,所以丢包的第一反应发生在 TCP 层:发送方在超时或收到三个重复 ACK 后重传丢失的段,接收方靠序号发现空洞,把后续到达的乱序段先缓存,等缺口补齐再按序交付给应用层。这意味着丢包对应用表现为「延迟」而不是「数据缺失」——WebSocket 是有序可靠流,收到的一定不丢,但可能突然卡顿一下,等重传成功后一次性把积压的数据全部推上来(head-of-line blocking,队头阻塞)。真正会导致「丢失」的只有上层语义:连接被中间设备掐断、重连期间的消息没有补发(应用层没有 ack/序号/离线消息机制),或者业务用了不可靠通道(WebRTC DataChannel 的 unreliable 模式、UDP)。所以业务上要做的是给消息加序号和 ack,重连后按序号拉取缺失区间,而不是指望 TCP。
TCP Server 怎么知道丢包:三条机制。一是超时重传(RTO):发送方为每个已发送未确认的段起定时器,超时未收到 ACK 就认为丢了并重传,RTO 由 RTT 平滑估计动态算出(RTO = SRTT + 4·RTTVAR)。二是快速重传:接收方收到乱序段时立即重复发送「期望的下一个序号」的 ACK(dup ACK),发送方连续收到 3 个重复 ACK 就断定该段丢失并立刻重传,不用等超时。三是选择性确认(SACK):接收方在 TCP 选项里告诉发送方「我已经收到了哪些不连续的区间」,发送方据此只重传真正缺失的段而不是整段窗口。此外接收方靠序号发现空洞,发送方靠 ACK 号推进窗口;拥塞控制(Reno/CUBIC/BBR)会在丢包时收缩拥塞窗口,这才是丢包导致吞吐下降的主因。
ping 一个目标主机的完整过程:ping example.com 时,先做域名解析(查 hosts、本地 DNS 缓存、递归查询 DNS 服务器,返回 A/AAAA 记录),然后查路由表决定下一跳与出口网卡:同网段则用 ARP 请求目标 IP 的 MAC;跨网段则 ARP 请求网关 MAC(ARP 走二层广播,无 ARP 缓存时才发)。拿到 MAC 后封装成以太网帧发出,链路层类型为 0x0800(IPv4)。IP 层设置 TTL(通常 64)、协议号 1(ICMP),把 ICMP Echo Request(type 8)交给网络层发送;沿途每台路由器把 TTL 减 1,减到 0 就回 ICMP Time Exceeded(这正是 traceroute 的原理)。目标主机收到后回 ICMP Echo Reply(type 0),源端据此计算 RTT。所以 ping 用的协议是 ICMP(严格说是 ICMP 承载在 IP 之上,不是 TCP/UDP),也因此很多防火墙与云安全组默认禁 ping,出现「ping 不通但端口能连」很正常。排查顺序一般是:能解析吗(DNS)→ 同网段还是跨网段(ARP/网关)→ 通不通(ICMP)→ 端口通不通(TCP 握手/telnet/nc)。
Electron 的多进程架构与主进程卡顿:Electron 是「一个主进程 + 多个渲染进程 + 若干工具进程」的结构。Main Process 是 Node 环境,掌管应用生命周期、创建与管理窗口(BrowserWindow)、菜单/托盘/快捷键、原生对话框、自动更新、进程间通信的中枢,以及所有需要访问操作系统能力的逻辑;每个 Renderer Process 是一个 Chromium 页面,负责 UI 渲染,默认开启 contextIsolation 并用 preload + contextBridge 暴露受限 API。大计算量任务绝不能放在主进程或渲染进程的主线程:放到 Node 的 worker_threads、Chromium 的 Web Worker、或者 Electron 的 utilityProcess(官方推荐用来跑不受信任的 Node 代码,可设置独立沙箱),把 CPU 密集或可能崩溃的活隔离出去,用消息传递结果。主进程卡顿的后果很严重:它驱动所有 IPC 与窗口事件,卡住会导致窗口无法响应、菜单点不动、渲染进程的同步 IPC(ipcRenderer.sendSync)直接冻结界面,甚至被系统判定为无响应。所以最佳实践是主进程只做编排,重活交出去;同时避免在主进程里做同步 IO(用 fs.promises)、避免长循环,把耗时逻辑拆成异步分批执行,并控制 IPC 消息体积(大文件走流或共享内存,不要一次性序列化几 MB 对象)。
Electron 里 Worker 的取舍:worker_threads 是 Node 侧的线程,共享同一个进程内存空间(可用 SharedArrayBuffer 传大数据),开销比新开进程小,但一个线程崩溃可能拖垮整个进程;utilityProcess 是独立子进程,隔离性好、可以限制能力,适合跑插件、跑不可信代码、跑可能 OOM 的任务;Web Worker 在渲染进程内,适合把前端的数据处理(解析大 JSON、图片处理、加解密)从 UI 主线程挪走;如果任务是 GPU 或重计算(视频转码、模型推理),优先考虑 WASM/原生模块或直接起后台服务进程。选择标准就是:隔离级别、数据传递成本、崩溃影响面这三条。
AI 主进程卡顿对渲染进程的影响:直接表现是渲染进程发起的 IPC 调用得不到响应;如果用了 sendSync 或 remote 这类同步调用,渲染进程的主线程会被阻塞,页面直接掉帧甚至假死(因为等待结果期间事件循环停转)。间接影响包括窗口尺寸变化、菜单状态、拖拽文件等由主进程驱动的交互全部延迟;渲染进程如果依赖主进程提供的初始化配置(比如窗口位置、主题),启动会变慢。此外主进程崩溃会带走所有窗口,而单个渲染进程崩溃只影响那一个页面(可用 render-process-gone 事件兜住)。所以排查渲染卡顿时要同时看主进程的 CPU 占用,不能只盯前端。
SSE 每 token 一次 setState 的问题与优化:问题有三层。性能层:React 每次 setState 都会触发一次调度与重新渲染,token 级别(每秒几十到上百次)会产生大量渲染与 diff,长会话下消息列表全量重渲染,主线程被占满,输入框卡顿、滚动掉帧。语义层:React 18 的自动批处理只在事件处理器与 await 边界内生效,SSE 回调属于原生事件/宏任务,默认不批处理(unstable_batchedUpdates 或 React 18 的 createRoot 下大多数场景已自动批处理,但流式回调仍可能逐次触发)。状态层:把整段文本放在一个字符串 state 里,每次追加都复制整串,长回答下内存与 GC 压力都大;如果还放在全局 store(Redux/Zustand)里,会连带触发所有订阅组件重渲染。正确的做法是「缓冲 + 按帧提交」:把 token 先写入 useRef 里的缓冲区(或用 startTransition 降低优先级),用 requestAnimationFrame 节流,每帧只 setState 一次把缓冲区内容合并进消息;渲染上把已完成的消息与正在流式的消息拆成两个组件,已完成消息用 memo 冻结,流式的那条单独订阅自己的内容;长会话列表用虚拟滚动。useSyncExternalStore 或外部 store 也可以避免把高频数据塞进 React 状态。
requestAnimationFrame 能不能用来解决:能,而且是这个场景的推荐做法,但要理解它的语义边界。rAF 的回调在浏览器下一次重绘前执行,频率通常 60Hz(约 16.7ms 一次)并自动与显示器刷新率对齐,页面处于后台标签页时会暂停或降到极低频率——这正好满足「按帧合并渲染」的需求,也不会在用户看不见时浪费 CPU。典型写法是收到 token 就写 ref 缓冲,并判断「是否已安排帧回调」,没有则 requestAnimationFrame(flush);flush 里一次性把累积内容写入 state 并清空缓冲。注意三件事:一是 rAF 只节流渲染,不节流网络解析,所以在时间维度上要保证最终一致性(流结束后必须再 flush 一次,防止最后几个 token 卡在缓冲里);二是它不解决「每次 setState 都重新渲染整个列表」的问题,组件拆分与 memo 仍要做;三是后台标签页 rAF 暂停,如果需要在后台继续累积,得用 setTimeout 兜底或改用 requestIdleCallback/Worker 处理。
如何约束 AI 生成的代码符合项目规范:手段按「离得越近越有效」排序。第一层是静态工具,把规范写成可执行的检查——ESLint/Prettier/Biome 配置、TypeScript 严格模式、提交钩子(lint-staged/husky)与 CI 门禁,让不符合规范的代码根本进不来,这一步比任何提示词都可靠。第二层是上下文注入,AGENTS.md/CLAUDE.md/rules 文件写清目录约定、命名、状态管理选型、禁止事项(附带一个正例一个反例),放在仓库根目录让 AI 自动读到。第三层是提示词与示例,把「照着这个已有文件写」作为默认要求,给参照实现比给抽象规则有效得多。第四层是任务形式,要求 AI 先给计划再写码、改完自己跑 lint 与测试、输出改动说明,把审查成本前移。第五层是人的把关,关键路径(权限、支付、并发、销毁逻辑)不交给 AI 定稿。
Context 变长后规范被忽略怎么办:这不是「记住」问题而是注意力预算问题:上下文越长,开头注入的规则被稀释得越厉害,而且很多模型对中间位置的内容注意力最弱。可行的组合拳是:把长会话切成短任务(一个任务一个新会话,靠文件而不是对话历史保存状态);把规范从「一次性大段注入」改成「按当前文件路径动态注入相关规则」;把关键约束写成可机器校验的东西而不是靠模型自觉(lint 规则、类型、测试、CI 检查);在每轮任务开始时用一段「当前任务约束清单」做复述(把规则贴近任务而不是埋在开头);把长文档拆成索引 + 按需检索,而不是全量塞进 context;必要时在关键步骤前让模型先输出一份 checklist 再动手。Sub-Agent 的价值也在这里:把长流程拆成多个短上下文,每个子 Agent 只带自己需要的那几页规范,主 Agent 只保留结论。
Spec(规范驱动开发)能解决什么、又有什么问题:Spec 把「需求 → 设计 → 任务」固化成先写文档、评审通过、再照着实现的流程,好处是让人和 AI 有共同的契约:范围清楚、验收标准明确、AI 生成的代码更容易对齐预期,也能减少来回返工。它自身的问题也很明显:写 Spec 本身有成本,需求一变 Spec 就要同步维护,否则会变成过期的摆设;Spec 写得越细越容易与实现脱节,越粗又不能约束模型;它管得住「做什么」,管不住「做得好不好」(性能、边界、安全仍要靠测试和评审);对探索型任务是负担,对小改动是过度流程。合理的用法是按任务风险分级:高风险/跨模块的活写 Spec,小改动直接靠测试与 lint 兜底。
Multi-Agent / Sub-Agent 与「更进一步的方法」:Sub-Agent 解决的是「上下文隔离 + 并行 + 专业化」:主 Agent 只保留计划与结论,把检索、写码、审查分别交给上下文独立的子 Agent,每个子 Agent 只需带一小段最相关的规范,注意力自然集中。代价是通信损耗(任务描述写不清就会跑偏)、成本上升、以及结论不可控,所以任务要能被独立验证才有意义(审查子 Agent 必须拿到可执行的验证手段,如跑测试)。在 Rules/Memory/Spec/Sub-Agent 之上,更根本的一层其实是「把正确性外化成机器可判定的东西」:测试与类型系统、可执行的验收脚本、CI 门禁、可观测性(让 AI 能自己看到运行结果并据此迭代),再加上权限与沙箱边界(限制它能改什么、能跑什么)。当判断标准可以被机器执行时,模型的注意力就不再是瓶颈——这比任何提示词技巧都稳。