news 2026/9/25 18:04:14

纯前端复刻网易云年度听歌报告:HTML/CSS/JS 滚动叙事实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯前端复刻网易云年度听歌报告:HTML/CSS/JS 滚动叙事实战

简介:这是一套可直接运行的网页版年度音乐报告前端模板,面向具备基础HTML/CSS/JS能力、希望快速搭建数据可视化报告页的开发者与设计爱好者,解决从零实现翻页交互与动态渲染成本高的问题。资源包共25个文件,约825KB,以9个HTML页面构成多页报告结构,6个JS脚本负责数据渲染、翻页逻辑与埋点,3个CSS文件统一管理样式,另含PNG图片、部署说明文档及仓库同步脚本,整体为静态文件架构,无需后端即可本地打开预览。已有20人学习关注。读者可获得完整的页面骨架与Swiper滑动翻页实现思路,理解CSS/JS分离与哈希命名带来的加载优化方式,并参考二维码生成、资源监听与基础埋点等细节,适合二次开发定制个人年度总结、企业用户行为报告或活动数据可视化场景。

1. 仿网易云年度听歌报告:一个纯前端项目为什么值得你花一个周末

每年年底,网易云音乐的年度听歌报告都会在朋友圈刷屏。那种一页页往下滑、每页一个数据卡片、背景色随内容渐变的交互,很多人第一反应是「这得后端渲染吧」。实际上,这类页面完全可以只用 HTML、CSS、JS 三件套做出来,不需要任何框架,不需要后端接口,甚至不需要构建工具。你打开浏览器就能跑,双击index.html就能看效果。

这个源码包要解决的核心问题是:如何用纯前端手段复现那种「沉浸式滚动叙事」的体验。它适合三类人——想拿一个完整项目练手的前端新手、需要做年度总结类 H5 的开发者、以及准备前端面试时想找一个能讲清楚 CSS 动画和 JS 数据驱动案例的人。接下来我会把这个项目的结构、关键实现、参数调优和踩坑点全部拆开讲,你照着做就能跑通一个属于自己的版本。

2. 项目结构拆解:HTML/CSS/JS 三件套怎么组织才不乱

2.1 为什么不用框架反而更难

很多人觉得不用框架就是「简单」,实际恰恰相反。框架帮你解决了状态管理和组件复用,纯三件套意味着这些都要自己手写。年度听歌报告这个场景有个特点:页面是线性的、一次性的、不需要复杂状态流转。用户从第一页滑到最后一页,数据从头到尾只读不写。这种场景下,引入 Vue 或 React 反而是负担——你得多引入几百 KB 的运行时,还得配构建工具。

我一般会这样组织目录:

annual-report/ ├── index.html ├── css/ │ ├── reset.css │ ├── layout.css │ └── animation.css ├── js/ │ ├── data.js │ ├── render.js │ └── scroll.js └── assets/ └── (封面图、图标等)

index.html只负责骨架,所有卡片用 JS 动态生成。data.js存放模拟数据,格式和真实接口返回保持一致。render.js负责把数据映射成 DOM。scroll.js处理滚动监听和页面切换。这样拆的好处是:你想换成真实接口,只改data.js的获取方式就行;你想调动画,只动animation.css。

2.2 HTML 骨架:一个容器加一个滚动监听

先看index.html的核心结构:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <title>年度听歌报告</title> <link rel="stylesheet" href="css/reset.css"> <link rel="stylesheet" href="css/layout.css"> <link rel="stylesheet" href="css/animation.css"> </head> <body> <div id="app"> <!-- 卡片由 JS 动态插入 --> </div> <script src="js/data.js"></script> <script src="js/render.js"></script> <script src="js/scroll.js"></script> </body> </html>

这里有几个关键点。viewport里的maximum-scale=1.0和user-scalable=no是为了防止移动端双指缩放破坏布局,年度报告类页面几乎都会加。#app是唯一容器,所有内容动态生成,这样 HTML 文件本身非常干净。脚本放在body末尾,避免阻塞渲染。

