ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

纯前端复刻网易云年度听歌报告: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 langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno title年度听歌报告/title link relstylesheet hrefcss/reset.css link relstylesheet hrefcss/layout.css link relstylesheet hrefcss/animation.css /head body div idapp !-- 卡片由 JS 动态插入 -- /div script srcjs/data.js/script script srcjs/render.js/script script srcjs/scroll.js/script /body /html这里有几个关键点。viewport里的maximum-scale1.0和user-scalableno是为了防止移动端双指缩放破坏布局年度报告类页面几乎都会加。#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 classintro-title${data.title}/h1 p classintro-subtitle${data.subtitle}/p ; break; case number: innerHTML p classnumber-label${data.label}/p div classnumber-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都很小但累加起来很大。解决加一个时间锁两次翻页之间至少间隔 800mslet 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: visiblebody { 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。希望帮到你。本文还有配套的精品资源点击获取
返回列表