都说后端项目看状态流转,前端项目看交互设计。但如果你想把 Vue 3 和 TypeScript 同时练扎实,音乐类项目确实是一个绕不开的样本——它既需要真实的网络请求和状态管理,又需要处理音频播放、数据缓存、列表渲染这些高频场景。我在复盘 vue-typescript-music 这个项目时,最大的感受是:它不是单纯教你怎么把页面画出来,而是把一套小型商业应用该有的技术决策全塞进来了。这篇文章我会从工程搭建、网络层、状态流、播放器、搜索优化到构建调优,把项目里值得吸收的东西拆开来讲,特别适合已经能写基础 Vue 组件、但还不清楚一个完整项目如何分层和组织的开发者。
1. 音乐项目为什么值得拆解:它把前端核心场景都撞了一遍
1.1 从业务场景反推技术需求
很多人一开始会觉得音乐项目不就是“放个音频、切切歌”吗?真的做完一遍才会发现,音乐类应用是少有的能把前端高频场景全部覆盖一遍的项目形态。
首先是数据获取,榜单、推荐位、搜索结果这些页面都需要请求接口,而且请求之间还有依赖关系;其次是状态管理,播放器不能因为用户切换了页面就停止,当前播放的歌曲、播放模式、播放进度必须是全局状态;再次是性能优化,排行榜和搜索建议通常都是长列表,不做处理滚动起来就会明显掉帧;最后是移动端兼容,音频自动播放限制、音频中断恢复、缓存策略这些坑是普通管理后台根本遇不到的。
一个完整的音乐项目,本质上是把“信息展示型页面”和“播放器型应用”两种体验绑在一起,前端开发者能想到的绝大部分技术点都能在这个项目里找到落地场景。这也是 vue-typescript-music 这类项目能持续被推荐的原因——它不是玩具项目,而是复杂度刚好卡在“足够练手又不会失控”的区间。
1.2 TypeScript 在这里不是可选项
如果只是用 Vue 写音乐项目,JS 完全也能跑通,但加上 TypeScript 之后,整个项目的开发体验会发生质变。最典型的就是接口数据的类型保护。
音乐项目的数据结构普遍嵌套很深,比如一个榜单页返回的歌手信息、专辑信息、歌曲时长、音质标识,字段动不动就是三四层。如果用 JS 写,所有字段都得靠记忆和文档,某个字段拼错了只能等运行时报错;但项目里的接口类型定义完整之后,编辑器就能直接提示“这里的 duration 是 number,不是 string”,很多低级错误在写代码的时候就被拦下来了。
还比如播放器状态,播放状态、当前歌曲、播放列表、播放模式,这些数据在各种组件里被共享。TypeScript 的联合类型和穷尽检查可以保证状态流转不出格,比如播放模式只需要写成'sequence' | 'loop' | 'random',就不可能出现“第三种模式”这种跑着跑着崩掉的情况。
1.3 项目拆解应该按什么顺序看
我不建议一开始就从头到尾读源码,那样很容易陷进某个组件的细节里出不来。我自己的拆解顺序是:先看工程配置,搞清楚项目是怎么搭起来的;然后看接口层,确认所有数据从哪来、怎么被加工;接着看状态仓库,理解全局数据如何流转;再看播放器这个核心模块;最后回到业务页面,看这些状态和数据是怎么被消费掉的。
这个顺序其实是按照数据流向来的——配置决定你怎么跑,接口决定你有什么数据,状态决定数据放哪,组件决定数据怎么展示。接下来的内容也会按这个思路推进。
2. 工程底座与类型体系:从脚手架到 tsconfig
2.1 初始化工具链的选择
vue-typescript-music 的工程底座用的是 Vite,这一点在现在来看基本没有悬念。Vite 开发服务器的启动速度和热更新速度都比传统打包器快一个量级,尤其是项目里涉及大量单文件组件编译的时候,体感非常明显。
初始化时用的命令是常规的 Vue 官方脚手架。需要注意的关键点在于,创建时要选上 TypeScript 和 Vue Router,因为音乐项目天然需要多个页面,路由懒加载也能顺便把分包的问题一起解决。项目还用了 Sass 作为样式预处理方案,全局变量和 mixin 在歌曲列表、播放器这类复用的地方很省事。
一个值得借鉴的小细节是把@路径别名启用了。没有别名的时候,组件里经常要写../../../../types/song这种东西,一旦调整目录结构就会碎一片引用。有了别名之后,所有导入都从@/开头,路径和目录的耦合度大大降低。这个习惯建议所有中大型项目都养着。
2.2 目录按“能力”而不是按“页面”切分
我看目录结构的时候比较在意一点:它是按“页面”切还是按“能力”切。后者对复杂项目的长期维护更有利。这个项目的目录大致是:
api/:所有接口请求函数的集中地,每个页面模块对应一个文件types/:全局共享的类型定义,歌曲、专辑、歌手、榜单等实体都有单独的类型store/:Pinia 状态仓库,播放器、用户信息、播放列表等模块分开components/:通用组件,比如歌曲列表、加载状态、空状态views/:路由页面级组件,只负责拼装数据和传递事件utils/:工具函数,比如歌曲时长格式化、歌词解析、防抖封装hooks/:可组合的逻辑函数,把音频播放、列表加载等逻辑从组件里抽出来
这种分法的好处是“高内聚、低耦合”在目录上就能看出来。比如一个排行榜页面,它需要的数据方法在api/top.ts里,需要的数据类型在types/song.ts里,公共展示逻辑在组件里,页面本身只保留组装逻辑。后面想加一个类似的页面,基本就是复制页面结构然后换接口。
2.3 tsconfig 里值得照抄的配置
一个音乐项目的 TypeScript 配置通常有几个关键设置,我建议直接对着抄。
strict必须开。很多人初学 TypeScript 会觉得 strict 太烦人,总是报“可能为 null”,但这个报错的本质是把运行时的空指针风险提前暴露在编译期。组件里最常见的ref初始化,比如const audioRef = ref<HTMLAudioElement | null>(null),配合 strict 模式就能逼着你在访问.play()之前做空值判断,这对播放器这种大量操作 DOM 元素的项目尤其重要。
noImplicitAny也应该开。接口里经常会有一些临时返回、嵌套对象,关闭这个选项很容易让大家写业务代码时“偷懒”用any,最后类型保护全线失守。
路径别名的配置需要同时出现在tsconfig.json和 Vite 配置里,否则编辑器识别了但打包器不认,或者反过来报错。resolve.alias和compilerOptions.paths两处保持一致,这个问题才算真正闭环。
2.4 环境变量与接口地址管理
项目里还应该有一个好的环境变量设计。开发环境、测试环境、生产环境的主机地址都不一样,如果直接写在常量文件里,每次发布都要改代码。我通常是在项目根目录放.env.development和.env.production,里面写VITE_BASE_URL,然后网络层的封装统一从import.meta.env.VITE_BASE_URL读取。音乐项目的接口往往还涉及跨域代理,开发阶段在 Vite 的server.proxy里代理一下生产环境的主机地址就可以了,这样也能避免把线上地址直接暴露在浏览器网络面板里。
3. 网络数据层:请求封装、类型保护与缓存策略
3.1 为什么网络层能决定整个项目的稳定性
音乐项目的数据来源比较杂,有榜单、有实时搜索、有歌曲详情、有播放地址,而且不同接口的错误处理方式还不一样。网络层如果不做统一封装,业务代码里就会出现大量重复的loading状态判断、错误弹窗和请求模板,最终的结果就是项目越大越难维护。
更实际的问题是请求并发和重复请求。用户从推荐页切到搜索页再切回来,网络请求可能已经发出去好几个,响应回来的数据顺序一旦错位,页面展示就会出现“旧数据覆盖新数据”的诡异现象。这个问题在组件内单独处理很难根治,必须靠网络层统一解决。
3.2 封装一个可用的请求函数
这个项目里网络层的设计思路可以抽象成三层:拦截器负责公共处理,请求函数负责具体接口,业务模块负责参数组织和数据消费。核心是封装一个带泛型的请求函数,大致长这样:
import request from './instance' interface ApiResponse<T> { code: number data: T message: string } export function getCurrentUser<T>(params: Record<string, any>) { return request.get<T, ApiResponse<T>>('/user/detail', { params }) }这里有个容易被忽略的细节:request.get之后拿到的应该是已经解包过的data,而不是整个响应体。我见过不少项目在组件里写res.data.data.songList,这种写法等于把网络层的职责泄露出去了。封装的时候应该在拦截器里统一判断业务状态码,成功就把数据字段丢给业务,失败集中处理错误提示,业务层拿到的永远是最干净的结果。
拦截器还需要做“取消重复请求”的处理。音乐项目里最容易重复请求的是搜索建议接口,用户每敲一个字母就可能触发一次,连续的abort或者标记忽略过期请求,能够很大程度避免响应顺序错乱。常见做法是在请求拦截器里根据url + 参数生成一个请求键,如果相同键已经有一个请求未完成,要么复用上一次的 Promise,要么取消旧的只保留新的。
3.3 把接口 JSON 变成可推导的 TS 类型
接口返回的 JSON 是后端定的,类型定义是前端补的,这两者之间需要有一个明确的映射关系。我见过很多项目的类型定义是“想到哪写到哪”,结果有些字段用string,有些干脆用any,时间长了类型文件就变成了一堆没有意义的壳。
好的做法是给每个实体单独建类型,然后组合。比如歌曲、歌手、专辑建独立类型,搜索接口返回的列表是“歌曲列表”,榜单详情是“歌曲列表加一个榜单名”,这样组合出来的类型非常灵活。
export interface Artist { id: number name: string avatar: string } export interface Song { id: number name: string duration: number artists: Artist[] album: string playUrl: string } export interface SongListResponse { list: Song[] total: number }3.4 缓存与刷新:榜单和推荐位的数据更新策略
音乐项目里推荐位和榜单数据相对固定,不需要每次进页面都重新拉取。项目里常见的方案是给请求加一层“短时间缓存”,比如在内存里存一个Map,key 是请求参数,value 是 Promise 或者响应数据,过期时间设置在 5 到 10 分钟。这样用户反复切换页面,绝大多数请求可以命中缓存,体验会非常流畅。
缓存的失效策略也需要考虑,比如用户刷新榜单的操作,应该能主动触发clearCache。我看到过一种优雅的做法:请求函数支持一个force参数,默认读取缓存,传了true就绕过缓存重新请求,这样既保留了缓存收益,又给了业务方手动刷新能力的出口。
3.5 错误处理的边界不能忘
网络层还必须有兜底错误处理。音乐项目的接口经常会返回空列表、无版权资源、歌单失效这些业务错误,如果只在组件里各自弹提示,用户看到的就是东一个西一个的奇怪弹窗。统一在拦截器里对业务码和 HTTP 状态码分开处理,业务码错误抛给调用方,比如“无版权资源”这种需要展示给用户的提示;HTTP 层错误统一进入全局错误队列,由全局组件负责 toast 展示。这样业务组件只需要关心成功路径,异常路径不会把代码搅成一团。
4. 状态管理:Pinia 驱动播放器状态的实战组织
4.1 Pinia 比 Vuex 强在哪
在 Vue 3 时代再选状态管理,基本就是 Pinia 了。它对 TypeScript 的支持是原生级别的,不需要像 Vuex 那样写一堆字符串 mutation type 和复杂的映射。更重要的是 Pinia 的 store 之间可以直接互相引用,这在音乐项目里非常实用——播放器 Store 需要知道用户 Store 里的 ID 来组装个性化推荐,直接useUserStore就能跨 Store 消费。
对比下来,Vuex 的模块化往往是把 state、getters、mutations、actions 拆散在不同的文件里,为了找一个小逻辑要在不同文件之间来回跳。Pinia 把每个 Store 写成一个集中式模块,定义状态、修改状态、异步方法都放在同一文件里,阅读和维护的心理负担低很多。
4.2 Store 模块怎么划分
状态仓库如果只有一个大 Store,很快会变成垃圾场。这个项目里的划分思路值得参考:
playerStore:当前播放的歌曲、播放列表、播放状态、播放模式、当前进度searchStore:搜索关键词、搜索历史、搜索结果、当前搜索类型favoriteStore:收藏的歌手和专辑userStore:用户信息、登录状态discoveryStore:推荐位数据、榜单列表
划分的核心原则是“一个 Store 对应一类明确的数据责任”。播放器 Store 是全局共享的,因为切换页面也不能打断播放;榜单 Store 更接近目标页面,作为页面级缓存即可。把所有数据都塞到全局 Store 会让内存占用上涨,而且刷新某个页面时还要考虑清空,很麻烦。
4.3 播放器 Store 的设计细节
播放器 Store 是这个项目的灵魂。它至少需要维护以下几组数据:
听的歌曲实体,包括歌曲 ID 和播放地址;播放队列,通常是一个歌曲列表;当前索引,决定正播放的是队列里的第几首;播放状态,是暂停还是播放中;播放模式,顺序、单曲循环、随机;当前播放百分比和已播放时长。
播放器的核心逻辑里最容易出错的是“当前歌曲”和“播放列表之间的关系”。如果你只存了当前歌曲对象,那用户从某个页面点进来之后,返回上一个页面再点下一首,系统还能不能知道你之前把哪些歌放进队列了?所以必须有一个独立的播放列表集合,当前歌曲只是这个集合的一个快照,切歌操作其实是“更新索引”而不是“替换当前歌”。
另外,播放器的进度回调用得非常频繁,每秒甚至更快。如果把进度写到全局 Store,所有依赖进度的组件都会频繁更新,性能压力很大。比较稳妥的做法是 Storage 中存一份播放元数据,进度保留在播放器内部或独立状态里,传递给进度条组件时用局部状态或函数式响应做隔离。
4.4 状态持久化
音乐项目的另一个高频需求是刷新页面后播放器不能突然消失。刷新浏览器之后,路由会回到当前页面,但播放器如果只能通过点击某个组件才能出现,那用户每次刷新都必须手动重新点歌曲,体验非常割裂。
解决方法是把播放器的最小信息持久化到浏览器存储里,比如当前歌曲 ID、播放队列 ID 列表、播放进度时间点。刷新之后启动时先从存储恢复数据,网络层再根据 ID 去拉详情,播放器面板还是那个播放器面板。持久化不建议把整个歌曲对象塞进去,因为对象经常会带一些不稳定的字段,新增字段会导致存储结构变化。存核心的 ID 集合更稳妥,需要展示时再组装完整对象。
5. 播放器与核心功能实现:Audio 管理、切歌、歌词
5.1 全局只维护一个 Audio 实例,对吗
播放器模块首先要回答一个问题:页面里多少个地方在放歌?答案只能是“同时只有一个”。浏览器中同一时间播放多个音频会产生音轨混合、资源占用飙升,系统级别也会判断异常。这个项目里正确的实现是全局维护一个 Audio 实例,所有播放操作都通过一个封装好的播放器 Hook 调用,而不是每个组件各自建new Audio()。
const audio = new Audio() audio.src = currentSong.playUrl audio.play()这段代码看着简单,真正写好很难。play()返回的是一个 Promise,它可能因为网络、格式、自动播放限制被 reject,如果没人接住这个 Promise,控制台会一直报未捕获错误。更稳的写法是封装一个playSong(song)方法,内部先处理上一次播放的停止逻辑,再设置新源,最后用try/catch包住播放调用,失败时还能回退到暂停状态而不是抛错。
5.2 三种播放模式的实现逻辑
顺序播放、单曲循环、随机播放是音乐项目的标配。它们的实现差异并不在 Audio 上,而在“如何确定下一首的索引”。
顺序播放最简单,索引加一,到达末尾就停在最后一首。单曲循环要注意的是不能简单复用“下一首”逻辑,否则会触发一次切歌然后再回到同一首,中间会有音频加载的顿挫感;正确的做法是检测到单曲循环时直接手动让audio.currentTime = 0然后继续播。随机播放可以用Math.random()生成下一个索引,但需要避免和当前索引完全一样,否则会有明显的“为什么没切歌”的错觉。
播放模式的枚举类型不要用数字,用字符串字面量:
export type PlayMode = 'sequence' | 'singleLoop' | 'random'数字枚举在代码里犹如天书,看到0、1、2完全不知道什么意思。用字符串字面量,状态演示层和调试工具里都能直接看出当前模式,后期排查问题会省很多事。
5.3 歌词解析与滚动高亮
歌词是音乐项目里比较有挑战的一个模块。拿到的歌词通常是一个 LRC 格式的字符串,每一行是“时间戳 + 歌词内容”,比如:
[00:12.34] 第一句歌词 [00:18.56] 第二句歌词 [00:24.78]解析的关键是用正则把每行的分钟、秒、毫秒拆出来,转换成统一的秒数,再构建一个结构化数组。项目里的解析函数可以放在独立工具文件里,这样既能单测又能复用。
滚动高亮的核心是一个“当前播放歌词索引”的计算逻辑。最朴素的思路是在timeupdate事件里遍历歌词数组,找到第一个时间戳大于当前播放时间的项,前一项就是当前歌词。如果歌词行数很多,最坏情况是每秒遍历几百行,性能其实能接受,但更优雅的做法是维护一个“上一次索引”做二分查找,或者直接缓存下一句歌词的时间,达到时间才更新。
歌词容器还需要做滚动偏移量计算。说白了就是用当前歌词索引乘上行高,把容器向下平移,让当前歌词始终停留在中线附近。这里面最容易忽略的是过渡动画和快速拖动播放进度的配合,拖动后歌词应该瞬间跳到对应位置,而不是带着一段长动画慢慢滚过去。
5.4 进度条、缓冲与播放暂停联动
播放器底部的进度条是一个需要非常谨慎封装的部分。用户拖动进度条时,你不能一边拖动一边不停地修改audio.currentTime,那样会导致音频卡顿。常规方案是本地维护一个“拖动中状态”,拖动过程中只更新 UI 显示的进度,拖动结束后再一次性把audio.currentTime赋给目标时间。
进度条的数据来源有两个:timeupdate事件负责更新时间;canplay或progress事件负责更新缓冲条。两者合起来才能给用户一个完整的加载反馈。开发的时候我踩过一个坑,是只监听timeupdate,结果缓冲条一直没有,用户感觉播放器卡住了,其实只是网络数据还没到位。
暂停和恢复的联动也值得注意。切后台再回到页面、插拔耳机、来电打断,这些系统事件都会导致播放暂停。稳妥的做法是监听全局的visibilitychange和页面的play/pause状态,回到前台时如果 Store 里标记的仍是播放中,就自动调用audio.play()。
5.5 歌曲封面的懒加载与错误兜底
音乐项目离不开大量封面图,封面加载失败是常态。如果用深色背景兜底,可以在img的error事件里替换为本地占位图。但更系统化的做法是抽象一个CoverImage组件,内部处理默认图、加载失败事件,同时通过loading="lazy"让浏览器帮你做图片懒加载。这个组件还能统一处理圆角、裁剪样式,各页面不用重复写类似逻辑。
6. 搜索体验与构建优化:从防抖到包体积控制
6.1 搜索输入到出结果的完整链路
搜索框是音乐项目里体验最敏感的地方。用户每敲一个字,理论上都会触发一次请求,但不加约束的请求会让结果列表疯狂闪烁。
第一步是防抖。一般搜索建议的防抖时间是 300 毫秒,搜索主结果的防抖时间可以稍微长一点,500 毫秒左右,因为搜索建议的响应是即时的,主结果页承担的是更多数据的渲染成本。防抖函数不应该每个组件各写一份,应该在utils/里抽一个通用函数,用clearTimeout+setTimeout实现,使用者甚至不用关心它内部怎么实现。
第二步是竞态处理。用户先输入“周杰伦”再改成“林俊杰”,前一次的结果响应如果比后一次慢,页面最后就会显示李鬼搜索结果。解决方法是把每次请求串行化:只在最新一次请求返回时才更新状态,旧请求的返回直接忽略。这个逻辑通常会放在网络层的拦截器里,或者搜索 Store 的 action 中用一个请求序号来标记。
6.2 搜索结果列表的渲染性能
搜索结果动不动就是几百上千条。即使 Vue 的响应式更新性能很好,一次性渲染这么多 DOM 节点也会让页面和滚动比较吃力。项目里通常有两个优化方向,一个是用虚拟滚动,只渲染可视区域内的几十条;另一个是对列表项做轻量化设计,把不必要的事件监听和深层响应式属性收敛掉。
虚拟滚动在音乐场景里有一点点特殊:歌曲列表的行高需要稳定,因为行内可能有时间、音质标签、操作按钮,它们会动态撑开高度。稳的做法是设计固定行高组件,超出的内容用省略号处理。如果执意要做动态高度虚拟列表,实现复杂度和踩坑率都会直线上升,我建议还是先用固定行高。
列表项的按键点击也会带来性能问题。几百个歌曲条目如果把点击事件直接绑在每个 DOM 上,内存开销不可忽略。更好的做法是事件委托,在列表容器上监听一次点击,通过dataset拿到条目 ID 和操作类型,再统一分发到播放器 Store。这个方案在搜索页和排行榜页都用得上。
6.3 生产构建与包体积控制
音乐项目的构建产物通常会比较大,因为要包含播放器、路由、各种组件库和工具函数。我在优化生产构建时的思路是先把依赖拆分看清楚:用构建工具的分析插件打出依赖占比,然后针对大头做处理。
常见的优化手段有三个。第一,路由懒加载,每个页面组件用动态import()引入,让首屏只加载当前页面需要的代码;第二,第三方库按需引入,比如 UI 组件库、参考库等,不把整个库打包进去;第三,把不变的大型依赖单独分包,利用浏览器的长缓存让用户在后续访问时直接命中缓存。
const TopList = () => import('@/views/top-list/index.vue')这一行简单的懒加载代码,对首屏体积的贡献非常可观。把一个几 MB 的完整应用拆成首屏 1 MB 加各个分包,首屏加载时间能减少一半以上。
6.4 构建后部署的常见问题
生产构建还有一个容易忽略的细节是静态资源的 base 路径。音乐项目如果部署在域名子目录下,Vite 默认的/根路径会导致所有资源 404。配置base: './'或具体的子目录路径可以解决这个问题。路由还需要注意 history 模式在服务端的配置,使用 history 路由后,刷新页面时服务端必须把不识别的路径都指回入口 HTML,否则用户一刷新就白屏。这种问题在本地开发环境不存在,只有部署到真正的站点之后才会暴露,建议项目启动前就考虑好部署目标,路由用 hash 模式作为兜底方案。
7. 复盘高频坑位:类型保护、移动端兼容与生命周期陷阱
7.1 严格类型模式下最常报的错
严格模式下,新手的报错基本都集中在“手动 Object 可能为 null”。看两个高频场景。
第一个是使用ref挂载音频元素:
const audioRef = ref<HTMLAudioElement | null>(null) function handlePlay() { audioRef.value?.play() }如果用audioRef.value.play(),TypeScript 会直接报错,因为audioRef.value可能是null。可选链?.是最省事的解决方案,但要注意它的语义是“值存在才调用”,如果整个组件刚挂载完成、音频元素还没渲染出来,静默跳过可能让“用户点了播放却没反应”。更稳妥的方式是给音频元素加一个条件渲染,或者直接在播放器 Hook 内部统一管理音频实例,而不是依赖模板里的ref。
第二个场景是从Map或Object里取值后传给函数。接口拿到的数据经过缓存取出来,可能确实不存在,所以要养成一个习惯:所有“可能不存在”的取值都要先判空再使用,不要用一个非空断言!蒙混过关。!的本质是“我确定它存在”,如果这个判断是错的,运行时依然会崩,而且比编译时报错更难排查。
7.2 play() 返回 Promise 带来的移动端兼容问题
移动端出现“点击播放图标没有反应”的情况在音乐项目里非常常见。根因是audio.play()返回的是一个 Promise,浏览器出于自动播放限制,在某些情况下会拒绝这个调用。在旧浏览器或者较低版本的环境里,这种拒绝可能是静默的,看起来就是按钮没有反应。
处理方案是给播放调用做 Promise 捕获:
const playPromise = audio.play() if (playPromise !== undefined) { playPromise.catch(() => { // 播放失败统一回到暂停状态 }) }还有一个非常隐蔽的坑:iOS 系统上音频播放要求在用户交互事件里调用play(),首次播放如果不是直接响应用户手势,而是通过异步返回后再调用,就会因为不在手势上下文中被拒绝。也就是说,点击歌曲以后,请求完播放地址再调用play(),这个调用时机已经离开了用户点击的直接上下文,部分浏览器依旧认为是合理调用,但部分版本会拒绝。稳妥的做法是在组件挂载时先创建一个空的播放实例,并尝试让它静音播放,相当于主动向浏览器申请唤醒音频上下文。
7.3 搜索页 / 列表页的组件复用陷阱
音乐项目里同一个列表组件会被搜索页、推荐页、榜单页共用。如果列表组件内部自己管理“当前选中歌曲”,切换到别的页面再回来,就会出现“选中状态残留”的诡异表现。根本原因是组件被复用时,Vue 不会销毁重建,而是直接复用实例,组件的内部状态自然就还在。
解决方案是把“选中状态”放到依赖具体页面数据的层级。比如用“当前播放的歌曲 ID + 页面数据里的歌曲 ID 集合”来判断行高亮,而不是在列表组件内部维护一个activeId变量。这样状态天然跟着数据走,切页就自动消失了。
长列表还有一个生命周期坑:榜单数据在请求回来之后,下一秒用户就切换了页面。如果请求回调用的是组件方法,组件卸载之后更新状态就会报警告。所以异步请求务必配合“是否被取消”的标记,或者使用组件卸载时的清理钩子把响应处理屏蔽掉。网络层如果做到了请求拦截,这里也能统一兜住。
7.4 组件之间通信的过度设计
开发项目过程里还有一个值得反思的坑:组件通信层级搞太复杂。音乐项目里,“当前歌曲”在底部播放器里显示,但列表项点击之后要通知播放器。“播放器显示哪首歌”属于全局状态,用 Pinia 完全没问题;但有些组件内部直接调用playerStore的方法,层层传递 props 和事件反而把逻辑绕得很乱。我后来总结出一条经验:全局状态走 Store,组件内部交互走 props 和 emit,不要把两者混用。比如搜索框内部的输入状态就是局部状态,不需要塞进 Store;但用户点击搜索结果的歌曲,这一个动作跨了列表、播放器、歌单多个模块,这就应该直接调用 Store 里的 action。
8. 项目复盘带来的几点实操建议
把 vue-typescript-music 这个项目拆完以后,我认为它的价值不在“它能听歌”,而在于它逼着你把 Vue 3 和 TypeScript 的关键能力都用了一遍,并且要求你处理真实项目里才会出现的复杂状态。
一个更重要的建议是:拆解项目时不要只读最终代码,最好在本地跑起来,用一个最小改动去碰它。比如把搜索防抖的 300 毫秒改成 1000 毫秒,你会真实感受到请求数和页面闪烁的变化;把虚拟滚动关掉,你会看到长滚动掉帧的效果。这种“动手验证”学到的东西比干看代码多得多。
如果打算自己从零搭一个类似项目,我的路径建议是:先用纯 Vue 把播放器流程跑通,再逐步引入 TypeScript,把接口数据逐层加类型,最后再把网络层、Store、虚拟列表这些优化项一层层叠加上去。一步一步加复杂度的过程,代码出问题的时候你知道该去哪一层排查,掌握感会强很多。
技术选型也建议保持克制。Vue、Vite、Pinia、TypeScript 已经是完整且舒适的组合,不需要为了炫技额外引入重型状态库或复杂的端侧框架。音乐项目的本质是状态管理和用户交互,用足够简单的工具解决足够复杂的问题,才是这个项目真正的示范意义。
最后一个小建议:看完这篇文章之后,试着自己画一张这个项目的数据流图,从接口返回开始,一直画到 UI 上的进度条更新。能把这个数据流完整讲清楚,Vue 和 TypeScript 在这类业务里的基本功就算真正上身了。