注意:如果你在本地直接双击打开,file://协议下某些浏览器会限制fetch加载本地 JSON。所以我把数据直接写成 JS 变量,而不是用fetch读 JSON 文件。这是纯前端项目最常见的翻车点之一。

2.3 CSS 分层:reset 管归零,layout 管位置,animation 管动效

reset.css做最基础的归零:

* { margin: 0; padding: 0; box-sizing: border-box; } html, body { width: 100%; height: 100%; overflow: hidden; /* 关键:禁止原生滚动,由 JS 控制 */ font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; -webkit-font-smoothing: antialiased; }

overflow: hidden是这类滚动叙事页面的核心设置。原生滚动条会破坏沉浸感,而且移动端惯性滚动很难精确控制。我们用自己的滚动逻辑替代。

layout.css定义卡片的基本布局:

.card { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: 40px 24px; opacity: 0; transform: translateY(60px); transition: opacity 0.6s ease, transform 0.6s ease; will-change: opacity, transform; } .card.active { opacity: 1; transform: translateY(0); }

每张卡片绝对定位铺满视口,默认透明且下移 60px。加上.active类后淡入并归位。will-change提前告诉浏览器这两个属性会变,让 GPU 提前准备图层,动画更顺滑。

animation.css放具体的动效,比如数字滚动、进度条增长、涟漪扩散。这部分后面单独讲。

2.4 JS 数据层:模拟数据长什么样

data.js的结构直接决定渲染逻辑的复杂度。我一般会设计成数组,每个元素对应一张卡片:

const reportData = [ { type: 'intro', title: '你的年度听歌报告', subtitle: '2025 年,音乐陪你走过', bgColor: '#1a1a2e' }, { type: 'number', label: '这一年你听了', value: 1287, unit: '首歌', bgColor: '#16213e' }, { type: 'topSong', label: '你听得最多的一首', songName: '示例歌曲', artist: '示例歌手', playCount: 236, bgColor: '#0f3460' }, { type: 'topArtist', label: '你最常听的歌手', artistName: '示例歌手名', listenDays: 89, bgColor: '#533483' }, { type: 'summary', label: '音乐品味关键词', tags: ['深夜', '民谣', '循环'], bgColor: '#2b2d42' } ];

每个对象都有type和bgColor。type决定用哪个渲染函数,bgColor驱动背景渐变。这种设计的好处是:加一张新卡片只需要在数组里加一个对象,不用改渲染逻辑。

2.5 渲染层:根据 type 分发到不同模板

render.js的核心是一个分发函数:

