纯前端实战:用HTML+JavaScript抓取网易云热歌榜并解决跨域
说实话我最初动这个念头是想给手头的个人导航站加一个“今天大家都在听什么”的模块。网易云热歌榜每天更新如果能用最轻量的方式把榜单实时拉到自己的网页上这个功能既有真实数据又不用我去维护后端数据库。但实际操作起来第一行 fetch 代码就给我上了一课——浏览器里直接请求网易云的接口十有八九会撞上跨域拦截。这篇文章就把我踩过的坑和最终验证可行的方案完整走一遍内容包括接口选型、GET 请求写法、榜单数据解析、跨域的完整排查链路以及搜索、预览、自动刷新这些进阶玩法。前端刚入门的读者可以把整篇当成一个带真实数据的练手项目来复现有经验的同学可以直接跳到第四章看跨域那段那是我花了最多时间的地方。1. 为什么偏偏用纯HTML页面去抓热门榜单需求、定位与学习路径1.1 我到底想解决什么问题先交代清楚背景我需要的不是做一个完整的音乐网站也不是要把百万级歌曲存进数据库而是在一个普通的静态页面上展示“网易云热歌榜前 20 名”并且希望它自动更新。说白了就是给网页加一块能说话的真实数据模块。这个需求最合适的实现方式就是纯 HTML JavaScript 页面通过 GET 请求直接访问网易云的公开歌单接口。这里重点词是“公开”网易云音乐的热歌榜本质上是一个公开歌单不需要登录、不需要加密签名、不需要授权 token任何人都能通过歌单 ID 获取歌曲列表。正因为接口公开它才非常适合拿来教学。为什么不走后端方案因为我当时的前端页面托管在一个纯静态服务器上没有 Node.js 运行环境也不想为了一个小模块专门租一台云服务器。纯前端请求对我来说是维护成本最低的方案——页面打开自动拉取刷新即新数据。1.2 三种数据获取路线的对比实际做过这类功能的人都知道获取数据通常有三条路可以走。我把它们的优劣摊开对比一下方案优点缺点适合场景后端爬虫抓取可控制频率、可入库、可缓存需要服务器、需要写定时任务做数据分析、给整个站点提供聚合数据第三方聚合 API接入方便、通常有封装好的返回结构要注册 token、免费额度有限、稳定看天快速试功能、不怕第三方关停纯前端 GET 请求零依赖、零运维、刷新即新数据受 CORS 跨域限制、无法隐藏请求细节静态页小工具、教学演示、个人导航站对照下来我的场景明显属于第三类。GET 请求是 HTTP 协议里最基础的方法写在浏览器里几乎没有任何学习门槛唯一的拦路虎就是跨域而跨域问题也是本文要深入展开的核心内容。1.3 顺着这个项目能学到什么这个练手项目的价值不只是“会写一个接口请求”它覆盖了前端开发里常见的一条完整链路理解 GET 请求的基本格式和参数拼接方式学会用 fetch 发起异步请求并处理 Promise学会解析嵌套的 JSON 结构并映射到页面 DOM理解跨域CORS的前因后果掌握至少两种绕过手段体验真实项目里“加载中—成功—失败”三种状态的切换这些能力是可以直接迁移的。后面不管你是要去接天气接口、获取 GitHub 仓库信息还是使用公司内部的开放 API套路基本一致先找到公开接口、构建 GET 请求、解析数据、渲染页面、处理跨域或鉴权。把网易云热歌榜跑通了其他接口只是换个 URL 的事。2. 热歌榜数据源剖析接口选型与跨域问题的第一课2.1 榜单在哪热歌榜本质上就是一个歌单很多人以为“热歌榜”这种榜单一定藏在某个专属接口里其实不然。网易云音乐的热歌榜就是一个普通歌单所有榜单聚合页也不过是歌单的排列组合。因此只要找到榜单对应歌单的 ID就能用歌单详情接口拿到完整歌曲列表。经过多次验证热歌榜的歌单 ID 是 3778678。顺带记几个常用的榜单 ID方便你后面扩展新歌榜是 3779629飙升榜是 19723756。这三个 ID 在网易云 Web 端和很多第三方开源项目里都出现过做前端测试很稳定。2.2 接口长什么样返回字段说明获取歌单详情的接口有好几个版本我最终稳定使用的地址是https://music.163.com/api/v1/playlist/detail?id3778678这个接口返回的是一个标准的 JSON 对象最外层的playlist字段包含了歌单名称、封面、歌曲总数核心是里面的tracks数组也就是歌曲列表。每一首歌的关键字段如下字段含义实例值track.id歌曲唯一 ID186016track.name歌曲名晴天track.ar歌手数组[{ name: 周杰伦 }]track.al专辑信息内含picUrl封面图{ name: 叶惠美, picUrl: ... }track.dt歌曲时长单位毫秒269000track.fee付费标记0 表示免费8 表示 VIP0这里要提醒一下ar和al这两个字段名字是历史遗留的短命名分别代表 artists歌手列表和 album专辑新手第一次看到会有点懵解析数据时别找错字段。2.3 直接请求的致命问题CORS当我兴冲冲地在浏览器里写下第一段 fetch 请求时控制台毫不犹豫地给我抛出了一大段红色报错。核心内容是Access to fetch at https://music.163.com/api/v1/playlist/detail?id3778678 from origin http://127.0.0.1:5500 has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource.这不是接口挂了也不是代码写错了而是浏览器安全机制在起作用。浏览器规定A 站点的脚本去请求 B 站点的资源时B 站点必须在响应头里明确声明“我允许 A 站点访问”也就是给出Access-Control-Allow-Origin字段。网易云的这个接口只服务于它自家 Web 站的页面并没有给浏览器脚本开放跨域访问所以响应头里没有这个字段浏览器就把数据拦在了门外。理解这一点非常关键请求其实已经发出去了服务器也返回了完整数据但浏览器不让你的 JavaScript 代码读取。你可以在开发者工具的 Network 面板里清楚看到这个请求的状态是成功200但代码里拿到的是报错。2.4 破局的三条路公共代理、自建代理与JSONP跨域问题既然是浏览器机制造成的就不能靠改接口解决只能绕道。我梳理下来常用的路线有三条方案实现成本稳定性适用场景公共 CORS 代理最低改个 URL 前缀差公共资源易限流本地开发、教学演示自建反向代理中等需要后端环境好完全可控上线项目、正式使用JSONP 动态 script中等代码较绕取决于接口是否支持兼容性要求极高的旧系统三条路我都实际试过也都在后文有对应的代码。JSONP 那条路要额外说明一句网易云官方的歌单详情接口并不支持 JSONP 回调这个方法更多适用于你自己后端接口或者某些老系统教学演示里它存在的主要意义是帮你理解“为什么用 script 标签绕开 CORS”。3. 第一版榜单页页面骨架、GET请求与数据渲染3.1 页面结构一个简洁但完整的HTML骨架先给你一份完整的页面代码。这部分我故意把样式写得很克制目的是让你把注意力集中在数据请求和渲染逻辑上而不是被花哨的 CSS 迷惑。!DOCTYPE html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 title网易云热歌榜/title style * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: PingFang SC, Microsoft YaHei, sans-serif; background: #f5f5f5; max-width: 720px; margin: 0 auto; padding: 20px; } h1 { font-size: 24px; margin-bottom: 8px; } .update-time { color: #999; font-size: 12px; margin-bottom: 16px; } .list-header { display: flex; align-items: center; padding: 12px; background: #fff; border-radius: 12px; margin-bottom: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); } .list-header img { width: 60px; height: 60px; border-radius: 8px; object-fit: cover; margin-right: 12px; } .list-header .name { font-size: 16px; font-weight: 600; } .list-header .count { font-size: 12px; color: #999; } .song-item { display: flex; align-items: center; background: #fff; padding: 10px 12px; border-radius: 10px; margin-bottom: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03); transition: background 0.2s; } .song-item:hover { background: #fafafa; } .song-rank { width: 36px; text-align: center; font-weight: 700; font-size: 16px; color: #999; } .song-rank.top-1 { color: #ec4141; } .song-rank.top-2 { color: #ff7d2e; } .song-rank.top-3 { color: #ffb02e; } .song-cover { width: 44px; height: 44px; border-radius: 6px; object-fit: cover; margin-right: 12px; background: #eee; } .song-info { flex: 1; overflow: hidden; } .song-name { font-size: 14px; font-weight: 600; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; } .song-artist { font-size: 12px; color: #999; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; } .loading, .error { text-align: center; color: #666; padding: 40px 0; } /style /head body h1网易云热歌榜/h1 div classupdate-time idupdateTime正在加载.../div div idlist/div script // 公共CORS代理本地演示用 const proxy https://api.allorigins.win/raw?url; const target https://music.163.com/api/v1/playlist/detail?id3778678_ Date.now(); fetch(proxy encodeURIComponent(target)) .then(res { if (!res.ok) throw new Error(HTTP res.status); return res.json(); }) .then(data { renderHeader(data.playlist); renderTracks(data.playlist.tracks.slice(0, 20)); }) .catch(err { document.getElementById(list).innerHTML div classerror请求失败 err.message /div; }); function renderHeader(playlist) { const now new Date(); document.getElementById(updateTime).textContent 更新于 now.toLocaleTimeString(zh-CN); const list document.getElementById(list); const header document.createElement(div); header.className list-header; const cover document.createElement(img); cover.src playlist.coverImgUrl ?param120y120; cover.referrerPolicy no-referrer; header.appendChild(cover); const info document.createElement(div); const name document.createElement(div); name.className name; name.textContent playlist.name; info.appendChild(name); const count document.createElement(div); count.className count; count.textContent 共 playlist.trackCount 首; info.appendChild(count); header.appendChild(info); list.appendChild(header); } function renderTracks(tracks) { const list document.getElementById(list); tracks.forEach((track, index) { const item document.createElement(div); item.className song-item; const rank document.createElement(div); rank.className song-rank (index 3 ? top- (index 1) : ); rank.textContent index 1; item.appendChild(rank); const cover document.createElement(img); cover.className song-cover; cover.src track.al.picUrl ?param100y100; cover.referrerPolicy no-referrer; item.appendChild(cover); const info document.createElement(div); info.className song-info; const name document.createElement(div); name.className song-name; name.textContent track.name; info.appendChild(name); const artist document.createElement(div); artist.className song-artist; artist.textContent track.ar.map(a a.name).join( / ); info.appendChild(artist); item.appendChild(info); list.appendChild(item); }); } /script /body /html这份代码已经能跑了且这个项目就可以复现出“热歌榜前 20 名”的页面。后面的内容是对其中每一部分背后的选择逻辑做更细致的拆解尤其是 GET 请求的细节和数据渲染的设计思路。3.2 GET请求怎么写避开几个常见误解很多新手写 GET 请求时会习惯性带上Content-Type: application/json请求头其实这是多余的。GET 请求没有请求体服务器不会去解析 body这个头加了也不会报错但属于无效信息。真正需要注意的有两点。第一URL 参数拼接。这里我用的是?id3778678_Date.now()这个_参数是典型的缓存绕过技巧。网易云接口虽然没主动开启强缓存但某些中间代理服务器会对相同 URL 的 GET 响应做缓存导致页面刷新后数据不更新。加上一个时间戳参数后每次请求的 URL 都不同就能拿到最新数据。第二fetch 默认就是 GET不需要显式声明method: GET但如果你需要高度显式地表达代码意图加上也没问题。这个项目里我选择不写保持代码简洁。真正的核心其实在 catch 分支任何网络请求都不可能保证成功必须有对应的错误反馈界面否则接口挂了你看到的只是一个空白页面排查成本非常高。3.3 数据解析从嵌套JSON到DOM渲染的关键一步拿到接口返回的 JSON 后最核心的一步是把嵌套结构“翻译”到页面上。{ playlist: { name: 热歌榜, trackCount: 100, coverImgUrl: https://p1.music.126.net/xxx.jpg, tracks: [ { id: 186016, name: 晴天, ar: [{ name: 周杰伦 }], al: { name: 叶惠美, picUrl: https://p1.music.126.net/yyy.jpg }, dt: 269000, fee: 0 } ] } }我的渲染逻辑分成了renderHeader和renderTracks两个函数原因是两者的职责完全不同头图信息只渲染一次歌曲列表则可能在后续加入搜索、分页等功能时反复调用。函数拆分能让代码在不同场景下复用这个习惯建议尽早养成。渲染歌曲列表时我用document.createElement创建 DOM 节点而不是直接拼接 HTML 字符串。这种方式的优势有两个一是避免 XSS 注入风险——如果歌曲名里包含script标签用innerHTML拼接会直接执行而textContent设置文本是安全的二是后续想要给某个节点绑定点击事件时不需要重新查找元素直接操作这个引用就行。比如后面做播放预览时我只需要给“播放按钮”这个 btn 节点添加事件监听。这里还有一个容易忽略的性能细节每首歌都会创建一个 img 节点来加载封面如果榜单有 100 首歌浏览器就会并发发起 100 个图片请求。我没让代码一次渲染全部而是先tracks.slice(0, 20)只渲染前 20 首。这个数字是故意控制的兼顾了首屏速度和内容完整度。3.4 封面图裁剪网易云图片服务的隐藏参数网易云的封面地址可以在 URL 后面直接拼接?param120y120来得到指定尺寸的图片这是一个很多人不知道的小技巧。原图往往是 1000x1000 甚至更大的尺寸直接放到 44px 的头像位置上浪费带宽不说移动端还会卡。拼接参数后服务端会返回一张正好 120x120 的缩略图加载速度明显提升。顺带提醒一个防 403 的细节网易云的图片服务器在某些环境下会校验 Referer 字段导致img标签加载封面时返回 403。解决办法是在 img 元素上加上referrerPolicyno-referrer告诉浏览器不要发送来源信息。我在上面的代码里已经处理好了直接抄作业就行。4. 跨域报错排查全过程从No Access-Control-Allow-Origin到代理方案落地4.1 第一次运行报错现场还原我最初在本地用 VS Code 的 Live Server 插件打开页面端口是 5500。打开页面后榜单区域一片空白控制台里躺着一整段报错。报错信息第一部分是请求地址第二部分是当前页面的源第三部分是那句关键的话has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource.这几个词拆开看其实很直白“资源已被 CORS 策略拦截因为请求的资源上没有 Access-Control-Allow-Origin 响应头”。4.2 排查链路三步定位到真正的元凶遇到跨域报错我习惯按下面三步排查这样不会把时间浪费在错误的方向上。第一步用 curl 直接请求接口排除接口本身的问题。curl https://music.163.com/api/v1/playlist/detail?id3778678 | head -c 500终端里能顺利返回 JSON 数据说明接口存活、参数正确、返回内容完整。注意这一步已经把 CORS 因素完全剔除了因为 curl 是命令行工具不经过浏览器自然不受跨域限制。第二步检查前端请求代码。确认 URL 没有拼错、fetch 语法没有写错、Promise 链正常。甚至在页面里临时加了一句console.log(proxy encodeURIComponent(target))把最终请求的 URL 复制到浏览器地址栏直接打开结果能正常显示 JSON。这再次证明代码逻辑没问题。第三步打开开发者工具的 Network 面板查看这个请求的详细信息。这里能看到一个非常关键的现象请求的状态码是 200响应内容也是完整的 JSON但 Console 里依然报错。这说明数据其实已经到了浏览器只是被浏览器拦在 JavaScript 沙箱之外了。到这里基本可以下结论接口没挂、代码没错、参数没问题就是 CORS 搞的鬼。这个定位过程很有价值因为它帮你区分了“后端返回错误”和“浏览器拦截响应”这两种完全不同的失败模式。4.3 方案A公共CORS代理五分钟跑通定位到问题后最快的绕行办法是使用公共的 CORS 代理服务。以api.allorigins.win为例它的用法很简单把原始接口地址作为url参数传给代理代理会从服务器端去请求目标接口并在返回时加上允许跨域的响应头。const proxy https://api.allorigins.win/raw?url; const target https://music.163.com/api/v1/playlist/detail?id3778678; fetch(proxy encodeURIComponent(target)) .then(res res.json()) .then(data console.log(data));原理讲清楚其实很有意思代理服务器运行在远程它发起请求时没有浏览器安全模型的限制可以正常读取网易云返回的数据然后代理把这些数据以“自己源”的身份配合Access-Control-Allow-Origin: *响应头转发给浏览器。浏览器一看哦是代理服务器允许我读取就放行了。整个过程里网易云只看到了代理服务器的请求前端页面也只需要和代理服务器打交道两边都满意。用上代理之后页面的榜单马上渲染出来了那一刻确实很爽。但爽过之后也要清醒公共代理服务是很多开发者在共用请求一多就容易触发限流甚至直接 503。我自己在测试时就遇到过连续刷新几次后返回429 Too Many Requests的情况。所以它只适合本地学习和快速验证上线项目绝对不能这么干。4.4 方案B自建代理服务上线更安心正式上线时最稳妥的方案是自建一个极简的反向代理。这个代理不需要任何复杂功能就是把前端的请求转发给网易云再把响应原样返回同时加上 CORS 响应头。下面是一个完整的 Node.js 示例const express require(express); const axios require(axios); const app express(); const PORT 3000; app.use((req, res, next) { res.setHeader(Access-Control-Allow-Origin, *); res.setHeader(Access-Control-Allow-Methods, GET, OPTIONS); res.setHeader(Access-Control-Allow-Headers, Content-Type); if (req.method OPTIONS) { return res.sendStatus(204); } next(); }); app.get(/api/playlist, async (req, res) { const id req.query.id || 3778678; try { const response await axios.get( https://music.163.com/api/v1/playlist/detail, { params: { id } } ); res.json(response.data); } catch (err) { res.status(500).json({ error: 获取失败, message: err.message }); } }); app.listen(PORT, () { console.log(代理服务已启动: http://localhost: PORT); });启动这个服务后前端代码只需要把请求地址从网易云改成自己的代理fetch(http://localhost:3000/api/playlist?id3778678)和公共代理相比自建代理最大的优点是可控。你可以给自己站点的域名加白名单可以给代理加缓存可以监控每天请求量。如果前端部署在 HTTPS 环境下记得代理地址也要用 HTTPS否则浏览器会因为“混合内容”再次拦截请求。4.5 两种方案怎么选我的判断标准判断标准其实很简单如果页面是临时演示、本地调试直接用公共代理省时省心。如果页面是要长期挂在网上的生产项目老老实实上自建代理别赌公共服务的稳定性。还有一个折中方案如果你用的静态托管平台支持 Serverless 函数比如 Vercel、Netlify可以用它们提供的前端函数功能写一个几行的代理接口既不需要维护独立服务器又能保证稳定性。5. 功能扩展搜索、预览、自动刷新与付费标记5.1 给歌曲打上免费与VIP标签拿到歌曲数据后你会注意到track.fee字段标识了付费情况。取值不完全固定但最常见的两种是 0免费和 8VIP 专属有些场景下还会出现 1付费购买。这是我实测下来比较稳定的一组值。function getFeeTag(fee) { if (fee 8 || fee 1) { return span classtag vipVIP/span; } return span classtag free免费/span; }我建议不要直接把这个 HTML 字符串塞进innerHTML而是在创建 DOM 节点时用document.createElement(span)来添加。原因和前面说的一样textContent永远是安全的而拼接字符串一旦某个字段里混入了特殊字符就可能出问题。歌曲这种数据源虽然相对稳定但数据驱动的页面一律按“不可信输入”对待。5.2 按关键词搜索榜单搜索功能不需要请求新接口因为榜单数据已经全量拉下来了前端直接在tracks数组里做筛选即可。这个思路很重要先判断数据是否需要重新获取很多时候本地过滤就够了没必要多一次网络请求。const searchInput document.getElementById(search); searchInput.addEventListener(input, function () { const keyword this.value.trim().toLowerCase(); const filtered allTracks.filter(track { const name track.name.toLowerCase(); const artists track.ar.map(a a.name.toLowerCase()).join( ); return name.includes(keyword) || artists.includes(keyword); }); renderTracks(filtered); });这里要注意几个细节先trim()去掉首尾空格避免用户误输入空格导致搜索不到统一toLowerCase()做大小写不敏感匹配搜索的是歌名和歌手两个维度而不是只搜歌名。还有一点搜索时要保留榜单的原始顺序不要做排序不然用户会疑惑为什么搜出来的歌排名变了。5.3 点击歌曲播放30秒预览播放预览是我觉得整个项目里最有成就感的扩展功能。网易云歌曲有一个外链播放地址https://music.163.com/song/media/outer/url?id歌曲ID.mp3这个地址对很多版权开放、非 VIP 的歌曲是有效的可以直接给audio元素使用。我在渲染歌曲项时给每行加了一个播放按钮点击后创建 Audio 对象开始播放let currentAudio null; function playPreview(songId, btn) { if (currentAudio) { currentAudio.pause(); currentAudio null; } const audio new Audio( https://music.163.com/song/media/outer/url?id songId .mp3 ); audio.onended () { btn.textContent 播放; currentAudio null; }; audio.play() .then(() { btn.textContent 播放中; currentAudio audio; }) .catch(() { btn.textContent 无法播放; }); }这里有几个需要说明的坑。第一如果上一首还没播完就点击下一首要先pause()暂停并清空引用否则页面会出现两个声音叠加。第二audio.play()返回的是一个 Promise必须处理它的 reject 分支——很多付费歌曲和 VIP 歌曲外链是 403 的播放会失败这时按钮要给出反馈而不是无动于衷。第三这个方式只是做预览不适合做完整播放器因为外链的稳定性和版权完全取决于网易云的服务策略做产品时要谨慎。5.4 定时自动刷新榜单热歌榜的更新有一定延迟不需要每秒钟刷新我设置的是 5 分钟一次。实现方式也很简单把之前的请求逻辑包成一个fetchData函数然后用setInterval驱动function fetchData() { // 原本的 fetch 请求逻辑 } fetchData(); setInterval(fetchData, 5 * 60 * 1000);这里要特别强调一下setInterval不会等待网络请求完成如果上一次请求因为网络慢还没返回下一次就开始了可能造成重叠请求。为了保险我用一个isLoading标志位来锁住并发请求let isLoading false; async function fetchData() { if (isLoading) return; isLoading true; try { // 请求逻辑 } finally { isLoading false; } }另外自动刷新应该只在页面可见时进行。用户把标签页切后台了没必要继续轮询。可以用document.visibilitychange事件来判断页面回到前台时立即拉一次切走时暂停定时器。这个小优化能显著降低无谓的请求量。6. 容易忽略的细节清单收尾6.1 封面图403与referrerPolicy的坑这个坑我在前面已经提过好几次但因为它太容易踩中了值得单独立一条。网易云图片服务器在某些来源下会校验 Referer当你的页面域名不是音乐平台自家域名时img请求可能返回 403。解决办法就是在所有加载远程图片的元素上加上referrerPolicyno-referrer。这个属性在现代浏览器上兼容性很好几乎没有副作用。6.2 必须有加载中和失败状态一个页面如果只有数据成功渲染后的样式那它是不完整的。我第一次调试时就遇到过接口临时抽风页面一片空白用户视角根本分不清是网络问题、脚本报错还是数据为空。所以我在代码里保留了两个状态加载中的提示和失败后的错误信息。加载状态用默认的 HTML 文本展示失败后用innerHTML替换成包含错误信息的提示框。这个习惯会伴随你每一次网络请求越早建立越好。6.3 别把代理地址写死在前端这个建议针对的是要上线的项目。前端代码是会被用户下载到本地的代理地址写死在 JS 里等于把基础设施的细节暴露给所有人。更好的做法是利用构建工具的环境变量机制在开发环境和生产环境分别配置不同的请求地址。开发环境用本地代理生产环境用线上域名只需要一个变量的切换代码里不需要任何改动。6.4 最后的一点经验从最开始的控制台报错到最后跑通搜索、播放、自动刷新这些功能整个项目给我的最大体会是做真实数据接口的项目先别急着写代码花十分钟把接口的返回结构摸清楚远比你反复调试要省时间。我用 curl 拉回 JSON 后会先把字段用表格列出来写渲染函数的时候几乎不会遇到找错字段的问题。如果你第一次尝试这种“用 GET 请求驱动页面”的项目建议也做一个类似的接口笔记把它当成你未来再做类似功能的模板后面你会发现这套流程对付大多数公开数据接口都通用。