Vue3 + TypeScript + Vite构建电影推荐系统前端实战与架构解析
做智能电影推荐系统的前端和做普通后台管理系统的前端完全是两种心境。普通系统的核心是表单、表格、权限而推荐系统的前端你的每一行代码都在直接影响用户看不看、点不点、留不留。推荐引擎算得再准前端展示拉胯转化率照样归零。这套项目是我从零写的完整电影推荐系统前端部分技术栈是 Vue3 TypeScript Vite Pinia覆盖了个性化推荐首页、电影详情、搜索筛选、实时埋点等核心模块。开发周期差不多三周中间重构了两版数据流也踩了不少比较深的坑。如果你正在做内容推荐类产品或者正准备接手推荐系统的前端开发这篇记录值得你花十分钟读完里面有不少可以直接抄作业的方案。1. 项目概述与核心思路拆解1.1 推荐系统前端到底在解决什么问题先说结论推荐系统的前端表面上是把电影列表渲染出来实际上是在完成一件更微妙的事情——在用户和算法之间建立信任感。为什么这么说回到真实场景里看。一个用户打开首页看到的不是自己主动搜索的结果而是系统猜给他的内容。这种被动接收的模式用户天然带着怀疑和防备。电影海报摆在那里他不知道为什么会出现也不知道和自己的口味有什么关系。如果前端只是简单地把算法返回的 item 数组平铺到页面上用户大概率划两下就走了因为他感受不到这个系统懂我。所以前端要解决的核心问题有三个。第一个是解释性怎么把算法觉得你会喜欢这件事翻译成用户能感知、能理解的语言。比如在海报卡片下方展示因为你收藏了某部影片或者和你最近看的科幻片风格一致这种推荐理由不是可有可无的装饰它是建立信任的桥梁。第二个是流动感推荐内容天然是动态的、个性化的需要前端用流畅的交互把这种活的感觉传递出来而不是一个死板的静态列表。第三个是反馈闭环用户每一次点击、评分、划走都是算法的养料前端能否无感地收集这些行为并回传服务端直接决定了推荐效果能不能持续变好。这三个问题我在项目启动前就反复确认过。因为它们的优先级会直接影响技术方案的形态比如解释性决定了推荐数据接口里必须要有 reason 字段流动感决定了我要在首页做横滑卡片流而不只是普通列表反馈闭环决定了埋点要从第一版就接入而不是等产品试用两周再补。1.2 前端在推荐链路中的角色定位想清楚前端在整条推荐链路里的位置架构设计才不会跑偏。一套经典的推荐系统后端链路通常分为离线计算、近线更新、在线服务三段。离线阶段做协同过滤或者向量召回把海量的“用户—电影”关系算成候选集近线阶段根据用户最新的行为做增量更新在线阶段则接收前端请求完成粗排、精排、混排最终吐出一个带有排序分数的电影列表。前端处在链路的最末端但绝不是被动接球的角色。我在整个开发过程中把前端的职责拆成了四块展示层负责把推荐结果以合理的视觉层级呈现出来交互层负责滑动、评分、收藏这些操作的自然度采集层负责把用户行为无感地上报缓存层负责在推荐接口抖动时降级展示不让用户面对白屏。这四块职责里最容易做砸的是缓存层。推荐内容虽然有个性化属性但也没有精细到每秒都变。用户在一个 session 内看到的推荐流保持相对稳定反而有利于建立信任感——如果他上一秒看到的第一部电影刷新后突然沉到底部他会立刻觉得系统紊乱。所以我在前端维护了一份带过期时间的推荐缓存过期时间设置为 5 分钟既保证新鲜度又避免频繁刷新导致的内容跳动。1.3 技术选型的真实考量技术选型阶段我在社区里看到过不少推荐系统前端的方案分享有的用 React 全家桶有的用 Vue 2 配 Nuxt还有的老项目直接用 jQuery 加模板渲染。我没有盲目跟风而是基于当前团队的技术储备和项目形态做了取舍最终敲定 Vue3 TypeScript Vite Pinia。选 Vue3 而不是 React核心原因是团队同学对 Vue 的熟悉度更高Composition API 在逻辑复用上的表现足够支撑推荐场景里大量取数据、塞缓存、上报埋点的逻辑抽离。TypeScript 则是硬性要求推荐场景的数据结构层级深、字段多如果没有类型系统的约束前后端接口联调时双方对字段的理解很容易产生分歧运行期才暴露错误。Vite 带来的开发体验提升也很明显尤其是在组件文件多、页面路由多的情况下冷启动和热更新速度比 Webpack 时代的体感快了一个量级。Pinia 替代 Vuex 的理由比较实际。我需要管理的状态有用户信息、推荐数据、搜索历史、播放记录几个模块Vuex 那种严格的 mutation 写法在跨模块状态不算复杂的场景下显得笨重。Pinia 的 store 即用即建配合 Composition API 可以少写大量模板代码。UI 组件库方面我没有选用重型的桌面端组件库而是以自研组件为主、辅以少量基础组件因为电影推荐产品的界面高度定制化现成组件库的卡片、轮播、评分样式都需要大改引入成本反而更高。2. 前端架构设计与工程落地2.1 目录结构与模块划分搭架构的第一件事是定目录。推荐系统前端不是一个小页面它的业务将来一定会膨胀所以我在项目初始化阶段就把目录按照“业务域”而不是“文件类型”来划分。最终落地的结构大致是这个样子src/ ├── api/ │ ├── modules/ │ │ ├── recommend.ts // 推荐相关接口 │ │ ├── movie.ts // 电影详情、评分、收藏 │ │ ├── search.ts // 搜索联想、结果查询 │ │ └── user.ts // 登录、用户偏好 │ ├── request.ts // axios 实例、拦截器、token 刷新 │ └── types/ │ ├── recommend.ts // 推荐结果类型 │ ├── movie.ts // 电影实体类型 │ └── response.ts // 统一响应包装 ├── components/ │ ├── MovieCard/ // 电影卡片 │ ├── RecoSection/ // 推荐模块容器 │ ├── RatingStars/ // 评分交互 │ ├── SearchBar/ // 搜索栏 │ └── SkeletonBlock/ // 骨架屏 ├── views/ │ ├── HomeView/ // 推荐首页 │ ├── MovieDetail/ // 电影详情页 │ ├── SearchView/ // 搜索结果页 │ └── UserCenterView/ // 个人中心 ├── stores/ │ ├── user.ts │ ├── recommend.ts │ └── search.ts ├── composables/ │ ├── useRecommend.ts // 推荐数据拉取与缓存 │ ├── useTrack.ts // 行为埋点 │ └── useDebounce.ts // 防抖逻辑 ├── router/ └── utils/这个结构的好处是当你想改某个业务功能时相关的组件、接口、类型、状态都在同一个业务域内能找到不需要在几十个目录里跳来跳去。比如推荐首页的改动基本上只在views/HomeView、components/RecoSection、api/modules/recommend.ts、stores/recommend.ts四个地方进行。我们用的是按路由懒加载的方式每个 view 独立打包通过动态 import 引入。首屏只加载首页相关的 chunk详情页和搜索页的代码等用户真正访问时才拉取。首页的推荐模块比较多我把 RecoSection 也做了异步组件拆分配合defineAsyncComponent使用进一步拆分主 bundle。2.2 状态管理与数据流设计推荐系统的前端状态管理和普通后台系统有个显著差异普通系统的状态来自用户操作推荐系统的状态有一大半来自服务端推送的个性化数据这些数据的生命周期、过期策略、替换时机都需要前端精心设计。我在设计 store 时遵循了一条主线用户态影响推荐态推荐态驱动页面态。用户登录后服务端返回新的推荐内容前端根据用户 id 维度和场景维度分别缓存。举个例子我在 recommend store 里维护了下面的状态结构// stores/recommend.ts export const useRecommendStore defineStore(recommend, { state: () ({ // 按场景拆分的推荐列表首页有多个场景模块 scenes: {} as Recordstring, RecoSceneState, // 全局 loading 状态避免多个模块同时请求导致重复 loading loadingCount: 0, // 用户身份切换标记用于清空旧身份缓存 userVersion: 0 }) })userVersion这个字段是我在第一版重构时加进去的。当时线上出现了一个很严重的 bug用户从账号 A 退出登录账号 B 之后首页短暂显示出账号 A 的推荐数据。原因就是旧身份的数据缓存在 pinia 里没有被清理。后来我在用户登录态变更的事件里做了一次 store 重置把这个字段递增同时把当前离线内容请求序列号和后端返回的请求序号做对比只有序号匹配的请求才允许写入状态。关于数据流的另一个重要设计是推荐内容的前端 merge 策略。后端在首页会返回一个大的推荐集合同时也有单独的“为你推荐”接口用于用户下拉刷新时获取新内容。第一次进入首页时拉大集合之后触底加载或下拉刷新时只拉增量。增量和存量数据在前端合并时必须用movieId做去重避免同一部电影在屏幕上出现两遍。这个去重逻辑我还加了保护开关如果后端在增量接口里携带了重复数据前端优先保留旧版本因为用户可能已经看过那部电影的详情页了。2.3 接口层设计与推荐数据对接接口层是我在项目里花最多时间打磨的部分。推荐系统前后端沟通的核心不是接口通不通而是数据契约稳不稳定。我在项目一开始就和后端同学把推荐结果的响应结构定成了下面这样// api/types/recommend.ts export interface RecoResponse { code: number data: { scene: string // 推荐场景如 home_reco / similar_movies items: RecoItem[] // 推荐电影列表 trackId: string // 推荐轨迹 ID埋点必须携带 expireAt?: number // 缓存过期时间戳 requestSeq: number // 请求序号用于防止并发竞态 } message: string } export interface RecoItem { movieId: number title: string posterUrl: string score: number // 预测评分 reason: string // 推荐理由 tags: string[] extra?: Recordstring, unknown // 扩展字段如预告片地址 }trackId这个字段容易被忽略但它对推荐效果评估至关重要。用户在首页点击了某部电影前端上报的埋点里必须带上这条推荐记录的 trackId后端才能知道这次点击是由哪一次推荐产生的进而评估推荐算法的点击率、转化率。没有 trackId 的埋点对算法团队来说基本没有分析价值。axios 实例的封装也做了不少细节处理。我在拦截器里统一处理了三种情况token 过期后的静默刷新、接口超时后的降级处理、不同业务错误码的全局提示。请求超时设置的是 10 秒如果首屏推荐接口在这个时间内没返回前端直接跳过推荐模块展示兜底内容——热门电影列表走的是一个单独的轻量接口这个接口挂在更稳定的资源服务器上避免推荐主链路出问题时首页完全不可用。3. 核心功能实现与交互细节3.1 个性化推荐首页的实现首页是整个系统最复杂的页面它不是一个单独的列表而是由多个推荐模块纵向拼接而成的信息流。常见的模块包括顶部横幅推荐、为你推荐、高分口碑榜、和你口味相似的人在看、最近上映。每个模块的数据来源不同更新频率也不同不能用一个接口一把梭。我把每个推荐模块抽象成一个RecoSection组件它接收 scene 标识和标题内部自己拉取数据、自己渲染骨架屏、自己处理错误态。这样做的最大好处是模块之间完全解耦一个模块数据失败不影响其他模块展示。核心组件长这样template section classreco-section v-ifvisible h2 classreco-section__title {{ title }} span classreco-section__subtitle v-ifsubtitle{{ subtitle }}/span /h2 div v-ifloading classreco-section__skeleton SkeletonBlock v-fori in 6 :keyi / /div div v-else-iferror !items.length classreco-section__error clickretry {{ errorText }}点击重试 /div div v-else refscrollRef classreco-section__row touchstart.prevent.passiveonTouchStart MovieCard v-foritem in items :keyitem.movieId :dataitem clickonCardClick(item) / /div /section /template横滑卡片流是移动端和桌面端通用的交互形态。横向滑动区我用了原生的overflow-x: auto配合 CSS 的scroll-snap-type: x mandatory做滑动吸附没有引入任何轮播库。原生方案在性能上完胜第三方库而且在触控板、鼠标滚轮上也能有自然的横向滚动体验。每个 MovieCard 卡片展示的内容包括海报、标题、推荐理由和预测评分。这里有一个交互细节预测评分显示的是后端算出来的分数而不是真实用户评分所以卡片上我特意加了为你预测的标签避免用户误以为是全网评分。同理推荐理由如果后端给了就展示在卡片底部如果没给整个卡片的布局高度要保持一致防止列表在加载过程中跳动。3.2 电影详情页与评分交互用户从推荐流点击进入详情页这个页面的核心任务是促进转化看预告片、看简介、评分、收藏、跳到相似推荐。详情页的数据分为静态部分海报、简介、主创信息和动态部分当前用户是否已评分、是否已收藏、服务端实时算出的相似影片静态部分走常规接口动态部分走个性化接口两条请求并发发出。评分组件是我比较用心做的一个交互。用户点击星星打分不是弹出一个模态框跳出当前语境而是在海报下方直接展开一排可交互的星星。鼠标移上去有预览效果点击之后立刻上报然后用一个轻量动画把刚才点的星星点亮。整个交互的目标是让用户在两秒内完成表达偏好这个动作因为评分行为本身是推荐系统最核心的正反馈信号。评分的时候还做了一个决策一次评分提交成功后页面上的相似推荐模块会静默刷新一次。这个体验细节是产品同学提出的我一开始觉得有点过度设计但实际做下来发现效果很好。用户评完之后推荐模块会出现因为你刚给《某片》打了高分推荐类似作品让用户直观感受到自己的操作影响了下一次推荐这正是建立信任感的关键时刻。详情页的预告片播放我采用的是点击后拉取一个轻量播放组件而不是直接跳到播放页。播放组件内部处理了视频地址的拼接、封面图占位、播放失败的降级提示。视频播放器的实现没有用第三方库直接用了浏览器原生 video 标签配合poster属性简单可靠还能省掉引入播放器库的体积。3.3 搜索与多维筛选的体验优化搜索在推荐系统里的角色比较特殊。推荐是系统猜你喜欢什么搜索是你自己说你要什么。两者天然互补搜索做得越好用户的主动表达越多推荐算法的素材就越丰富。所以我没有把搜索做成一个孤立的页面而是让它和推荐流产生联动。搜索栏组件用了防抖处理用户输入停止 300 毫秒后才发起联想请求。联想词接口不只是返回片名还会带上类型和年份标签这样用户能看到某电影·剧情·2024信息密度比纯文字高很多。防抖逻辑我抽成了 composable在其他场景也能复用// composables/useDebounce.ts import { ref, watch, onBeforeUnmount } from vue export function useDebouncedSearch(delay 300) { const keyword ref() const debouncedKeyword ref() let timer: number | undefined watch(keyword, (val) { window.clearTimeout(timer) timer window.setTimeout(() { debouncedKeyword.value val.trim() }, delay) }) onBeforeUnmount(() window.clearTimeout(timer)) return { keyword, debouncedKeyword } }搜索结果页按类型做了多维度筛选栏类型、年代、评分区间、时长。筛选条件的变化不会立即触发请求而是先更新 URL 的 query 参数通过监听路由变化再拉取数据。这样做的好处是筛选条件可以分享用户把带参数的链接发给别人对方打开看到的是同样的筛选结果同时浏览器的前进后退也能正确还原筛选状态。搜索结果的展示也复用 MovieCard 组件但根据场景传入不同的展示模式。在推荐流里卡片突出推荐理由在搜索结果里卡片突出评分和年份。组件内部通过 prop 切换展示字段避免为了两个场景维护两套卡片代码。3.4 用户行为埋点与反馈回路埋点这部分我要着重讲因为推荐系统的效果迭代完全依赖数据质量。前端采集的行为数据包括曝光某部电影出现在用户视野中超过 500 毫秒、点击、评分、收藏、滑动速度、停留时长、下拉刷新操作。其中曝光和点击是最重要的两个事件曝光数据的准确性直接决定推荐算法训练时的正负样本质量。埋点实现遵循无感采集原则不在业务代码里到处塞上报逻辑而是通过统一的 track 函数// composables/useTrack.ts import { useUserStore } from /stores/user export function useTrack() { const userStore useUserStore() function track(event: string, payload: Recordstring, unknown) { const body { event, userId: userStore.userId, timestamp: Date.now(), page: currentRoute.value?.name ?? unknown, ...payload } // 批量上报避免高频事件打爆服务端 trackBuffer.push(body) if (trackBuffer.length 10) { flushTrack() } } return { track } }埋点上送做了批量合并攒够十条或者每隔五秒统一发送一次降低网络请求频率。页面卸载的时候用navigator.sendBeacon把剩余缓冲区里的数据送出去避免用户关掉页面的瞬间丢掉最后一批行为数据。sendBeacon 这个 API 在页面卸载场景下比 fetch 可靠得多它不会因为页面销毁而中断请求。MovieCard 的曝光统计用的是 IntersectionObserver观察卡片进入视口后标记曝光同时记录曝光时长。这个时长的阈值过滤对推荐质量评估很重要用户划得飞快时路过的电影不算有效曝光只有停留超过一定时间的才算。我在实现时把阈值设成了 500 毫秒并且只在元素进入视口 50% 面积以上时才触发上报。4. 性能优化与体验打磨4.1 首屏加载与骨架屏方案推荐首页因为模块多、请求多首屏是优化的重点。第一版上线后测了下首屏可交互时间在 2.3 秒左右主要瓶颈是三个并行请求用户态、推荐数据、配置信息的串行等待和图片资源体积过大。经过两轮优化指标降到了 900 毫秒左右核心做了三件事。第一件是请求并行化。把用户态、推荐数据、全局配置三个互不依赖的请求从串行改为并行这一步省了大约 400 毫秒。第二件是图片资源的体积控制。海报图原来直接使用原图一张动辄两三 MB我做了按需尺寸裁剪列表卡片用 300 宽度版本详情页用 800 宽度版本配合 WebP 格式图片传输体积平均降了 70% 左右。第三件是骨架屏这也是体验上观感提升最大的一项。骨架屏不是简单的 loading 动画我按最终页面的结构画了卡片位每个推荐模块在数据返回之前先渲染一排灰色的海报占位块宽度和最终的电影卡片一致。这样用户感知到的不是页面在等数据而是内容在逐渐浮现。骨架屏的动画用了 CSS 的渐变位移模拟扫描光效没有引入额外的动画库。我的经验是骨架屏的占位布局必须和真实内容布局完全对齐否则数据到达后用户瞳孔会在两个位置之间跳来跳去反而比 loading 转圈更难受。为了做到这点骨架屏组件和 MovieCard 组件共用同一个布局类名卡片内部的间距、圆角、标题行高全部一致。4.2 长列表渲染性能优化首页的推荐流和搜索结果页都可能出现长列表。搜索结果最多可以返回两百条电影数据如果一次性全部渲染成 DOM移动端基本必卡。我使用的方案是虚拟滚动窗口内只渲染可视区域附近的卡片上下各加一个缓冲区。虚拟滚动的核心代码并不复杂关键是要理解两个计算滚动容器的可视高度和每个列表项的高度。电影卡片在固定宽度下高度是确定的这给虚拟滚动提供了很大的便利。如果卡片高度不固定需要动态测量复杂度会上升一个量级。// composables/useVirtualList.ts import { ref, computed, onMounted } from vue export function useVirtualList(total: number, itemHeight: number, bufferRatio 0.5) { const containerRef refHTMLElement() const scrollTop ref(0) const viewportHeight ref(0) const visibleStart computed(() { const buffer Math.floor(viewportHeight.value * bufferRatio) const start Math.floor(scrollTop.value / itemHeight) - buffer return Math.max(0, start) }) const visibleEnd computed(() { const buffer Math.floor(viewportHeight.value * bufferRatio) const end Math.ceil((scrollTop.value viewportHeight.value) / itemHeight) buffer return Math.min(total, end) }) const visibleItems computed(() { const result [] for (let i visibleStart.value; i visibleEnd.value; i) { result.push({ index: i, offsetTop: i * itemHeight }) } return result }) return { containerRef, scrollTop, viewportHeight, visibleStart, visibleEnd, visibleItems } }虚拟滚动有一个容易被忽略的坑滚动容器的滚动条位置和可视区渲染内容联动后需要给列表容器设置一个总高度的占位元素否则滚动条的长度会随着滚动不断跳动。我在容器内部放了一个高度为total * itemHeight的占位 div让滚动条的轨道保持稳定。搜索结果页虚拟滚动时每个卡片的内容尤其是图片在滚动时会频繁进入和离开视口对图片的加载策略要格外小心。我采用了图片只加载一次的方案图片加载完成后把地址缓存到内存里即使卡片被移出视口再回来也不会重新请求图片。4.3 图片资源的懒加载与缓存电影推荐场景的图片以海报为主量大、体积大、来源可能是多个 CDN。图片懒加载的实现选择上我没有用第三方插件而是直接用浏览器原生的loadinglazy属性配合decodingasync做解码优化。原生懒加载在兼容性上已经非常成熟而且省掉了大量 JavaScript 判断逻辑。真正麻烦的是图片加载失败的兜底。海报图如果加载失败会显示一个破图对用户体验的伤害非常大。我在图片组件里封装了失败处理加载失败时先用一张内置的渐变占位图替换同时上报埋点记录是哪条电影数据的海报失效了。这样前端不仅兜住了体验还能反馈数据问题给后端。图片缓存方面一个有价值的实践是给海报 URL 拼接协议和版本参数。比如加载失败后重试时URL 上追加?retry1绕过 CDN 缓存避免一直命中那个已经失效的缓存节点。另外对于用户刚刚评分或收藏过的电影海报我会主动把对应图片资源用new Image()预加载这样用户在个人中心查看收藏列表时图片能瞬间展示感知上会非常流畅。5. 踩坑记录与排查实录5.1 推荐接口慢导致的白屏问题项目上线前做了一次弱网模拟结果发现一个很尴尬的情况推荐接口在 3G 环境下响应超过 8 秒首页一直显示骨架屏用户等不到任何内容。虽然接口慢的根源在后端但前端必须有对应的缓冲方案不能看起来像是挂掉了。我的处理方案分为三档。第一档是接口 2 秒内返回正常渲染推荐流第二档是接口超过 2 秒未返回前端先渲染一个轻量的热门电影兜底模块数据从一个不依赖个性化计算的热门榜单接口获取这个接口本身做了缓存响应基本在 100 毫秒内第三档是接口最终失败或者超时前端明确显示错误提示并提供重试按钮。这样即使个性化推荐挂了用户依然有内容可看不至于对着空页面干等。排查过程中还发现了一个前端自己的问题推荐接口的超时时间原来设置成了 15 秒太长了。用户等 15 秒没有反馈早就关掉了页面。后来我把超时调整到 8 秒配合第二档兜底逻辑整体体验有了明显改善。推荐系统的接口超时设置比普通接口短一些是合理的因为推荐内容本身有兜底可降级不需要无限等待。5.2 登录态切换后推荐数据串位这个坑我在前面提过这里详细说说排查过程。现象是用户退出登录后首页推荐流没有清空再登录其他账号时新账号的首页在一瞬间显示出旧账号的历史观看足迹和推荐内容。排查时我先看了 store 的生命周期。Vue 应用里退出登录通常只是清掉 token 并跳转到登录页首页组件实例可能还保持在内存中pinia 里存的推荐数据自然不会消失。解决思路是建立一个身份切换的响应式信号所有依赖用户身份的请求和缓存都要在信号变化时重新初始化。我在 user store 里维护了userVersion登录成功和退出登录时都让这个值递增同时页面级的 watch 监听到变化就触发缓存清理和强制刷新。另一个连带问题是并发请求的竞态。如果用户快速切换登录态前一个身份的推荐请求可能比后一个身份的推荐请求更晚返回导致后登录的用户看到先前的数据被覆盖。解决办法是给每个请求带上身份相关的请求序号数据返回时校验序号是否还是当前最新版本不是就丢弃。这个校验逻辑我已经在前面接口层设计里提过实现不复杂但非常关键。5.3 跨域与 token 刷新竞态问题开发联调阶段遇到最多的问题就是跨域和鉴权。本地开发环境通过 Vite 的 proxy 配置把/api前缀代理到测试服务器线上则依赖网关做同域转发这样前端代码里不需要感知不同环境下地址的变化// vite.config.ts 关键配置 export default defineConfig({ server: { proxy: { /api: { target: http://test-api.internal, changeOrigin: true, // 开发阶段关掉证书校验否则本地 https 证书会拦截 secure: false } } } })token 刷新竞态是个比较经典的坑用户的 token 过期后前端同时发出三个请求三个请求都返回 401于是三个请求各自去刷新 token产生三次刷新请求而且旧的 token 可能已经失效导致刷新接口也报错。正确的做法是做一个刷新互斥队列第一个 401 触发刷新时后续的 401 请求全部挂起等新 token 回来后一起重新发送。// api/request.ts 简化逻辑 let isRefreshing false let pendingQueue: Array(token: string) void [] function onResponseError(error) { const { config, response } error if (response?.status ! 401 || config.url.includes(/auth/refresh)) { return Promise.reject(error) } if (isRefreshing) { return new Promise((resolve) { pendingQueue.push((newToken) { config.headers.Authorization Bearer ${newToken} resolve(service(config)) }) }) } isRefreshing true return refreshToken().finally(() { isRefreshing false pendingQueue [] }).then((newToken) { config.headers.Authorization Bearer ${newToken} return service(config) }) }这个互斥刷新方案我建议做前端的朋友都提前备好因为只要系统里有登录态就一定会遇到这个问题。写的时候要注意刷新失败时一定要清空队列并 reject 所有挂起的请求否则会造成请求永远卡死。5.4 移动端滑动冲突与触摸细节移动端适配这块遇到的坑比较细碎但每一个都直接影响手感。第一个是横滑卡片流和竖滑页面之间的手势冲突。用户在竖滑页面时手指有一定程度的横向偏移浏览器可能把这次操作识别成横滑导致页面在纵向滚动和横向滚动之间犹豫产生卡顿感。我用的方案是给横滑容器监听touchstart和touchmove在touchmove里判断手指移动的横向位移与纵向位移的比例当横向位移明显更大时阻止事件的默认行为否则不干预。这个判断要在事件监听的早期阶段完成否则浏览器已经做出滚动决策就来不及了。第二个坑是 iOS Safari 上 CSSscroll-snap和动态高度容器组合时偶尔出现的白屏。排查后发现是因为滚动容器内部某些卡片的高度在图片加载前后发生了变化导致吸附位置错乱。解决方案是卡片高度固定图片区域用固定宽高比的容器包裹无论图片是否加载完成卡片高度不变这样吸附计算就稳定了。第三个问题是移动端点击延迟。使用touchstart或click事件时300 毫秒的点击延迟在 iOS 上会影响推荐卡片的点击响应速度。处理方式是在全局 CSS 里设置了touch-action: manipulation这个属性可以去掉点击延迟且不影响滚动手势比在 JS 里手动做点击转发要简单可靠得多。6. 给同样在做推荐前端的你一些建议这个项目做完以后我最大的体会是推荐系统的前端难的不是某一帧画面做得多华丽而是把无数个微小的体验细节叠加起来让用户真正产生它开始懂我了的感知。这里分享几个可能对你有用的心得。第一和后端的数据契约要用 TypeScript 类型锁死。推荐接口的字段变更非常频繁算法团队可能随时在响应里增加新的推荐理由字段、调整埋点参数。我在项目里把所有接口响应类型收敛到api/types目录下任何字段变更必须同步修改类型定义跑一次全量类型检查才能合并代码。这个习惯帮我们避免了很多线上运行时才发现的数据结构错误。第二埋点字段的表达一定要和算法、产品同学当面确认。我在项目初期自己拍板了一套埋点命名结果算法同学拿到数据后要对半天才能对上概念后来标准化成exposure_item、click_item、rate_movie这样的事件命名并且在每个事件里统一携带 movieId、trackId、scene、position 四个上下文字段。数据规范化的价值在后续分析时逐渐显现混乱的埋点在数据量变大后几乎不可用。第三不要迷信重型组件库。电影推荐类产品的核心交互是卡片流的滑动、图片的加载、评分的轻盈反馈这些用原生能力加少量自定义代码就能做到非常精细的效果。第三方轮播库、头像库、动画库带来的体积和定制成本往往比自研高得多。我们最终产出的基础包大约只有 200KB在同类产品里算比较轻的。如果你正要启动一个推荐类产品的前端希望这篇项目记录能帮你避开一些我踩过的坑。推荐系统的前端本质上是在做一件很细腻的事——让算法和用户之间产生温度。抓住解释性、流动感、反馈闭环这三个支点你的前端就成功了一大半。