接手 Easysearch 助手的前端还真是个“时间胶囊”——代码里还留着六年前的 jQuery 插件,搜索页还是传统的服务端模板加 iframe 嵌套结果页,智能助手入口藏在二级菜单里,点进去之后每次对话都要整页刷新。这轮 Web 端升级,目标是把“能用”的工具变成“好用”的产品,过程中把技术选型、架构调整、交互重构和性能优化整个走了一遍,踩了不少坑,也沉淀了一些实操经验。这篇就围绕这次 Easysearch 智能助手 Web 端的升级实战,把关键决策和落地细节拆开聊聊。
1. 升级前的账本:旧 Web 端到底卡在哪
1.1 贴满补丁的 jQuery 多页架构
旧系统的主链路并不复杂:用户打开搜索主页,输入关键词,表单提交到服务端,服务端渲染结果页。问题在于这中间穿插了大量“额外需求”——搜索联想、热搜榜、历史记录、智能助手的小窗对话。每个功能都是不同时期由不同同学临时加的,实现方式也五花八门,有的塞了一段内联 script,有的直接 new 一个 iframe,有的在 DOMReady 里异步拉接口再手动拼接 HTML。
这种架构初看还能跑,但在智能助手这类高频交互场景里会迅速暴露问题。助手对话是个典型的“短连接长交互”模式,用户连续追问多轮,每次都要走完整的页面请求-响应-渲染周期,输入框还常常因为全局事件绑定冲突而失焦,真实体验基本是“开启十秒,卡顿五次”。我在升级前的调研阶段拉了一遍主流程的埋点,发现搜索页首屏时间平均在 2.6 秒左右,其中服务端模板渲染占了将近一半;而助手对话页的静态资源重复加载率达到 76%,也就是说用户每发一句话,浏览器都要重新拉一遍 JS 和 CSS。
1.2 数据层面的体感:搜索与助手的使用率反差
另一个必须正视的数据是入口使用率。搜索框日均使用率很高,但智能助手的日活不到搜索的十分之一。一开始团队讨论归因于“用户不需要助手”,后来我翻了会话日志,发现大量用户点进助手页面后 5 秒内就退出,根本没发出第一条消息。这个模式更像是页面加载太慢导致用户失去耐心,而不是功能本身没价值。
把用户路径复现一遍就清楚了:从搜索主页点“智能助手”入口,浏览器跳转到一个新的 HTML 页面,重新建立连接,重新拉取公共库,然后再初始化输入框。中间还要经过登录态校验、权限接口查询、历史会话列表加载。旧页面里这些操作全是同步阻塞的,用户感受到的就是“点了没反应,白屏好几秒”。所以这次升级首要任务不是加功能,而是先把交互链路缩短,把这些阻塞项改成异步并行。
1.3 业务新需求倒逼架构出清
当时产品侧还提了两个新需求,直接成了架构升级的硬约束。一是跨设备会话同步,也就是用户在办公室电脑上问了一半的问题,回家打开浏览器还能接着看上下文;二是搜索结果页要内嵌助手推荐内容,不再是独立的 iframe 页面。这两个需求放到旧架构里实现不难,但会很难维护——因为要同时维护两套页面逻辑,一套在服务端模板里,一套在前端 JS 里,状态一致性靠“复制粘贴”维持,谁来改都容易改出偏差。
我在需求评审会上直接说:如果继续在旧架构上打补丁,这两个功能的开发成本要翻倍,后续维护更是无底洞。与其这样不如把整个 Web 端重写一遍,把“搜索 + 助手”作为同一个应用的前后两个场景来设计。这个提议通过了,于是有了这次升级。
2. 技术选型:为什么最终是 Vue 3 + TypeScript 而不是其他组合
2.1 框架选型不是技术崇拜,而是团队习惯的延伸
综合团队情况,这次选择了 Vue 3 + TypeScript + Vite + Pinia。框架层面,团队大多数成员此前的主力框架是 Vue 2,直接切换到 Vue 3 的学习成本最低。有人问为什么不借机换 React,我当时的判断是:重构最大的风险不是技术栈不够先进,而是团队对新体系的磨合成本;如果框架变了、构建工具变了、状态管理也变了,再加上交互重写,整个项目失控的概率会成倍上升。
Vue 3 本身也足够支撑这次的业务场景。Composition API 对逻辑复用的改善非常明显,比如把“搜索联想”“历史记录”“会话上下文”拆成独立的 composable 模块,页面组件只负责组装,逻辑清晰度比 Vue 2 时代的 mixin 好太多。TypeScript 则是为上规模做的铺垫——这个项目后续肯定不止一个前端在维护,接口返回类型定死了,大家协作的时候能少很多“对方传的参数到底是什么结构”这类低级问题。
2.2 单页应用而不是 SSR:一次“反向”选择
很多团队在这个节点会选择 SSR(服务端渲染)或 SSG(静态站点生成),认为首屏性能更好。我这次反而坚持做纯 SPA。原因是评估下来,Easysearch 这个产品的核心使用场景是“登录后高频操作”,而不是内容站点的“未登录浏览”。如果上 SSR,就要同时维护 Node 服务端和前端构建链路,单次请求的链路变长,机器成本也会涨。纯 SPA 配合路由懒加载,把首屏需要的最小代码量控制住,整体性价比是最高的。
当然,SPA 的代价是白屏风险,首屏 JS 没加载完之前页面是空的。我通过两个手段来对冲:一是在 HTML 模板里内联一个骨架屏 CSS,二是把公共依赖拆包,确保 gzip 后总体积不超过 200KB。实际上线后首屏时间完全可接受,这个取舍是划算的。
2.3 API 层设计:一个 client 统一所有请求
升级前的前端请求代码极度分散,每个页面自己写 fetch,超时时间、错误提示、登录失效处理逻辑各不相同。这次我设计了一个统一的 apiClient 模块,所有的请求都走这一个入口。核心功能包括:基础 URL 配置、请求拦截器注入认证信息、响应拦截器统一解包、HTTP 错误码映射为业务错误类型、自动带上设备信息用于服务端日志追踪。
// src/apis/client.ts 核心结构 import axios from 'axios'; export const apiClient = axios.create({ baseURL: '/api/v1', timeout: 15000, withCredentials: true, }); apiClient.interceptors.request.use((config) => { const token = authStore.getToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); apiClient.interceptors.response.use( (response) => response.data, (error) => { if (error.response?.status === 401) { authStore.redirectToLogin(); } return Promise.reject(normalizeError(error)); } );这个设计看似简单,但对整个升级的推进帮助巨大。前端切换页面时不需要关心登录态是否过期,拦截器统一处理;后端接口调整返回格式时,只需要改一个解包逻辑,不用满仓库找散落的调用点。后来排查线上问题的时候也方便,所有请求日志都带上了统一格式的 requestId,从浏览器到后端全链路能串起来。
2.4 目录结构向领域而非技术类型划分
旧项目是按技术类型分目录的,比如 components 下面放几十个组件,views 下面放页面,但搜索相关的逻辑散落得到处都是。这次我改成按业务域划分目录,把“搜索”和“助手”作为两个顶层域,分别管理各自的组件、composable、store 和 API 调用。
按领域划分的好处是:改搜索联想时,基本不用跨目录找文件;新同事接手助手模块时,打开一个目录就能看到所有相关代码。这个结构随着模块变大才会体现出价值,项目初期可能感觉只是换了种摆放方式而已。但维护半年后回头看,这个决定省了大量上下文切换的时间。
3. 搜索与助手交互重构:从“能搜”到“好用”的关键细节
3.1 搜索联想下拉:防抖只是起点,竞态处理才是重点
搜索联想是老功能了,升级前就存在,但问题很多。旧实现是每次键盘输入都发一次请求,接口返回后不管用户是否已经输入了新内容,直接把结果渲染到下拉框里。于是出现经典的竞态问题:用户快速输入“Easysearch”,中间可能触发了五次请求,最后渲染出来的却是其中一次慢请求的旧结果。
新的实现里我做了三层处理。第一层是防抖,输入停顿 300ms 后才发起请求;第二层是请求序号管理,后发出的请求如果先返回,直接丢弃之前的结果;第三层是兜底,响应回来之后对比当前输入框的值和发起请求时的值,不一致就放弃渲染。三层下来,联想下拉基本上没有出现过“内容对不上”的情况。
// 简化版:竞态处理逻辑 let requestSeq = 0; inputElement.addEventListener('input', () => { const keyword = inputElement.value.trim(); if (!keyword) { renderSuggestions([]); return; } const currentSeq = ++requestSeq; debounce(() => { fetchSuggestions(keyword).then((items) => { if (currentSeq !== requestSeq) return; // 过期响应 if (inputElement.value.trim() !== keyword) return; // 内容已变化 renderSuggestions(items); }); }, 300); });这些细节对用户来说是隐性的,但直接决定了输入框跟不跟手。升级前联想功能偶尔会“闪烁”——下拉框先出旧词再跳成新词,升级后再没出现过。
3.2 搜索结果列表:虚拟滚动解决十万条数据的卡顿
搜索结果页在大数据量场景下也做了改造。旧实现是一股脑把结果全部渲染到 DOM 里,一次搜索返回几百条还好,返回上万条时页面滚动明显掉帧。这次我引入了虚拟滚动,只渲染可视区域内的 DOM 节点。
虚拟滚动本身的实现不难,难在跟搜索结果的“不定高度”结合。每条结果摘要的行数不固定,直接按固定高度算会导致滚动条跳动。我的方案是:初始渲染时按固定高度估算,等每条结果实际渲染完成后再缓存真实高度,滚动过程中动态修正。这是列表性能优化的通用策略,但在搜索结果页这种“内容高度随机”的场景里,试探了几版才稳定下来。
实现虚拟列表时还有一个容易忽略的问题:滚动容器的 scroll 事件频率非常高,直接绑定会导致主线程持续忙碌。我用 requestAnimationFrame 做了节流,保证滚动时只渲染需要更新的帧,滚动停止后不浪费渲染资源。这个处理让搜索结果的滚动流畅度好了不少。
3.3 智能助手流式渲染:打字机效果不只是一个动画
升级前,智能助手回复的方式是:用户点击发送,请求后端接口,等完整的回答生成后一次性返回,再渲染到页面上。普通问题还好,稍微复杂一点的问题,用户看到的就是长达十几秒的空白等待。这次重点改成了流式返回,后端把回答切分成片段,前端通过 WebSocket 或者 HTTP 流实时接收并渲染,形成打字机的效果。
实现流式渲染之前,我团队内部做过一次小评审,结论是必须用 WebSocket。因为后续的会话功能不只涉及“一问一答”,还有“实时纠错”“推荐追问”等需要服务端主动推送的场景,HTTP 单向流的可扩展性不够。WebSocket 的稳定性问题主要集中在连接保持和重连策略上,这个我在下一节详聊。
前端流式渲染的代码结构:
// 流式渲染核心:接管 WebSocket 消息并递增式渲染 socket.onmessage = (event) => { const chunk = JSON.parse(event.data); if (chunk.type === 'token') { currentText += chunk.data; renderer.update(currentText); } else if (chunk.type === 'done') { renderer.finalize(); } };这里有一个关键点:不要每收到一个 token 就更新整个内容块。如果每条消息都重建 DOM,浏览器会忙不过来,页面反而更卡。我的做法是设置一个渲染调度器,用 requestAnimationFrame 把同一帧内的多个 token 合并为一次 DOM 更新。实测下来,即使后端一秒推送 30 个片段,页面也能保持 60fps 的流畅度。
3.4 会话历史的本地缓存与同步策略
跨设备同步会话,第一版实现是纯服务端存储,前端每次进入助手页面都拉全量历史。后来发现两个问题:一是历史多的时候列表加载慢,二是弱网环境下用户打开页面要等很久才能看到之前的会话。我的优化方案是 IndexedDB 本地缓存 + 后台静默同步。
首次打开助手页时,先渲染 IndexedDB 里的会话,同时向服务端发起同步请求。服务端返回会话列表后,与本地缓存做 diff,有新变化才更新 UI 和本地存储。这样用户在弱网环境下看到的不是空白,而是上一次访问时候的历史会话,体验上会觉得“页面秒开”。会话内容的实时同步则由 WebSocket 负责,新消息进来时同步写入本地。
需要考虑的是缓存失效策略。我设计了一个简单的版本号机制,服务端会话数据发生较大结构变化时递增版本号,前端对比发现版本不一致,就丢弃本地缓存重新拉取。这个机制避免了“旧版本前端 + 新数据结构”导致的缓存解析失败。
4. 性能和网络层实测:首屏、热更新、缓存全面优化
4.1 构建体积控制:路由懒加载 + 手动分包
升级前的页面加载周期是:先拉 jQuery 和几个公共插件,再拉页面自己的业务 JS,最后初始化数据。升级后首屏只加载 Vue 运行时、路由对应的页面代码和公共样式,其余全部按需加载。
Vite 的默认分包策略对单页应用不太友好,容易出现“一个主包包含所有页面代码”的问题。我手动配置了 splitChunks,把 Vue 全家桶、axios、编辑器这类体积大又不常变的依赖拆成独立 chunk,利用浏览器缓存避免重复下载。
// vite.config.js 部分配置 build: { rollupOptions: { output: { manualChunks: { vue: ['vue', 'vue-router', 'pinia'], network: ['axios'], editor: ['monaco-editor'] } } } }这套配置下来,首屏 JS + CSS 经过 gzip 后大约 180KB,相比升级前平均 800KB 的整页资源,降幅很明显。实际首屏在千兆内网环境下从 2.6 秒降到了 1.2 秒左右,外网环境按带宽不同大概在 1.5 到 2 秒之间,体感好了不少。
4.2 WebSocket 重连机制:不能只做“断了就重连”
流式渲染依赖 WebSocket,连接稳定性就是头等大事。我踩过的第一个坑是“重连风暴”——服务端重启后,所有在线客户端的 WebSocket 同时断开,又同时发起重连,直接把网关打挂。这个问题在旧架构里不存在,因为旧架构压根没用 WebSocket。
最后采用的重连策略是:指数退避 + 随机抖动。第一次重连等 1 秒,第二次 2 秒,第三次 4 秒,依次翻倍,最大 30 秒;每次重连前加一个 0 到 1000ms 的随机偏移。这样即使用户量很大,重连请求也能均匀分散,不会出现同时冲击的情况。
重连之外,消息可靠性也要考虑。断开期间用户发的消息不能丢。我的方案是:前端发送消息时先写入本地发送队列,收到服务端确认后再移除;断线重连后,先把发送队列里的消息重新发送,服务端根据消息 ID 去重。这套机制实现下来,实际使用中极少出现消息丢失的情况,比“提示用户重新发送”的体验好得多。
4.3 浏览器缓存策略:强缓存和协商缓存怎么配合
升级后静态资源都带上了 hash 指纹,非常适合强缓存策略。我给静态资源设置了 Cache-Control: max-age=31536000, immutable,也就是一年内浏览器直接从本地加载,不再发请求。而入口 HTML 文件设置为 no-cache,确保每次部署后用户都能拿到最新的版本引用。
这个策略的坑在于:如果 HTML 文件被某些代理或 CDN 额外缓存了,用户会一直加载旧的 JS 文件,导致页面报错。我后来在发布流程里加了一步“缓存刷新”,前端静态资源版本更新时,手动刷新一遍 CDN 缓存。升级初期出现过一次线上用户白屏,排查下来就是这个原因,之后的发布流程规范了就没再出过问题。
4.4 性能指标的前后对比
| 指标 | 升级前 | 升级后 |
|---|---|---|
| 首屏 FCP | 2.6s | 1.2s |
| 整页资源体积 | 约 800KB | 约 180KB |
| 助手首条消息发出前等待 | 3-5s 页面跳转 | 秒开,常驻 |
| 搜索结果渲染 1 万条 | 明显掉帧 | 稳定 |
| WebSocket 断线恢复 | 无此能力 | 平均 3s 内自动恢复 |
这不是实验室数据,都是接入前端性能监控后,在真实线上环境连续记录一周取的中位数。数据本身不惊艳,但考虑到这次升级只是前端层面的优化,后端接口时延没有变化,能有这样的提升已经很说明问题——优化前端资源的加载链路和渲染效率,回报率比想象中高。
5. 升级过程中的深坑记录:最费时间的五个问题
5.1 老接口返回格式不统一,apiClient 拦截器几乎变成“补丁仓库”
升级后第一个拦路虎不是框架问题,而是老接口的返回格式五花八门。有的接口返回 { code: 0, data: ... },有的返回 { success: true, data: ... },还有的直接把数据放在顶层,没有包装。apiClient 的响应拦截器如果强行统一解包,会在线上出现大量解析错误。
我花了两个晚上把所有接口的返回格式摸了个底,整理出一张映射表:哪些接口是标准格式,哪些是历史遗留格式。最终的拦截器没有做透明解包,而是只做错误码拦截和请求 ID 注入,业务代码里通过封装的 getData 函数显式解包。这样虽然改起来多写几行代码,但不会因为“隐式约定”导致线上事故。这次的教训是:历史系统的混乱无法靠一层拦截器抹平,正视现实比理想化设计重要。
5.2 流式渲染偶尔 UI 卡死:ReadableStream 分块处的编码问题
HTTP 流模式实现的初期,出现过一个诡异的 bug:助手回答流式返回时,偶尔会出现输出一半就停住,页面整块白屏。排查了很久,最后发现是后端返回的流数据在传输层被拆成了多个 chunk,某些中文字符被拆成了残缺的 UTF-8 字节,前端解析 JSON 失败,异常冒泡到了渲染层,直接把页面搞崩了。
解决办法是前端做流数据缓冲,不再假设每个 chunk 都是完整的 JSON 片段。维护一个 buffer 变量,收到数据先拼接,再用正则或手动尝试解析完整消息,解析不了就等下一块数据。这个处理是流式渲染的基础功,但确实容易踩,分享出来供大家参考。
5.3 虚拟滚动和移动端触摸的兼容性问题
虚拟列表在 PC 端表现很好,但一到移动端就出现“滚动条拖不动”的问题。排查后定位到:我的虚拟列表实现里对滚动容器的 touchmove 事件做了 preventDefault,导致页面手势被拦截。这个问题的根因是 PC 开发时没留移动端适配的余量。
修起来不复杂,区分平台判断是否拦截默认行为即可。但这个 bug 暴露了一个流程问题:升级期间移动端的真机测试没有做到前置,基本靠开发完再补测。后来调整了节奏,前端改动稍微涉及交互,就先用手机浏览器打开验证一遍,不再攒到最后。
5.4 灰度发布时的双版本并存:路由设计和全局状态不能冲突
升级版新前端需要灰度发布,意味着老页面和新 SPA 会并存一段时间。问题出在路由上:老页面在 /search 和 /assistant 两个路径下,新 SPA 也有同样的路由,灰度策略如果只按域名或者百分比分流,会出现同一用户来回跳转时时而新时而旧,状态不一致。
最终的方案是:所有新前端入口统一挂到一个 /v2 前缀下,老页面保持原路径不变。灰度期间老页面上的侧边栏改为展示 /v2 的链接,引导用户平滑迁移。等新前端稳定运行后,再把 /v2 前缀去掉,替换老路径。这个策略虽然简单,但避免了双版本并存期的大量逻辑冲突和用户困惑。
5.5 回归测试用例:以“真实用户路径”而非“功能清单”来补
升级后期,团队按功能点列了一份回归测试清单,跑下来全绿,结果灰度第一天就出了线上问题。复盘发现,测试用例是“点击 ×× 页面 ×× 模块”,只验证了功能本身,没有按真实用户路径来串联。比如:用户在最新版页面搜索完,又切回老版本页面继续操作,这时候登录态在两者之间传参方式不一致,导致老页面识别登录失败。
后来我要求测试同学从用户视角写路径用例:登录 → 搜索 → 点开助手 → 多轮对话 → 切页签 → 刷新 → 恢复会话,一条链路上的每一步都要验证。这样补出来的用例才真正覆盖了跨页面、跨版本的操作场景。这个经验后来也被用于其他项目的验收标准,前端升级类项目尤其适用。
6. 升级后的复盘:投入产出比和后续待办
6.1 收益不只在性能数字上
重构升级结束后回看,最直接的收益是性能数据改善,但更深层的收益是团队维护信心的重建。旧代码库动一发而牵全身,新人改页面要小心绕开各种隐式依赖;现在模块边界清晰了,搜索和助手可以独立迭代,新功能排期的开发量估算也准确了很多。
用户侧的数据也跟着变好。助手功能的 5 秒内退出率从之前的约 55% 降到了约 20%,会话平均轮次从 1.2 轮提升到 2.8 轮。虽然不能说全是前端的功劳,但交互链路缩短、流式输出带来的“秒回”感,对对话类产品的影响是切实的。
6.2 团队协作流程的变化
升级前,前端和后端的接口约定靠口头沟通,联调阶段互相猜字段类型。这次因为有了 TypeScript 类型系统和 apiClient 统一拦截,接口字段变动会直接暴露为编译错误,很多问题在开发阶段就被拦住了。前后端联调的时间至少缩短了三分之一。
另一个变化是 Code Review 的讨论质量提高了。以前看代码主要盯着“这段逻辑会不会出错”,现在可以讨论“这个领域划分是否合理”“这个 composable 的边界是否清晰”。这种变化不是技术栈自带的红利,而是整体重构后,代码库的复杂度降到了人能读懂的范围,讨论才可能有价值。
6.3 顺手整理的技术债清单
这次升级解决了大部分核心问题,但还有几笔技术债需要后续处理。一是自动化测试覆盖不足,当前核心链路的 E2E 用例只覆盖了搜索和助手主流程,边缘场景还缺不少;二是后端接口的返回格式还没完全统一,虽然前端已经做了兼容,但长期看还是要推动后端规范化;三是可访问性优化刚起步,键盘操作、读屏软件的适配还不到位,这块考虑到产品用户的多样性,后续需要投入专门迭代。
这些债不会自动消失,但至少现在有人在持续记账,并且修债的成本比以前低很多。前端重构最大的意义,就是把修债的成本降到了团队可以承受的水平。
回归到这次升级本身,我的体会是:重构前先把目标定清楚,想清楚要解决什么问题,再选技术和排节奏,比一上来就“把技术栈换成最新的”要靠谱得多。如果你是带着类似历史包袱的前端,真不必羡慕别人家从零搭建的新系统——旧系统里的数据沉淀和业务逻辑,才是升级真正要守住的底盘。