资讯动态

Easysearch智能助手Web端升级实战:Vue3+TypeScript重构与性能优化

发布时间:2026/10/13 18:58:23 来源:尧图企业网站定制
接手 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-age31536000, immutable也就是一年内浏览器直接从本地加载不再发请求。而入口 HTML 文件设置为 no-cache确保每次部署后用户都能拿到最新的版本引用。这个策略的坑在于如果 HTML 文件被某些代理或 CDN 额外缓存了用户会一直加载旧的 JS 文件导致页面报错。我后来在发布流程里加了一步“缓存刷新”前端静态资源版本更新时手动刷新一遍 CDN 缓存。升级初期出现过一次线上用户白屏排查下来就是这个原因之后的发布流程规范了就没再出过问题。4.4 性能指标的前后对比指标升级前升级后首屏 FCP2.6s1.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 用例只覆盖了搜索和助手主流程边缘场景还缺不少二是后端接口的返回格式还没完全统一虽然前端已经做了兼容但长期看还是要推动后端规范化三是可访问性优化刚起步键盘操作、读屏软件的适配还不到位这块考虑到产品用户的多样性后续需要投入专门迭代。这些债不会自动消失但至少现在有人在持续记账并且修债的成本比以前低很多。前端重构最大的意义就是把修债的成本降到了团队可以承受的水平。回归到这次升级本身我的体会是重构前先把目标定清楚想清楚要解决什么问题再选技术和排节奏比一上来就“把技术栈换成最新的”要靠谱得多。如果你是带着类似历史包袱的前端真不必羡慕别人家从零搭建的新系统——旧系统里的数据沉淀和业务逻辑才是升级真正要守住的底盘。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