期末大作业需要组长上台展示,晓浮雨所在小组是第一个。
由于当时组员名单是鹿笙上报的,所以组长由鹿笙担任。
她走上讲台,望着台下陌生的面孔,心里不免有些紧张。
直到目光掠过晓浮雨、何清晏和法观澜,她才深吸一口气,慢慢平静下来。
鹿笙大家好,我是「bingo」小组的组长鹿笙,这是我们小组的大作业「Cheese Music」。
她打开项目文件,里面排列着多个文件与文件夹。
鹿笙在终端中启动项目,页面一展现出来,台下顿时响起一片低呼。
万能NPC(男)哇哦,这UI做的很漂亮。
鹿笙望向老师,露出从容的微笑。
鹿笙UI虽然是我们小组一起设计的,但最终由清晏统一做了视觉优化,才让整体风格更协调。
鹿笙Cheese 的功能模块包括基础的登录注册、搜索、添加音乐、删除音乐、创建歌单、播放……
鹿笙一边讲解一边演示,台下的学生渐渐骚动起来。
万能NPC(女)我靠!功能这么完善的吗?
万能NPC(男)牛啊,真她们做的?十几天做出这么多来?
台下议论声此起彼伏,鹿笙面不改色,继续她的介绍与演示。
老师的神情从最初的欣赏逐渐转为怀疑。待鹿笙汇报完毕,他开口问道:
万能NPC(男)功能很完善了,我想知道你们用了哪些技术栈?
鹿笙前端部分,我们主要使用 React 框架构建用户界面,搭配 Ant Design 组件库提升开发效率和视觉一致性。响应式布局和移动端适配,我们引入了 Tailwind CSS。
鹿笙页面动画和交互体验方面,使用了 Framer Motion 增强视觉效果。
鹿笙后端采用 Node.js 和 Express 框架搭建服务器,数据库使用 MongoDB,通过 Mongoose 进行数据建模与操作。
鹿笙用户认证方面,我们采用 JWT 实现登录注册模块。
鹿笙音频播放部分,集成了 Howler.js 库,支持播放、暂停、切换等功能。
鹿笙同时,为了提升用户体验,我们使用 Redux 管理全局状态,确保页面间数据同步与响应速度。
鹿笙整个开发流程也使用了 Git 进行版本控制和协作。
鹿笙停顿两秒,继续补充:
鹿笙在这里,我要特别感谢我的组员何清晏和晓浮雨。没有清晏,我们的界面风格不会如此统一和精美。
鹿笙浮雨无疑是付出最多的那一个,她是我们组技术最强的成员,承担了测试、调试、优化、细化等大量工作——甚至可以说,是在我们“粗糙”的代码上进行精雕细琢。
鹿笙说到这儿的时候忍不住苦笑。
鹿笙总之,正是浮雨对细节的执着和扎实的技术能力,才让「Cheese Music」功能如此完善。
老师沉默着点了点头,眼底的怀疑依旧没有散去。
老师沉默地点了点头,眼中的疑虑仍未散去。
从教多年,他很少见到如此“硬核”的学生。
仅一个学期就掌握了这么多技术栈。她们的自学能力真的这么强?在繁重课业下还能学以致用、完成如此完整的项目?
老师微笑着鼓掌,从后排走到讲台前,脸上看不出情绪。
鹿笙见他上来,立即让出位置。他打开源码,浏览目录结构,随后切换到 server/routes/playlist.js 第 47 行。

万能NPC(男)我就问三个问题。
万能NPC(男)为什么用事务?
万能NPC(男)为什么先 findById 再 updateOne,而不是直接用 updateOne 的过滤条件 { _id: playlistId, songs: { $ne: songId } }?
万能NPC(男)为什么先 findById 再 updateOne,而不是直接用 updateOne 的过滤条件 { _id: playlistId, songs: { $ne: songId } }?
老师看向鹿笙,眼神带着审视。
她感到紧张,脑中开始思考,这段代码不是她写的,她也不清楚为什么。
晓浮雨老师,这段是我写的,我来回答吧。
晓浮雨站了起来,看向老师。
万能NPC(男)好。
晓浮雨使用事务是为了在并发场景下保证操作的幂等性和数据一致性。比如同一首歌被连续点击两次,没有事务可能导致 songCount 重复 +1,或数组中插入重复的 songId。
晓浮雨先查询再更新,是为了在业务层能抛出明确的错误信息。如果合并为一条updateOne,匹配失败时无法区分是“歌单不存在”还是“歌曲已存在”,前端难以给用户准确反馈。
晓浮雨数组确实会膨胀,但我们的场景是 课程演示,单歌单上限 200 首,MongoDB 16 MB 文档上限足够。
晓浮雨真到生产,我们会把 songs 拆成 junction collection;现在为了开发效率,先 array。
晓浮雨一口气说完,没有停顿,很是丝滑。
老师挑眉,似乎没料到她答得这么快。他又把页面切到前端 store/playerSlice.js 第 112 行。