function renderCard(data, index) { const card = document.createElement('div'); card.className = 'card'; card.dataset.index = index; card.style.backgroundColor = data.bgColor; let innerHTML = ''; switch (data.type) { case 'intro': innerHTML = ` <h1 class="intro-title">${data.title}</h1> <p class="intro-subtitle">${data.subtitle}</p> `; break; case 'number': innerHTML = ` <p class="number-label">${data.label}</p> <div class="number-value">let currentIndex = 0; let isAnimating = false; const cards = document.querySelectorAll('.card'); const total = cards.length; function goTo(index) { if (index < 0 || index >= total || isAnimating) return; isAnimating = true; cards[currentIndex].classList.remove('active'); currentIndex = index; cards[currentIndex].classList.add('active'); // 触发当前卡片的入场动画 triggerCardAnimation(cards[currentIndex]); setTimeout(() => { isAnimating = false; }, 700); // 和 CSS transition 时长匹配 } // 滚轮事件 window.addEventListener('wheel', (e) => { if (isAnimating) return; if (e.deltaY > 0) { goTo(currentIndex + 1); } else { goTo(currentIndex - 1); } }, { passive: true }); // 触摸事件 let touchStartY = 0; window.addEventListener('touchstart', (e) => { touchStartY = e.touches[0].clientY; }, { passive: true }); window.addEventListener('touchend', (e) => { const deltaY = touchStartY - e.changedTouches[0].clientY; if (Math.abs(deltaY) < 50) return; // 阈值,防止误触 if (deltaY > 0) { goTo(currentIndex + 1); } else { goTo(currentIndex - 1); } }, { passive: true }); // 初始化 cards[0].classList.add('active'); triggerCardAnimation(cards[0]);

isAnimating锁是关键。没有它,用户快速滚动会一次性跳好几页,动画也会打架。setTimeout的 700ms 要和 CSS 里transition的0.6s匹配,留 100ms 余量。触摸事件的50px阈值是血泪经验——太小了手指轻微抖动就翻页,太大了滑半天没反应。

3. 核心动效实现:数字滚动、渐变背景与涟漪扩散

3.1 数字滚动:从 0 到目标值的缓动函数

年度报告里最抓眼球的动效就是数字从 0 涨到最终值。实现方式是用requestAnimationFrame做缓动:

function animateNumber(el, target, duration = 1500) { const start = performance.now(); const startVal = 0; function update(now) { const elapsed = now - start; const progress = Math.min(elapsed / duration, 1); // easeOutCubic 缓动 const eased = 1 - Math.pow(1 - progress, 3); const current = Math.floor(startVal + (target - startVal) * eased); el.textContent = current.toLocaleString(); if (progress < 1) { requestAnimationFrame(update); } else { el.textContent = target.toLocaleString(); } } requestAnimationFrame(update); } function triggerCardAnimation(card) { const numberEl = card.querySelector('.number-value'); if (numberEl) { const target = parseInt(numberEl.dataset.target, 10); animateNumber(numberEl, target); } }

easeOutCubic的公式是1 - (1 - t)^3,效果是开始快、结尾慢,数字涨到后面越来越慢,视觉上更有「定格」感。toLocaleString()自动加千分位逗号,1287 会显示成 1,287。

参数方面,duration设 1500ms 比较合适。太短了看不清数字变化,太长了用户等得不耐烦。如果你想让数字更有冲击力,可以改成easeOutExpo,公式是1 - Math.pow(2, -10 * t),结尾会更「急停」。

3.2 背景渐变:用 CSS 变量驱动整页色调

每张卡片的背景色不同,但直接切换backgroundColor会很生硬。更好的做法是用linear-gradient配合 CSS 变量做过渡:

.card { background: linear-gradient( 135deg, var(--bg-from, #1a1a2e) 0%, var(--bg-to, #16213e) 100% ); transition: opacity 0.6s ease, transform 0.6s ease; }

然后在 JS 里设置变量:

card.style.setProperty('--bg-from', data.bgColor); card.style.setProperty('--bg-to', adjustColor(data.bgColor, -20));

adjustColor是一个简单的颜色加深函数:

function adjustColor(hex, amount) { let r = parseInt(hex.slice(1, 3), 16); let g = parseInt(hex.slice(3, 5), 16); let b = parseInt(hex.slice(5, 7), 16); r = Math.max(0, Math.min(255, r + amount)); g = Math.max(0, Math.min(255, g + amount)); b = Math.max(0, Math.min(255, b + amount)); return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; }

这样每张卡片都有从主色到深色的渐变,比纯色更有层次。amount设-20到-40之间比较自然,太深了会发黑。

3.3 涟漪扩散:用伪元素和 keyframes 做点击反馈

热词里提到的「css 涟漪光圈扩散」在这个项目里可以用在按钮或卡片点击上。核心是用::after伪元素加@keyframes:

.ripple-btn { position: relative; overflow: hidden; } .ripple-btn::after { content: ''; position: absolute; top: 50%; left: 50%; width: 0; height: 0; border-radius: 50%; background: rgba(255, 255, 255, 0.3); transform: translate(-50%, -50%); transition: width 0.6s ease, height 0.6s ease, opacity 0.6s ease; opacity: 0; } .ripple-btn:active::after { width: 300px; height: 300px; opacity: 1; transition: 0s; }

按下时伪元素瞬间扩大到 300px 并显示,松开后通过transition慢慢消失。transition: 0s在:active里覆盖,保证扩大是瞬间的,消失是渐进的。

3.4 卡片入场:错峰动画让内容依次出现

一张卡片里可能有多个元素(标题、数字、单位),如果同时出现会显得很平。用transition-delay做错峰:

.card.active .intro-title { opacity: 1; transform: translateY(0); transition-delay: 0.1s; } .card.active .intro-subtitle { opacity: 1; transform: translateY(0); transition-delay: 0.3s; } .intro-title, .intro-subtitle { opacity: 0; transform: translateY(20px); transition: opacity 0.5s ease, transform 0.5s ease; }

标题先出,副标题晚 200ms。这种细微的时间差会让页面看起来「有呼吸感」。延迟间隔一般设 0.1s 到 0.3s,超过 0.5s 用户会觉得卡。

4. 避坑与排查:纯前端年度报告最容易翻车的 5 个地方

4.1 移动端 100vh 不是真正的视口高度

现象:在 iOS Safari 和部分安卓浏览器上,height: 100vh会把地址栏也算进去,导致卡片底部被裁掉一截。

原因:vh单位在移动端定义的是「最大视口高度」,地址栏收起时视口变大,但100vh不变。

解决:用 JS 动态设置--vh变量:

function setVH() { const vh = window.innerHeight * 0.01; document.documentElement.style.setProperty('--vh', `${vh}px`); } setVH(); window.addEventListener('resize', setVH);

CSS 里改成height: calc(var(--vh, 1vh) * 100)。这样无论地址栏怎么变,卡片始终铺满可见区域。

4.2 wheel 事件在触控板上触发过于频繁

现象:用 MacBook 触控板滑动时,一次轻扫就翻了好几页。

原因:触控板一次滑动会触发几十个wheel事件,每个事件deltaY都很小但累加起来很大。

解决:加一个时间锁,两次翻页之间至少间隔 800ms:

let lastWheelTime = 0; window.addEventListener('wheel', (e) => { const now = Date.now(); if (now - lastWheelTime < 800) return; if (isAnimating) return; lastWheelTime = now; // ...翻页逻辑 }, { passive: true });

isAnimating锁管的是动画期间,lastWheelTime管的是动画结束后的冷却。两个一起用才稳。

4.3 数字滚动在快速翻页时数值错乱

现象:快速从第 2 页翻到第 4 页再翻回来,数字显示的是上一轮动画的中间值。

原因:requestAnimationFrame的回调没有取消,多个动画同时往同一个 DOM 元素写值。

解决:在元素上存一个动画 ID,新动画开始前取消旧的:

function animateNumber(el, target, duration = 1500) { if (el._animId) { cancelAnimationFrame(el._animId); } // ...动画逻辑 function update(now) { // ... if (progress < 1) { el._animId = requestAnimationFrame(update); } else { el._animId = null; } } el._animId = requestAnimationFrame(update); }

4.4 背景色切换时出现白闪

现象:卡片切换的瞬间,背景会闪一下白色。

原因:opacity从 0 到 1 的过程中,卡片背后的body背景透出来了。

解决:给body设一个深色兜底背景,并且让卡片在opacity: 0时也保持visibility: visible:

body { background-color: #0a0a0a; } .card { opacity: 0; visibility: visible; /* 不要用 hidden */ }

如果还是闪,可以把transition的opacity改成用background-color过渡,但那样性能差一些。

4.5 本地打开时字体和图标加载失败

现象:双击index.html打开,自定义字体不生效,图标显示成方块。

原因:file://协议下,浏览器对跨域资源加载有限制,某些字体格式(如.woff2)会被拦截。

解决:要么起一个本地服务器(python -m http.server或npx serve),要么用系统字体栈兜底。图标建议用内联 SVG 而不是字体图标,SVG 在file://下没有加载问题。

5. 进阶技巧:把静态数据换成真实接口与性能优化

5.1 用 fetch 加载数据并做降级处理

如果你想把data.js换成真实接口,核心改动在render.js的入口:

async function init() { let data; try { const res = await fetch('/api/annual-report'); if (!res.ok) throw new Error('HTTP ' + res.status); data = await res.json(); } catch (err) { console.warn('接口失败,使用本地兜底数据', err); data = window.reportData; // data.js 里的兜底 } renderAll(data); cards[0].classList.add('active'); triggerCardAnimation(cards[0]); } init();

这里的关键是try/catch兜底。年度报告类页面往往在跨年时流量暴涨,接口挂了不能白屏。本地留一份模拟数据,接口失败时直接顶上,用户至少能看到内容。

5.2 用 IntersectionObserver 替代手动索引管理

手动维护currentIndex在卡片少的时候没问题,但如果你想支持用户直接跳转到某一页,或者页面结构变复杂,IntersectionObserver更稳:

const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('active'); triggerCardAnimation(entry.target); } else { entry.target.classList.remove('active'); } }); }, { threshold: 0.6 // 卡片 60% 可见时触发 }); cards.forEach(card => observer.observe(card));

threshold: 0.6表示卡片露出 60% 就算激活。这个方案的好处是:你不需要管currentIndex,也不需要处理wheel和touch的冲突,浏览器原生帮你算好了。缺点是翻页动画的控制粒度不如手动精细,适合对交互要求不那么极致的场景。

5.3 性能优化:减少重绘和图层数量

年度报告页面动效多,低端机上容易掉帧。几个实用的优化点:

第一,will-change不要滥用。只在.card和数字元素上加,加多了会创建过多图层,反而拖慢合成。

第二,背景渐变用background-image而不是background-color过渡。background-color过渡会触发重绘,background-image配合opacity可以走合成层。

第三,数字滚动用textContent而不是innerHTML。innerHTML每次都会触发 HTML 解析,textContent只改文本节点。

第四,如果卡片数量超过 10 张,考虑只渲染当前页和前后各一页,其他页用display: none移出渲染树。这个在纯前端项目里手动做有点麻烦,但效果立竿见影。

5.4 一个我常用的调试习惯

每次改完动画参数,我会在 Chrome DevTools 的 Performance 面板录 3 秒,看 FPS 曲线。如果掉到 30 以下,先检查是不是box-shadow或filter在动画里被频繁触发——这两个属性极其耗性能。能改成border或outline就改,改不了就用伪元素模拟。

另外,移动端调试一定要用真机。模拟器里的滚动和触摸事件和真机差别很大,尤其是touchend的触发时机。我吃过亏,模拟器上好好的,真机上滑三次才翻一页,最后发现是touchstart的passive没加,浏览器在等preventDefault。

希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 18:02:14

高温热管工质选择:钠钾锂的工作温度窗口与兼容性

高温热管工质按工作温度选&#xff1a;钾热管约400-700℃&#xff0c;钠热管约600-900℃&#xff0c;锂热管可达1200℃以上。除温度窗口外&#xff0c;还要看工质与管材的兼容性&#xff08;腐蚀&#xff09;和启动特性。选型原则是"工质匹配工况、管材匹配工质"。高…

作者头像 李华
网站建设 2026/9/25 18:01:36

8GB 显存跑通实时视频生成:LTX-Video 部署

8GB 显存跑通实时视频生成&#xff1a;LTX-Video 部署 【免费下载链接】LTX-Video Official repository for LTX-Video 项目地址: https://gitcode.com/GitHub_Trending/ltx/LTX-Video LTX-Video 是 DiT 架构的实时视频生成模型&#xff1a;因果视频自编码器把视频压进潜…

作者头像 李华
网站建设 2026/9/25 17:56:50

飞腾 D2000 性能怎么样?子卡 + 载板架构的高可靠嵌入式优势解析

在高端嵌入式开发领域&#xff0c;传统整机式开发板逐渐暴露出体积较大、嵌入困难、配置不灵活、开发与量产衔接复杂等问题。相比之下&#xff0c;子卡加载板的分层架构通过小型化核心子卡与功能载板分离设计&#xff0c;能够更好地兼顾灵活性、可扩展性和量产平滑性&#xff0…

作者头像 李华
网站建设 2026/9/25 17:56:21

第38篇-系统设计一-概要设计与模块设计

【软考高级系统分析师全链路通关实战】第 38 篇&#xff1a;系统设计&#xff08;一&#xff09;——概要设计与模块设计 本系列定位&#xff1a;面向有开发经验、从零备考软考高级「系统分析师」的工程师&#xff0c;以《系统分析师教程&#xff08;第 2 版&#xff09;》为主…

作者头像 李华