正在整理这一页的内容…
Project Detail
用 React 模仿网易云音乐:从 API 调用到播放器状态管理
这篇笔记记录一下我用 React 模仿网易云音乐页面的过程。 这个项目的起点比较简单。 网上已经有开源的网易云音乐 API 反向代理项目,可以获取歌曲、歌单、搜索结果、歌词和播放地址等数据。 所以我不需要从头研究音乐平台的接口,也不需要自己写一套完整后端。 我主要做的是前端: 一开始我以为这个项目主要是还原页面。 比如

这篇笔记记录一下我用 React 模仿网易云音乐页面的过程。
这个项目的起点比较简单。
网上已经有开源的网易云音乐 API 反向代理项目,可以获取歌曲、歌单、搜索结果、歌词和播放地址等数据。
所以我不需要从头研究音乐平台的接口,也不需要自己写一套完整后端。
我主要做的是前端:
用 React 写一个音乐页面,
再自己控制播放器的功能、交互和样式。一开始我以为这个项目主要是还原页面。
比如:
- 左侧导航。
- 推荐歌单。
- 歌曲列表。
- 底部播放栏。
- 封面和歌词。
真正做下来以后,我发现最有意思的部分不是把页面画得像。
而是怎么把浏览器原生的音频能力整理成一个真正能用的播放器:
当前播放哪首歌?
播放到哪里了?
什么时候切下一首?
拖动进度条后怎么同步?
音量、播放模式和播放队列放在哪里?
切歌后歌词怎么跟着变化?这些问题让这个项目从一个普通的页面模仿,变成了一次比较完整的 React 状态管理练习。
先说结论
这个项目可以分成两部分:
网易云 API
-> 提供歌曲、歌单、歌词和播放地址
React 前端
-> 展示数据并管理播放器状态播放器的核心流程大概是:
用户选择歌曲
-> 获取歌曲播放地址
-> 设置当前歌曲
-> audio 加载音频
-> 监听播放进度和状态
-> React 更新界面
-> 歌曲结束后根据播放模式切歌如果用一句话总结这个项目:
API 解决音乐数据从哪里来,
React 解决这些数据怎么展示和交互,
HTMLAudioElement 负责真正播放声音。为什么使用现成的 API 项目
音乐应用需要的数据很多:
- 推荐歌单。
- 歌单详情。
- 歌曲详情。
- 歌手和专辑信息。
- 搜索结果。
- 歌词。
- 播放地址。
- 封面图片。
如果这些接口全部自己实现,项目重点很快就会变成后端数据抓取。
但我这次更想练习的是:
前端页面组织和音乐播放器控制。所以直接使用开源 API 反向代理项目比较合适。
它把网易云相关数据整理成 HTTP 接口,React 前端只需要正常请求:
const response = await fetch(
`${API_BASE_URL}/search?keywords=${keyword}`,
);
const result = await response.json();这样我可以把更多精力放在用户真正能看到和操作的部分。
这个项目有哪些页面
一个基础版本可以先包含这些区域:
侧边导航
-> 发现音乐
-> 搜索
-> 歌单
主内容区
-> 推荐内容
-> 歌曲列表
-> 歌单详情
播放器
-> 当前歌曲
-> 播放控制
-> 进度条
-> 音量
-> 播放模式
-> 播放队列如果继续扩展,还可以增加:
- 歌曲详情页。
- 滚动歌词。
- 收藏歌单。
- 最近播放。
- 登录状态。
- 每日推荐。
- 歌手和专辑页面。
但第一版最重要的还是:
找得到歌,也真的能播放。音乐播放不是 React 自己完成的
React 负责页面和状态。
真正播放音乐的是浏览器的 <audio> 元素,也就是 HTMLAudioElement。
可以直接写:
<audio src={currentSongUrl} controls />这样浏览器会提供一个默认播放器。
但如果想自己控制样式,就不能只依赖默认 controls。
我会创建一个隐藏或不显示原生控件的 audio:
const audioRef = useRef<HTMLAudioElement>(null);
return (
<audio
ref={audioRef}
src={currentSong?.url}
/>
);然后用自己的按钮控制:
function handleTogglePlay() {
const audio = audioRef.current;
if (!audio) {
return;
}
if (audio.paused) {
void audio.play();
} else {
audio.pause();
}
}这样播放按钮、图标、进度条和音量条都可以完全自己设计。
播放器需要保存哪些状态
一个播放器至少会有这些状态:
type PlayerState = {
currentSong?: Song;
queue: Song[];
currentIndex: number;
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
playMode: "sequence" | "loop-one" | "shuffle";
};它们分别代表:
- 当前播放的歌曲。
- 当前播放队列。
- 当前歌曲在队列中的位置。
- 是否正在播放。
- 当前播放时间。
- 歌曲总时长。
- 当前音量。
- 顺序播放、单曲循环或随机播放。
这些状态彼此有关。
例如切换 currentIndex 后:
currentSong 要变化
-> 播放地址要变化
-> audio 要加载新歌曲
-> 进度要回到 0
-> 歌词要重新请求
-> 如果原来正在播放,新歌曲应该继续播放所以播放器不只是几个分散按钮。
它更像一个有明确状态变化规则的小系统。
当前歌曲和播放队列怎么管理
用户可能从不同地方点击歌曲:
- 搜索结果。
- 推荐列表。
- 某个歌单。
- 最近播放。
点击以后,可以把当前歌曲加入播放队列:
function playSong(song: Song) {
const existingIndex = queue.findIndex(
(item) => item.id === song.id,
);
if (existingIndex >= 0) {
setCurrentIndex(existingIndex);
return;
}
setQueue((currentQueue) => [
...currentQueue,
song,
]);
setCurrentIndex(queue.length);
}不过这里要注意 React 状态是快照。
如果直接依赖当前闭包里的 queue.length,连续操作时可能拿到旧值。
更稳妥的方式是把队列和索引更新放进统一逻辑,或者使用 reducer 管理。
为什么播放器适合用 reducer
如果播放器只有一个播放按钮,几个 useState 就够了。
但状态越来越多以后,更新逻辑会散落:
切歌时改 currentSong
某个 Effect 里改 currentTime
播放按钮里改 isPlaying
歌曲结束时改 currentIndex
删除队列时又要调整 currentIndex这些状态之间有明显关系。
这时可以使用 useReducer:
type PlayerAction =
| { type: "play-song"; song: Song }
| { type: "toggle-play" }
| { type: "set-progress"; time: number }
| { type: "set-duration"; duration: number }
| { type: "next" }
| { type: "previous" }
| { type: "set-volume"; volume: number }
| { type: "set-mode"; mode: PlayMode };Reducer 统一描述播放器怎么变化:
function playerReducer(
state: PlayerState,
action: PlayerAction,
): PlayerState {
switch (action.type) {
case "set-volume":
return {
...state,
volume: action.volume,
};
case "set-progress":
return {
...state,
currentTime: action.time,
};
default:
return state;
}
}这样代码会更容易追踪:
发生了什么动作?
播放器状态应该怎么变?如果播放状态需要很多页面共享,也可以继续放进 Context、Redux、MobX 或其他状态管理工具。
React 状态和 audio 状态要怎么同步
这里是播放器里比较容易绕的地方。
React 有一份状态:
isPlaying
currentTime
duration
volumeaudio 元素自己也有状态:
audio.paused
audio.currentTime
audio.duration
audio.volume如果两边都随便修改,就可能不同步。
例如 React 认为:
isPlaying = true但浏览器因为自动播放限制,audio.play() 实际失败了。
这时按钮显示正在播放,声音却没有出来。
所以对于播放结果,更可靠的方式是监听 audio 事件:
<audio
ref={audioRef}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
/>也就是说:
用户操作表达意图,
audio 执行真正动作,
audio 事件再把实际状态同步回 React。这样界面更接近播放器的真实状态。
播放和暂停
播放时调用:
await audio.play();暂停时调用:
audio.pause();play() 会返回 Promise。
它可能因为浏览器自动播放策略而失败,所以最好处理异常:
async function play() {
const audio = audioRef.current;
if (!audio) {
return;
}
try {
await audio.play();
} catch (error) {
console.error("播放失败", error);
}
}通常用户主动点击播放按钮后,浏览器会允许播放。
但页面打开后直接自动播放,可能会被阻止。
进度条怎么做
audio 播放过程中会触发 timeupdate。
可以读取:
audio.currentTime更新页面上的进度:
function handleTimeUpdate() {
const audio = audioRef.current;
if (!audio) {
return;
}
setCurrentTime(audio.currentTime);
}歌曲元信息加载完成后,可以取得:
audio.duration进度条的百分比是:
const progress = duration > 0
? currentTime / duration
: 0;使用原生 range:
<input
type="range"
min={0}
max={duration || 0}
value={currentTime}
onChange={(event) => {
const nextTime = Number(event.target.value);
if (audioRef.current) {
audioRef.current.currentTime = nextTime;
}
}}
/>拖动时不仅要改 React 状态,还要修改 audio 的真实播放位置。
否则界面动了,歌曲却还在原来的位置播放。
不要每一帧都依赖 React 重新渲染
播放进度会频繁变化。
如果为了一个非常平滑的进度动画,每几十毫秒都更新 React 状态,可能会带来大量重新渲染。
普通播放器使用 timeupdate 通常已经够用。
如果想做更平滑的进度动画,可以在播放时使用 requestAnimationFrame,但也要控制更新范围。
不要让整个音乐页面都因为进度变化反复渲染。
可以把播放器进度拆成独立组件,或者使用 selector 让其他区域不订阅 currentTime。
这个项目让我更明显地感受到:
全局状态不代表所有组件都要监听它的每一次变化。音量控制
audio 的音量范围是:
0 到 1例如:
audio.volume = 0.5;表示 50% 音量。
页面滑块可以使用 0 到 100:
<input
type="range"
min={0}
max={100}
value={volume * 100}
onChange={(event) => {
const nextVolume =
Number(event.target.value) / 100;
setVolume(nextVolume);
if (audioRef.current) {
audioRef.current.volume = nextVolume;
}
}}
/>音量比较适合保存到 localStorage。
这样用户刷新页面以后,还能保留上次设置:
localStorage.setItem(
"music-player-volume",
String(volume),
);不过当前播放进度是否要持久化,就要看产品需求。
上一首和下一首
顺序播放时,下一首比较简单:
const nextIndex =
(currentIndex + 1) % queue.length;上一首:
const previousIndex =
(currentIndex - 1 + queue.length) %
queue.length;但真实播放器还会考虑一个细节:
点击上一首时,是回到当前歌曲开头,
还是切换到上一首?很多播放器会在当前歌曲已经播放几秒后,先回到开头:
if (audio.currentTime > 3) {
audio.currentTime = 0;
return;
}否则才切上一首。
这种小细节会明显影响播放器使用手感。
播放模式怎么处理
常见播放模式有:
顺序播放
单曲循环
随机播放歌曲播放结束时,audio 会触发 ended。
这时根据模式决定下一步。
顺序播放
切换到队列下一首:
currentIndex + 1到队尾后可以停止,也可以回到第一首,取决于产品定义。
单曲循环
可以设置:
audio.currentTime = 0;
await audio.play();也可以使用 audio 的 loop 属性。
不过如果播放模式由统一状态控制,我更倾向于在结束事件里明确处理。
随机播放
随机模式不是简单地:
Math.floor(Math.random() * queue.length)因为可能连续随机到当前歌曲。
至少要排除当前索引。
如果想做得更自然,还可以记录随机历史,让上一首能够回到刚才听过的歌曲。
歌曲切换后为什么不能立即播放
切歌时通常会更新:
currentSong然后 audio 的 src 变化。
但 React 更新和浏览器加载音频都需要时间。
如果代码顺序处理不好,可能出现:
currentSong 已经变了,
但 audio 还没加载完新地址,
play() 调用到了旧资源或直接失败。可以监听 canplay 或在地址更新后调用 load():
useEffect(() => {
const audio = audioRef.current;
if (!audio || !currentSong?.url) {
return;
}
audio.load();
if (shouldAutoPlay) {
void audio.play();
}
}, [currentSong?.url]);还要注意 play() 的 Promise 异常。
播放器里的很多 bug,不是接口数据不对,而是状态更新和媒体加载时机没有对齐。
播放地址可能会失效
音乐播放地址不一定永久有效。
有些地址可能带有时效参数,过一段时间以后就无法继续使用。
所以播放队列里更适合长期保存歌曲 ID 和基本信息:
type Song = {
id: number;
name: string;
artists: string[];
cover: string;
};真正播放前,再根据歌曲 ID 获取最新播放地址。
不要把临时 URL 当成永久数据保存。
如果播放失败,也可以重新请求一次地址再尝试。
歌词怎么显示
歌词接口通常会返回带时间的 LRC:
[00:12.30]第一句歌词
[00:18.50]第二句歌词需要先解析成结构化数据:
type LyricLine = {
time: number;
text: string;
};例如:
[
{ time: 12.3, text: "第一句歌词" },
{ time: 18.5, text: "第二句歌词" },
]然后根据当前播放时间找到当前行:
const activeIndex = lyrics.findLastIndex(
(line) => line.time <= currentTime,
);当前歌词可以高亮,歌词容器再自动滚动到对应位置。
这里也要注意:
不是每一次 timeupdate 都要重新解析歌词。歌词只需要在歌曲变化后解析一次。
播放过程中只根据 currentTime 查找当前行。
歌词滚动不要直接写在渲染过程里
高亮歌词变化后,需要滚动 DOM。
这属于和浏览器 DOM 同步的副作用,应该放进 Effect:
useEffect(() => {
activeLineRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, [activeIndex]);不要在 JSX 计算过程中直接调用滚动。
这个场景也让我更清楚地理解了:
当前歌词是哪一行,是计算结果。
让 DOM 滚动到这一行,是副作用。搜索要注意防抖和竞态
搜索输入框不能每输入一个字就立即请求。
可以使用防抖:
const debouncedKeyword =
useDebouncedValue(keyword, 300);然后根据防抖后的关键词搜索。
还要处理请求竞态。
例如用户快速输入:
周
周杰
周杰伦旧请求可能比新请求更晚返回。
如果不处理,页面最后可能显示“周”的搜索结果。
可以使用 AbortController 取消旧请求:
useEffect(() => {
const controller = new AbortController();
searchSongs(debouncedKeyword, {
signal: controller.signal,
});
return () => {
controller.abort();
};
}, [debouncedKeyword]);样式模仿不只是把颜色改成红色
网易云音乐最明显的视觉元素之一是红色。
但如果只是把按钮和导航改成红色,页面不会自然地像一个音乐应用。
还要继续处理:
- 页面区域层级。
- 歌单封面比例。
- 歌曲列表密度。
- 当前播放歌曲的高亮。
- 底部播放器固定高度。
- 封面、歌名和操作按钮的对齐。
- 进度条和音量条的交互反馈。
- 图标尺寸和点击区域。
- 滚动内容不要被底部播放器遮挡。
播放器是一个需要长期停留在页面上的工具区域。
它应该稳定、紧凑,不要因为歌曲名称太长或按钮状态变化而改变高度。
歌曲名和歌手名可以使用省略:
.song-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}主内容区域则要留出播放器高度:
.page-content {
padding-bottom: var(--player-height);
}浏览器刷新后要不要恢复播放
可以把这些信息保存到 localStorage:
- 播放队列。
- 当前歌曲 ID。
- 当前索引。
- 音量。
- 播放模式。
刷新后恢复播放器界面。
但自动恢复声音要谨慎。
浏览器通常不允许页面在没有用户操作时直接播放音频。
所以刷新以后可以恢复:
当前歌曲
播放队列
播放进度但保持暂停状态,等用户点击以后再播放,会更符合浏览器规则。
哪些状态应该全局保存
播放器通常固定在整个应用底部。
用户从推荐页切到歌单页时,音乐不能停止。
所以这些状态适合放在路由页面之外:
- audio 实例。
- 当前歌曲。
- 播放队列。
- 播放状态。
- 进度和音量。
- 播放模式。
可以用一个 PlayerProvider 包住整个应用:
<PlayerProvider>
<AppRouter />
<PlayerBar />
</PlayerProvider>页面只需要调用:
const player = usePlayer();
player.playSong(song);不需要每个页面自己创建一个 audio。
一个应用最好只有一个真正负责播放的音频实例。
否则路由切换或组件重复挂载时,可能出现两首歌同时播放。
API 服务和前端要保持边界
开源 API 项目负责提供数据。
React 项目负责展示和交互。
我不会把播放器状态放到 API 服务里。
这些状态属于当前浏览器:
正在播放哪首歌
进度是多少
音量是多少
播放队列是什么后端更适合提供:
歌曲元数据
搜索结果
歌词
歌单
播放地址边界清楚以后,前端和 API 都更容易替换。
使用开源接口时要注意什么
这个项目适合学习和个人实验。
但使用第三方平台的非官方反向代理接口时,也要注意几个问题:
- 接口可能随平台变化而失效。
- 部分歌曲可能因为版权没有播放地址。
- 登录、Cookie 和用户数据要谨慎处理。
- 不要把个人凭证提交到公开仓库。
- 不要把学习项目当成官方服务。
- 公开部署前要了解平台规则和内容版权要求。
尤其是音乐内容本身受到版权保护。
模仿项目的重点应该放在前端学习和个人使用,不应该重新分发受版权保护的音频资源。
我最后想通的地方
这个项目表面上是在模仿网易云音乐。
但真正让我练习到的内容比页面还原多很多:
怎么调用现成 API
怎么组织歌曲和歌单数据
怎么封装全局播放器
怎么同步 React 状态和 audio 状态
怎么处理播放队列和播放模式
怎么实现进度、音量和歌词滚动
怎么处理请求竞态和媒体加载时机我以前觉得音乐播放器就是:
一个 audio 标签,加几个按钮。真正自己写以后才发现,它更像一个持续运行的小型状态系统。
页面可以切换,组件可以重新渲染,但音乐播放必须保持连续。
用户拖动进度条、切换歌曲、调整音量时,界面状态和浏览器音频状态也必须始终一致。
开源 API 项目帮我解决了数据来源。
我自己写 React 前端,则让我真正控制了播放器长什么样、怎么操作、状态怎么流动。
这也是我觉得模仿项目有价值的地方:
不是只把别人的页面画一遍,
而是把一个熟悉产品背后的交互逻辑亲手实现出来。