简介这是一套可直接运行的网页版年度音乐报告前端模板面向具备基础HTML/CSS/JS能力、希望快速搭建数据可视化报告页的开发者与设计爱好者解决从零复刻网易云年度听歌报告交互与视觉风格的成本问题。资源包共25个文件约825KB以9个HTML页面构成首页入口与多页报告展示结构6个JS脚本负责数据渲染、滑动翻页与埋点逻辑3个CSS文件统一管理样式另含PNG图片、部署说明文档及仓库同步脚本整体采用静态文件架构无需后端即可本地打开预览。已有20人学习关注。读者可获得完整的页面骨架与翻页交互实现思路理解CSS/JS分离与哈希命名带来的加载优化方式并借助二维码生成、资源监听等预置能力将其二次开发为个人年度总结、企业用户行为报告或活动数据可视化页面目录结构清晰便于按模块替换数据与样式。1. 仿网易云年度听歌报告从一张截图到一个能跑的前端源码包年底朋友圈被网易云年度听歌报告刷屏的时候我盯着那张「你今年听了 1287 首歌最常听的是凌晨 2 点」的页面看了很久。不是感慨是职业病——这种页面用纯前端能不能复刻出来答案是能而且不需要后端、不需要接口、不需要任何账号体系一个 HTML 文件加 CSS 和 JS 就能跑。这个源码包要解决的核心问题就是把年度报告那种「一页一屏、滚动切换、数据驱动、动画入场」的交互体验用最朴素的三件套实现出来不依赖框架不依赖构建工具双击打开就能看效果。适合谁如果你是前端新手想找一个比 TodoList 有成就感、比后台管理系统简单的练手项目这个方向非常合适如果你在准备前端面试面试题里经常问的滚动监听、CSS 动画、数据渲染、事件节流这个项目能一次性覆盖如果你只是想给自己或朋友做一份「假年度报告」当礼物改改数据就能用。整篇文章会从页面结构怎么拆、CSS 动画怎么做、JS 数据怎么驱动、滚动切换怎么实现一路讲到实际写的时候容易翻车的地方。2. 页面结构拆解年度报告为什么适合用「全屏滚动 数据驱动」来做2.1 先想清楚一屏放什么再动手写 HTML年度听歌报告这类页面有一个很明显的特征它不是传统意义上的「网页」而更像一个可以滑动的故事板。每一屏只讲一件事——今年听了多少歌、最常听的歌手是谁、听得最多的那一天、你的听歌时间段分布。这种「一屏一信息」的结构决定了它天然适合全屏滚动布局而不是常规的文档流。我一般会先把每一屏的内容列出来再决定 HTML 的骨架。常见的做法是用一个外层容器包住所有 section每个 section 占满视口高度通过滚动或切换来控制当前显示哪一屏。下面是一个最小可用的 HTML 结构你可以直接抄!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title我的年度听歌报告/title link relstylesheet hrefstyle.css /head body !-- 整个报告的外层容器负责滚动或切换 -- div classreport-wrapper idreportWrapper !-- 第一屏年度总览 -- section classreport-page>/* 全局重置去掉默认边距 */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Hiragino Sans GB, Microsoft YaHei, sans-serif; background: #1a1a2e; color: #fff; overflow: hidden; /* 外层不滚动由 wrapper 控制 */ } /* 滚动容器开启纵向滚动每一屏吸附 */ .report-wrapper { height: 100vh; height: 100dvh; /* 优先用动态视口高度 */ overflow-y: scroll; scroll-snap-type: y mandatory; /* 滚动吸附一屏一停 */ scroll-behavior: smooth; } /* 每一屏占满视口内容居中 */ .report-page { height: 100vh; height: 100dvh; scroll-snap-align: start; /* 吸附到顶部 */ display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; } /* 内容区限制最大宽度防止大屏上文字拉太长 */ .page-content { text-align: center; padding: 0 32px; max-width: 480px; width: 100%; } /* 数字样式大号、加粗、渐变文字 */ .page-number { font-size: 72px; font-weight: 700; background: linear-gradient(135deg, #ff6b6b, #ffa502); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; line-height: 1.2; } .page-label { font-size: 16px; color: rgba(255, 255, 255, 0.6); margin-bottom: 12px; letter-spacing: 2px; } .page-unit { font-size: 18px; color: rgba(255, 255, 255, 0.8); margin-top: 8px; } .page-artist, .page-time, .page-keyword { font-size: 40px; font-weight: 700; margin: 16px 0; } .page-desc { font-size: 15px; color: rgba(255, 255, 255, 0.5); margin-top: 12px; } /* 进度条固定在右侧 */ .progress-bar { position: fixed; right: 20px; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; gap: 12px; z-index: 10; } .progress-dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(255, 255, 255, 0.3); cursor: pointer; transition: all 0.3s ease; } .progress-dot.active { background: #ff6b6b; transform: scale(1.5); }这里最核心的两个属性是scroll-snap-type: y mandatory和scroll-snap-align: start。前者告诉浏览器「这个容器要按纵向吸附滚动」后者告诉浏览器「每个 section 滚动停止时对齐到容器顶部」。这两个属性配合起来就实现了类似网易云年度报告那种「滑一下翻一页」的效果而且不需要写任何 JS 滚动逻辑。scroll-behavior: smooth让滚动过程有过渡动画不会显得生硬。提示scroll-snap在 iOS Safari 和 Chrome 上支持都很好但如果你需要兼容非常老的浏览器就得用 JS 手动计算滚动位置来模拟代码量会大不少。做练手项目的话直接用scroll-snap就够了。3. JS 数据驱动数字滚动、入场动画和滚动监听怎么串起来3.1 数字从 0 涨到 1287用 requestAnimationFrame 做递增动画年度报告里最有仪式感的一个效果就是那个大数字从 0 快速涨到最终值。这个效果用requestAnimationFrame来做最自然因为它能跟着浏览器的刷新节奏走不会掉帧。核心思路是记录动画开始时间每一帧计算已经过去了多少时间根据进度算出当前应该显示的数字直到进度达到 1 就停止。/** * 数字递增动画 * param {HTMLElement} el - 要显示数字的元素 * param {number} target - 目标数字 * param {number} duration - 动画时长毫秒 */ function animateNumber(el, target, duration 1500) { const startTime performance.now(); function update(currentTime) { const elapsed currentTime - startTime; // 计算进度最大为 1 const progress Math.min(elapsed / duration, 1); // 用 easeOutCubic 缓动函数让数字先快后慢 const eased 1 - Math.pow(1 - progress, 3); const current Math.floor(eased * target); el.textContent current.toLocaleString(); // 加千分位 if (progress 1) { requestAnimationFrame(update); } else { el.textContent target.toLocaleString(); // 确保最终值精确 } } requestAnimationFrame(update); }这段代码里几个参数需要解释。duration控制动画总时长我一般设 1500 毫秒左右太快没有仪式感太慢用户会等得不耐烦。缓动函数easeOutCubic的公式是1 - (1 - progress)^3效果是数字一开始涨得很快接近目标值时变慢视觉上更舒服。toLocaleString()会自动给数字加千分位逗号1287 会显示成 1,287看起来更正式。注意performance.now()比Date.now()精度更高而且不受系统时间调整影响做动画计时优先用它。3.2 入场动画用 IntersectionObserver 判断当前屏是否可见数字动画什么时候触发总不能页面一加载就全部跑完那样用户滑到第二屏的时候动画已经结束了。正确的做法是当某一屏进入视口时才触发这一屏的动画。IntersectionObserver就是干这个的它比监听 scroll 事件再手动计算位置要高效得多而且代码更简洁。// 配置当元素有 50% 进入视口时触发 const observerOptions { root: document.getElementById(reportWrapper), threshold: 0.5 }; const observer new IntersectionObserver((entries) { entries.forEach(entry { if (entry.isIntersecting) { const page entry.target; const index page.dataset.index; // 根据当前是第几屏触发不同的动画 if (index 0) { const numberEl page.querySelector(.page-number); const target parseInt(numberEl.dataset.count, 10); animateNumber(numberEl, target); } // 给当前屏加一个 active 类CSS 里可以写入场动画 page.classList.add(page-active); // 更新进度条 updateProgress(index); } }); }, observerOptions); // 观察每一屏 document.querySelectorAll(.report-page).forEach(page { observer.observe(page); }); /** * 更新右侧进度条高亮 * param {string} index - 当前屏的索引 */ function updateProgress(index) { document.querySelectorAll(.progress-dot).forEach(dot { dot.classList.toggle(active, dot.dataset.target index); }); }threshold: 0.5表示当目标元素有 50% 的面积进入视口时才触发回调。这个值可以根据需要调整设成 0.3 会更早触发设成 0.8 会更晚。root指定为滚动容器reportWrapper因为滚动发生在它身上而不是整个页面。如果不指定 root默认是视口在这个项目里也能用但指定了更精确。配合这段 JSCSS 里可以加入场动画。比如让内容从下方淡入.page-content { opacity: 0; transform: translateY(30px); transition: opacity 0.8s ease, transform 0.8s ease; } .page-active .page-content { opacity: 1; transform: translateY(0); }这样当某一屏被IntersectionObserver检测到可见时JS 给它加上page-active类CSS 过渡就会自动播放。数字动画和内容淡入可以同时进行视觉层次更丰富。3.3 进度条点击跳转scrollIntoView 的用法和参数右侧的进度点不只是装饰用户点击某个点应该能直接跳到对应屏。实现方式很简单用scrollIntoView就行document.querySelectorAll(.progress-dot).forEach(dot { dot.addEventListener(click, () { const targetIndex dot.dataset.target; const targetPage document.querySelector( .report-page[data-index${targetIndex}] ); if (targetPage) { targetPage.scrollIntoView({ behavior: smooth, block: start }); } }); });behavior: smooth让跳转有平滑滚动效果block: start表示对齐到容器顶部。这两个参数配合scroll-snap使用跳转后会自动吸附到位不会出现「跳过去又弹回来」的情况。提示如果点击进度点后滚动位置不对检查一下scroll-snap-type是不是设在了正确的容器上。scrollIntoView是让元素滚动到可视区域而scroll-snap是容器级别的行为两者作用对象不同但配合起来没问题。4. 避坑与排查仿网易云年度报告最容易翻车的 5 个地方4.1 移动端 100vh 高度跳动页面出现「半截屏」现象在手机浏览器上打开地址栏显示的时候页面正常地址栏隐藏后底部露出一截空白或者内容被裁掉。原因100vh在移动端浏览器里指的是「最大视口高度」不随地址栏的显示隐藏而变化。当地址栏隐藏时实际可视区域变大了但100vh还是按原来的算导致内容对不齐。解决优先使用100dvh动态视口高度它会根据当前可视区域动态变化。写法是height: 100vh; height: 100dvh;不支持dvh的浏览器会回退到vh。如果还需要更精确的控制可以在 JS 里监听resize事件用window.innerHeight动态设置高度。4.2 数字动画重复触发滑回去再滑过来数字又从头涨现象第一屏的数字已经涨到 1287 了用户滑到第二屏再滑回第一屏数字又从 0 开始涨。原因IntersectionObserver在元素每次进入视口时都会触发回调没有做「只触发一次」的判断。解决在元素上加一个标记比如dataset.animated触发过就跳过。或者用observer.unobserve(page)在触发后停止观察该元素。我一般用前者因为代码更直观if (entry.isIntersecting !page.dataset.animated) { page.dataset.animated true; // ... 触发动画 }4.3 scroll-snap 和 scroll-behavior 冲突滚动卡顿现象设置了scroll-snap-type之后手动滚动变得很「粘」有时候滑不动有时候一下子跳好几屏。原因scroll-snap-type: y mandatory要求每次滚动都必须停在一个吸附点上如果内容高度和视口高度不完全一致或者有 margin 干扰就会导致吸附行为异常。另外scroll-behavior: smooth和scroll-snap同时使用时某些浏览器版本会有兼容问题。解决确保每个 section 的高度严格等于容器高度不要加 margin 或 padding 影响高度计算。如果还是卡顿把mandatory改成proximity它只在靠近吸附点时才吸附滚动更自由。scroll-behavior: smooth可以保留但如果你发现点击进度点跳转时动画不流畅可以暂时去掉它用 JS 的scrollTo加behavior: smooth替代。4.4 渐变文字在部分安卓机上不显示现象.page-number用了background-clip: text做渐变文字在 Chrome 桌面正常但在某些安卓机自带浏览器上文字直接消失。原因-webkit-background-clip: text需要配合-webkit-text-fill-color: transparent使用但部分安卓浏览器对这两个属性的支持不完整导致文字被「裁掉」了。解决加一个降级方案用supports检测.page-number { color: #ff6b6b; /* 降级颜色 */ } supports (-webkit-background-clip: text) { .page-number { background: linear-gradient(135deg, #ff6b6b, #ffa502); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } }这样不支持渐变文字的浏览器会显示纯色至少不会看不见。4.5 滚动监听用 scroll 事件导致性能问题现象一开始用window.addEventListener(scroll, ...)来判断当前在哪一屏页面滑动时明显掉帧手机上尤其严重。原因scroll 事件触发频率极高每一帧可能触发多次如果在回调里做 DOM 查询或复杂计算主线程会被阻塞。解决换成IntersectionObserver它由浏览器底层调度不占用主线程。如果非要用 scroll 事件至少加一个节流函数比如用requestAnimationFrame包一层确保每帧最多执行一次。这个项目里IntersectionObserver完全够用没必要用 scroll 事件。5. 进阶技巧把静态数据换成可配置的 JSON以及一个验证动画是否流畅的笨办法5.1 数据抽离让不懂代码的人也能改报告内容现在数据是写死在 HTML 里的改起来要翻源码。更好的做法是把数据抽成一个 JSON 对象JS 读取后动态渲染。这样即使不懂 HTML 的人只要会改 JSON 里的文字和数字就能生成自己的报告。// data.js - 报告数据配置 const reportData { totalSongs: 1287, topArtist: 陈奕迅, artistCount: 326, topTimeRange: 凌晨 2:00 - 3:00, timeCount: 89, keyword: 单曲循环, keywordDesc: 有一首歌你听了 47 遍 }; // main.js - 渲染逻辑 function renderPage(page, data) { const index page.dataset.index; if (index 0) { page.querySelector(.page-number).dataset.count data.totalSongs; } else if (index 1) { page.querySelector(.page-artist).textContent data.topArtist; page.querySelector(.page-desc).textContent 你听了 TA 的 ${data.artistCount} 首歌; } else if (index 2) { page.querySelector(.page-time).textContent data.topTimeRange; page.querySelector(.page-desc).textContent 这个时间段你听了 ${data.timeCount} 次; } else if (index 3) { page.querySelector(.page-keyword).textContent data.keyword; page.querySelector(.page-desc).textContent data.keywordDesc; } } // 初始化时渲染所有页面 document.querySelectorAll(.report-page).forEach(page { renderPage(page, reportData); });这样改数据只需要动data.js一个文件HTML 结构完全不用碰。如果你想把数据放到单独的.json文件里用fetch加载也可以但那样就必须起一个本地服务器不能直接双击 HTML 打开了。做练手项目的话用 JS 对象更省事。5.2 验证动画流畅度用 Chrome DevTools 的 Performance 面板录一段动画写完怎么知道流不流畅别靠肉眼猜用工具测。Chrome DevTools 的 Performance 面板可以录制一段时间的页面运行情况然后看 FPS 曲线。具体操作打开 DevTools切到 Performance 面板点录制按钮然后在页面上滑动几屏停止录制。你会看到一条 FPS 曲线绿色表示流畅接近 60fps红色表示掉帧。重点看两个东西一是 FPS 曲线有没有大幅波动二是 Main 线程有没有长任务超过 50ms 的任务会标红。如果数字动画期间 FPS 掉到 30 以下说明requestAnimationFrame里的计算太重了可以简化缓动函数或者减少每帧的 DOM 操作。我一般会把textContent的更新放在requestAnimationFrame里但确保不做其他多余操作。另一个笨办法但很有效在动画运行时打开 DevTools 的 Rendering 面板勾选「Paint flashing」如果数字区域频繁闪烁绿色说明重绘太频繁可以考虑用transform或opacity做动画而不是改textContent。不过数字滚动必须改文本内容所以这个场景下重绘不可避免只要 FPS 达标就行。提示Performance 面板录制时尽量模拟真实使用场景比如在手机上滑动而不是在桌面用鼠标滚轮。桌面滚轮和触摸滑动的滚动行为差异很大测试结果也会不同。5.3 一个我踩过的坑scroll-snap 在 Safari 上需要加前缀最后说一个血泪经验。scroll-snap-type和scroll-snap-align在较老版本的 Safari 上需要加-webkit-前缀才能生效。如果你发现代码在 Chrome 上正常在 Safari 上滚动不吸附先检查前缀.report-wrapper { -webkit-overflow-scrolling: touch; /* iOS 惯性滚动 */ scroll-snap-type: y mandatory; } .report-page { scroll-snap-align: start; }-webkit-overflow-scrolling: touch是 iOS 上开启惯性滚动的属性加上之后滑动更跟手。虽然现在新版 Safari 已经支持无前缀的scroll-snap但加上前缀不亏兼容性更好。我现在的习惯是任何涉及滚动、动画、视口高度的 CSS 属性先在 Can I use 上查一眼兼容性然后在真机上跑一遍。桌面浏览器和手机浏览器的差异比你想象的大得多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?