前端 微信小程序点歌 设计思路
点歌小程序不是 CRUD:歌手端从曲库、歌词匹配到演出队列的状态设计
如果只看页面,点歌小程序很容易被误解成几个普通功能:
- 我的歌曲
- 添加歌曲
- 编辑歌词
- 上台演出
- 查看点歌订单
但真正做下来会发现,它不是一个简单的列表 CRUD。
我之前做过一个歌手端点歌小程序,歌手需要先认证、绑定酒吧,然后维护自己的歌单和歌曲;用户点歌后,歌手在“演出模式”里处理待完成、演唱中、已结束的订单。中间还要支持云端搜索歌曲、匹配歌词、重新编辑歌词、启停歌曲、批量管理。
这类项目的难点不在某一个 API,而在整条链路的状态设计。
先说业务链路
歌手端小程序大概有三条主线。
第一条是上台链路:
登录 -> 实名认证 -> 绑定酒吧 -> 上台 -> 进入演出模式
第二条是曲库链路:
歌单列表 -> 展开歌单 -> 已启用歌曲 / 已停用歌曲 -> 添加或编辑歌曲 -> 启停或删除
第三条是歌词链路:
输入歌曲名 -> 云端搜索 -> 选择候选歌曲 -> 拉取歌词 -> 编辑歌词 -> 回填歌曲表单 -> 保存到曲库
还有一条演出链路:
待完成订单 -> 开始表演 -> 演唱中 -> 表演结束 -> 已结束
这些链路互相影响。比如歌手没有绑定酒吧,就不能上台;歌曲被停用,用户就不能点播;歌词没有匹配到,歌手要能手动编辑;已经有歌曲演唱中时,不能再开始另一首。
所以这篇文章想讲的是:点歌小程序怎么把这些状态写稳,而不是怎么写一个 uni.request。
为什么不能按普通 CRUD 写
最开始很容易把“我的歌曲”写成普通管理页:
查歌单
查歌曲
新增
修改
删除
但项目里实际不是这样。
一个歌手的曲库至少有这些状态:
- 当前选中的酒吧
organizationId。 - 当前展开的歌单。
- 当前查看的是已启用歌曲,还是已停用歌曲。
- 当前管理模式是歌单,还是歌曲。
- 当前是否处于批量选择状态。
- 当前弹窗是新增歌单、新增歌曲、确认停用、确认删除,还是编辑歌曲。
- 当前歌词来自云端匹配,还是本地手动编辑。
这些状态叠在一起,如果每个按钮都直接改页面字段,很快会出现一种问题:操作成功后页面不知道该刷新哪里。
比如停用一首歌曲后,页面应该刷新当前歌单下的“已启用”列表;如果当前正在看“已停用”,则应该刷新“已停用”列表;如果当前歌单是折叠状态,则只刷新歌单数量就够了。
这就是项目里 globalUptata 这种方法存在的原因:它不是简单重新请求,而是要恢复用户当前上下文。
曲库页的核心状态
在 pageWdgq/wdgq/wdgq.vue 里,曲库页维护了很多状态。
我更愿意把它整理成四类:
state = {
// 当前业务上下文
organizationId,
activeSongTypeIndex,
activeSongUseTab,
// 列表数据
songTypes,
songs,
// 编辑态
editorMode, // songType or song
selectedSongTypeIds,
selectedSongIds,
// 弹窗态
modalType,
editingSong,
editingSongType
}
项目里有几个变量很能说明问题:
songMenuformDataIndex:当前展开的歌单。GQUsejudge:当前看启用歌曲还是停用歌曲。bottomEditorState:底部操作当前针对歌单还是歌曲。selectGD/selectGQ:批量选择的歌单或歌曲。modalForm.editGq:当前正在添加或编辑的歌曲。
这些变量不是 UI 细节,它们是曲库管理的“操作上下文”。
刷新不是重新加载,而是恢复上下文
很多管理页在操作成功后会直接重新拉一遍列表。
点歌曲库不能这么粗暴。
假设歌手正在做这件事:
展开“热门歌曲”歌单
切到“已停用”
勾选三首歌
点击启用
启用成功后,理想状态应该是:
- 仍然停留在“热门歌曲”这个歌单。
- 仍然能看到“已停用”列表被刷新。
- 已选择项清空。
- 已启用 / 已停用数量更新。
这就是“恢复上下文”的刷新。
伪代码可以写成:
async function refreshAfterMutation(message) {
const openedIndex = state.activeSongTypeIndex
const openedTab = state.activeSongUseTab
await fetchSongTypes()
if (openedIndex >= 0) {
state.activeSongTypeIndex = openedIndex
if (openedTab === 'enabled') {
await fetchEnabledSongs()
} else {
await fetchDisabledSongs()
}
}
clearSelection()
toast(message)
}
这个逻辑看起来比 fetchList() 多很多,但真实项目里很有必要。否则用户每操作一次,就被打回默认状态,体验会很差。
歌词匹配链路的难点
添加歌曲时,歌手一般先输入歌曲名,然后系统去云端搜索候选歌曲。选择候选歌曲后,再去拉歌词。
这个链路里有几个细节:
- 云端搜索可能返回多个同名歌曲。
- 同一首歌可能因为歌手名、版本不同重复出现。
- 歌词有时直接返回文本,有时返回歌词地址,需要二次请求。
- 歌词可能不准,歌手需要手动编辑。
- 编辑歌词是在另一个页面完成的,保存后要回填到当前歌曲表单。
所以它不是简单的:
搜索 -> 选择 -> 保存
而是:
搜索歌曲 -> 去重候选项 -> 选择歌曲 -> 拉取歌词 -> 暂存到全局 -> 进入歌词编辑页 -> 保存后回填表单
项目里用了 Vuex 的 song 做跨页面暂存:
state: {
song: {}
}
mutations: {
setsong(state, val) {
state.song = val
}
}
这不是复杂架构,但很实用。
因为小程序页面跳转时,如果把整段歌词塞到 query 参数里,会有长度、编码和可读性问题。歌词这种多行文本,更适合用全局临时状态或本地缓存承接。
候选歌曲要做去重
云端搜索歌曲时,返回结果可能有重复。
项目里用歌曲名和歌手名做了一层去重:
if (songName === item.songName && singerName === item.songerName) {
duplicated = true
}
这个判断不复杂,但它解决的是产品体验问题。
歌手输入一首歌,如果候选列表里出现一堆重复项,他很难判断该点哪一个。前端先做一层轻量去重,可以明显减少误选。
如果现在再做一版,我会把候选项设计成这样的结构:
{
cloudSongId,
songName,
singerName,
albumName,
source,
hasLyric
}
展示时优先显示歌曲名和歌手名,必要时再显示专辑或版本。这样既不会丢掉版本差异,也能避免纯重复。
歌词编辑页其实是一个小型解析器
歌词不是普通文本,它有时间轴。
常见 LRC 格式长这样:
[00:12.340]第一句歌词
[00:18.120]第二句歌词
编辑页要做两件事:
- 把 LRC 字符串解析成可编辑数组。
- 保存时再拼回 LRC 字符串。
项目里的编辑页会把每行拆成:
{
s: '00',
f: '12',
m: '340',
content: '第一句歌词'
}
这样用户可以单独改分钟、秒、毫秒和歌词内容。
保存时再拼回:
value += `[${s}:${f}.${m}]${content}\n`
这个设计的关键点是:编辑态和存储态不要混在一起。
存储态是 LRC 字符串,适合提交给服务端;编辑态是数组,适合页面渲染和用户修改。
修改时间后要重新排序
歌词编辑还有一个很隐蔽的问题:用户改了某一句歌词的时间后,顺序可能变了。
比如原来是:
00:20 第一行
00:25 第二行
用户把第二行改成 00:18,那它应该排到第一行前面。
项目里在输入框失焦时做了排序处理,按秒、毫秒分段比较。这个点很值得写,因为它不是页面样式问题,而是数据一致性问题。
我更建议把排序抽成独立方法:
function lyricTimeToNumber(row) {
return Number(row.s) * 60 * 1000 +
Number(row.f) * 1000 +
Number(row.m)
}
function sortLyrics(rows) {
return rows.slice().sort((a, b) => lyricTimeToNumber(a) - lyricTimeToNumber(b))
}
这样比在输入框事件里分别按 s、f、m 排更容易验证。
演出模式是订单状态机
歌手上台以后,会进入“演出模式”。
这个页面里有三个 tab:
- 待完成
- 演唱中
- 已结束
这不是简单筛选,它对应订单状态流转:
待完成 -> 演唱中 -> 已结束
歌手点击“开始表演”时,前端还要判断当前是否已经有歌曲演唱中。
项目里会先请求演唱中列表,如果已经有进行中的歌曲,就提示“当前正在有歌曲进行演奏”,避免同时开始多首歌。
这个约束很重要。因为点歌场景里,同一时间只能有一首歌正在被歌手演唱。前端必须做一层防误触,服务端也应该做最终校验。
伪代码可以这样表达:
async function startSing(orderId) {
const singingOrders = await fetchOrders({ state: 'singing' })
if (singingOrders.length > 0) {
toast('当前已有歌曲演唱中')
return
}
await singStart(orderId)
await refreshOrders('waiting')
await refreshSingingSnapshot()
}
这就是典型的多状态协作:按钮点击、订单列表、当前演唱中快照、服务端状态必须一致。
上台前置条件不要散在各页面
歌手点击“上台”前,需要满足两个条件:
- 已完成歌手认证。
- 已绑定酒吧。
项目里在上台首页做了判断:未认证弹窗提示,未绑定酒吧则跳转绑定页面。
这类前置条件很容易散在不同页面,比如“我的歌曲”判断一次,“演出模式”判断一次,“上台”又判断一次。散开以后,后续改规则会很麻烦。
更稳的做法是抽一个业务守卫:
function assertSingerCanPerform(actorInfo) {
if (!actorInfo.authenticated) {
return { ok: false, reason: 'need_auth' }
}
if (!actorInfo.organizationId) {
return { ok: false, reason: 'need_bind_bar' }
}
return { ok: true }
}
页面根据 reason 做弹窗或跳转。
这样认证、绑定、上台的规则就不会到处复制。
我会怎么重构这套代码
如果现在重新整理,我会先拆出三个业务模块,而不是先拆组件。
第一块:曲库模块。
songLibraryService = {
fetchSongTypes,
fetchSongsByType,
saveSongType,
saveSong,
enableSongs,
disableSongs,
deleteSongs
}
第二块:歌词模块。
lyricService = {
searchCloudSongs,
fetchCloudLyric,
fetchLyricByUrl,
parseLrc,
stringifyLrc,
sortLyricRows
}
第三块:演出模块。
performanceService = {
fetchOrders,
startSing,
endSing,
goOnStage,
stepDown
}
页面只保留状态和交互,不直接塞满接口细节。
这样后面要改歌词匹配规则、换接口、加批量复制歌曲、加演出倒计时,都不会牵动整个页面。
这篇技术点的验证方式
写完这类功能,我会重点验证这些场景:
- 未认证歌手点击上台,只弹认证提示,不进入演出模式。
- 已认证但未绑定酒吧,点击上台跳转绑定酒吧。
- 展开某个歌单后切换已启用 / 已停用,数量和列表正确。
- 停用歌曲后,当前 tab 和展开歌单不丢失。
- 批量选择歌曲后,启用、停用、删除都会清空选择态。
- 搜索云端歌曲时,重复候选项不会反复出现。
- 选择候选歌曲后,歌词能回填到编辑表单。
- 重新匹配歌词后,编辑页能拿到新的歌词。
- 修改歌词时间后,保存前顺序正确。
- 已有歌曲演唱中时,不能开始另一首。
- 表演结束后,待完成、演唱中、已结束三个列表刷新正确。
这些验证点覆盖了异步时序、多状态协作、跨页面数据、异常恢复和业务规则。
最后总结
点歌小程序表面上是“歌手维护歌曲,用户点歌”,但前端真正要解决的是状态流转。
我的经验是:这类项目不要从页面开始理解,要从链路开始理解。
认证绑定链路
曲库管理链路
歌词匹配链路
演出订单链路
链路清楚以后,再写页面就不会乱。
曲库页的难点是恢复操作上下文;歌词页的难点是解析和回填;演出页的难点是订单状态机和并发约束。把这几个点想明白,点歌小程序就不再是“几个列表页面”,而是一个完整的歌手工作台。
这也是我觉得这个项目值得拿出来分享的地方:它不是炫技项目,但里面的状态设计非常真实。很多前端项目写到后期难维护,往往不是因为组件不会封装,而是业务状态一开始没有被当成系统来设计。

浙公网安备 33010602011771号