跳到主要内容

项目介绍稿(面试口述版)

自我介绍(1 分钟版)

面试官你好,我叫李文杰,9 年前端开发经验。

我的技术栈以 React 相关生态 + TypeScript 为主,Next.js、Node.js 也用得比较多,最近两年做了不少全栈的工作。

职业经历大概分三个阶段:

最早在凡科做了4年,从前端开发做到带领最多 7 个人的前端组长,先后负责一个H5搭建平台和一个在线图片设计平台,就是类似 Canva 那种产品,核心是 Canvas 和 SVG 的可视化编辑器开发。这段经历让我在工具类产品、可视化编辑器、图形计算这块积累比较深。

第二段在虎牙做了3年前端,主要负责虎牙视频和虎牙直播相关的开发工作。这段经历让我在性能优化和音视频领域有了比较系统的理解。后期的话通过内部转岗去了一个新的部门,加入了一个Web3 创新产品的研发。

然后作为初创员工和当时那个团队一起出来加入了一家创业公司,担任 Web 开发负责人,最多的时候带领一个 3 人的前端小组。先后主导了两个海外产品的 Web 端架构设计和核心开发——一个就是之前在虎牙就在做的那个 Web3直播社交平台,核心是区块链集成和实时互动功能。另一个是 AI 社交娱乐平台, 核心是 AI 驱动的社交互动和内容生成。这段经历让我从纯前端成长为能独立负责全栈交付的工程师。

后期的话,这家公司也是全面投入到AI中。所以, AI 应用开发这块,LLM API 集成、流式渲染、Prompt 工程这些在实际项目中都有落地。平时也在用 Claude Code 这类 AI 工具辅助开发,对研发效率提升帮助很大。

以上就是我的简单介绍,接下来我可以详细聊聊项目经历。


按时间倒序介绍。每个项目先用 1-2 句话概括,再展开关键技术点。「追问预备」是面试官可能追问的方向,平时记住要点,面试时自然展开即可。


一、Swee — AI 社交娱乐平台(2024 - 至今)

开场概括(30 秒版)

Swee 是一个 AI 驱动的社交娱乐平台。我在里面负责的比较多,主要做了四块:移动端 PWA、一个 Prompt 模板管理系统、NestJS 后端服务、还有事件埋点系统。前后端都是我做的。 系统采用单仓模式,用vercel提供的Turborepo做前后端代码管理,所有项目都在同一个仓库里,公共组件和工具函数放在 packages 目录下,前端项目和后端项目分别在 apps 目录下。 系统部署方案是:GitHub Actions CI/CD , PM2,Docker、Kubernetes,静态资源部署到阿里云oss。 鉴权方案是通过调用同一个k8s集群的java服务的内部dns服务发现地址(K8s Service 名称 + 命名空间 + 集群内部域名 + 服务端口)。


1. AI 移动端 PWA (多类型内容混排,性能优化,缓存)

一句话:Next.js做的 PWA 应用,用户与 AI 角色进行个性化互动,覆盖聊天,剧情任务(根据互动情况生成多条选项,用户选择对应选项来推动故事情节,也可以自定义回复),互动挑战(比如 10 回合判断角色好人坏人),AIGC 内容生成(生成图集,生成视频,可以选择自己创建的或者系统预设的角色出场)等场景。核心是 AI 对话界面,需要处理流式渲染、多类型内容混排和复杂的聊天状态管理。

技术栈:Turborepo + Next.js + Shadcn/ui + Zustand + Tailwind CSS + next-intl

展开讲

这个应用的核心页面是 AI 对话,跟普通聊天不一样,AI 回复是流式返回的,而且消息类型很多——文本、Markdown、代码块、图片、音频、卡片(好感度变化 +5、任务触发卡片、对话次数超过次数引导充值)这些都要混排展示。示例如下:

"今天的约会真开心呢~下次我们去水族馆吧?"
[structured:affinity]{"type":"affinity","value":5,"current":75}[/structured]
[structured:choices]{"type":"choices","items":[{"id":"1","label":"好呀!"},{"id":"2","label":"我再想想"}]}[/structured]

核心思路是把流式文本拆成"纯 Markdown 段"和"结构化标记段"两类,分别渲染。

具体做法

后端通过 SSE 推流式返回 AI 响应,前端用 ReadableStream 逐 chunk 读取。后端在 LLM 输出中约定了一种标记格式:[structured:类型名]JSON数据[/structured]。前端写了一个增量解析器,每次收到新 chunk 拼接到累积文本后,先按换行符split成段,然后用正则扫描整段文本:

  1. 纯文本段(标记之间或之外的内容)→ 交给 react-markdown 实时渲染,支持 GFM 表格、代码高亮
  • 不完整的 Markdown 语法(比如 **加粗 只来了一半、代码块只有开头的 ``` 没有结尾)会导致渲染错乱,我在传给 react-markdown 之前做了预处理,检测未闭合的标记并自动补全闭合(如追加 **```)。
  1. 已闭合的结构化标记(开标签和闭标签都有)→ 提取 JSON,JSON.parse 后分发给对应类型的 React 组件渲染

  2. 未闭合的标记(只有开标签,闭标签还没到)→ 跳过,显示加载占位符,等后续 chunk 补全。 详情见ai-对话的流式渲染管线是怎么实现的文本和结构化内容怎么混排

性能优化

  • requestAnimationFrame 批量更新:流式渲染时每个 chunk 都会触发一次 React re-render,LLM 流式输出的频率约 30-100 token/秒,可能比浏览器正常的渲染频率要高。消息列表组件会重新计算布局,性能开销比较大。我用 requestAnimationFrame 做了一个批量更新机制,流式过程中收到 chunk 先更新一个 ref 里的最新消息内容,然后在下一帧统一调用 setState 更新状态,这样无论流式数据多快都保证每帧最多更新一次 DOM,大大降低了 CPU 占用和卡顿感。
  • 分块渲染: 拆分为多个独立组件,只有最后一个 chunk 在流式更新,前面的 chunk 内容已稳定,用 React.memo 跳过重渲染。
  • 长消息列表用虚拟滚动(只渲染可视区域 ± buffer 的消息),避免几百条消息时 DOM 节点爆炸,核心思路是先估算,渲染后测量,缓存真实高度,动态调整占位元素高度实现无缝滚动。使用现成的库react-virtuoso。
  • 媒体资源懒加载 + 骨架屏占位,滚动到可视区域时才加载
  • IndexedDB 存储历史消息,实现离线查看和快速加载
  • Service Worker 缓存策略:通过 next-pwa(底层基于 Workbox)对不同资源配置差异化缓存策略——Next.js 构建产物(_next/static/ 下带 hash 的 JS/CSS)走 Cache First(命中缓存直接返回,避免重复请求),API 走 Network First(优先网络保证数据新鲜,断网降级到缓存),图片走 Stale While Revalidate(先返回缓存保证速度,后台静默更新)。next-pwa 在构建时根据产物自动生成预缓存清单和 SW 文件,发版后 SW 检测到资源 hash 变化自动拉取新版本

另外,流式过程中列表要自动滚动到底部,但用户手动上滑查看历史时不能打断,我用一个 isUserScrolling 标记来区分,基于 IntersectionObserver 检测底部哨兵元素的可见性

亮点

  • 流式渲染管线设计——把"纯文本段"和"结构化标记段"拆分渲染,既支持 Markdown 实时渲染,又能嵌入任意类型的 React 组件(好感度卡片、选择按钮等),扩展性很强,新增消息类型只需注册对应组件
  • requestAnimationFrame 批量更新 + 分块渲染 + React.memo,把流式渲染的 CPU 占用从 30%+ 压到 5% 以下,肉眼可见的流畅度提升
  • PWA 离线体验做得比较完整——Service Worker 差异化缓存 + IndexedDB 历史消息,弱网/断网下用户还能查看历史对话

需要改进的点

  • 消息类型的注册是硬编码的 switch-case,随着类型增多维护成本上升,应该做成配置化的组件注册表(Map + 动态 import)
  • 虚拟滚动用的 react-virtuoso,虽然开箱即用,但对不定高度消息的滚动定位偶尔有抖动,后来发现是因为消息里有图片异步加载导致高度突变,应该在图片加载前就预留占位高度
  • IndexedDB 的数据和服务端同步策略比较粗暴(每次进入对话页全量拉取最新 50 条覆盖),没有做增量同步,离线时间长了数据会有缺口

