我先说说这个项目的来龙去脉。
标题《基于Vue.js 的天天影视云视听平台的设计》,说白了是要做一个浏览器端就能直接看视频、搜影片、刷榜单的影视类单页应用。这类平台在业内通常被叫做“云视听”或者“在线影视聚合平台”,核心诉求就三个:影片信息要丰富、播放体验要顺畅、用户操作要跟手。而Vue.js在这类场景里恰好是做前端落地最顺手的框架之一——组件化拆分明细、状态管理成熟、路由切换轻量,再加上Pinia和Vue Router这套官方组合,一个人从零到一完成整个前端工程完全可行。
这篇博文不是教学视频的文案,也不是官方文档的翻译,而是我按实际开发节奏走完一遍后的项目复盘。适合谁看?如果你是正在做毕设或个人影视站点的Vue开发者,或者想了解SPA架构如何承载视频播放、搜索、推荐这些重交互业务的人,这篇应该能给你省下不少摸索时间。下面我按模块设计、播放器集成、性能调优、部署上线这条线来拆。
1. 项目立项与核心技术选型:为什么影视平台最适合用Vue去搭
1.1 影视平台的功能模型决定了前端形态
先把这个项目到底要做什么摆清楚。天天影视云视听平台的前端页面,大致可以拆成这么几块。
首页展示的是顶部推荐位、热门榜单、分类入口、最新上线影片列;列表页支持按照类型、地区、年份、评分筛选影片;详情页展示影片的海报、简介、演员信息、相关推荐;搜索页根据关键词实时查询影片;播放页承载视频流的加载、清晰度切换、选集逻辑;再往后就是用户登录、观看历史、收藏列表这些账号功能。
这套业务形态有一个共同特点:页面状态多、组件复用度高、路由层级深。拿“影片卡片”来说,它在首页热门榜、分类结果页、搜索面板、演员详情页里都会出现,形态基本相同只是数据源不同。用Vue的单文件组件把这张卡片抽出来,2分钟不到就能在任意页面复用,这就是组件化对这类品类的天然适配。
另一个关键点是,影视平台本质上是一个“浏览型”应用,用户大多数时间在翻列表、看封面、跳详情,真正停留在播放大页的时间反而不多。这种高频切换、轻量交互的模式,正是SPA的舒适区。传统多页应用每跳一个页面就要重新拉HTML和JS,白屏时间无法接受;Vue Router配合懒加载路由,页面切换只替换必要的组件块,配合浏览器内存缓存可以做到近乎零闪烁。
1.2 技术栈确定:Vue 3 + Vite + Pinia + Vue Router
我最终落地的技术组合是下面这一套。
Vue 3.4作为核心框架,采用组合式API(Composition API)开发业务组件。选Vue 3的理由很实际:Composition API让逻辑复用不再是mixin那一套黑魔法,播放器状态、搜索防抖、数据请求全部可以用usePlayer、useSearch这种自定义hook封装,跨组件共享逻辑时非常干净。
Vite作为构建工具。影视平台涉及大量静态资源——海报图、影片封面、切图,Vite基于ESBuild的预构建速度远超Webpack,开发环境下几乎秒级热更新,这对频繁调样式、调接口联调非常友好。生产构建用Rollup做底层打包,配合代码分割可以按路由拆分JS,首屏载入只加载用得到的那部分代码,后面会详细讲配置。
Pinia替代Vuex做全局状态管理。影视类应用全局共享的状态主要有这么几类:用户信息(登录态、收藏列表、观看历史)、影片数据快照(搜索结果、筛选条件、列表页码)、播放器状态(当前播放剧集、清晰度、进度条位置)。Pinia这几块天然分模块,对应的store拆分是useUserStore、useMovieStore、usePlayerStore,store之间通过action互相调用,逻辑链比Vuex的commit/dispatch那一套直观太多。
Vue Router 4负责路由搭建。项目使用history模式,路由结构上分为主框架路由和业务分组路由,播放页独立出来做全屏布局,后面详细展开。
1.3 Vue 3和Vue 2在影视项目里的实际差异
如果你之前的主力是Vue 2,有几个差异在这个项目里感受会特别明显。
响应式系统换了实现方式。Vue 3的Proxy代理比Vue 2的Object.defineProperty强大得多,数组的索引操作和length修改都能被响应式捕获。在影视平台里,播放列表、搜索结果都是频繁更新的数组数据,你不用再担心“我改了下标为什么页面没更新”这种老问题。
Fragment支持让组件结构更干净。影片卡片这类小组件,以前要么套一个多余的div,要么用render函数返回数组,现在模板里可以直接放多个根节点,布局少了一层元素,这在flex和grid排版里省了不少事。
Teleport组件也帮了大忙。播放页的清晰度切换弹窗、视频倍速设置面板,用Teleport直接挂载到body下,避免被父级容器的overflow: hidden裁掉,这在弹窗类组件里是刚需功能。
2. 天天影视平台的前端架构设计:从目录到路由的数据流
2.1 项目目录结构与模块划分
架构设计的第一步是目录划分。天天影视的前端工程目录遵循“按功能模块聚合、公共层下沉”的原则,具体结构如下。
src/ ├─ api/ │ ├─ modules/ │ │ ├─ movie.js # 影片列表、详情、分类接口 │ │ ├─ search.js # 搜索、热搜词接口 │ │ ├─ user.js # 登录、收藏、历史记录接口 │ │ └─ player.js # 播放地址获取、播放上报接口 │ └─ request.js # axios实例封装,统一拦截器 ├─ components/ │ ├─ common/ # 全局通用组件 │ │ ├─ AppHeader.vue │ │ ├─ MovieCard.vue │ │ └─ LoadingSpinner.vue │ ├─ business/ # 业务容器组件 │ │ ├─ FilterBar.vue # 筛选栏 │ │ ├─ MovieGrid.vue # 影片网格容器 │ │ └─ ScrollLoad.vue # 滚动加载组件 │ └─ player/ # 播放器相关组件 │ ├─ PlayerCore.vue │ ├─ QualitySwitcher.vue │ └─ PlayerControlBar.vue ├─ stores/ │ ├─ user.js │ ├─ movie.js │ ├─ search.js │ └─ player.js ├─ router/ │ └─ index.js ├─ views/ │ ├─ home/ # 首页 │ ├─ category/ # 分类页 │ ├─ detail/ # 影片详情页 │ ├─ player/ # 播放页 │ ├─ search/ # 搜索页 │ └─ user/ # 个人中心 ├─ composables/ # 可组合逻辑 │ ├─ usePlayer.js │ ├─ useSearch.js │ └─ useVirtualList.js └─ utils/ ├─ format.js # 格式化工具 └─ storage.js # 本地存储封装这个结构里有一个容易忽略但极其重要的点——api/modules和stores之间必须保持单向依赖。组件只调用store的action,store通过api模块发请求,组件永远不直接拿axios实例发接口。好处是当后端接口返回结构变化时,你只需要改api层或store里的数据映射,其他页面代码完全不用动。我在项目中期把后端返回的字段从filmName命名为title时,就是靠这个隔离机制只改了一个文件。
2.2 路由设计的边界问题
路由是整站的地图,影视项目的路由设计有两个关键决策。
第一,播放页做独立路由且有自己独立的布局。大多数页面共用顶部导航加内容区的结构,但播放页进入播放模式后需要沉浸式全屏,顶部导航会转移注意力。我在路由配置上用了嵌套路由配合命名视图:
const routes = [ { path: '/main', component: MainLayout, children: [ { path: 'home', component: HomePage }, { path: 'category', component: CategoryPage }, { path: 'detail/:id', component: DetailPage }, { path: 'search', component: SearchPage } ] }, { path: '/player/:id', component: PlayerPage, meta: { fullscreen: true } } ]播放页跳出来做顶级路由后,还有一个好处——播放页内部可以自由控制是否显示底部推荐栏、是否隐藏鼠标,而不会影响其他页面的布局。
第二,详情页的参数传递必须只走URL。很多新手会把影片完整对象直接通过router.push的query或params传来传去,这在刷新页面时数据会丢。我的做法是URL里只保留影片ID(/detail/4399),详情页组件在onMounted里通过ID调用接口拉详情数据。这样保证了任何入口进入详情页都能拿到最新数据,而且用户手动复制链接分享也不怕。
2.3 数据流设计:从接口请求到页面渲染
天天影视平台的数据流可以归纳为一条闭环链路,我在架构图上画下来的顺序是:
- 用户交互触发组件事件
- 组件调用对应store的action
- action内部调用api模块的请求函数
- 请求结果经过拦截器处理后返回给store
- store更新state,计算属性getter重新计算
- 模板中的响应式数据发生变更,视图自动更新
举一个实际例子——首页进入时加载热门榜单。HomePage组件在onMounted中执行useMovieStore.fetchHotMovies(),store的action调api.modules.movie.getHotList(),axios拦截器统一处理loading状态和错误提示,返回的影片数组存入store的hotMovies状态。首页模板通过storeToRefs解构出hotMovies,配合Vue的computed做排序、分组,最后渲染成海报网格。
这套单向下行数据流的好处是排错时特别舒服。页面显示不对,你按链路倒查——先是模板取值对不对,再查store状态有没有更新到,最后看接口数据是否异常,任何一步都能用Vue Devtools定位到,不需要像Vue 2时代那样到处console.log。
3. 播放器模块:天天影视最核心也最容易被低估的组件
3.1 播放器的技术选型和封装边界
一个影视平台能不能留住用户,70%看播放页体验。播放器这个模块我单独抽出来说,是因为它的复杂度远超“页面里放个video标签”这么简单。
选型上我没有直接用第三方的video.js或plyr,而是在原生HTML5 video元素之上自研了一层控制逻辑。原因有三:
一是第三方播放器大多面向通用场景,皮肤固定、控制条复杂,跟影视平台的定制需求(选集、清晰度切换、倍速面板、截图、弹幕扩展位)会有大量冲突改造成本;二是原生video标签在现代浏览器里已经非常稳定,HLS和MP4都能原生播放,根本不需要为“最基本的播放”引入重型依赖;三是自己做控制层,交互完全可控,后续接清晰度切换、记忆续播、加密视频这些需求时不会受制于插件的开放程度。
封装上我拆了两个层次:底层是PlayerCore.vue,只负责处理video元素本身、事件监听(loadedmetadata, timeupdate, ended等)、对外暴露播放暂停切换进度等基础props和emit;上层是同业务绑定的容器组件,负责加载播放地址、更新清晰度选项、联动选集、处理播放记录埋点。这样做的边界是:核心播放器不关心你的影片分类,业务层也不碰video的底层API。
3.2 播放地址获取与清晰度切换的实现
影视平台的视频源往往不是单一清晰度,后端接口会返回多个清晰度的播放地址。天天影视的播放地址接口数据格式大致长这样:
{ "code": 0, "data": { "movieId": 4399, "episodes": [ { "episode": 1, "sources": [ { "quality": "sd", "label": "标清", "url": "https://cdn.example.com/video/4399-1-sd.m3u8" }, { "quality": "hd", "label": "高清", "url": "https://cdn.example.com/video/4399-1-hd.m3u8" }, { "quality": "uhd", "label": "超清", "url": "https://cdn.example.com/video/4399-1-uhd.m3u8" } ] } ] } }清晰度切换这块,我采用的策略是销毁重建video元素。PlayerCore.vue内部通过:key="currentQuality"来强制重新创建video标签,切换清晰度时记录当前播放时间,新元素触发loadedmetadata后跳回原进度。
这个方案的取舍在哪?市面上很多播放器会做多分辨率自适应(MediaSource切换),但实现成本极高,而且对切片格式有要求。天天影视走的HLS流,在支持原生HLS的Safari和Edge上性能很好,Chrome需要hls.js转MSE来播放,不同清晰度切换本身伴生的加载缓冲无法彻底避免,那不如就做干净利落的销毁重建,让用户看到明确的加载提示而不是诡异的卡帧。
配合清晰度记忆功能,用户在第一次选择超清后,后续打开同部影片会默认加载超清源,这个偏好存在localStorage里。要不要提供这个能力?我认为要,因为反复切换清晰度是播放体验里最影响连续感的行为之一。
3.3 续播、观看历史与进度条拖动
续播机制是云视听平台区别于普通视频网站的核心体验之一。用户上次看到32分15秒,今天打开同一部剧的同一集,播放器应该直接从32分15秒开始播放,而不是从头再过一遍广告和前情提要。
实现上分三步:
第一步,timeupdate事件每5秒上报一次进度,写入后端用户观看历史接口;前端同时写一份到localStorage做兜底,后端挂了本地也不丢。
第二步,播放器组件onMounted时从store里读viewHistory,命中当前影片当前集就调video.currentTime = savedTime。
第三步,拖动进度条时,在seeking事件里做一个“拖过缓存区就显示loading,拖到缓冲完成再播放”的状态机,避免用户拖到一个新位置后画面卡死。
为了避免频繁请求把后端打崩,进度上报必须做节流处理,我在usePlayer.js里包了个定时器逻辑,每5秒钟最多上报一次。这个细节在开发时不起眼,但真正上线后对服务端的压力差距是天壤之别。
3.4 小窗模式和全屏适配
天天影视加了画中画小窗播放功能,这是移动端场景里的刚需。用户在看电影时切出去刷信息流,播放器缩小到右下角悬浮窗,视频不中断。
桌面端我基于浏览器原生的Picture-in-Picture API,调video.requestPictureInPicture()即可实现,代码只关联video元素本身;移动端H5没有系统级画中画,我用了一个固定定位的迷你Player组件,大小约320×180,挂在全局app下面,播放页离开时不销毁PlayerCore而是把它传进全局store,这样路由切换后播放器仍在运行。
全屏适配方面有两个容易被忽略的细节:一是iOS Safari上video.webkitEnterFullscreen()才能触发真正的系统全屏;二是全屏状态下键盘事件监听的是fullscreenchange后的新上下文,监听器要在全屏元素上而非window上。这两处我在联调时都踩过,会排在后面的踩坑实录里细说。
4. 影视数据的列表渲染与页面性能优化
4.1 长列表的虚拟滚动:网剧列表不卡顿的关键
影视平台的首页和分类页会加载大量影片卡片,一次请求返回50条、100条甚至更多。如果直接把整个数组v-for渲染出来,DOM节点数量会急剧增加,页面滚动时帧率直线下降。
解决这个问题的标准方案是虚拟滚动——只渲染视口内可见的列表项,其他项用空白占位撑高度。天天影视里我实现了一个通用的useVirtualListcomposable,核心逻辑是:
- 监听滚动容器的
scroll事件,计算当前视口的起始索引和结束索引 - 只渲染这个区间内的影片卡片组件
- 用一个totalHeight撑起滚动条的高度,让滚动条长度和内容总长一致
export function useVirtualList(total, itemHeight, containerRef, overscan = 5) { const startIndex = ref(0) const endIndex = ref(0) const scrollTop = ref(0) function updateVisibleRange() { const el = containerRef.value if (!el) return scrollTop.value = el.scrollTop const viewportHeight = el.clientHeight const start = Math.max(0, Math.floor(scrollTop.value / itemHeight) - overscan) const end = Math.min(total.value, Math.ceil((scrollTop.value + viewportHeight) / itemHeight) + overscan) startIndex.value = start endIndex.value = end } return { startIndex, endIndex, scrollTop, updateVisibleRange, totalHeight: computed(() => total.value * itemHeight) } }这个组件的体验优化非常直观——首页从500个DOM节点直接降到视口内可见的20个左右,滚动跟手度提升明显。注意overscan参数的作用,它让视口上下各多渲染几个节点,快速滚动时不容易露白,这个经验是我反复调出来的。
4.2 图片资源的懒加载与占位策略
影视海报图片的体积通常很大,在弱网环境下,一屏20张海报如果全部同时加载,用户的流量和首屏等待时间都受不了。懒加载是必须的。
天天影视没有引入懒加载插件,而是基于IntersectionObserver自己实现了一个v-lazy指令,核心代码非常简单:
const lazyDirective = { mounted(el, binding) { const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) { el.src = binding.value observer.disconnect() } }) observer.observe(el) } }配合图片懒加载,还有一层是低质量占位策略。我用一张非常小的Base64模糊图作为海报的默认背景,真实海报加载完成后替换上去。这张占位图体积只有几百字节,但能避免图片未加载时区域空白导致的布局抖动。
布局稳定性同样重要。海报卡片的宽高比固定为2:3,使用aspect-ratio: 2/3属性提前撑开高度。如果图片加载前容器高度为0,加载后会顶飞下面的元素,用户会看到列表位置不断跳动,这个问题在移动端尤其明显。
4.3 keep-alive缓存页面状态
影视平台用户在首页和分类页之间来回切换的频率非常高,如果每次回退都重新请求接口、重新渲染列表,体感会很糟糕。Vue Router的keep-alive指令专门解决这个问题。
我在路由配置里的MainLayout外层套了keep-alive,并且用meta.keepAlive控制哪些页面缓存:
<router-view v-slot="{ Component }"> <keep-alive> <component :is="Component" v-if="Component && Component.meta?.keepAlive" /> </keep-alive> <component :is="Component" v-if="Component && !Component.meta?.keepAlive" /> </router-view>首页、分类页开启缓存之后,用户返回时列表位置和请求结果都被保留,体验几乎和原生App一致。但这里有一个需要注意的坑——缓存的页面onMounted只会执行一次,后续重新进入走的是onActivated钩子,如果需要在每次进入时刷新数据,记得把接口请求放在onActivated里而不是onMounted里。我在开发时就把“返回首页自动刷新榜单”这个需求写错地方,排查了半天才发现钩子问题。
4.4 组件缓存与响应式依赖的取舍
Vue 3的响应式系统是细粒度的,但也不是所有状态都需要实时响应。在天天影视里,我给影片卡片组件做了明确的props定义,避免整个影片对象作为props传递后组件内部产生过多响应式依赖追踪。
一个优化细节是使用shallowRef或markRaw标记不参与深层响应式转换的数据。比如播放器实例、分页配置这类不会被模板频繁更新的对象,用markRaw包裹后减少了Vue代理的开销。再比如分类页的筛选条件对象,通过reactive保持响应性,但筛选结果列表则用shallowRef存储,因为列表更新时只需要整体替换,不需要深层监听每一项的字段变化。
这些优化手段在小项目里也许感觉不到区别,但当天天影视的数据量涨到几千部影片、上万个海报节点时,它们就是页面流畅度从可用到优秀的分界线。
5. 搜索与推荐:影视平台体验的分水岭
5.1 搜索防抖与实时联想
影视平台的搜索框一般承载两种功能:用户输入关键词后回车触发完整搜索结果;输入过程中实时展示联想词条(热搜、相关影片)。
这两个场景都离不开防抖。不做防抖的话,用户每敲一个字符就发一次请求,键盘输入很快,几毫秒内能打出十几次请求,后端和浏览器都被无意义请求淹没。我的useSearchcomposable里封装了一个标准的防抖函数:
function useDebounce(fn, delay = 300) { let timer = null return (...args) => { clearTimeout(timer) timer = setTimeout(() => fn(...args), delay) } }联想词条的请求在300毫秒防抖后发出,完整搜索请求在用户点击搜索按钮或敲击回车时发出。实际测试下来,300毫秒的防抖时间既能覆盖大部分输入停顿,又不会让联想严重滞后。
搜索页还有一个容易被忽略的处理——请求竞态。用户输入“长津湖”后按回车,紧接着又改了词搜“流浪地球”,两个请求可能同时发出,后发请求不一定后返回。如果先发的慢请求后到,页面上就会显示错误的旧结果。我的解法是给搜索请求加一个递增的请求序号,只处理最新序号对应的响应,旧响应直接丢弃。这种竞态问题在异步请求场景里非常普遍,尤其做输入型交互时必须处理。
5.2 筛选条件的状态建模
分类页通常提供类型、地区、年代、排序方式等多个筛选维度。这些条件组合后的状态管理要特别清晰。
我在useMovieStore里用一个filterState对象保存所有筛选条件:
const filterState = reactive({ type: 'all', region: 'all', year: 'all', sortBy: 'hot', page: 1, pageSize: 20 })任何筛选条件的变更只需要修改filterState对应字段,store的action监听变化后自动重新请求列表并重置页码。页面组件只需要把筛选组件的v-model绑定到filterState字段上,完全不需要自己管理请求时机。
出问题的点在于“改变筛选后要不要自动滚动回顶部”。我一开始的版本没有做,用户从列表中间位置筛选,结果列表刷新后页面还停留在原来的滚动位置,感觉像数据没变。后来在watch(filterState, ...)回调里手动调用滚动容器的scrollTo(0, 0),并且重置虚拟滚动组件的scrollTop,问题解决。
5.3 推荐位和榜单的简单有效策略
影视平台的推荐位不用上复杂的推荐算法,在初期阶段,“运营规则+数据统计”的思路就够用。
天天影视首页的推荐位我设计了四种形态:编辑精选(固定推荐N部影片,由后端配置)、热门榜单(按播放量统计TOP20)、最新上线(按上映时间倒序)、正在热播(按最近7天播放增速排序)。这些推荐数据全部由后端接口返回,前端只是根据不同维度渲染不同的卡片样式。
用户详情页里的“相关推荐”模块则基于简单的标签匹配——同一主演、同一类型、相似年代优先匹配,匹配度排序取前四部展示。这种方案实现简单、推荐结果不算差,初版完全够用。等用户量上来以后再考虑基于协同过滤的个性化推荐,这是所有中小型影视平台的标准演进路线。
6. 开发期间踩过的坑:播放器、路由、Git协作三座大山
6.1 播放器事件监听的内存泄漏
开发时出现过一次“播放页进进出出几十次之后页面越来越卡”的现象,用Chrome Performance面板一查,发现每次进入播放页都新增了多个sources监听器,退出后没有移除。
原因很典型:我在PlayerCore.vue的onMounted里做了video.addEventListener('timeupdate', handler),但在onUnmounted时忘了移除。Vue 3组件的卸载确实会自动清理DOM元素,但事件监听器不会自动移除,每次进出都会累积。
修复方式有两类:一是手动在onUnmounted里逐个removeEventListener;二是更推荐的做法,在组合式API里直接用onScopeDispose来统一回收。如果是用第三方播放器库(比如hls.js),还必须在销毁时调hls.destroy()释放资源。这个坑本质上是组件生命周期管理的疏忽,但凡是做播放器、地图、富文本这类第三方库集成时都会遇到,值得记下。
6.2 路由切换导致播放中断
另一个播放器相关的问题是路由切换时播放声音还在。用户从播放页跳回详情页,按道理播放应该停止,但声音还在响。查下来是因为我在跳转时没有回收播放器组件,由于keep-alive缓存了播放页,PlayerCore其实没被销毁,导致后台仍在播放。
解决思路:播放页本身不该被keep-alive缓存,因为播放状态太大,缓存整个播放器成本太高,而且播放页通常是一次性使用场景。我把播放页路由的meta.keepAlive显式设为false,同时在onBeforeRouteLeave守卫里调用播放器暂停方法:
onBeforeRouteLeave(() => { playerRef.value?.pause() })还要处理的是从播放页跳到详情页推荐影片时,旧播放器如果没释放,声音会在新页面里继续。在路由离开时暂停是一种兜底,但如果用户从播放页直接切换回首页,就需要在播放页组件卸载时彻底销毁PlayerCore释放video资源。这两种场景我在代码里都做了对应的守卫逻辑。
6.3 搜索页兼容性问题:中文输入法
中文输入法遇到搜索框时有一个很隐蔽的bug——用户输入拼音时,输入法组词的过程会触发键盘事件,如果搜索逻辑监听的是keyup或者input事件且没有处理compositionstart/compositionend,就会在拼音拼完之前触发多次搜索请求,结果搜出来一堆不相关的内容。
解决方案是监听composition事件。在拼音输入期间(compositionstart执行后到compositionend执行前),所有搜索请求都被挂起;等到compositionend触发后才读取输入框的完整值并搜索。Vue自带的v-model指令在部分情况下也会受到composition事件影响,所以搜索框里我改用了原生的@input+compositionstart/compositionend管理状态。这个小坑基本上每个做搜索功能的中文开发者都会遇到一次。
6.4 Git协作时路由文件冲突不断
项目做到中期,我和另一个同学协作开发功能分支,几乎每次合并分支都冲突在同一个文件——router/index.js。原因很简单:每个人都往路由表里加自己负责页面的配置段,但行数又十分接近,Git合并时无法自动判定哪个是哪个。
后面总结出一套缓解办法:路由表按模块拆分成独立文件,然后在index.js里统一合并。比如router/modules/home.js、router/modules/category.js、router/modules/user.js,每个人只改属于自己的模块文件,冲突少了很多。如果连模块文件也冲突,那就是工作分工有问题,得先停下来对齐功能边界。这个经验在多人协作项目里几乎通用——凡是“所有人都要改”的公共文件,都值得做拆分隔离。
6.5 开发环境的跨域代理和正式环境的nginx配置
本地开发调接口时,Vite提供的proxy代理帮了大忙。配置非常简单:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } })但部署上线后,接口跨域就不是前端能解决的问题了,需要在nginx层做反向代理:
location /api/ { proxy_pass http://127.0.0.1:3000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }这两个配置虽然简单,但很多新人在本地跑通后把代码扔到生产环境,接口全挂,就是因为没有做对应的生产环境代理配置。天天影视这种前后端分离项目,跨域问题的正确解法就是:开发靠vite proxy,生产靠nginx代理,前端代码里不写任何完整的后端地址。
7. 部署上线前的性能体检与构建优化
7.1 首次交互时间的优化:按路由拆包
天天影视的主包如果全部打包成一个JS文件,首屏加载会非常慢。优化手段是按路由做代码分割,让每个页面的JS只在被访问时才加载。
Vue Router配合Vite的defineAsyncComponent或动态import实现按需加载,最常见的写法:
{ path: '/detail/:id', component: () => import('@/views/detail/DetailPage.vue') }打包后,DetailPage.vue相关代码会单独拆成一个chunk,用户在首页时只加载首页的chunk,点击进详情页时才额外加载详情页的代码。首页的JS体积从原来的300KB降低到120KB左右,配合gzip压缩后的传输体积更小,体感加载速度提升明显。
但拆包不是拆得越细越好。分太细会导致小文件太多,浏览器并发请求受限反而更慢。天天影视把页面分为两类:主框架相关的公共代码(UI组件、状态管理、axios封装)打进vendor包做长期缓存;每个一级路由单独一个chunk。两个优化方向之间需要找一个适合自己项目的平衡点。
7.2 静态资源的CDN分发
影视平台最大的静态资源是海报图片和视频切片。视频切片走专门的视频CDN,海报图片则通过对象存储服务绑定CDN域名,前端代码里的图片URL全部使用CDN地址。
Vite构建时生成的静态资源(JS/CSS/图片)默认会带上hash,比如index-abc123.js,hash变化代表文件内容变化,浏览器就能自动弃用旧文件加载新文件。在vite.config.js里我配置了base: '/static/',让生产环境静态资源地址正确指向CDN或者是nginx的static目录。
另外要给CDN域名配置跨域头。海报图片加载还好,但如果是WebGL做特效或视频封面等场景,跨域资源请求会失败。我在nginx的图片location里加了add_header Access-Control-Allow-Origin *,保证图片在各种canvas场景中能被正常解析。
7.3 性能指标检查清单
上线前我给自己列了一个体检清单,照着走一遍基本能暴露大部分性能问题。
首屏加载时间用Lighthouse检测,目标移动端3G网络下首屏可交互时间(TTI)控制在5秒以内。如果超过,优先检查入口JS体积、图片请求数量和是否有同步阻塞脚本。
接口响应时间用浏览器Network面板观察,主接口(首页列表)从发起到完成不超过500ms。如果超过,前端层面要确认有没有多余请求,后端层面则需要检查数据库查询。
滚动帧率在列表页快速滚动时观察,Chrome Performance面板记录flame图,看长任务(Long Task)是否频繁出现。虚拟滚动如果没做,这个指标一定会崩。
内存占用在播放页连续切换20集后,用Memory面板堆快照对比,确认没有持续增长。如果持续涨,优先检查播放器资源是否释放、事件监听是否残留。
这四项是影视平台最核心的性能指标,每一项都有对应的优化手段,我上面讲到的虚拟滚动、懒加载、keep-alive、监听器清理、路由拆包,都是为了这些指标服务的。也是实际开发中真正让项目从“能跑”变成“能上线”的关键步骤。
7.4 构建产物分析与包体积控制
Visualize打包后的chunk体积时我用的工具是rollup-plugin-visualizer,在vite.config.js里加一个插件配置就能生成可视化的依赖图谱。看了一遍之后发现,一个意想不到的包体积大头是我在全局引入了一个完整的地图渲染库,但实际业务里只用到了其中很小一部分功能。当时直接改成了按模块引入,包体积立减200KB。
影视平台项目通常还会用到日期处理、格式化工具、图片懒加载等公共依赖,建议用ESModule按需引入,而不是整包引入。比如日期格式化我自研了一个utils/format.js,只处理项目用到的“X年X月X日”“X分钟前”这些格式,完全没有必要为了这些简单逻辑引入moment.js或dayjs这类重库。
构建完成后的产物还建议开启gzip或brotli压缩。天天影视的nginx配置里我开启了brotli压缩,前端JS文件的传输体积直接减少约70%,这个优化几乎零成本,但收益非常可观。
8. 一些更长远的设计思考:如果项目继续迭代,我会这么做
天天影视目前是一个功能完整的云视听平台前端工程,但如果作为长期项目来迭代,有三个方面值得尽早布局。
第一是接入弹幕系统。弹幕是影视类平台的社交功能,技术实现并不复杂——新建一个WebSocket服务接收弹幕消息、前端在播放器上层叠加一个弹幕轨道Canvas层、后端做弹幕池管理。关键的难点在于弹幕显示的时间同步,需要配合播放器的currentTime做定时扫描渲染。这个功能如果现在不做,视频播放架构至少要留出“播放器上方可以叠加透明覆盖层”的能力。
第二是登录态与会员体系。目前用户登录主要是记录观看历史和收藏,一旦要做会员专享内容,前端需要在路由守卫生效前校验用户的会员状态,并区分“游客模式”“普通用户”“会员用户”三档权限。这些状态管理可以进一步细化到useUserStore的权限计算属性里,拦截逻辑统一放在全局路由守卫中,而不是每个页面自己判断。
第三是PWA离线访问能力。影视平台看似和离线无关,但用户可能在信号不好的地铁站想浏览影片信息。通过PWA的Service Worker能够缓存应用外壳页面(导航、首页框架、海报占位图),让用户在没有网络的状态下先看到部分内容,网络恢复后再拉取详细数据。这也算是一种体验优化,实现成本在于SW缓存策略的设计,需要避开“缓存了过期数据就永远看不到新内容”的坑。
这些扩展方向从技术难度上看都不算大,但它们都建立在一个干净、可维护的前端架构之上。天天影视的模块拆分、store管理、组件封装方式保证了这些功能接入时,不需要推倒重建,只要在现有模块里加插槽、加状态、加路由而已。
最后再分享一个小技巧:在开发这类中大型Vue项目时,建议从一开始就保持“组件内部状态优先、store存共享状态、接口数据以模块化隔离”的习惯。虽然短期内看起来多写了几行配置代码,但项目推进到中后期,你会感谢这些当初的克制。毕竟影视平台这类项目,需求变化是最快的,前端架构留出的弹性空间,就是项目的生命力。