资讯动态

手写音乐应用第一步:用Node.js+Express搭建API接口层

发布时间:2026/10/14 1:16:38 来源:尧图企业网站定制
最近在做一个音乐播放类的前端项目核心思路是用Vue生态手写一个类似QQ音乐的Web应用。系列的第一篇我选择先把API接口层搭起来。原因很简单前端做得再花哨没有数据喂进去就是空壳子接口层就是整个项目的底盘。这篇博文就专门讲接口服务怎么设计、怎么写、怎么跟前端联调包含我实际踩过的坑和最后确定的方案。系列规划中后续会围绕播放器内核、状态管理、组件抽象、性能优化几个方向展开。但所有模块都依赖一个稳定、语义清晰的API层所以这一篇的内容偏基础却是后面所有功能的地基。如果直接把所有逻辑堆在Vue组件里后期维护成本会很难受接口层独立出来反而轻松很多。1. 项目整体设计为什么第一步是API接口层1.1 先想清楚要做什么样的音乐应用动手写代码之前我先把需求拆了一遍。虽然目标是“手写QQ音乐”但版权相关、真实推荐算法这类能力显然不在复刻范围内。我能做、也应该做的是把音乐应用最常见的功能链路完整走通首页内容展示、歌单列表、歌手信息、关键词搜索、播放行为。这个定位很重要它决定了接口设计是两个方向。一是做真实的B端数据对接二是做可复现的前端练手项目。我选择的是后者用自定义的接口服务提供数据好处是数据格式、字段含义、返回策略完全由项目自己控制不需要受制于第三方接口的变动。接口层拆出来之后前端开发可以完全并行。做轮播图组件的人只需要知道接口返回什么结构不需要关心数据从哪来后续接入真实数据源时替换接口实现即可组件代码几乎不用动。这比把数据请求散落在各个组件里要清晰得多。1.2 技术选型为什么用Node.js Express自建服务做接口服务可选方案其实不少。直接用第三方音乐API最快但稳定性、字段不确定性完全不可控接口一旦变更整个项目就崩了。用本地JSON模拟数据最简单但后续扩展真实请求逻辑时又得全部重写。我选的是Node.js Express 搭建独立接口服务数据先用静态JSON文件兜底。Express逻辑简单、上手成本低、包体积小一个几百行的服务端脚本就能把路由、跨域、静态资源全部搞定。运行时用nodemon自动重启改动代码立即生效开发体验比每次手动重启好很多。打包后用PM2部署也完全可行因为项目本身是纯Node应用只要接口服务挂掉才需要关心进程状态。这套方案对单人开发和前后端分离的协作模式都非常友好也不会引入过重的后端框架增加理解成本。提示不要把业务逻辑塞进接口服务。接口层只做数据转发和格式整理所有跟音乐播放、界面状态相关的逻辑必须留在前端这样边界才清晰。2. 接口服务搭建从初始化到跑通第一个路由2.1 初始化项目与依赖安装我习惯把所有后端接口单独放在一个目录里跟Vue工程平级互不干扰。这里先演示完整初始化过程。# 在项目根目录下创建server文件夹 mkdir music-api-server cd music-api-server # 初始化Node项目全部默认配置即可 npm init -y # 安装Express框架 npm install express # 安装cors中间件解决开发环境跨域问题 npm install cors # 安装nodemon作为开发依赖修改代码后自动重启服务 npm install -D nodemonpackage.json里我会加上启动脚本方便区分开发和生成环境。{ name: music-api-server, version: 1.0.0, description: 音乐应用模拟接口服务, main: app.js, scripts: { dev: nodemon app.js, start: node app.js }, dependencies: { cors: ^2.8.5, express: ^4.18.2 }, devDependencies: { nodemon: ^2.0.22 } }2.2 搭建服务入口HTTP服务、跨域与路由组织项目不大我会把入口、路由、数据拆成不同文件。入口文件只负责创建服务、绑定中间件、启用路由。路由文件只负责URL分发。数据文件只负责导出数据。创建一个app.js作为服务入口const express require(express); const cors require(cors); // 引入路由模块 const bannerRouter require(./routes/banner); const songListRouter require(./routes/songList); const searchRouter require(./routes/search); const singerRouter require(./routes/singer); const app express(); const PORT process.env.PORT || 3000; // 解析JSON请求体 app.use(express.json()); // 解决跨域 app.use(cors()); // 路由挂载所有接口统一以 /api 前缀开头 app.use(/api/banner, bannerRouter); app.use(/api/songList, songListRouter); app.use(/api/search, searchRouter); app.use(/api/singer, singerRouter); // 根路径提示方便浏览器直接访问确认服务已启动 app.get(/, (req, res) { res.json({ code: 200, message: music api server running, docs: /api/banner }); }); app.listen(PORT, () { console.log(API server is running at http://localhost:${PORT}); });cors中间件为什么必须用因为Vue项目开发服务器默认跑在8080或5173端口接口服务跑在3000端口两边端口不同就属于跨域。不加这个中间件浏览器会直接拦截响应前端怎么请求都拿不到数据。路由按业务模块拆开的好处是新增模块时不需要改动入口逻辑在routes目录里新建一个文件然后在入口挂载一行就行。这个扩展方式在项目逐步变大时尤其省心。启动服务后浏览器访问http://localhost:3000/api/banner能看到JSON数据就说明服务通了。我看到那个JSON出现在浏览器里的时候就知道整条链路已经打通后续的活儿都是在往上添砖加瓦。2.3 目录结构与各文件职责说明整个后端服务最终的目录结构大概是这样的music-api-server ├── app.js # 服务入口 ├── package.json ├── routes/ # 路由模块 │ ├── banner.js │ ├── songList.js │ ├── search.js │ └── singer.js ├── data/ # 静态数据 │ ├── banner.json │ ├── songList.json │ ├── songs.json │ └── singers.json └── utils/ # 工具函数 └── response.js # 统一响应格式响应格式的统一甚至比接口本身更重要。我封装了一个简单的response工具所有接口返回结构保持一致code表示业务状态message表示提示信息data为实际数据。// utils/response.js module.exports { success(data, message ok) { return { code: 200, message, data }; }, fail(message error, code 500) { return { code, message, data: null }; } };前后端联调时最怕的就是每个接口返回格式都不一样前端每请求一个接口就得单独适配。统一响应格式之后前端的请求封装可以做到高度一致axios拦截器里判断code字段即可不用每个接口单独写数据处理逻辑。这是项目工程化中很小的细节但收益立竿见影。3. 核心API设计与数据结构规范3.1 首页轮播图接口设计思路首页顶部轮播图是音乐应用视觉冲击力最强、最先展示的内容。这个接口的数据结构设计核心是让前端拿到的数据能直接渲染不需要二次加工。{ code: 200, message: ok, data: [ { id: 1, picUrl: /images/banner/banner-1.jpg, linkType: songlist, targetId: 1001, title: 今日热歌推荐 }, { id: 2, picUrl: /images/banner/banner-2.jpg, linkType: url, targetUrl: /page/album?id2002, title: 新歌首发 } ] }linkType字段是我设计时特别留出来的。它告诉前端这张轮播图点击之后是什么行为跳歌单、跳页面、还是跳外链。这样轮播图组件只需要维护一份点击事件处理逻辑根据linkType分发即可。如果不设计这个字段前端就得在组件里写if-else判断甚至是硬编码后期改需求时极其痛苦。需要注意一个细节图片地址我用的是相对路径。这意味着接口服务除了提供数据还要负责把images目录作为静态资源暴露出去。在app.js里加一行代码就能实现// 将public目录作为静态资源目录 app.use(express.static(public));这样图片放在public/images/banner/目录下前端直接用相对路径访问走接口服务当前域名下的资源既没有被防盗链的风险也不用额外处理跨域。3.2 歌单、歌手与搜索结果接口的数据结构歌单接口需要支持分页查询因为歌单列表通常数据量很大一次全量返回会让前端渲染卡顿。分页参数我常用page和pageSize这两个名字直观且业界通用。{ code: 200, message: ok, data: { list: [ { id: 1001, name: 清晨播放清单, coverUrl: /images/songlist/1001.jpg, playCount: 128080, trackCount: 24, creator: 宁静的夏天 } ], total: 76, page: 1, pageSize: 20 } }返回体里同时携带total、page、pageSize前端分页组件就有了完整的渲染和翻页状态依据。很多刚转型全栈的写接口时容易漏掉total前端拿到数据根本不知道总共有多少页翻页逻辑就只能猜这是个低级但常见的坑。歌手接口和搜索接口的数据结构也按同样风格设计。搜索接口是音乐应用中出现频率最高的接口之一因为搜索框几乎是每个音乐应用的标配。我们的实现里search接口会模拟真实搜索的返回过程根据关键词做一次性模糊匹配搜索结果里包含歌曲、歌手、专辑三个维度前端可以用tab切换展示也可以一次性全部展示。3.3 接口参数设计分页、关键词与容错处理接口参数看着简单但设计不严谨会带来大量前端兼容问题。以搜索接口为例我设计了一个keywords参数和一个type参数type用来区分搜索维度歌曲、歌手还是专辑。前端可以按需传递减少不必要的数据量。// routes/search.js const express require(express); const router express.Router(); const songs require(../data/songs.json); const { success, fail } require(../utils/response); // 搜索接口支持歌曲/歌手/专辑维度 // GET /api/search?keywords周typesong router.get(/, (req, res) { const { keywords , type song } req.query; if (!keywords.trim()) { return res.json(fail(搜索关键词不能为空, 400)); } let results []; const kw keywords.trim().toLowerCase(); if (type song) { results songs.filter(item item.name.toLowerCase().includes(kw) || item.artist.toLowerCase().includes(kw) ); } return res.json(success({ list: results, total: results.length })); }); module.exports router;注意参数默认值的处理。使用const { keywords , type song } req.query这句前端不传参数时也不会报错而是用默认值兜底。接口最忌讳的就是前端传参稍有疏漏就返回500那是把不稳定因素直接甩给了调用方。我在所有接口里都做了关键词为空、数据不存在等边界情况的处理。可以把接口服务当成内部文档使用即使参数传得不对返回的错误信息也会直接提示是什么问题调试起来非常顺畅。4. 前端联调从跨域代理到请求封装4.1 开发环境跨域代理配置接口服务搭好之后前端工程需要解决跨域问题。这里我同时给出Vite和Vue CLI两个体系的配置方法因为目前两个生态都有大量用户哪个都缺一不可。如果是Vite创建的项目配置文件是vite.config.jsimport { defineConfig } from vite; import vue from vitejs/plugin-vue; export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { // 所有以 /api 开头的请求都会转发到3000端口 /api: { target: http://localhost:3000, changeOrigin: true } } } });如果是Vue CLI创建的项目需要修改vue.config.jsconst { defineConfig } require(vue/cli-service); module.exports defineConfig({ transpileDependencies: true, devServer: { port: 8080, proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } });配置的原理是一致的前端页面访问/api/banner时开发服务器不是直接把这个请求发往前端服务器而是通过代理转发到后端的3000端口。浏览器里看到的请求URL是前端的地址浏览器根本没有跨域因此压力只存在于服务器之间。changeOrigin:true的作用是让代理转发时修改请求头中的Host字段避免目标服务器因为Host不匹配而拒绝请求。这个参数建议直接加上能省很多排查时间。4.2 统一请求封装让每个接口调用都简单可靠代理配置好之后前端请求接口的路径就可以写成相对路径。为了让接口调用更统一我封装了一个request模块所有请求统一走这个模块统一处理响应、错误、加载状态。// src/utils/request.js import axios from axios; const service axios.create({ baseURL: /api, timeout: 10000 }); // 请求拦截器可以在请求发送前统一处理loading状态或token service.interceptors.request.use(config { return config; }); // 响应拦截器统一处理接口返回的code状态 service.interceptors.response.use( response { const res response.data; if (res.code ! 200) { // 这里可以触发全局错误提示 return Promise.reject(new Error(res.message || 接口异常)); } return res.data; }, error { return Promise.reject(error); } ); export default service;重点在响应拦截器里。接口统一返回{ code, message, data }拦截器直接判断code是否为200不是就抛错是就把data解出来返回。这样一来业务组件里请求接口的方法就简洁得多import request from /utils/request; export function getBanner() { return request({ url: /banner, method: get }); }调用侧只需要关心业务数据本身不该展示loading或错误时出现一堆乱七八糟的状态码判断。注意不要把业务处理塞进拦截器里。拦截器只做通用逻辑所有跟具体页面相关的处理必须放在业务代码中否则后续复用时会越写越复杂。4.3 启动顺序与联调链路验证前后端都配好后标准启动流程是先在server目录启动接口服务确认3000端口有响应再启动前端开发服务器访问前端页面在浏览器控制台里发起接口请求最后打开Network面板查看请求状态和返回数据。接口联调有个小技巧可以直接在浏览器地址栏访问http://localhost:5173/api/banner如果能看到JSON数据说明代理配置完全正确。如果这里都能通前端页面里请求不通那就是前端代码本身的问题反之则说明问题出在代理或者后端服务上。这个定位方法在实战中特别高效省得在层层代码里排查位置。另外我习惯把请求封装成API模块一个业务模块对应一个API文件src/api/ ├── banner.js ├── songlist.js ├── search.js └── singer.js整个项目的数据入口非常清晰任何一个接口有问题直接找对应的API文件改动影响范围一眼就看出来。5. 实战中遇到的典型问题与排查技巧5.1 端口冲突与代理失效接口服务启动时偶尔会碰到端口占用。Express启动报错EADDRINUSE就是端口被其他进程占了。排查方法很直接在命令行执行netstat -ano | findstr 3000Windows或lsof -i :3000Mac/Linux找到占用进程号结束进程后再重启。代理失效是另一种常见问题表象是前端请求/api/banner返回404。我先检查后端服务是否正常再检查代理配置是否生效。有一个很容易被忽略的点是后端接口路径本身就必须以/api开头如果后端路由挂载的是/banner前端代理转发时路径就拼不上了。解决方案是在路由挂载时就统一加/api前缀保持前后端路径完全一致。5.2 接口返回格式不规范与前端解析错误项目刚起步时我也犯过接口格式不统一的错误有的接口返回{code:200, data:[]}有的接口返回{status:success, result:[]}。前端组件五花八门的兼容写法越写越混乱最后不得不回头把所有接口统一成一套格式。在这个问题上我的经验是后端必须首先明确响应格式规范并在所有接口中强制执行。前端拦截器才能做到统一判断。这也是为什么我在一开始就封装response工具的原因不是代码量的问题是工程规范的问题。只要出现需要前端做兼容处理的接口字段差异设计上就已经埋下了隐患。5.3 请求超时与加载状态管理音乐接口有时会因图片体积过大导致请求比较慢。我在axios封装里配置了10秒超时实际使用中这个值不算长也不短。接口数据大的时候前端要有加载状态避免用户反复点击或者以为卡死了。代码层面我习惯在请求拦截器里使用一个简单计数器维护全局loading状态请求发起时加一请求完成时减一计数器归零才关闭loading。这个方法能避免多个并发请求时loading闪烁的问题属于一个特别实用的防御性设计。5.4 常见问题速查表问题现象可能原因解决方案浏览器请求接口报CORS错误后端没启用cors中间件在app.js中加入app.use(cors())前端请求接口返回404代理路径或后端路由路径不匹配检查后端路由挂载前缀是否含/api接口返回数据但前端页面空白字段名不一致或数据结构被拦截器拦截在Network面板查看原始返回字段检查与组件里使用的字段是否一致服务启动报端口占用3000端口被其他进程占用查端口占用进程并结束或改PORT环境变量修改后端代码后接口无变化nodemon没安装或没使用dev命令用 npm run dev 启动服务6. 数据Mock策略静态数据如何支撑复杂业务6.1 用静态JSON模拟真实接口的价值可能有人觉得反正都是静态数据直接在Vue项目本地引用JSON文件不就行了何必单独开一个HTTP服务一开始我也这样想过但实际开发一段时间后改变了看法。独立的API服务带来最大的好处是前端工程可以完全按照真实接口的调用方式来开发、联调和部署。将来要对接真实后端时前端代码一行都不用动只要后端接口遵循同样的路径和返回结构直接在nginx层做转发就可以平滑切换。如果现在就在前端工程里直接importJSON将来迁移时重构量极大。其次接口服务的模拟数据可以在后期逐步替换为数据库数据或者第三方数据源。服务启动后先读取静态JSON后续改造为请求远程数据源后端代码的结构也可以保持不变。数据来源可以轻易替换但接口语义一旦成形就为整个项目提供了一份稳定的契约。6.2 数据组织方式把数据类目拆细我在设计数据文件时尽可能把数据类目拆得更细。歌曲放一个文件歌单放一个文件歌手放一个文件轮播图单独放。接口返回的数据结构需要调整时直接改对应文件就行。关键点是数据文件的结构要和接口返回的data字段结构完全对齐。接口返回什么样JSON文件里就存什么样。这样开发过程中遇到疑惑直接打开JSON文件对照能节省大量摸索时间。如果数据文件结构和接口数据结构不一致前端联调时还得进行二次转换就破坏了接口层的透明度。6.3 从Mock到真实数据源的无缝换轨项目到了后期我会把数据源逐步换掉。换轨时不改变路由层的代码新增一个service目录路由从service取数据service内部决定读JSON还是请求远程地址。这样只需替换service内部实现路由完全不用动。这个过渡方式听上去很简单但它背后的设计思想是依赖倒置上层路由层不关心数据从哪来只关心取到的是什么结构。这在整个项目演进中帮了我很多次属于值得长期保持的一个架构习惯。现在整套接口服务已经能为后续开发提供稳定支撑轮播图、歌单、搜索等功能模块都可以在此基础上并行开发。API接口搭建看起来只是第一步但这一步扎实了后面的路会顺畅很多。以我自己的项目实践来说接口层做得越标准后面的页面开发效率提升越大。这个系列的下一篇计划进入播放器核心模块实现包括音频状态管理、播放列表切换、进度同步和控制逻辑有了现在这个接口层支撑数据链路可以完全专注在业务逻辑上。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