万能NPC(男)为什么不直接用 Set 去重,而要用 filter + includes?
晓浮雨快速接话。
晓浮雨Redux Toolkit 的 Immer 把 state 转成了 Proxy,Set 是可变结构,Immer 跟踪不到变化;用数组方法最贴合 Immer 的 draft 语义,也避免额外的序列化开销。
台下传来一片吸气声。
但老师的提问并未停止,他似乎想彻底了解这四位学生的自学深度。
在晓浮雨连续几次流畅作答后,他示意她坐下,转向其他组员,要求晓浮雨不再参与回答。
他点开 IDE,直接切到 web/src/pages/Player/AudioPlayer.tsx 第 88 行,看向鹿笙:

万能NPC(男)为什么把 Howler.unload() 放在 cleanup 里?
教室瞬间安静,连呼吸声都刻意压低。
鹿笙攥了攥衣角,强迫自己抬起头。这段代码是晓浮雨写的,她对音频处理并不太熟悉。
她努力保持冷静,回忆之前和晓浮雨的交流——接手她的代码时,自己确实问过相关问题。
鹿笙unload() 会释放 Web Audio 节点和缓冲,防止内存泄漏;cleanup 保证组件卸载时一定执行,不会留下僵尸实例。
万能NPC(男)如果用户快速切换歌曲,unload 可能打断当前音轨,怎么避免爆音?
鹿笙我们在切歌前先调用 Howler.fade(currentId, currentVolume, 0, 100) 做 100 ms 淡出,等 fade 完成再 unload,爆音就不会出现。
老师点了点头。
万能NPC(男)何清晏。
被点名的何清晏立刻站了起来。
万能NPC(男)前端你负责的对吧?
何清晏点头。
老师把切换到了 web/tailwind.config.js 第 15 行,光标落在 plugins: [] 里那行自定义插件:

万能NPC(男)为什么不直接改 Ant Design 的 less 变量,而要写 Tailwind 插件去覆盖?
万能NPC(男)既然用了 Tailwind,为什么还保留 Antd 的样式文件?不怕两份原子类冲突吗?
台下呼吸声再次压低,所有目光刷地聚到何清晏身上。
她深吸一口气,声音不大,却稳。
何清晏Ant Design 的 less 变量在编译期锁定,一旦改动需要重新起 dev 服务器;写成 Tailwind 插件是运行时覆盖,热更新秒级生效,设计师坐在旁边都能实时看效果。
何清晏冲突用 prefix 解决:Tailwind 加 tw- 前缀,Antd 保留原命名空间;再配 corePlugins.preflight: false 禁用 Tailwind 的基础样式,只引入我们需要的工具类,两者和平共处。
一口气说完,她抬眼望向老师,指尖还因为紧张微微发白。
老师盯着屏幕里的插件代码,确认关键字:css 变量、prefix、preflight,全部命中,终于咧嘴一笑,眼底闪着光。
万能NPC(男)你坐。
何清晏松了口气,坐回座位。但教室里的气氛依旧凝重,大家清楚,“考验”还未结束。
万能NPC(男)法观澜,该你了。
老师把屏幕一切,这回直接跳到 server/models/song.js 最底部,光标落在一行虚拟字段上:

万能NPC(男)虚拟字段默认不会进数据库,为什么还把它挂在 model 上?
万能NPC(男)globalToken 是谁给的?如果服务端重启,token 丢了,这链接是不是全 404?
法观澜缓缓站起,声音低沉却条理分明,目光平静。
法观澜虚拟字段只做序列化出口,前端拿歌单时一并拿到带签名的流地址,省一次二次请求;toJSON 里已配 { virtuals: true },数据库侧依旧干净。
法观澜globalToken 是启动时一次性生成的 uuidv4,存在 memory-cache 里,十分钟刷新一次;重启会丢,所以我在 cluster.js 里用 sticky-session 把进程盯在同一 worker,热更新先fork再切流量,重启对外无感。
老师彻底的笑了出来,眼里的欣赏、欢喜、兴奋难以掩盖。
万能NPC(男)满分。你们是我教过最出色的学生。
与此同时,下课铃响了。
万能NPC(男)好,下课,下节课继续。
老师快步离开教室,他已经迫不及待向他的同事们炫耀她们了。
教室外喧闹起来,而教室内的气氛却依旧低沉。其他小组感到了前所未有的压力。
万能NPC(女)……下节课继续?继续什么?
万能NPC(男)不是我说你们,要不要这么卷啊?
万能NPC(女)接下来和公开处刑有什么区别,这显得我们很low啊。
其他人忍不住抱怨。
万能NPC(男)我还以为你们是找人代写了,没想到来真的啊?
鹿笙笑眯眯的看着他们。
鹿笙我们以为你们很强,因为有几次看见你们用了老师没教的技术栈,所有熬夜改了代码。
万能NPC(女)谢谢,没有这个水平。
万能NPC(男)一个学期自学这么多?能把一个框架学透就不错了。
万能NPC(女)我甚至不知道老师问你们的问题是什么东西。
教室哀鸣一片,有的甚至临时改代码。
第一是没指望了,但起码要拿个第二吧?