遇到的坑

  • 流式渲染导致 iOS Safari 内存暴涨闪退。上线初期收到大量 iOS 用户反馈聊天页闪退,排查发现是流式渲染过程中,每次 setState 更新消息内容,react-markdown 会完全销毁旧的 DOM 树再创建新的,一条长消息流式输出几百个 token,就会创建/销毁几百次 DOM 子树。iOS Safari 的 GC 回收不及时,内存在十几秒内从 80MB 飙到 500MB+ 然后被系统杀掉。解决方案是前面说的"分块渲染"——把已稳定的段落和正在流式输出的段落拆成独立组件,已稳定的用 memo 跳过重渲染,只有最后一个正在输出的段落会频繁更新。改完后内存峰值降到 150MB 以内,闪退问题彻底解决。
  • LLM 自发输出方括号文本被增量解析器误判为结构化标记。线上出现 AI 回复里突然插入一个空白的加载占位符,用户看到一段话中间莫名"断了"。排查发现是 LLM 回复中包含了 [structured: 这几个字符(模型在某次回复中引用了一段代码示例),被增量解析器当成了未闭合的结构化标记,显示为 pending 占位符。解决方案是收紧正则匹配规则——要求开标签和 JSON 数据之间不能有换行符,且 JSON 必须以 { 开头;同时在后端的 System Prompt 里追加了一条约束 "Never output text matching the pattern [structured:...]",从源头降低误触发概率。
  • Next.js Image 组件的优化路径与 PWA 离线缓存不兼容,离线时图片全部 404。PWA 离线模式下用户打开历史对话,所有角色头像和消息图片都显示不出来。排查发现 next/image 默认走 /_next/image?url=xxx&w=256&q=75 这个服务端优化路径,而 SW 缓存的是原始图片 URL(如 https://cdn.xxx.com/avatar.jpg),两个 URL 对不上,缓存命中不了。解决方案是针对需要离线可用的图片(头像、表情包等静态资源)用原生 <img> 标签 + loading="lazy" 替换 next/image,让 URL 和 SW 缓存的 key 保持一致;同时把 /_next/image 路径也加入 SW 的 runtimeCaching,用 StaleWhileRevalidate 策略缓存经过优化的图片。
  • Mermaid 图表在流式渲染过程中疯狂报错闪烁。AI 回复里带 Mermaid 图表时,页面会出现大片红色的 Parse error 报错块反复闪烁,严重时整个聊天区卡顿。原因是 Mermaid 的 mermaid.render() 是异步的,而流式输出每追加一个 token 都会触发 react-markdown 重新解析整棵 AST,残缺的图描述(如 graph TD\n A-->)喂给 Mermaid 会直接抛 ParseError;加上 Mermaid 内部会修改 DOM,和 React 虚拟 DOM 冲突,多个 render 调用之间还有竞态,渲染结果相互覆盖。解决方案分三步:一是延迟渲染,流式过程中 Mermaid 代码块只显示原文占位(<pre> 包裹),等代码块的 ``` 闭合标记出现后才调用 mermaid.render;二是错误兜底try/catch 包裹渲染调用,失败时降级为原文显示,避免整块红色报错块;三是稳定 key + 取消标记,给每个 Mermaid 块一个基于代码 hash 的稳定 key(避免组件频繁卸载重建),useEffect 内部用 cancelled 闭包变量忽略过时的 Promise 结果(解决竞态)。改完后流式过程顺滑不闪烁,图表在最后一次性出现。
  • LaTeX 公式在流式输出中报 ParseError 导致整条消息渲染中断。用户问数学题时,AI 回复到公式中间(如 $\frac{a 只来了一半)会导致整条消息显示成一片红色错误提示,等公式输出完整后才恢复正常,体验非常割裂。排查发现 rehype-katex 默认 throwOnError: true,残缺公式直接抛错中断渲染。解决方案是双管齐下:插件配置层把 throwOnError 改成 falsestrict: 'ignore',让 KaTeX 宽容残缺语法;同时在 Markdown 文本进入 react-markdown 之前做预处理——统计 $$$ 的数量,如果是奇数说明有未闭合分隔符,临时追加一个 $$$ 闭合(和前面提到的 **``` 自动闭合是同一套机制)。改完后公式在流式过程中以"渐进显现"的方式出现,不再有红色报错闪烁。

2. Prompt 模板管理系统 (原因,表结构,模板编辑器,语法高亮,变量引擎,playground 调试,执行引擎,版本管理,SDK 集成)

一句话:接下来是Prompt 模板管理系统,作用是把 公司内部平时会反复使用的,不管是员工使用,还是业务上(比如agent)使用的 AI Prompt 集中管理起来,用户可以在线编辑、复制、调试、发布 Prompt,全程不需要开发介入。

技术栈:Ant Design + Tailwind CSS + CodeMirror 6(前端)/ NestJS + PostgreSQL + Redis(后端)+ 阿里云 OSS(存储)

展开讲

做这个系统的原因是——我们平台有 100 多个 Prompt,分散在 Java 后端和 NestJS 后端代码里,运营想改个措辞都要找开发改代码、部署,至少半天。这明显不合理,Prompt 应该和代码解耦。

核心功能

3张核心表: prompt_templates 存模板内容和元信息,template_versions 版本快照表,每次保存/回滚创建一条,和prompt_templates是多对一的关系,execution_logs 执行日志表,Playground 调试和 SDK 正式调用都记录。执行日志超过 90 天的会归档到 OSS,PostgreSQL 中只保留近 90 天的数据。

比较核心的是模板编辑器是——采用三栏布局,左边是变量面板(定义变量名、类型、是否必填、最大长度)和模型配置(模型版本、温度、Top-p 等)、中间 CodeMirror 6 代码编辑器写 Prompt、右边实时预览渲染结果。

语法高亮是编辑器的核心交互之一,编辑器支持变量 {{xxx}} 语法高亮(黄色)和条件块 {{#if}} 高亮(蓝色),输入 {{ 会自动弹出已定义的变量列表补全。我用 ViewPlugin + Decoration 的方式做了轻量的覆盖高亮——先让 CM6 按纯文本解析,再用 RangeSet 给 {{...}} 区间添加颜色 Decoration。然后使用 @codemirror/autocomplete 的 autocompletion 扩展,注册自定义补全源,检测光标前是否刚输入了 {{,如果是则弹出已定义的变量列表补全。

变量引擎是核心——我设计了一个三步渲染管线:先处理条件块 {{#if}}(决定哪些文本段保留),再处理循环块 {{#each}}(展开数组),最后做变量插值 {{variable}}。顺序不能反,因为先插值会破坏条件块的语法结构。处理的步骤就是通过对应语法的正则,在循环内不断匹配,然后根据变量值决定是否保留或展开。举个例子,数字人人格生成的模板里有 {{#if favoriteCharacter}},如果用户没填这个字段,整段"最喜欢的角色"就不会出现在最终 Prompt 里。

Playground 调试是运营用得最多的功能——改完 Prompt 填入测试变量,直接调 LLM 看效果。关键设计是渲染追踪,面板会展示三步管线的每步中间结果,还会高亮未匹配变量和未使用变量。用户自己就能定位 80% 的问题。

执行引擎 执行引擎连接模板和 LLM,核心是在调 LLM 之前把能拦截的问题全部拦截。执行管线:校验 → 限流 → 渲染 → Token 预检 → 调用 LLM

版本管理每次保存都是完整快照(模板通常 < 5KB,存储成本忽略不计),回滚是创建一个"内容等于旧版本的新版本",保留完整的版本线。并发编辑用乐观锁(WHERE version = currentVersion)。

SDK 集成提供 render-only 模式给 Java 后端和 NestJS 后端,只渲染模板返回 messages,LLM 调用由业务方自己完成。多级缓存:SDK 内存 60s → Redis 5min → PostgreSQL。迁移是分 4 批灰度的,每批都有双写兼容逻辑,SDK 调不通就回退到硬编码,全程零事故。

亮点

  • 从 0 到 1 独立完成前后端全栈开发,真正做到了 Prompt 与代码解耦,运营迭代周期从 2-3 天缩短到分钟级
  • Playground 调试 + 渲染追踪的设计,运营自己就能定位 80% 的变量问题,极大减少了找开发排查的沟通成本
  • 灰度迁移策略设计合理——分 4 批 + 双写 + 降级回退,60+ 个 Prompt 全程零事故切换

需要改进的点

  • 没有做模板继承/组合机制,数字人相关的 10 多个模板共享大段 System Prompt,修改一处要同步十几个模板,维护成本高
  • 缺少 LLM 输出质量的自动化评估,目前只能人工判断 Prompt 改完效果好不好,应该集成 LLM-as-Judge 自动评分
  • 缺少 A/B 测试功能,改完 Prompt 后无法对比新旧版本的实际效果差异,应该支持同一用户随机分配使用不同版本的 Prompt 来做对比测试

遇到的坑

  • 多级缓存未主动失效导致 Prompt 发布后线上长时间不生效。系统设计了三级缓存:SDK 内存缓存 60s → Redis 5min → PostgreSQL。运营在后台发布了一个紧急修改的 Prompt(修正了一个导致 AI 回复出现幻觉的措辞问题),Playground 里验证效果正确,点了发布,但线上用户反馈 AI 还是在说错误的内容。原因是发布操作只更新了 PostgreSQL,没有主动清除 Redis 和 SDK 内存缓存——Redis 要等 5 分钟 TTL 过期,SDK 内存要再等 60 秒,最坏情况下发布后 6 分钟线上才能拿到新版本。对于普通迭代这个延迟可以接受,但紧急修复场景完全不能忍。修复方案是发布时主动清除 Redis 对应 key(DEL prompt:template:{id}),同时 SDK 提供了一个 invalidateCache(templateId) 方法供业务方手动刷新内存缓存;此外在管理后台的发布按钮旁加了"预计生效时间"的提示,常规发布显示"最长 60 秒"(只剩 SDK 内存缓存),紧急发布可勾选"立即生效"同时清 Redis + 通过 WebSocket 通知在线的 SDK 实例刷新内存缓存。
  • 乐观锁冲突导致运营丢失编辑内容version 是进入编辑页时 GET 接口返回的,前端保存在组件状态里,保存时随请求体一起发给后端,后端执行 UPDATE ... SET version = version + 1 WHERE id = :id AND version = :clientVersion,如果 affectedRows === 0 说明有人先保存过了,返回 409 冲突。完整的 bug 流程:运营 A 和 B 在 10:00 同时打开了同一个模板编辑页,拿到的都是 version = 5。A 在 10:15 先保存,后端匹配到 version = 5 成功更新,数据库 version 变成 6。B 在 10:30 保存,请求带的还是 version = 5,后端 WHERE version = 5 匹配不到行(已经是 6 了),返回 409。问题在于前端收到 409 后的处理太粗糙——只弹了个 "保存失败" 的 toast,然后路由跳转回了列表页,B 编辑了半小时的内容直接丢了。修复方案是:收到 409 时前端不跳转、不清空编辑器,而是拉取服务端最新版本弹一个 Diff 对比弹窗,左边显示 A 保存后的最新内容,右边显示 B 本地的编辑内容,B 可以选择"覆盖保存"(以自己的为准)或手动合并后再保存。同时加了编辑器的 localStorage 自动保存(每 30 秒),作为最后的内容恢复手段。
  • 执行日志归档定时任务阻塞了正常的 Playground 写入。归档任务每天凌晨 3 点跑,SQL 是 DELETE FROM execution_logs WHERE created_at < NOW() - INTERVAL '90 days'。这条 DELETE 一次性要删除几十万行过期数据,PostgreSQL 会对这些行加排他锁(Row Exclusive Lock),整个操作在一个长事务里执行,持续好几分钟。虽然 PostgreSQL 的 MVCC 下 DELETE 是行级锁不会锁整张表,但大量行锁导致锁管理器开销急剧增大,同时删除产生的大量 WAL 日志和死元组(dead tuples)会拖慢并发 INSERT——INSERT 需要在堆中找可用的空闲页,而 VACUUM 还没来得及回收删除行的空间,I/O 争抢严重。结果那天有个运营在凌晨加班调试 Prompt,Playground 的 INSERT 响应时间从正常的几十毫秒飙到 30 秒以上,最终超时报错,运营以为系统挂了。解决方案是把一次性 DELETE 改为分批删除(DELETE ... WHERE id IN (SELECT id ... LIMIT 1000),循环直到删完,每批之间 sleep 100ms 释放锁和 I/O 压力),大幅降低对并发写入的影响。同时把归档时间收窄到凌晨 4:00-4:30(运营完全不活跃的时段),加双重保险。

3. 事件埋点分析服务(SDK,管理后台,服务端)

一句话:自己设计的埋点系统 + BI 分析,包含一个事件上报SDK,后台管理系统和后端服务,NestJS + PostgreSQL + Kafka + Redis + Grafana ,负责全端事件数据的实时采集、缓冲写入和多维度分析查询,覆盖用户全链路行为追踪和 25 项核心 KPI,支撑运营数据看板和定时报表推送。

展开讲

SDK: 合并请求(事件先进缓冲队列,满10条或者5秒定时器触发1次上报,哪个先到就批量写入,减少请求数,减轻服务器压力)、多级降级(sendBeacon -> fetch -> xhr)、页面卸载兜底(visibilitychange + beforeunload 事件触发最后一次发送,保证数据尽可能完整)、内置用户行为追踪(pv,uv,页面停留时长等)。

管理后台:一套完整的埋点事件定义与上报数据查询系统,支持事件集合管理、事件定义(含自定义属性和枚举值(String(字符串)、Int(数值)、Enum(枚举)))、事件发布、上报数据查看和埋点代码自动生成。

后端:校验后投递到 Kafka 解耦采集与入库,应对前端突发流量,Consumer 端攒批后批量 INSERT 到 PostgreSQL((满 500 条或 5s 超时,先到先刷),避免高峰期单条写入打满连接池,低峰期数据滞留。事件属性用 PostgreSQL JSONB 存储,加 GIN 索引支持灵活的自定义字段查询。

聚合计算 25 项核心 KPI:日活、注册转化率、内容生成量、VIP 转化、充值金额等,通过定时任务聚合后推送到飞书看板。

亮点

  • 完整的端到端设计——从前端 SDK 的合并上报、多级降级,到后端 Kafka 解耦、攒批写入,再到 KPI 聚合和飞书推送,形成完整的数据闭环
  • 事件属性用 JSONB + GIN 索引,不需要提前定义表结构,新增埋点字段零成本,查询性能也有保障
  • 管理后台的"埋点代码自动生成"功能,运营定义完事件后直接复制代码片段给开发,减少沟通环节

需要改进的点

  • KPI 聚合是定时任务跑 SQL 聚合的,数据量上来后(日均百万级事件)查询变慢,应该引入预聚合表或者物化视图,把实时聚合改为增量更新
  • 缺少数据质量监控——比如某个端突然停止上报、某个事件的上报量异常波动,目前没有自动告警,只能人工发现
  • SDK 的缓冲只用了内存队列,页面崩溃或 App 被杀后缓冲区数据就丢了,应该加一层 IndexedDB 持久化兜底

遇到的坑

  • Kafka Consumer 攒批逻辑的边界条件导致数据重复写入。上线第一周发现部分事件数据在 PostgreSQL 里出现了重复记录,DAU 指标比实际偏高约 15%。排查发现是 Consumer 端的攒批逻辑有 bug:当批次刚好攒满 500 条触发 flush,但 flush 过程中如果 PostgreSQL 响应慢(超过 5 秒),定时器也会触发一次 flush,导致同一批数据被写入两次。修复方案是加了一个 isFlushing 互斥锁,flush 进行中时定时器触发的 flush 直接跳过,等当前 flush 完成后重置定时器。同时给 event_tracker 表加了 (event_id, client_timestamp, user_id) 的唯一约束做最后一道防线,INSERT 时用 ON CONFLICT DO NOTHING 忽略重复数据。
  • SDK 的缓冲队列在单页应用路由切换时被意外清空,丢失跨页事件。SDK 设计了一个内存缓冲队列,满 10 条或 5 秒定时器触发才批量上报。但在 Next.js 的客户端路由切换(router.push)时,页面组件卸载触发了 SDK 的 destroy 清理逻辑(原本是为页面关闭设计的),缓冲队列里还没来得及上报的 3-5 条事件被直接清空了,而 destroy 里调用的 sendBeacon 发送的是清空前的快照——但实际代码里 destroy 先清了队列再调 sendBeacon,发出去的是空数组。这个 bug 只在 SPA 路由切换时触发,整页刷新和页面关闭不受影响,所以测试时没发现。结果跨页面的事件(比如用户在 A 页面点击跳转到 B 页面,A 页面的点击事件还在缓冲区)丢失率约 15%。修复方案是把 destroy 的逻辑改为"先 flush 再清空"(先把当前缓冲区数据用 sendBeacon 发出去,确认发送后再清空队列),同时区分"组件卸载"和"页面关闭"两个场景——SPA 路由切换时只 flush 不 destroy,SDK 实例继续存活;只有 visibilitychange hidden 或 beforeunload 时才真正 destroy。
  • sendBeacon 在 iOS Safari 页面卸载时被限流导致数据丢失。SDK 在页面卸载时用 navigator.sendBeacon 发送最后一批缓冲数据。Chrome 对 sendBeacon 的限制比较宽松——即使页面正在卸载,只要 payload 不超过 64KB 基本都能成功发出。但 iOS Safari 对页面卸载(pagehide/visibilitychange 触发 hidden)时的网络请求有更严格的限制:它会在页面进入后台后极短的时间窗口内(约几百毫秒)就终止所有还未完成的网络请求,sendBeacon 虽然是异步的"fire-and-forget",但如果请求体较大(几十 KB)还没发完就被系统切断了,返回 true(表示已加入浏览器发送队列)但实际没发出去,服务端根本收不到。排查时很隐蔽——sendBeacon 返回的是 true 不是 false,前端以为发成功了。最终通过服务端对比"最后一批事件到达率"发现 iOS 用户的页面离开事件丢失率高达 30%。解决方案是减小单次 sendBeacon 的 payload(缓冲区阈值从 10 条降到 5 条,页面卸载时如果积累超过 5 条就拆成多个小请求分别 sendBeacon),同时在 visibilitychange 事件触发时立即发送而不是等 beforeunloadvisibilitychange 触发更早,留给网络请求的时间窗口更长)。

4. NestJS 后端服务

一句话:基于 NestJS 10 的 BFF 层,负责多语言文案管理、文件管理、配置管理、AI 对话、素材库等功能。

展开讲

  • 多语言文案管理:支持 8 种语言,文案以 JSON 格式存储在 MySQL 中,集成微软翻译器 API 做自动翻译,运营编辑中文后一键翻译为其他 7 种语言,也支持各端的开发一键下载国际化相关文件到项目中,同时也提供脚本给各端直接执行更新最新文案。有完整审计日志:操作前后内容对比 + IP + 操作人
  • 素材库:树形文件夹结构,用 TypeORM 的 Materialized Path(物化路径) 模式存储层级关系(多一个mpath字段,如1.2.4,表示层级路径),支持多级文件夹嵌套、多类型文件上传/预览/编辑/替换。阿里云 OSS 直传 + CDN 自动刷新。上传后自动生成 CDN 地址,旧文件覆盖时主动调 CDN 刷新 API 清缓存
  • 配置管理:相当于配置一个json,作为一个通用配置。可视化编辑器,根据数据内容自动推断列类型,展示对应类型的组件(图片显示图片预览和上传,链接显示链接样式,其他使用输入框)。JSON 配置存 MySQL,多级缓存(内存 → Redis → MySQL),支持版本控制和一键回滚。每次修改自动创建版本快照。允许授权给其他用户查看和编辑。

亮点

  • 一个人承担了整个 BFF 层的设计和开发,覆盖多语言、文件、配置、素材库等多个业务模块,体现了全栈交付能力
  • 素材库用 Materialized Path 做树形存储,查询性能好(一次 LIKE 查所有子孙),且 TypeORM 原生支持,开发成本低
  • 多语言文案一键翻译 + 审计日志的设计,既提升了运营效率,又保证了可追溯性

需要改进的点

  • 微软翻译器的翻译质量参差不齐,尤其是涉及产品专有名词和口语化表达时经常翻错,运营还是需要逐条人工校对,应该维护一个术语表做翻译后置换
  • 配置管理的可视化编辑器只支持一维 JSON,嵌套对象的编辑体验不好,只能切到 JSON 源码模式手动改
  • 缓存一致性靠 TTL 过期,没有做主动失效,修改配置后最长要等 5 分钟缓存过期客户端才能拿到新数据

遇到的坑

  • OSS 文件覆盖上传后 CDN 缓存未刷新,导致线上图片显示旧版本。运营替换了一张活动 banner 图(同文件名覆盖上传),但用户看到的还是旧图。原因是 OSS 文件路径没变,CDN 缓存没有失效,默认 TTL 是 24 小时。紧急手动刷了 CDN 缓存后恢复。之后改进方案是:上传覆盖时自动调用阿里云 CDN 刷新 API 清除对应 URL 的缓存,同时在 OSS 上传路径中加入内容 hash(如 banner_abc123.png),从根本上避免同名覆盖的缓存问题。但考虑到运营习惯和已有的引用链接,最终采用了"覆盖 + 主动刷新"的折中方案。
  • Materialized Path 的素材文件夹移动操作导致子树路径不一致。素材库支持拖拽移动文件夹,移动时需要更新该节点及所有子孙节点的 mpath 字段。一开始只更新了当前节点的 mpath,忘了递归更新子树,导致移动后子文件夹的层级关系乱了——查询子孙节点时用 LIKE '1.2.%',但子孙的 mpath 还是旧路径 1.5.3.%,查不到了。修复方案是移动文件夹时先查出所有子孙节点,用事务批量更新 mpath(把旧前缀替换为新前缀)。
  • 多语言一键翻译的并发请求触发微软翻译器 API 限流。运营一次性新增了 200 多条文案然后点"全部翻译",后端并发发了 200+ 请求给微软翻译器,触发了 API 的 rate limit(免费层 200 万字符/月,且有每秒请求数限制),大批翻译请求返回 429。解决方案是在后端做了请求队列,限制并发为 5,超出的排队等待,同时翻译结果写入 Redis 缓存,相同文案不重复翻译。
  • Nest 应用不支持引入未编译的内部包。Turborepo 官方其实是建议应用层直接引用内部包的源码,也就是内部包的 package.json 写 "main": "src/index.ts",这样应用层引入时会直接引入源码,Turborepo 会在构建时自动编译内部包的源码并缓存编译结果,构建速度很快。但是 Nest 的模块解析机制不支持直接引入 ts,只编译当前项目 src 中的 ts,不编译外部 packages,会报错。官方推荐方法是使用 Nest 原生 Monorepo,但是不想用。解决方案是:内部包的 package.json 里 main 字段指向编译后的文件(比如 dist/index.js),应用层正常引入,Turborepo 构建时先编译内部包生成 dist/index.js,再编译 Nest 应用,这样就兼容了 Nest 的模块解析。

二、RYZZ — Web3 直播社交平台(2023 - 2024)

开场概括(20 秒版)

RYZZ 是一个 Web3 直播社交平台,融合直播互动(送礼打赏弹幕)、预测市场、Token 对战、NFT 等功能,是一个融合 Web2 社交直播与 Web3 去中心化金融的平台。


1. 主站

技术栈:Next.js + React + TypeScript + Recoil + WebSocket + Protobuf + Wagmi + Ethers.js + Tailwind CSS

展开讲

主站的架构设计有两个核心难点——实时通信Web3 集成

WebSocket + Protobuf 实时通信:直播间的开播状态,弹幕、礼物特效、投注通知、在线人数这些都是实时推送的,web3的交互也有大量异步操作,需要异步通知。一开始是用 JSON,但是数据量大的时候带宽消耗明显,后来换成 Protobuf 二进制协议,定义了 90 多个 Proto 消息类型,相比 JSON 大概节省 50% 带宽。前端封装了 WebSocket 管理层,做了自动重连(指数退避),心跳保活,消息队列缓冲(实例初始化前发送的消息队列,需要在初始化后发送)和状态重放(断连后重连时先重新发送登录和进房的消息)。事件分发基于 EventEmitter 的事件驱动架构,解耦消息处理与业务逻辑

Web3 钱包连接:用 Wagmi + Ethers.js 做钱包连接,支持 MetaMask 等外部(去中心化)钱包和 DAuth MPC(通过邮箱号/第三方/手机号) 托管钱包。链上交互包括投注、Token 对战、NFT 铸造,前端负责allowance检测,approve,封装构造交易(组装调用数据)、发起签名、监听链上确认等流程。

亮点

  • WebSocket + Protobuf 的通信方案,相比 JSON 节省约 50% 带宽,90+ 个 Proto 消息类型覆盖了直播间全部实时交互场景
  • WebSocket 管理层的健壮性设计——自动重连(指数退避)、心跳保活、消息队列缓冲、断连后状态重放,保证了直播间体验的连续性
  • Web3 双钱包方案(MetaMask + DAuth MPC),让 Web3 新手也能通过邮箱一键创建钱包参与链上交互,降低了使用门槛

需要改进的点

  • Recoil 状态管理在这个项目里用得比较重,Provider 嵌套了 15 层,新人上手困难,应该考虑迁移到 Zustand 这类更轻量的方案
  • WebSocket 消息分发基于字符串事件名,没有类型约束,Proto 消息类型和前端事件名的映射是手动维护的,容易出错
  • 链上交易的错误处理比较粗糙,用户看到的都是 "Transaction failed",没有区分具体原因(余额不足、Gas 估算失败、合约 revert),排查问题时很痛苦

遇到的坑

  • Protobuf 版本不一致导致消息解析出空对象。后端更新了几个 Proto 文件新增了字段,但前端忘了同步更新编译后的 JS 文件,导致新字段解析不出来。更隐蔽的是 Protobuf 的向后兼容机制——旧版本解析新消息不会报错,只是忽略未知字段,所以没有任何错误日志,只是部分数据显示为空。排查了很久才定位到是 Proto 文件版本不同步。解决方案是把 Proto 文件放到 Monorepo 的公共包里,CI 构建时自动编译生成 JS/TS 类型文件,确保前后端使用同一份 Proto 定义。
  • MetaMask 签名弹窗在移动端 WebView 里被系统拦截。部分 Android 设备的 WebView 把 MetaMask 的 deep link 跳转当作弹窗拦截了,用户点"连接钱包"后没有任何反应。排查发现是 WebView 的 setSupportMultipleWindows(false) 导致的。最终在 App 端配合修改了 WebView 配置,同时前端做了超时检测——点击连接后 5 秒没收到钱包响应就弹出"请确认已安装 MetaMask"的引导弹窗。
  • 直播间礼物特效动画在低端设备上导致页面卡死。高并发送礼场景(比如 PK 对战时),短时间涌入几十条礼物消息,每条都会触发 Lottie/CSS 动画,低端设备 GPU 直接扛不住,页面掉帧到个位数。解决方案是做了礼物队列 + 优先级调度:相同礼物合并计数(5 秒内重复的礼物只播一次动画 + 数字累加),低端设备检测到 GPU 压力大时自动降级为静态图片 + 数字动画,跳过 Lottie 全量动画。

三、虎牙直播 & 虎牙视频(2020 - 2023)

开场概括(15 秒版)

在虎牙主要做两块——直播 M 站和视频站的 SSR 重构,还有视频播放器 SDK。


1. 直播 M 站 & 视频站 SSR 重构

一句话:把虎牙直播 M 站和视频站从 PHP + Smarty 模板引擎重构为 React + TypeScript SSR 架构,重构的过程中也把性能优化和架构改进一起做了,最终一些核心性能指标都有了一些提升。

技术栈:React + TypeScript + Redux 展开讲

基于团队自研的 SSR 框架(Koa 2 内核),实现文件系统路由、getServerProps 服务端数据预取、Redux Store 序列化注入和客户端 Hydration 的完整链路。

有几个比较重要的设计:

  • 在迁移的过程中,除了用新技术还原原有功能,还做了性能优化和架构改进,比如为组件设置优先级(高优先级组件先渲染,低优先级组件延迟渲染),图片优化(图标使用svg sprites,webp),缓存策略(静态资源长缓存,HTML 页面短缓存),数据聚合(利用Promise.allSettled 聚合多个异步请求,并根据情况设置缓存
  • 重构后页面秒开率平均提升约 41%,白屏时间 1s 内占比平均提升约 34%

亮点

  • 性能提升数据扎实——秒开率提升 41%、白屏时间 1s 内占比提升 34%,是有监控数据支撑的量化结果
  • 组件优先级渲染机制——首屏核心内容(直播流/视频播放器)优先渲染,非关键组件(推荐列表、评论区)延迟加载,体感上"秒开"
  • 从 PHP + Smarty 到 React SSR 的渐进式迁移策略——按页面维度逐步切换,通过 Nginx 灰度控制流量比例,而不是一刀切替换,保证了线上稳定性

需要改进的点

  • SSR 框架是团队自研的,没有对标 Next.js 的 ISR/Streaming SSR 能力,服务端渲染是完整等待所有数据返回后才输出 HTML,首字节时间受最慢接口拖累
  • 缺少 SSR 的性能监控——服务端渲染耗时、内存使用、接口调用耗时没有独立的监控面板,排查慢渲染时只能靠日志
  • Redux 的模板代码太多(action/reducer/selector),后来的项目都转向了 Zustand,但这个项目因为体量大没有再迁移

遇到的坑

  • 302 被 CDN 缓存导致 PC 用户跳转移动端。通过 UA 判断跳转移动端,移动端触发了回源,302 被缓存,PC 用户也会拿到这个 302。解决方案是:CDN 边缘做 UA 判断,不在源站做重定向(另一个方案是改为前端 JS 跳转)。
  • SSR 内存泄漏导致服务端 OOM 重启。上线后运行一段时间(约 4-6 小时)Node.js 进程内存持续增长直到被 K8s OOM Kill。排查发现是 Redux Store 在每次 SSR 请求中创建后没有被 GC 回收——因为某个全局的事件监听器引用了 Store 对象,导致每个请求创建的 Store 都无法被释放。修复方案是确保每次 SSR 请求结束后手动解除事件监听器的引用,同时用 --max-old-space-size 限制堆内存并配合 PM2 的 max_memory_restart 做保底重启。
  • Hydration Mismatch 导致页面闪烁。部分页面在客户端 Hydration 时出现内容闪烁——服务端渲染的 HTML 和客户端首次渲染的 DOM 不一致。排查发现是因为服务端和客户端的时区不同(服务器是 UTC,用户是 UTC+8),日期格式化结果不一样,React Hydration 检测到不匹配后会丢弃服务端 DOM 重新渲染。解决方案是把时间相关的组件标记为 client-only,服务端渲染占位符,客户端再渲染真实内容。

2. 视频播放器 SDK

一句话:虎牙视频播放器核心 SDK,四层分层架构,插件化设计,支持多格式播放和自适应码率。

技术栈:TypeScript + EventEmitter + MSE + HLS.js

展开讲

播放器采用四层分层架构

  • UI 层:控制栏、进度条(缩略图预览)、弹幕层、与播放逻辑完全解耦
  • 播放器核心层:Player Core 负责播放流程编排,PluginManager 管理插件生命周期(install → init → ready → running → destroy),EventBus 事件总线做层间通信,StateStore 管理播放状态
  • 引擎层:抽象了 PlayEngine 接口,三种实现——HTML5 Video(MP4 渐进式)、MSE Engine(HLS/DASH 流式)、WebCodecs Engine(低延迟场景)。自动降级策略:优先 MSE,iOS Safari 降级到原生 HLS,最后兜底 WebCodecs + Canvas
  • 解码层:优先浏览器原生硬解,H.265/AV1 格式用 WASM 软解兜底

插件系统是扩展性核心——弹幕、字幕、广告、数据上报、快捷键、画中画、清晰度切换全都是插件,不是硬编码在核心里。插件通过 EventBus 订阅播放事件(play / pause / timeupdate / error 等),核心包 < 30KB gzipped,插件按需加载。

自适应码率(ABR):实现了混合算法——结合带宽估计(滑动窗口平均下载速度)和 Buffer 水位(高于上阈值升档、低于下阈值降档),在画质和流畅度之间取平衡。切换清晰度时通过 MSE 的 SourceBuffer 无缝切分片,用户感知不到切换过程。

弹幕系统:Canvas 渲染(比 DOM 性能好一个量级),实现了轨道分配算法做碰撞检测、弹幕密度控制、滚动/顶部/底部三种弹幕类型。直播弹幕和录播弹幕用不同的时间线同步策略。

可观测性:内置数据上报插件,采集首帧时间、卡顿率、Buffer 健康度、seek 耗时等播放质量指标,用于质量监控和调优。

亮点

  • 四层分层架构 + 插件化设计,核心包 < 30KB gzipped,弹幕、广告、字幕等功能全部按需加载,不污染核心包体积
  • 混合 ABR 算法(带宽估计 + Buffer 水位双因子),比纯带宽估计更稳定,弱网下用户感知卡顿率降低明显
  • Canvas 弹幕渲染 + 轨道分配碰撞检测,万级弹幕场景下依然流畅,性能远超 DOM 方案

需要改进的点

  • 播放器使用 ES6+ JavaScript(Babel 转译),没有用 TypeScript,事件名和插件接口没有类型约束,重构或新增插件时容易踩坑
  • 构建工具还是 Webpack v1 + Babel 6,版本太老,构建速度慢,升级成本高但一直没推动
  • 弹幕的轨道分配算法是固定轨道数,没有根据视频画面尺寸动态调整,小窗播放时弹幕挤在一起

遇到的坑

  • HLS.js 在某个版本升级后出现 seek 卡死。用户拖动进度条后视频卡在 loading 状态不动,排查发现是升级 HLS.js 后新版本的 Buffer 管理策略变了——seek 时会清空所有已缓存的 Buffer 重新拉取,但我们的 CDN 对 Range Request 的响应有延迟,导致 SourceBuffer 长时间处于空状态。降级到旧版本后恢复,最终方案是 seek 时保留前后 10 秒的 Buffer 不清空(配置 HLS.js 的 maxBufferHolebackBufferLength 参数)。
  • iOS Safari 低电量模式下视频自动降级到音频模式。收到用户反馈说视频只有声音没有画面,排查发现是 iOS Safari 在低电量模式下会静默停止视频解码(只保留音频),但 video 元素的 readyState 显示正常,没有触发任何错误事件。解决方案是监听 video.videoWidth,如果连续 3 秒 videoWidth 为 0 但 currentTime 在增长,就判定为"只有音频"的状态,给用户弹提示"低电量模式可能影响视频播放"。
  • 弹幕和视频时间线在 seek 后不同步。用户 seek 后弹幕还在播放旧时间点的内容,要等几秒才能追上。原因是弹幕系统有自己的时间线(基于 requestAnimationFrame 的增量计时),seek 事件到达弹幕系统时有延迟,且弹幕队列里已经预加载了旧时间段的弹幕。修复方案是 seek 时立即清空弹幕渲染队列,重置弹幕时间线到 video.currentTime,然后从弹幕数据源重新加载对应时间段的弹幕。

四、凡科快图(2016 - 2020)

一句话:在线图片设计平台,Web 端 PS 的定位。这个项目是我从0开始搭建的,技术挑战主要在于复杂的图形编辑器,需要处理大量计算密集型的图形变换、滤镜特效、图层管理等操作,同时保证编辑体验流畅。

简单提

  • Web 端轻量级 PS,基于 Vue + Canvas + SVG 构建可视化编辑器,支持图层管理、滤镜特效、拖拽操作、旋转拉伸、文本编辑、矢量图形等功能。
  • 重构编辑器技术架构,从canvas转向SVG,减少40%开发成本,提升性能和可维护性。
  • 编辑器内包含大量计算密集型操作(变换矩阵、碰撞检测、边界判断、滤镜等)。
  • 把数据传给 Web Worker ,Web Worker 使用 OffscreenCanvas 做渲染,转成base64图像数据发回主线程,主线程更新 DOM 显示,大幅降低主线程负载,编辑操作流畅度提升约 60%。比如每个元素从svg生成canvas,就是把svg元素的outerHTML发给Worker,Worker里用OffscreenCanvas渲染成图像后转成base64发回主线程,主线程保存下来。如果主线程每次都要同步更新base64,那会很卡顿。缩略图的canvas也是Worker渲染的,把每个元素的base64和对应尺寸位置发给worker,worker画进canvas再把base64传回来,主线程只负责显示。

亮点

  • 架构重构从 Canvas 转向 SVG,开发成本降低 40%——SVG 天然支持事件绑定、CSS 样式、DOM 操作,不需要手写 Canvas hit testing 和坐标变换
  • Web Worker + OffscreenCanvas 的离线渲染方案,把计算密集型操作(滤镜、图片处理)从主线程剥离,编辑操作流畅度提升 60%
  • 自研组件库 KtuUI(50+ 组件,支持 14 种语言国际化),沉淀了图片编辑器场景特有的交互组件(颜色选择器、图层面板、属性面板等)

需要改进的点

  • 技术栈偏老(Vue 2 + Webpack 4 + jQuery),尤其是编辑器核心代码里混用了 jQuery DOM 操作和 Vue 响应式,数据流不清晰
  • 撤销/重做用的是状态快照(每次操作保存整个编辑器状态的深拷贝),图层多的时候快照数据量大,内存占用高,应该改为命令模式(只记录操作的增量)
  • 导出图片的清晰度在高 DPI 屏幕上不够——SVG 转 Canvas 再导出 PNG 时没有做 2x/3x 倍率适配,导出的图片在 Retina 屏上看起来模糊

遇到的坑

  • SVG 的 foreignObject 在不同浏览器渲染不一致导致导出图片错位。编辑器里的富文本用 foreignObject 嵌入 HTML 到 SVG 中,编辑时显示正常,但导出(SVG → Canvas → PNG)时发现 Chrome 和 Safari 对 foreignObject 的坐标计算方式不同,Safari 的文本位置偏移了十几个像素。排查了很久才发现是 Safari 对 foreignObject 内的 line-height 计算规则不一样。最终方案是导出时把 foreignObject 内的富文本用 Canvas measureText 重新计算位置,绕过浏览器差异。
  • 大量图层拖拽排序时页面卡顿到无法操作。用户设计模板时经常有 100+ 图层,拖拽排序时每次 mousemove 都会触发 Vuex 状态更新 → 所有图层组件重渲染,帧率掉到 5fps 以下。解决方案是拖拽过程中不更新 Vuex,只用 CSS transform 移动"拖拽影子"元素做视觉反馈,mouseup 时才一次性提交最终的图层顺序到 Vuex。
  • Web Worker 通信的序列化开销反而拖慢了小图的处理速度。把所有图片处理都扔给 Web Worker 后,发现处理小图(< 100KB)时反而比主线程慢——因为图片数据要序列化传给 Worker 再反序列化回来,这个拷贝过程的开销比实际处理还大。最终方案是做了阈值判断:大于 500KB 的图片走 Worker + Transferable Object(零拷贝传输 ArrayBuffer),小图直接在主线程处理。

五、面试官追问题库

按项目分类整理面试官可能追问的问题。每个问题标注了可参考的文档链接,面试前翻阅对应文档即可准备答案。

Swee — AI 移动端 PWA

#追问方向具体问题参考文档
1流式渲染SSE 和 WebSocket 有什么区别?为什么选 SSE?WebSocket 与 SSE
2流式渲染ReadableStream 怎么用的?流式数据怎么逐步解析?流式渲染与 Markdown
3流式渲染增量 Markdown 解析具体怎么实现的?遇到不完整语法怎么处理?流式渲染与 Markdown
4流式渲染流式渲染过程中怎么控制自动滚动?用户上滑时怎么判断?AI 聊天界面设计
5性能优化虚拟滚动怎么实现的?用的什么库还是自己写的?长列表优化
6性能优化requestAnimationFrame 批量更新具体怎么做的?和直接 setState 有什么区别?requestAnimationFrame
7性能优化图片懒加载用什么方案?IntersectionObserver 还是 scroll 事件?图片优化
8状态管理为什么选 Zustand 而不是 Redux 或 Jotai?状态管理方案
9状态管理Zustand 的 subscribeWithSelector 怎么避免不必要的重渲染?React 性能优化
10PWAService Worker 缓存策略是怎么设计的?有哪些坑?Web Workers
11PWAPWA 的离线体验怎么做的?离线时用户能做什么?PWA 渐进式 Web 应用
12React消息列表组件的 key 怎么设计的?为什么不能用 index?列表渲染优化
13React对话页面有哪些组件?组件之间怎么通信的?组件通信方案
14Next.js为什么用 Next.js?哪些页面用了 SSR,哪些用了 CSR?Next.js 核心知识
15AILLM API 怎么调的?前端直接调还是走后端代理?前端接入大模型 API
16AIFunction Calling 用过吗?怎么处理工具调用的?Function Calling 与 Agent
17多内容类型消息渲染管线具体是怎么设计的?怎么识别不同类型的段落?AI 聊天界面设计
18MonorepoTurborepo 的 monorepo 怎么组织的?packages 目录怎么拆分?Monorepo 管理
19MonorepoTurborepo 的缓存机制是什么?对构建速度提升有多大?Monorepo 管理
20部署GitHub Actions CI/CD 流程是什么?怎么和 K8s 配合的?CI/CD 与自动化部署
21鉴权K8s 内部 DNS 服务发现鉴权具体怎么做的?为什么不直接调外网地址?Kubernetes 基础
22离线存储IndexedDB 存历史消息具体怎么设计的?数据结构是什么?和服务端怎么同步?前端存储技术
23多语言next-intl 多语言方案怎么做的?SSR 下多语言路由怎么处理?设计多语言管理系统
24分块渲染流式消息按换行符拆分为多个组件,具体怎么切分的?切分粒度怎么定?流式渲染与 Markdown
25协议设计[structured:xxx] 这种自定义标记协议是怎么设计的?为什么不用 JSON 包裹整条消息?AI 聊天界面设计

Swee — Prompt 模板管理系统

#追问方向具体问题参考文档
1架构设计为什么不用 LangSmith / Humanloop?Build vs Buy 怎么决策的?Swee-Prompt 模板管理系统 Q2
2变量引擎三步渲染管线的顺序为什么不能变?举例说明反了会出什么问题?Swee-Prompt 模板管理系统 Q3
3变量引擎变量的 maxLength 有什么用?不设会出什么问题?设计 Prompt 模板管理系统 六、变量引擎
4编辑器CodeMirror 6 自定义高亮怎么做的?为什么不用 Lezer Grammar?Swee-Prompt 模板管理系统 7.2
5编辑器变量自动补全怎么实现的?数据源从哪来?Swee-Prompt 模板管理系统 3.3
6版本管理为什么用完整快照而不是增量 Diff?Swee-Prompt 模板管理系统 Q4
7版本管理回滚怎么实现的?为什么是"创建新版本"而不是覆盖?Swee-Prompt 模板管理系统 Q4
8版本管理乐观锁怎么实现的?冲突了前端怎么处理?事务与并发控制
9缓存SDK 的多级缓存怎么设计的?发布后怎么保证一致性?Swee-Prompt 模板管理系统 Q5
10缓存Redis 缓存的 TTL 为什么加随机偏移?服务端缓存策略
11迁移60 多个 Prompt 怎么迁移过来的?迁移过程中最大的坑是什么?Swee-Prompt 模板管理系统 Q6
12迁移双写兼容具体怎么做的?降级逻辑是什么?Swee-Prompt 模板管理系统 7.1
13安全Prompt 注入攻击了解吗?你的系统怎么防的?AI 应用安全
14数据库为什么用 PostgreSQL 而不是 MySQL?JSONB 有什么优势?PostgreSQL 特性
15系统设计如果让你重新设计,会加什么功能?Swee-Prompt 模板管理系统 Q11
16系统设计系统挂了怎么办?线上 AI 功能会受影响吗?Swee-Prompt 模板管理系统 Q8
17Prompt你对 Prompt Engineering 有什么了解?Few-shot 用过吗?Prompt 工程
18效果评估怎么知道改完 Prompt 效果好不好?有没有做 A/B 测试?Swee-Prompt 模板管理系统 Q7

Swee — NestJS 后端 & 埋点服务

#追问方向具体问题参考文档
1NestJSNestJS 的 Module / Controller / Service 是怎么组织的?NestJS 框架深入
2NestJSGuard 和 Middleware 有什么区别?你的鉴权用的是哪个?NestJS 框架深入
3NestJSNestJS 的请求处理管道是什么顺序?异常怎么统一处理?错误处理
4数据库TypeORM 的 Materialized Path 是什么?怎么查询子孙节点?ORM 框架对比
5数据库多语言文案用 JSON 存,查询性能怎么样?有没有考虑过其他方案?MySQL 基础与查询
6文件管理OSS 直传是怎么做的?前端直传还是后端中转?文件存储与 OSS
7文件管理CDN 缓存刷新是怎么做的?什么时候触发?CDN 原理
8埋点为什么要用缓冲策略?直接逐条写入不行吗?前端监控与埋点
9埋点双触发缓冲(600 条 OR 3 秒)的参数怎么定的?前端监控与埋点
10埋点JSONB + GIN 索引是什么?为什么适合埋点数据?PostgreSQL 特性
11埋点异步非阻塞写入怎么实现的?如果写入失败怎么办?消息队列
12埋点IP 地理位置解析为什么要限制在缓冲区 < 45 条时执行?限流与熔断
13认证调用外部 Java 认证服务,网络延迟怎么处理?有没有缓存 Token?JWT 认证
14埋点执行日志超过 90 天归档到 OSS,这个定时任务怎么实现的?归档后还能查吗?定时任务与后台作业
15埋点25 项 KPI 聚合计算是实时的还是离线的?定时任务多久跑一次?定时任务与后台作业
16埋点飞书看板推送怎么实现的?数据格式是什么?Webhook 设计与实现
17埋点异步非阻塞写入如果 DB 挂了,内存缓冲区的数据怎么办?会丢数据吗?消息队列
18埋点埋点 SDK 前端怎么上报的?用 sendBeacon 还是 fetch?页面关闭时数据怎么保证不丢?前端监控与埋点
19多语言微软翻译器 API 的翻译质量怎么样?运营需要人工校对吗?设计多语言管理系统
20素材库Materialized Path 和嵌套集(Nested Set)、闭包表(Closure Table)有什么区别?为什么选它?ORM 框架对比

RYZZ — Web3 直播社交平台

#追问方向具体问题参考文档
1WebSocketWebSocket 的自动重连怎么做的?指数退避具体是什么策略?WebSocket 与 SSE
2WebSocket心跳保活是怎么实现的?多久发一次?服务端超时怎么处理?WebSocket 服务端
3WebSocketWebSocket 断开重连后,丢失的消息怎么补回来?断线重连与离线处理
4ProtobufProtobuf 和 JSON 有什么区别?为什么能节省 50% 带宽?TCP 与 UDP
5Protobuf前端怎么编译 Proto 文件?类型怎么和 TypeScript 对齐?JavaScript 模块化
6Web3钱包连接的流程是什么?怎么判断用户有没有安装 MetaMask?Web3 钱包连接方案
7Web3链上交易的前端签名流程是什么?怎么监听交易确认?Web3 钱包连接方案
8状态管理为什么选 Recoil 而不是 Redux?15 层 Provider 不会有性能问题吗?状态管理方案
9直播追帧策略是什么?为什么要 1.1-1.2 倍速?设计视频播放器 SDK
10直播FLV 和 HLS 有什么区别?什么时候用哪个?设计视频播放器 SDK
11架构Monorepo 的 7 个共享包是怎么拆分的?用什么工具管理?Monorepo 管理
12图片处理Sharp 的性能怎么样?和 ImageMagick 比呢?设计图片处理 CDN 服务
13图片处理URL 参数 DSL 怎么解析的?多个操作怎么链式执行?设计图片处理 CDN 服务
14DevOpsDocker 多阶段构建是什么?为什么能减小镜像体积?Docker 与容器化
15监控OpenTelemetry 是什么?怎么做分布式链路追踪?日志与监控体系

虎牙 — SSR 重构

#追问方向具体问题参考文档
1SSRSSR 和 CSR 有什么区别?SSR 的优缺点是什么?SSR 与 SSG
2SSRHydration 是什么?Hydration 失败(mismatch)怎么排查?SSR 与 SSG
3SSRgetServerProps 里的数据怎么传给客户端的?序列化有什么坑?Server Components 深入
4SSR内存泄漏在 SSR 场景下更容易出现,你遇到过吗?内存泄漏排查
5性能秒开率提升 41% 具体做了哪些优化?最有效的是哪个?首屏优化
6性能懒加载、预渲染、懒执行分别是什么?怎么做的?懒加载与代码分割
7SEOSSR 对 SEO 有什么帮助?Meta 标签怎么动态注入的?meta 标签与 SEO
8路由文件系统路由是怎么实现的?和 Next.js 的有什么异同?React Router 原理
9状态管理Redux Store 在 SSR 场景下怎么处理?服务端和客户端的 Store 怎么同步?状态管理方案
10部署Serverless 部署和传统 Node 部署有什么区别?冷启动怎么处理?Serverless 与边缘计算
11监控Sentry 怎么区分 SSR 端和客户端的错误?前端监控与埋点
12缓存踩坑302 被 CDN 缓存导致 PC 用户跳移动端,这个问题怎么发现的?排查过程是什么?CDN 原理
13缓存踩坑CDN 边缘做 UA 判断和前端 JS 跳转,两种方案各有什么优缺点?CDN 原理
14迁移PHP + Smarty 迁移到 React SSR,怎么保证迁移过程中不影响线上?灰度策略是什么?技术栈升级迁移
15性能组件优先级渲染是怎么实现的?高低优先级怎么定义?渲染优化

虎牙 — 视频播放器 SDK

#追问方向具体问题参考文档
1架构四层架构每层的职责是什么?为什么这样分?设计视频播放器 SDK
2架构播放器的核心包怎么做到 < 30KB 的?打包优化
3插件系统插件的生命周期是什么?插件之间怎么通信?设计视频播放器 SDK
4插件系统EventBus 和 React 的事件系统有什么区别?观察者模式与发布订阅
5插件系统如果两个插件有依赖关系,怎么保证加载顺序?设计视频播放器 SDK
6引擎MSE 和 HTML5 Video 有什么区别?SourceBuffer 怎么用的?设计视频播放器 SDK
7引擎HLS 的 m3u8 文件格式是什么?前端怎么解析的?设计视频播放器 SDK
8引擎引擎自动降级策略是什么?怎么检测浏览器能力?浏览器兼容性
9ABR混合 ABR 算法的带宽估计怎么做的?滑动窗口多大?设计视频播放器 SDK
10ABR清晰度切换时怎么做到无缝?用户感知不到切换过程?设计视频播放器 SDK
11弹幕为什么用 Canvas 而不是 DOM 渲染弹幕?性能差多少?设计直播弹幕系统
12弹幕轨道分配算法怎么做碰撞检测的?设计直播弹幕系统
13弹幕直播弹幕和录播弹幕的时间线同步有什么区别?设计直播弹幕系统
14性能首帧时间怎么优化的?目标是多少?首屏优化
15性能播放卡顿怎么监控?Buffer 健康度怎么定义的?Web 性能指标与监控系统
16SDK 设计SDK 的 API 怎么设计的?考虑过向后兼容吗?前端 SDK 通用架构设计
17WebCodecsWebCodecs 是什么?什么场景下用?和 MSE 什么区别?设计视频播放器 SDK

凡科快图 — 在线图片设计平台

#追问方向具体问题参考文档
1架构重构为什么从 Canvas 转向 SVG?减少 40% 开发成本体现在哪?Canvas 与 SVG
2架构重构Canvas 和 SVG 在图片编辑器场景下各有什么优缺点?Canvas 与 SVG
3架构重构SVG 处理复杂滤镜(模糊、色彩调整)性能怎么样?有没有不如 Canvas 的地方?SVG 进阶与动画
4图层管理图层的数据结构怎么设计的?怎么处理图层的层级关系和 z-index?设计在线图片编辑器
5图层管理撤销/重做怎么实现的?用的命令模式还是状态快照?命令模式
6Web Worker哪些计算放到 Web Worker 里了?主线程和 Worker 之间怎么通信?Web Worker 优化
7Web Worker变换矩阵在编辑器里怎么用的?旋转+缩放+平移怎么组合计算?Transform 变换与矩阵
8碰撞检测碰撞检测和边界判断怎么做的?旋转后的元素碰撞怎么计算?碰撞检测与边界判断
9交互拖拽操作怎么实现的?拖拽过程中的吸附对齐(辅助线)怎么做?拖拽排序
10性能编辑器里元素很多时(比如几百个图层)性能怎么保证?大数据量渲染优化
11文本编辑SVG 里的文本编辑怎么实现的?富文本还是纯文本?设计在线图片编辑器
12导出编辑完成后怎么导出图片?SVG 转 PNG/JPG 怎么做的?Canvas 与 SVG

通用 / 跨项目追问

#追问方向具体问题参考文档
1职业9 年前端,为什么后面转全栈?NestJS 后端是自学的吗?前端工程师的职业发展路线
2职业你做的项目里最有挑战的是什么?本文档「通用追问准备」
3职业如果让你重新设计,会改什么?本文档「通用追问准备」
4架构你怎么理解前端架构?做架构设计时考虑哪些因素?如何理解前端架构
5工程化你怎么理解前端工程化?你在项目中做了哪些工程化的事?如何理解前端工程化
6性能你做过哪些性能优化?用什么工具分析性能?常见性能问题与排查
7测试你的项目有写测试吗?怎么保证代码质量?前端测试策略
8CI/CD你的项目怎么部署的?CI/CD 流程是什么?CI/CD 与自动化部署
9团队你怎么做 Code Review?关注哪些点?如何做好 Code Review
10技术选型你怎么做技术选型?有什么方法论?前端技术选型方法论
11AI你怎么看 AI 对前端的影响?如何看待 AI 对前端的影响
12AI你用 AI 辅助开发吗?怎么用的?AI 辅助开发
13学习你怎么持续学习前端技术的?如何持续学习前端技术
14趋势你觉得前端未来 3-5 年的趋势是什么?前端未来 3-5 年的趋势
15管理你有带团队的经验,怎么做技术管理的?如何带领前端团队
16重构如果让你重构一个遗留项目,你会怎么做?接手遗留项目重构策略
17安全你的项目怎么处理 XSS / CSRF 安全问题的?XSS/CSRF 漏洞修复实战
18错误处理线上出了 Bug 你怎么排查?有什么方法论?线上故障应急
19Docker/K8s你的 Docker 镜像怎么构建的?多阶段构建用了吗?Docker 与容器化
20Docker/K8sK8s 的 Pod、Service、Deployment 分别是什么?你的服务怎么编排的?Kubernetes 基础
21协作一个人做前后端,你怎么保证代码质量和进度?如何在有限时间内交付高质量代码
22数据库MySQL 和 PostgreSQL 你都用过,什么场景选哪个?PostgreSQL 特性

六、通用追问准备

"你做的项目里最有挑战的是什么?"

Prompt 模板管理系统的迁移过程。60 多个 Prompt 散落在 Java 和 Node 两个后端,不可能一次切换。我分 4 批灰度迁移,每批都做了双写兼容——SDK 调不通就回退硬编码。最大的坑是变量名不统一,同一个字段 Java 叫 userName、别的地方叫 user_name,最后在 SDK 层加了 fieldMapping 做映射。全程零事故。

"如果让你重新设计,会改什么?"

Prompt 系统会加 LLM-as-Judge 自动评分(哪怕最简单的让 GPT-4o-mini 打个 1-5 分),还会做模板继承/组合——数字人相关的 10 多个模板共享大段 System Prompt,现在每个都复制一遍,改一个地方要同步改十几个。

"为什么从纯前端转全栈?"

在 Swee 项目中,团队 Node.js 后端最开始没有专职后端负责,只有 Java 后端。很多运营工具类的需求(文案管理、配置管理、文件管理)不适合让 Java 团队做——太轻量了,排不上优先级。我自己用 NestJS 搭了一套,后来越做越多,Prompt 管理系统、埋点系统也一起做了。不是刻意转全栈,是业务需要驱动的自然扩展。NestJS 的模块化思想和 Angular 类似,对前端来说学习成本不高。

"AI 对话的流式渲染管线是怎么实现的?文本和结构化内容怎么混排?"

我们的 AI 对话不只是纯文本,模型输出里会混着结构化内容——好感度变化动画、剧情任务卡片、选择按钮这些。所以我基于 react-markdown 设计了一套流式消息渲染管线,核心思路是把流式文本拆成"纯 Markdown 段"和"结构化标记段"两类,分别渲染。

具体做法

后端在 LLM 输出中约定了一种标记格式:[structured:类型名]JSON数据[/structured]。前端写了一个增量解析器,每次收到新 chunk 拼接到累积文本后,用正则扫描整段文本:

  1. 已闭合的结构化标记(开标签和闭标签都有)→ 提取 JSON,JSON.parse 后分发给对应的 React 组件渲染
  2. 纯文本段(标记之间或之外的内容)→ 交给 react-markdown 实时渲染,支持 GFM 表格、代码高亮
  3. 未闭合的标记(只有开标签,闭标签还没到)→ 跳过,显示加载占位符,等后续 chunk 补全
LLM 输出流: "你好!\n[structured:affection]{"delta":5}[/structured]\n继续聊..."

解析结果:
├─ TextBlock("你好!") → react-markdown
├─ StructuredBlock("affection") → AffectionAnimation 组件
└─ TextBlock("继续聊...") → react-markdown

流式 Markdown 的坑:流式输出时内容随时可能截断——代码块只有开头的 ``` 没有结尾、粗体 ** 没闭合。我在传给 react-markdown 之前做了预处理:检测奇数个围栏/粗体标记时自动追加闭合。只对流式中的消息做预处理,已完成的消息直接渲染原始内容。

核心思路:把流式文本拆成"纯 Markdown 段"和"结构化标记段"两类,分别用不同方式渲染

SSE chunk 到达 → 拼接到累积文本 → 增量解析器扫描

┌─────────────────────────────┐
│ 纯文本段 → react-markdown │
│ [structured:xxx] 已闭合 │
│ → JSON.parse → React 组件 │
│ [structured:xxx] 未闭合 │
│ → 跳过,等后续 chunk 补全 │
└─────────────────────────────┘

1. 增量解析器:把累积文本拆成 Block 数组

lib/stream-parser.ts
interface TextBlock {
type: 'text';
content: string;
}

interface StructuredBlock {
type: 'structured';
name: string; // 如 'affection', 'task_card', 'choices'
data: unknown; // JSON.parse 后的数据
}

interface PendingBlock {
type: 'pending'; // 未闭合的结构化标记,暂不渲染
raw: string;
}

type ContentBlock = TextBlock | StructuredBlock | PendingBlock;

/**
* 将累积的流式文本解析为 Block 数组
* 每次新 chunk 到达后,对整段累积文本重新解析
*
* 标记格式:[structured:name]{"key":"value"}[/structured]
*/
function parseStreamContent(accumulated: string): ContentBlock[] {
const blocks: ContentBlock[] = [];
// 匹配已闭合的结构化标记
const pattern = /\[structured:(\w+)\]([\s\S]*?)\[\/structured\]/g;

let lastIndex = 0;
let match: RegExpExecArray | null;

while ((match = pattern.exec(accumulated)) !== null) {
// match 前面的纯文本
if (match.index > lastIndex) {
const text = accumulated.slice(lastIndex, match.index).trim();
if (text) blocks.push({ type: 'text', content: text });
}

// 已闭合的结构化块 → 解析 JSON
try {
const data = JSON.parse(match[2]);
blocks.push({ type: 'structured', name: match[1], data });
} catch {
// JSON 不合法,当普通文本处理
blocks.push({ type: 'text', content: match[0] });
}

lastIndex = match.index + match[0].length;
}

// 剩余部分:可能包含未闭合的标记
const remaining = accumulated.slice(lastIndex);
if (remaining) {
// 检测是否有未闭合的 [structured:xxx]
const unclosedMatch = remaining.match(/\[structured:(\w+)\][\s\S]*$/);

if (unclosedMatch) {
// 未闭合标记前面的纯文本正常渲染
const textBefore = remaining.slice(0, unclosedMatch.index!).trim();
if (textBefore) blocks.push({ type: 'text', content: textBefore });
// 未闭合部分标记为 pending,不渲染
blocks.push({ type: 'pending', raw: unclosedMatch[0] });
} else {
// 没有未闭合标记,全部是纯文本
blocks.push({ type: 'text', content: remaining });
}
}

return blocks;
}

2. 流式消息组件:根据 Block 类型分发渲染

components/StreamMessage.tsx
import { useMemo, memo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';

interface StreamMessageProps {
content: string; // 累积的流式文本
isStreaming: boolean;
}

export const StreamMessage = memo(function StreamMessage({
content,
isStreaming,
}: StreamMessageProps) {
const blocks = useMemo(() => parseStreamContent(content), [content]);

return (
<div className="stream-message">
{blocks.map((block, i) => {
switch (block.type) {
case 'text':
return (
<MarkdownBlock
key={i}
content={block.content}
isStreaming={isStreaming && i === blocks.length - 1}
/>
);
case 'structured':
return (
<StructuredRenderer key={i} name={block.name} data={block.data} />
);
case 'pending':
// 未闭合的标记:显示加载占位
return <div key={i} className="loading-placeholder" />;
}
})}
</div>
);
});

3. Markdown 渲染(处理流式未闭合语法)

components/MarkdownBlock.tsx
const MarkdownBlock = memo(function MarkdownBlock({
content,
isStreaming,
}: {
content: string;
isStreaming: boolean;
}) {
// 流式中需要补全未闭合的 Markdown 语法
const processed = useMemo(() => {
if (!isStreaming) return content;
return closeOpenMarkdown(content);
}, [content, isStreaming]);

return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// 代码块:语法高亮 + 复制按钮
code({ className, children, ...props }) {
const language = className?.replace('language-', '');
const codeString = String(children).replace(/\n$/, '');

if (!language) {
return <code {...props}>{children}</code>;
}

return (
<div className="code-block">
<div className="code-header">
<span>{language}</span>
<CopyButton text={codeString} />
</div>
<SyntaxHighlighter language={language} style={oneDark}>
{codeString}
</SyntaxHighlighter>
</div>
);
},
}}
>
{processed}
</ReactMarkdown>
);
});

/** 补全流式中未闭合的 Markdown 标记 */
function closeOpenMarkdown(content: string): string {
let result = content;

// 未闭合的代码块
const fences = result.match(/```/g);
if (fences && fences.length % 2 !== 0) {
result += '\n```';
}

// 未闭合的粗体
const bolds = result.match(/\*\*/g);
if (bolds && bolds.length % 2 !== 0) {
result += '**';
}

// 未闭合的行内代码(排除代码块内的)
const inlineCode = result.match(/(?<!`)`(?!`)/g);
if (inlineCode && inlineCode.length % 2 !== 0) {
result += '`';
}

return result;
}

4. 结构化内容渲染器

components/StructuredRenderer.tsx
/** 根据 name 分发到对应的 React 组件 */
function StructuredRenderer({ name, data }: { name: string; data: unknown }) {
switch (name) {
case 'affection':
// 好感度变化动画
return <AffectionAnimation data={data as AffectionData} />;
case 'task_card':
// 剧情任务卡片
return <TaskCard data={data as TaskData} />;
case 'choices':
// 选择按钮组
return <ChoiceButtons data={data as ChoiceData} />;
case 'gift':
// 礼物特效
return <GiftEffect data={data as GiftData} />;
default:
// 未知类型,降级为 JSON 展示
return (
<pre className="unknown-block">{JSON.stringify(data, null, 2)}</pre>
);
}
}

// 示例:好感度动画组件
function AffectionAnimation({ data }: { data: AffectionData }) {
return (
<div className="affection-change">
<span className="affection-icon">💗</span>
<span className={data.delta > 0 ? 'positive' : 'negative'}>
{data.delta > 0 ? '+' : ''}
{data.delta}
</span>
<span className="affection-total">好感度: {data.total}</span>
</div>
);
}

5. 整合到流式接收流程

hooks/useStreamChat.ts
function useStreamChat() {
const [content, setContent] = useState('');
const bufferRef = useRef('');
const rafRef = useRef<number>();

const onToken = useCallback((token: string) => {
// RAF 批量更新,避免每个 token 都触发重渲染
bufferRef.current += token;

if (!rafRef.current) {
rafRef.current = requestAnimationFrame(() => {
setContent((prev) => prev + bufferRef.current);
bufferRef.current = '';
rafRef.current = undefined;
});
}
}, []);

// content 变化 → StreamMessage 重新解析 → 自动拆分渲染
return { content, onToken };
}

数据流总结

LLM 输出: "你好!\n[structured:affection]{"delta":5,"total":80}[/structured]\n继续聊..."

▼ SSE chunk 到达,拼接到累积文本

▼ parseStreamContent() 解析

├─ TextBlock("你好!") → react-markdown 渲染
├─ StructuredBlock("affection") → AffectionAnimation 组件
└─ TextBlock("继续聊...") → react-markdown 渲染