简介:面向单机游戏资讯站、游戏专题页与个人作品站搭建者的网页模板,适合前端初学者、个人站长及小型团队快速完成静态站点原型。压缩包共59个文件、约1.3MB,包含7个HTML页面、5个CSS样式表、7个JavaScript脚本,以及30张JPG和7张PNG图片与字体文件;首页、关于、游戏详情、评测、图库、联系等页面结构齐全,配合Bootstrap、jQuery及轮播、灯箱等插件,可直接查看布局、导航、配色和交互效果。已有1691人学习下载,说明其在课程练习和轻量建站场景中具有一定参考价值。读者可据此拆解多页站点的组织方式、响应式栅格与组件复用方法,替换图片、文案和主题色后用于游戏介绍、作品展示或页面排版练习;也可作为二次开发的起点,在现有HTML/CSS/JS结构上增补分类、评论或后台接口。
1. 单机游戏网站模板能干的事,比你想的要多
解压一份单机游戏网站模板,很多人的第一反应是「不就是几张静态页,能有什么好拆的」。真把 index.html 双击跑起来才会发现,这类网页模版把游戏站最耗时的部分都提前写死了:首屏轮播、游戏卡片网格、详情页配置表、分类筛选、评分星级、分页器,连配色变量都留好了口子。它适合两类人——想快速搭一个单机游戏资讯站或下载导航站前端的人,以及想拿一套完整结构当二次开发起点的开发者。它也确实有边界:没有后台、没有数据库、没有真实下载链路,所有数据都写在前端,改内容靠改 JS 数组或换成接口返回,这一点在动手前必须先接受。
2. 技术选型与目录结构:一套静态网页模版的骨架
游戏站的首页天然是「图多、块多、层级浅」,首页要放下轮播、推荐位、排行榜、最新上架四五个区块,列表页要能筛能翻页,详情页要塞配置表和图集。这类页面用框架写反而累赘:为了一个卡片网格去装构建工具、配路由、写一堆配置,最后交付给别人的还是一个需要 npm install 才能看的工程。所以单机游戏网站模板这类资源,绝大多数走的是 HTML5 + CSS3 + 原生 JavaScript 三件套,双击就能看,扔到任何静态托管上就能跑。
2.1 技术栈的取舍:为什么坚持原生三件套
原生三件套的优势在交付场景里被放大得很明显。第一是零门槛,使用者不需要 Node 环境,浏览器直接打开文件就能看到成品,这对非前端出身的站长太重要了。第二是可嵌性好,无论后端是 PHP、Java 还是纯静态托管,前端页面都是可以直接搬过去的一坨 HTML,不需要改打包配置。第三是静态渲染天生对搜索引擎友好,游戏名、分类这些关键词在源码里就是实打实的文本,不用等 JS 执行完才出现。
代价也很清楚:组件复用只能靠复制粘贴,状态管理只能靠约定,页面之间传参只能走 query 或者 localStorage。我的处理办法是把「数据」和「渲染」彻底分开,所有页面共用一个 data.js 数组和一组 render 函数,页面本身只负责挂载点。这样后面换成后端接口时,只需要把 data.js 里那个数组换成一个 fetch 调用,其余代码几乎不用动——这条改造路径在第 6 章会详细走一遍。
引入顺序上有个小细节值得注意:CSS 要按 base → layout → components 的顺序引,因为后面的文件依赖前面定义的变量和工具类;JS 要按 data → render → main 的顺序引,main.js 里做初始化,必须等前两个文件里的函数都挂到全局作用域之后再执行。这个顺序搞反了,控制台第一行就是renderGames is not defined。
2.2 目录结构:从 index.html 到 assets 的完整映射
一份结构清晰的模板,目录本身就是说明书。下面这套是这类资源里最常见的组织方式,见过好几个版本都大同小异:
game-template/ ├── index.html # 首页:轮播 + 推荐位 + 最新上架 ├── list.html # 列表页:分类筛选 + 排序 + 分页 ├── detail.html # 详情页:截图区、配置表、下载信息 ├── rank.html # 排行榜页:按评分/热度重排 ├── news.html # 资讯列表 ├── news-detail.html # 资讯正文 ├── assets/ │ ├── css/ │ │ ├── base.css # reset、CSS 变量、排版基线 │ │ ├── layout.css # 栅格、页头页脚、容器宽度 │ │ └── components.css # 卡片、轮播、标签、评分、分页 │ ├── js/ │ │ ├── data.js # 假数据:games 数组 + 分类字典 │ │ ├── render.js # 纯函数:数据进,HTML 字符串出 │ │ └── main.js # 初始化、事件绑定、页面级逻辑 │ ├── img/ # 封面、截图、占位图 │ └── fonts/ # 图标字体(有些模板用 SVG 代替) └── README.md # 改色、换数据、加页面的说明这个划分的关键在于 data.js、render.js、main.js 三者的职责边界。data.js 只放数据,不写逻辑;render.js 全是纯函数,输入一个对象输出一段 HTML 字符串,不碰 DOM、不绑事件;main.js 才是唯一操作 DOM 的地方。这么切开之后,改数据不会影响样式,改样式不会影响逻辑,排查问题时能一眼定位是哪个文件的问题,而不是在一千行的 script 标签里翻。
assets/img目录里的占位图也值得说一句。模板自带的封面图通常是统一尺寸的(比如 3:4),换真实素材时如果长宽比不一致,卡片高度就会参差不齐,网格排出来歪歪扭扭。稳妥的做法是给封面容器固定 aspect-ratio,图片用 object-fit: cover 裁切,这个在第 5 章会作为踩坑项展开。
2.3 设计令牌与响应式断点:改一处就能换肤
这类网页模版有个很实用的设计:颜色、圆角、间距全部抽成 CSS 自定义属性,集中放在 base.css 顶部的:root里。想从暗色游戏风换成浅色资讯风,只需要改十来个值,不用满文件搜颜色值。
/* assets/css/base.css —— 换肤只动这一块 */ :root { /* 颜色 */ --c-bg: #0f1115; /* 页面底色,暗色游戏站常用 */ --c-surface: #171a21; /* 卡片、面板背景 */ --c-text: #e6e8ee; /* 主文字 */ --c-text-dim: #9aa3b2; /* 次要文字:分类、年份 */ --c-accent: #4c8dff; /* 主色:按钮、选中态、链接 */ --c-price: #ffb020; /* 强调色:评分星、价格 */ /* 尺寸 */ --radius: 10px; --gap: 20px; /* 网格间距,卡片和分栏统一用它 */ --container: 1200px; /* 内容最大宽度 */ --shadow-card: 0 6px 18px rgba(0, 0, 0, .35); }变量定好之后,卡片、标签、按钮全部引用var(--c-accent)这类值。要提醒的是,浏览器开发者工具里改:root的变量会立刻生效,所见即所得,调色阶段比反复改文件再刷新快得多——调满意了再把值抄回文件里。
断点方面,游戏站的内容密度大,三档基本够用:1200px 以上是内容容器宽度,卡片一行四到五个;768px 到 1200px 之间降成三到四个;768px 以下走两列,同时把页头的横向导航收进汉堡菜单。断点值本身不神秘,真正容易出问题的是断点写在哪个文件里——统一放在 layout.css 的末尾,用一组 min-width 媒体查询自下而上覆盖,比散落在各个组件文件里好维护得多。
3. 核心页面模块拆解:轮播、卡片网格与详情页配置表
模板值钱的地方不在 HTML 标签写得多标准,而在于这些反复出现的模块已经被调通了一遍。这一章挑四个最容易被复用的模块拆开看:轮播、卡片网格、详情页配置表与评分、筛选与分页。每个模块的代码都能直接抄进自己的项目。
3.1 首页轮播:不依赖插件的四十行实现
轮播是模板里最容易被忽略、又最容易出问题的一环。很多资源会引一个第三方轮播库,结果是「为了一个方块动画多加载 60KB」。原生实现其实很短,核心就三件事:位移、圆点状态、定时器。
// assets/js/main.js —— 首页轮播 function initBanner(el) { const track = el.querySelector('.banner__track'); const slides = [...track.children]; const dots = el.querySelector('.banner__dots'); const DELAY = 4000; // 自动播放间隔,想慢一点就改这里 let index = 0; let timer = null; // 1. 按 slide 数量生成圆点 slides.forEach((_, i) => { const b = document.createElement('button'); b.className = 'banner__dot'; b.setAttribute('aria-label', `第 ${i + 1} 张`); b.addEventListener('click', () => go(i)); dots.appendChild(b); }); function go(i) { // 取模兜住 -1 和越界,左右循环都不用写 if index = (i + slides.length) % slides.length; track.style.transform = `translateX(${-index * 100}%)`; [...dots.children].forEach((d, k) => d.classList.toggle('is-active', k === index)); } function play() { timer = setInterval(() => go(index + 1), DELAY); } function stop() { clearInterval(timer); timer = null; } // 2. 悬停暂停;切到后台标签页也暂停,回来不会连播好几张 el.addEventListener('mouseenter', stop); el.addEventListener('mouseleave', play); document.addEventListener('visibilitychange', () => { document.hidden ? stop() : play(); }); go(0); play(); } document.querySelectorAll('.banner').forEach(initBanner);translateX的百分比是相对 track 自身宽度算的,所以配套的 CSS 必须写成 track 宽度等于容器宽度、每张 slideflex: 0 0 100%,这样-100%正好是一屏。如果写成给 track 设width: 500%,位移就会差五倍,这是抄这段代码最容易翻车的地方。
go()里的取模是关键细节。手写轮播最常见的问题就是「点到最后一张再点下一张,画面卡住」——因为没做越界回绕,索引变成 3、4、5,位移越走越远,视觉上就是白屏。取模一行解决,还顺便支持了go(-1)往回走。
visibilitychange那段是我后加的,血泪经验。浏览器对后台标签页的setInterval有节流,切回来的时候积压的回调会连着触发,视觉上就是轮播「抽风式连跳」。监听页面可见性把定时器停掉,回来再重启,比调大间隔管用得多。
3.2 游戏卡片网格:一份数据渲染三个页面
首页的「最新上架」、列表页的筛选结果、详情页的「相似推荐」,用的其实是同一种卡片。模板里通常只写一次渲染函数,三处调用,靠传参区分。这种做法的价值在后期特别明显:改一次卡片布局,三个页面同时生效。
// assets/js/render.js // 转义:游戏名里出现 & < " 时不会撑破 HTML 结构 const esc = s => String(s).replace(/[&<>"']/g, c => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] )); function gameCard(g) { return ` <article class="card">/* assets/css/components.css */ .card-grid { display: grid; gap: var(--gap); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } .card__cover { aspect-ratio: 3 / 4; overflow: hidden; border-radius: var(--radius); } .card__cover img { width: 100%; height: 100%; object-fit: cover; display: block; }auto-fill加minmax的组合是这类网格的最优解:不用写任何媒体查询,容器变窄时列数自动减少,卡片宽度自适应。aspect-ratio加object-fit: cover保证封面比例统一,无论上传的图是方的还是长的,网格都是齐的。
3.3 详情页:配置表、评分星级与相似推荐
详情页是游戏站的核心页,它的信息密度远高于列表页。模板里通常有两块硬骨头:配置参数表和评分星。
// 配置表:把「字段名 → 数据」写成数组,加一行就多一行 function renderSpec(g) { const rows = [ ['游戏类型', g.genre], ['开发团队', g.studio], ['发行时间', g.year], ['支持语言', g.lang], ['存储空间', g.size], ['运行平台', g.platform], ]; return `<table class="spec">${ rows.map(([k, v]) => `<tr><th>${k}</th><td>${esc(v ?? '—')}</td></tr>`).join('') }</table>`; } // 评分星:用宽度裁切,支持 8.3 这种小数,不用切图 function renderStars(score) { const pct = Math.max(0, Math.min(10, Number(score))) / 10 * 100; return `<span class="stars" style="--fill:${pct}%" aria-label="评分 ${score}"> <span class="stars__bg">★★★★★</span> <span class="stars__fg">★★★★★</span> </span>`; }配置表用[k, v]数组而不是对象,是为了保证渲染顺序稳定。对象在某些引擎里会先排数字键,字段顺序跟书写顺序不一致,表格行序就乱了——这种问题不报错,只是看起来「莫名其妙」。v ?? '—'是兜底,字段缺失时显示占位符而不是undefined。
评分星的做法值得多说两句。很多模板用五张星星图拼,半颗星还得单独切一张半星图,改评分就得换图。宽度裁切法只需要两层同样内容、不同颜色的星星叠在一起,上层用width控制露出的比例,8.3分就露出 83%。
.stars { position: relative; display: inline-block; letter-spacing: 2px; color: #3a4150; } .stars__fg { position: absolute; inset: 0; width: var(--fill); /* JS 传进来的百分比 */ overflow: hidden; color: var(--c-price); white-space: nowrap; }white-space: nowrap不能省。星星字符在某些字体下会被当成可断行内容,宽度一变就折成两行,视觉上是「评分区塌了」。这个坑我在两套模板上都踩过,现在写这类裁切元素都条件反射加上。
相似推荐直接复用gameCard,数据来源是「同类型且 ID 不同」的前几个:
function similarOf(current, all, n = 4) { return all .filter(g => g.id !== current.id && g.genre === current.genre) .sort((a, b) => b.score - a.score) .slice(0, n); }3.4 分类筛选与分页:把条件收敛成一个 state
列表页最怕写成「点击一次就重新查一遍全量数据,各种 if 层层嵌套」。模板里稍微规范的写法是把所有筛选条件收进一个对象,渲染只依赖这个对象。
// assets/js/main.js —— 列表页状态 const state = { genre: 'all', keyword: '', sort: 'new', page: 1, pageSize: 12 }; function query(list, s) { // 1. 过滤 let out = list.filter(g => (s.genre === 'all' || g.genre === s.genre) && (!s.keyword || g.title.toLowerCase().includes(s.keyword.toLowerCase())) ); // 2. 排序:用映射表代替 switch,加规则只加一行 const sorters = { new: (a, b) => b.year - a.year, hot: (a, b) => b.score - a.score, size: (a, b) => parseFloat(a.size) - parseFloat(b.size), }; out.sort(sorters[s.sort] || sorters.new); // 3. 分页 const start = (s.page - 1) * s.pageSize; return { total: out.length, rows: out.slice(start, start + s.pageSize) }; }total单独返回是为了画页码,rows只给当前页,两者分开之后分页器逻辑就很干净:总页数 =Math.ceil(total / pageSize)。这里有个必须处理的边界:筛选条件变化后,如果当前页码超出新结果的总页数,slice会返回空数组,页面变成一片空白。稳妥做法是每次筛选变化都把page重置为 1,或者算出总页数后做一次Math.min收敛。
搜索框要加防抖,否则用户每敲一个字母就全量过滤加重新渲染一次,输入框会卡。
function debounce(fn, wait = 250) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); }; } searchInput.addEventListener('input', debounce(e => { state.keyword = e.target.value.trim(); state.page = 1; // 换关键词必须回第一页 refresh(); }, 250));4. 交互层:用原生 JS 把静态页做成「像有后端」
静态模板给人的第一印象是「点了就跳页、刷新就丢状态」,但只要把事件委托、URL 同步、本地存储这三件事做对,体验跟有后端的站点差别很小。这一章讲的都是模板里已经写好、但很多人没细看的部分。
4.1 事件委托:一个监听器管住整页卡片
卡片数量是不定的,如果给每张卡片的收藏按钮单独addEventListener,翻页之后新渲染的卡片就没有监听器,表现为「第一页能收藏,第二页点了没反应」。事件委托从根上解决这个问题:监听器挂在不会变的父容器(甚至document)上,靠冒泡和closest定位目标。
// 收藏按钮统一交给 document 处理,翻页/重新渲染都不影响 document.addEventListener('click', e => { const btn = e.target.closest('[data-action="fav"]'); if (!btn) return; e.preventDefault(); // 卡片外层是 <a>,不拦会直接跳详情页 const card = btn.closest('.card'); if (!card) return; toggleFav(card.dataset.id, btn); }); function toggleFav(id, btn) { const list = readFavs(); const i = list.indexOf(id); i > -1 ? list.splice(i, 1) : list.unshift(id); writeFavs(list); btn.classList.toggle('is-active', i === -1); // 切换后取反 btn.setAttribute('aria-pressed', i === -1); }e.preventDefault()这行不能漏。卡片整体包在<a>里,收藏按钮在<a>内部,不拦截的话点击会直接跳转到详情页,用户根本没机会看到收藏状态变化。这也是我在别人的模板上改得最多的一处——原版没拦,评论区常有人问「收藏按钮点了就跳走」。
另一个细节是aria-pressed。视觉上的is-active类只管颜色,屏幕阅读器读不到,加上aria-pressed才是完整的开关状态。这类无障碍属性不影响功能,但模板既然是给别人用的,多写一个属性让后面省心。
4.2 无刷新筛选与 URL 同步
列表页筛选如果每次都用location.href = 'list.html?genre=...',会整页重载,体验倒退。全部走前端过滤之后,又冒出另一个问题:用户筛好的条件没法分享、刷新就丢、浏览器后退键失效。解决办法是把状态同步到 URL 的 query 里,用history.replaceState更新地址栏但不产生新历史记录,页面加载时再从 URL 读回来。
// 状态 → URL function syncUrl(s) { const p = new URLSearchParams({ genre: s.genre, kw: s.keyword, sort: s.sort, page: s.page, }); history.replaceState(null, '', `${location.pathname}?${p}`); } // URL → 状态(首次加载时调用) function readUrl() { const p = new URLSearchParams(location.search); return { genre: p.get('genre') || 'all', keyword: p.get('kw') || '', sort: p.get('sort') || 'new', page: Math.max(1, Number(p.get('page')) || 1), }; }这里用replaceState而不是pushState是有意的:每敲一个字都往历史栈里塞一条,用户按一次后退只退回到上一个字母,得按十几次才能退出列表页,这是典型的体验灾难。筛选场景下地址栏跟着变、历史栈不动,才是对的。
page用Math.max(1, ...)兜底。用户手改 URL 输个?page=-3或者?page=abc,Number()出来是NaN,slice会返回空数组,页面白屏。加一层收敛,成本一行。
初始化的时候把两条路径串起来就行:
Object.assign(state, readUrl()); // 先吃 URL refresh(); // 再渲染4.3 收藏与最近浏览:localStorage 的读写边界
静态站没有后端,收藏和浏览历史只能放 localStorage。这块代码不长,但边界条件不少,模板里写得好不好直接影响稳不稳。
// assets/js/main.js const FAV_KEY = 'gt_favs_v1'; // 带版本号:结构变了直接换 key,不用写迁移 const HIS_KEY = 'gt_history_v1'; const MAX = 100; // 上限,防止写爆 5MB 配额 function read(key) { try { return JSON.parse(localStorage.getItem(key)) || []; } catch { return []; // 数据被外部改坏时不要让整页崩掉 } } function write(key, list) { try { localStorage.setItem(key, JSON.stringify(list.slice(0, MAX))); } catch (e) { console.warn('本地存储写入失败,可能是隐私模式或配额已满', e); } } const readFavs = () => read(FAV_KEY); const writeFavs = l => write(FAV_KEY, l); // 记录最近浏览:去重后放最前 function pushHistory(id) { const l = read(HIS_KEY).filter(x => x !== id); l.unshift(id); write(HIS_KEY, l); }try / catch是必须的,不能省。浏览器的无痕模式下localStorage.setItem会直接抛异常,不捕获的话整个 script 块后面的代码全都不执行,表现为「页面其他功能也一起失灵」。.slice(0, MAX)是防配额,游戏条目一多,几百条记录就能把 5MB 撑满,写失败之后收藏功能整体失效。取catch里console.warn而不是静默失败,是为了自己调试时能看见。
key 后缀带v1是个小习惯。等哪天收藏结构从「ID 数组」改成「ID + 时间戳对象」,直接换v2,老数据自然废弃,不用写迁移逻辑,也不会因为新旧格式混用导致渲染报错。
4.4 首屏性能:图片与长列表
模板最容易拖慢首屏的就是图片。详情页的截图集动辄十几张,全部同步加载会让白屏时间变得很长。除了前面提到的loading="lazy",还可以给图片加一层骨架占位,避免布局抖动:
.card__cover { background: var(--c-surface); } /* 图片没到之前先铺底色 */长列表则靠分页控制,每次只渲染pageSize条。这里有个反直觉的点:pageSize不是越大越好。设成 48 看着「一页装得多」,实际首屏要下载 48 张封面,弱网下反而更慢。我在实际项目里一般定在 12 到 24 之间,配合lazy刚好。
5. 排查清单:静态模板上手最容易翻车的五处
模板跑不起来,八成不是代码写错了,而是环境或者路径的问题。下面这五条按出现频率排,每条都按「现象 → 原因 → 解决」写,遇到问题可以对着查。
第一条:双击 index.html,样式全丢、控制台一片 404。
现象是页面能开,但完全没有样式,图片是裂的,控制台里一堆net::ERR_FILE_NOT_FOUND。原因是路径写成了绝对路径/assets/css/base.css,file://协议下/指向磁盘根目录,自然找不到。解决办法要么起一个本地静态服务(python3 -m http.server 8080然后访问 localhost),要么把所有资源引用改成相对路径./assets/...。我的建议是改路径——模板本来就是给人双击看的,改成相对路径后交付体验好得多。注意相对路径在子目录页面里会多一层,../assets/这种写法别漏。
第二条:卡片网格在手机上挤成一条竖线,或者横向溢出出现滚动条。
现象是桌面端正常,手机上一列排到底,或者右边多出白边能横向拖动。原因通常是网格用了固定像素宽度(比如width: 220px)而不是弹性单位,或者grid-template-columns写死了repeat(4, 1fr),小屏也强行四列,每列被压得只剩几十像素。解决办法是换成repeat(auto-fill, minmax(160px, 1fr)),让列数跟容器宽度自动协商。横向溢出则要查是不是某个元素有固定宽度加padding,box-sizing没设成border-box,宽度溢出容器。
第三条:切到别的标签页再切回来,轮播疯狂连跳好几张。
现象是离开页面几分钟,回来瞬间轮播快速闪过三四张才恢复。原因是setInterval在后台被浏览器节流,回调积压,回到前台时排队执行。解决办法是监听visibilitychange,页面隐藏时clearInterval,可见时重启定时器。这条在第 3 章的轮播代码里已经内置了,如果自己改造时删掉了,问题会重新出现。
第四条:改了 CSS 变量,大部分地方生效,但某一块颜色纹丝不动。
现象是:root里的--c-accent改了,按钮变了,某个卡片或者标题还是老颜色。原因是那块样式写的是硬编码色值,或者用了内联style,内联样式优先级高于外部 CSS 里的变量引用。排查方法是开发者工具选中该元素,看 Computed 面板里颜色值是不是十六进制——是十六进制说明没走变量。解决办法是把硬编码值搜出来替换成var(--c-accent),内联样式尽量挪进 class。另外还有一种情况:变量定义在某个组件选择器内部(比如.header { --c-accent: #xxx }),作用域只在那个子树里,外部改:root覆盖不到。
第五条:筛选到某一页之后,页面变空白,但数据明明有。
现象是点分类点到第三页,再切到另一个分类,内容区一片空。原因是切换分类时没重置page,新分类只有一页数据,slice((3-1)*12, 3*12)出来是空数组。解决办法是任何影响结果集的操作(换分类、改关键词、改排序)都把page重置为 1。更稳的做法是渲染前先算总页数,currentPage = Math.min(currentPage, totalPages)再取数据,双重保险。
6. 二次开发:把模板改成自己的站
拿到模板之后,我建议的改造顺序是「先换数据、再换皮、最后接后端」,而不是一上来就动 CSS。原因是数据先行能最快暴露结构问题:把 data.js 里的假数据换成十几条真实游戏,立刻就能看出卡片标题过长会不会撑破、封面比例不对会不会歪、配置表字段多了会不会挤。这些问题在假数据阶段完全看不出来。
第一阶段换数据,改的是 data.js 里的games数组和genres分类字典。字段名不要动,只换值,渲染函数一行都不用改。分类型字典这块要注意,模板里的分类通常是中文直接写在数据里的(genre: '角色扮演'),如果你想改成genre: 'rpg'加一张映射表,那就得同步改筛选逻辑和卡片渲染,属于第二阶段的事了。
第二阶段换皮,改的是 base.css 里的:root变量,加上替换assets/img里的占位图。这一步不需要动任何 JS,改完刷新就能看效果。想大改布局的话,重点改 layout.css 里的容器宽度和页头结构,组件级的样式尽量别碰——components.css被所有页面共用,改一个类名可能牵连好几处。
第三阶段接后端,也是最值钱的一步。核心思路是把renderGames(list, mount)的调用点从「直接传数组」换成「fetch 回来再传」:
// 改造前:同步拿本地数据 renderGames(query(games, state).rows, mount); // 改造后:接口只返回当前页,筛选和分页交给后端 async function refresh() { const p = new URLSearchParams({ genre: state.genre, kw: state.keyword, sort: state.sort, page: state.page, size: state.pageSize, }); const res = await fetch(`/api/games?${p}`); const data = await res.json(); // { total, rows } renderGames(data.rows, mount); renderPager(data.total, state, mount); syncUrl(state); }改造时最需要注意的是错误处理。原来读本地数组永远不会失败,换成接口之后,网络断了、后端 500、返回结构不对,任何一种都会让页面停在加载状态。至少加一层try/catch和空态提示,别让用户对着空白页猜。另外,原来在query()里做的排序和分页逻辑要整段删掉,交给后端,否则会出现「后端已经分好页,前端又切了一刀」的双重分页,表现是每页只有几条数据。
验证改造是否成功,我一般走三个动作:一是在 Network 面板里把网络限速调到 Slow 3G,看首屏有没有骨架或者加载提示;二是手动改 URL 参数(?genre=xxx&page=2)直接访问,看能不能正确还原筛选状态;三是关掉后端服务,看前端是不是给出可读的错误提示而不是白屏。第三条最能暴露问题,也是最少人做的。
最后一个技巧关于模板的复用成本:如果打算长期维护,把render.js里那几个纯函数单独抽成一个文件,页面里只留挂载点,能做到「换页面不换渲染逻辑」。我后来接的几个站都是这么做的,加一个新页面(比如专题页)只需要写 HTML 骨架和一行renderGames,不用复制几百行卡片模板。
从那以后我每次拿到一份新模板,都强制自己先跑一遍「改数据 → 看效果 → 断网 → 看提示」这条链路,再决定要不要动 CSS。跳过这一步直接改样式,后面往往要在结构问题上返工两遍。希望帮到你。
本文还有配套的精品资源,点击获取