简介:这是一个结合HTML、CSS与JavaScript的响应式可过滤作品集页面,面向希望学习前端布局与交互的开发者,用来展示100个小游戏和实用工具。页面通过HTML搭建语义化卡片模块,利用CSS3媒体查询适配不同屏幕,借助JavaScript的filter方法实现按分类或关键词即时筛选,并支持动态更新DOM;每张卡片可容纳标题、简介与操作按钮,替换内部数据即可变成自己的作品集导航。压缩包仅含1个html文件,大小3KB,浏览器直接打开即可体验完整筛选与响应式效果,整体设计轻量、无需后端,适合快速查看并理解核心代码结构。目前已有711人浏览学习,适合作为网页作品集、个人工具导航或前端练手项目参考。资源虽小,但完整呈现了从结构、样式到交互的实现思路,方便读者拆解卡片式布局、响应式断点设置与列表过滤逻辑。
1. 摸鱼清单装多了,就得自己造一个入口页
浏览器收藏夹里攒了上百个在线小游戏和工具链接,正经用的时候一个个翻,标签页开多了又找不到哪个是哪个。与其继续做收藏夹的搬运工,不如直接用 HTML、CSS 和 JS 三件套做一个单文件展示页,把这一百来个入口装进同一张响应式网格里。Haiyong 给出的moyu.zip里就是这样一个moyu.html,不依赖框架、不需要构建工具,双击就能跑,也能直接扔到任意静态服务器上。
这类页面的难点不在「把一百个链接堆上去」,而在「不同屏幕宽度下还能整齐展示」和「想找某个工具时能快速过滤出来」。拆开看就是三件事:响应式网格布局、数据驱动渲染、标签与关键词过滤。这三件事刚好对应 CSS 的媒体查询、JS 的数组方法filter()和 DOM 更新策略。下面按我拆这个项目时的思路,把每块怎么落地、参数怎么调、坑在哪里逐一说清。
2. 响应式网格与卡片布局:用 CSS Grid 把一百个模块装进不同屏幕
2.1 为什么卡片墙首选 Grid 而不是 Flex 或 Table
先回答一个选型问题:展示游戏和工具的页面,结构上是「一行多个、多行排列」的二维布局,这是 CSS Grid 的主场。Flexbox 适合一维排列,比如导航栏、按钮组,虽然也能用flex-wrap模拟换行,但每行占多少列、卡片宽度怎么均匀分配,要靠flex-basis反复算,多列时对齐也麻烦。Table 在响应式场景下基本不合格,语义错位且收缩到移动端会很痛苦。
Grid 里几个关键属性要理解透,因为它们决定栅格在不同宽度下的表现。grid-template-columns定义列宽规则,auto-fill表示尽量填满一行,minmax(240px, 1fr)表示每列最小 240px、最大按剩余空间均分。这两个组合起来的效果就是:屏幕越宽、能塞下的列数越多,列与列之间等宽,最后一行不满也不会拉伸变形。我给这个项目建议的栅格骨架如下。
.grid-wall { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; padding: 16px; }这段代码的逻辑很直接:repeat()是重复规则,auto-fill让浏览器数一数当前容器宽度能放几个minmax(240px, 1fr)的轨道,能放 3 个就 3 列,能放 5 个就 5 列。gap: 16px是卡片间距,移动端可以收到 12px 省空间。这里 240px 是最小卡片宽度,如果偏大,手机上一列都放不下;偏小,桌面端卡片又显得太窄。内容以标题加简介为主的卡片,220px 到 260px 是一个比较舒服的范围。
2.2 移动端优先的媒体查询断点怎么定
网格靠auto-fill已经能自适应,为什么还要媒体查询?因为要改的不只是列数,还有内边距、字号、甚至过滤栏的排布方式。我一般选移动端优先写法,即默认样式服务小屏,然后用min-width逐级增强。常见断点不是拍脑袋定的,而是跟着主流设备宽度走,核心是覆盖手机竖屏、小平板、桌面这几个典型区间。
| 断点 | 视口宽度 | 调整目标 |
|---|---|---|
| 默认 | 小于 480px | 单列、字号 14px、过滤栏纵向堆叠 |
min-width: 480px | 手机横屏/小平板 | 改为两列,过滤按钮开始换行 |
min-width: 768px | iPad 竖屏 | 三到四列,字号回到 16px |
min-width: 1200px | 标准桌面 | 四到六列,搜索框加宽,间距加大 |
实践中我发现 480px 这个断点很容易被忽视,但手机横屏时单列长卡片要滑很久,加上它体感差别明显。代码上就是标准媒体查询的叠加,嵌套在默认样式之后即可。
.card { padding: 12px; font-size: 14px; } @media (min-width: 768px) { .card { padding: 20px; font-size: 16px; } }min-width: 768px里的规则只在视口不小于 768px 时生效,这意味着小屏设备根本不会下载这段样式里的声明。断点数量控制在三个以内,维护成本最低。很多页面过度拆断点,每 50px 一个,实战里改一处动全身,反而不如让 Grid 自己在连续宽度区间内平滑变化。
2.3 高密度场景下的间距与层级控制
一百个卡片的页面,间距和层次感决定「看着乱不乱」。我常用的技巧是给容器和卡片分别设置不同的背景色,用gap制造自然分割,而不是每个卡片都加边框。卡片内部用三个层级的内容块:顶部是图标占位或截图,中间是标题和描述,底部是标签行和打开按钮。层级用间距拉开,而不是全靠边框线。
.card { background: var(--card-bg, #fff); border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; transition: transform 0.2s ease, box-shadow 0.2s ease; } .card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); }flex-direction: column让卡片内部从上到下排列,overflow: hidden保证图片圆角不被撑破。悬停时translateY(-4px)配合阴影切换,是代价最低的轻交互。注意transition只写在默认状态而非:hover里,否则鼠标移出时动画会瞬间跳变,这是一个很小的细节但影响手感。
提示:给整张卡片加上
cursor: pointer并在标题处用<a>包裹,方便键盘用户和自动化测试识别可点击区域。
3. 数据驱动渲染与过滤:把 filter() 和 DOM 更新变成一套组合拳
3.1 先抽象数据结构,再谈渲染
一百个项目如果直接在 HTML 里写死一百个<div>,过滤逻辑就无从下手。正确做法是把每个游戏或工具抽象成一条数据,页面渲染完全由数组驱动。这个思路和 Vue 的响应式有神似之处——Vue 3 靠Proxy拦截对象读写后自动触发依赖更新,原生 JS 里没有这套机制,但我们可以手动封装「改数据 → 重绘列表」的函数。你要懂 vue3 的响应式核心就是 Proxy 拦截加依赖收集,才能在对比里理解为什么这里要自己管渲染时机。
我给每个项目定义的数据结构如下。
const projects = [ { id: 'snake', title: '贪吃蛇', desc: '经典小游戏,方向键控制', tags: ['游戏', 'canvas'], icon: './icons/snake.png', url: 'https://example.com/snake' }, { id: 'json', title: 'JSON 格式化', desc: '粘贴即格式化,支持校验', tags: ['工具', '文本'], icon: './icons/json.png', url: 'https://example.com/json' } ];字段里id用于 DOM 定位和状态记录,tags是数组而非字符串,方便后面用every()做多标签交集匹配。这里特别强调url字段不直接写死到 HTML,这样以后批量换域名或加统计参数时只改一处。
3.2 过滤按钮与搜索关键词的叠加逻辑
过滤需求一般是两种方式组合:点标签按钮只显示某一类;输入关键词按名称或描述搜索。两者要能同时生效,即当前的显示结果等于「满足选中标签」且「满足搜索词」的数据。我的实现里维护两个独立状态变量,然后每次渲染前统一过滤一次。
let currentTag = '全部'; let keyword = ''; function filteredProjects() { return projects.filter(item => { const matchTag = currentTag === '全部' || item.tags.includes(currentTag); const lowerKw = keyword.trim().toLowerCase(); const matchKw = !lowerKw || item.title.toLowerCase().includes(lowerKw) || item.desc.toLowerCase().includes(lowerKw) || item.tags.some(t => t.toLowerCase().includes(lowerKw)); return matchTag && matchKw; }); }Array.prototype.filter()返回一个新数组,不会改动原数据,所以数据源始终保持完整。includes()是判断数组或字符串是否包含某个值,在这里做模糊匹配足够;some()对 tags 数组逐个检查,只要有一个标签命中关键词就算匹配。字段全部转小写再比较,避免用户输入「JSON」时小写判断漏掉。
3.3 事件委托:别给一百个按钮各绑一个监听
过滤按钮通常每个标签一个,如果页面里有一百个项目,标签数量可能在十几个。用document.querySelectorAll逐个addEventListener不是不行,但后续动态增加标签时要重复绑定,容易埋坑。推荐把监听绑在过滤栏父容器上,用事件委托处理。
document.querySelector('#filter-bar').addEventListener('click', e => { const btn = e.target.closest('[data-tag]'); if (!btn) return; currentTag = btn.dataset.tag; renderGrid(); });e.target.closest('[data-tag]')会从被点击元素逐级向上查找带>function renderGrid() { const list = filteredProjects(); const grid = document.querySelector('#grid'); grid.innerHTML = list.map(item => ` <a class="card" href="${item.url}" target="_blank" rel="noopener"> <img src="${item.icon}" alt="" loading="lazy"> <h3>${item.title}</h3> <p>${item.desc}</p> <div class="tags">${item.tags.map(t => `<span>${t}</span>`).join('')}</div> </a> `).join(''); }
innerHTML一次性写入比createElement循环后再appendChild更快,因为浏览器在一段 HTML 里批量解析节点的开销小于多次 DOM 插入。map返回数组后用join('')拼成单个字符串,避免数组默认的逗号分隔符出现在卡片中间。模板字符串里嵌套${item.tags.map(...)...}是典型的「映射后再拼字符串」写法,注意内层也要join。
3.4 空状态和结果计数不能省
过滤后可能什么都没有,此时页面显示一片空白非常劝退。我习惯在每次渲染时同时更新结果计数,并且对空结果单独处理。计数放在列表上方,空状态用一行提示文字加一个清除筛选的按钮,让用户知道是「没匹配到」而不是「页面坏了」。
const countEl = document.querySelector('#count'); countEl.textContent = `共 ${list.length} 个条目`; if (list.length === 0) { grid.innerHTML = `<div class="empty">没有匹配的内容,<a href="#" id="reset">清除筛选</a></div>`; document.querySelector('#reset').addEventListener('click', e => { e.preventDefault(); currentTag = '全部'; keyword = ''; document.querySelector('#search').value = ''; document.querySelector('#filter-bar')?.querySelectorAll('.active').forEach(el => el.classList.remove('active')); renderGrid(); }); }textContent更新计数比innerHTML +=快,因为没有 HTML 解析过程。空状态里的清除筛选按钮是唯一需要单独绑事件的地方,因为它是动态插入的节点。注意搜索框清空时要同步把值写回keyword变量,否则界面和数据状态不一致,这是过滤功能最常见的逻辑 bug 来源。
4. 交互细节与性能优化:悬停动效、懒加载、暗色模式和单文件部署
4.1 鼠标移入移出:用 CSS 变量统一动效参数
卡片墙动效最怕「每个卡片各写一套」,替换风格时要翻十几个地方。CSS 自定义属性可以从根部统一主题,比如把动效时长、阴影颜色、圆角半径都定义成变量,卡片、按钮、搜索框共享。
:root { --anim-speed: 0.2s; --radius-md: 10px; --shadow-hover: 0 8px 24px rgba(0, 0, 0, 0.12); } .card { transition: transform var(--anim-speed) ease, box-shadow var(--anim-speed) ease; } .card:hover { transform: translateY(-4px); box-shadow: var(--shadow-hover); }CSS 变量的好处是改一处全局生效,比如把--anim-speed从0.2s调到0.15s,所有用到该变量的过渡动画统一变快。鼠标移入事件里最常见的错误是把transform写在:hover上但没有在默认状态写transition,导致悬停有动画、移出时瞬间复位。始终把transition写在默认选择器里。
4.2 图片懒加载:一百张图不能一次性全拉
一百个卡片如果每张都带截图,图片体积轻松超过几十 MB。原生懒加载是最省事的方案:给<img>加loading="lazy"属性,浏览器只在图片即将进入视口时才发起请求。但这在innerHTML动态渲染时依然生效,因为loading属性是由浏览器解析 HTML 时读取的。
const observer = new IntersectionObserver(entries => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; observer.unobserve(img); } }); }, { rootMargin: '200px' }); document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));上面是更传统的懒加载方案,适合图片真实地址由 JS 控制的场景。rootMargin: '200px'表示在图片进入视口前 200px 就开始加载,体感上是用户快滑到时图片已经就绪。observer.unobserve(img)在加载完成后解除观察,避免重复触发。如果项目截图都存在本地,loading="lazy"配合普通src就够用;如果图片地址需要从数据里读取后拼接,则用>:root { --bg: #f5f6f8; --card-bg: #ffffff; --text: #1a1a1a; --text-sub: #666; } @media (prefers-color-scheme: dark) { :root { --bg: #121212; --card-bg: #1e1e1e; --text: #e8e8e8; --text-sub: #999; } } body { background: var(--bg); color: var(--text); }
prefers-color-scheme: dark是 CSS 原生媒体特性,系统处于暗色模式时自动套用右侧变量。这样设计的好处是明暗双主题共用一套布局逻辑,只是颜色变量不同。注意卡片里如果有直接写死的color: #333,在暗色模式下会看不清,排查时优先搜硬编码颜色。
4.4 单文件部署要注意的资源加载限制
moyu.html是一个文件,但它引用的图标、截图仍然是外部资源,这决定了它能不能直接双击打开。使用file://协议打开 HTML 时,浏览器通常不允许脚本通过fetch()读取本地其他文件,但通过<img src="./icons/x.png">和<script src="./data.js">这种标签引入是允许的。因此数据结构要么内嵌在 HTML 里,要么单独拆成data.js用全局变量暴露,避免用fetch加载 JSON 文件。
<script src="./data.js"></script> <script> // data.js 里定义了 const projects = [...] renderGrid(); </script>data.js里直接const projects = [...],由于它是普通脚本而非模块,projects会挂到全局作用域,后续<script>能直接访问。这种拆法在开发时方便单独维护数据,发布时再和主文件合并成一个 HTML 也行。如果图片资源也要全部内嵌,可以把小图标转成 Base64 字符串作为icon字段,但一百张图这样做体积会失控,所以一般只对小于 5KB 的图标内嵌,截图类资源保持外链引用。
5. 用数据模型思维维护入口页:新增项目、自动生成标签与验收清单
5.1 给每条数据定一个不可省略的字段约束
数据模型是唯一需要长期维护的东西。字段建议固定为id / title / desc / tags / icon / url,其中id必须唯一,tags至少一个元素,其余字段可以为空但键不能丢。定好规则后,新增一个游戏或工具只需要在数组末尾追加一条对象,渲染和过滤逻辑完全不用改。下面是字段说明和校验逻辑。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | 是 | 唯一标识,用于定位、URL 锚点 |
title | string | 是 | 卡片标题,也是搜索的主要匹配字段 |
desc | string | 否 | 一句话描述,控制在 60 字内 |
tags | array | 是 | 至少一个分类标签,用于过滤按钮 |
icon | string | 否 | 图标或截图地址,懒加载生效 |
url | string | 是 | 落地地址,新窗口打开 |
function validateProject(p) { return p.id && p.title && Array.isArray(p.tags) && p.tags.length > 0; }Array.isArray比typeof p.tags === 'object'更可靠,因为数组也是对象。validateProject可以在页面加载后对projects做一次全量校验,发现数据错误时打印警告而不是直接渲染,比如某个标签是字符串而不是数组时,tags.includes还能跑,但tags.some会直接抛错。
5.2 标签按钮自动生成,不用手维护第二份列表
很多人新增一个带新标签的项目后忘了同步过滤按钮,导致按钮区缺少新分类。既然标签本来就在每条数据里,不如渲染时从projects里汇总去重。
const allTags = [...new Set(projects.flatMap(item => item.tags))];flatMap先对每项的 tags 数组做一次映射,再把所有结果压平成一维数组。new Set把重复标签自动去重,[...set]将其转回数组用于渲染。Set是 ES6 引入的集合结构,它天然不允许重复元素,所以去重效率高于数组内两层循环。加上「全部」分类后,过滤按钮的完整数组就齐了。
const tagButtons = ['全部', ...allTags].map(tag => ` <button class="filter-btn ${tag === currentTag ? 'active' : ''}">function syncUrl() { const params = new URLSearchParams(); if (currentTag !== '全部') params.set('tag', currentTag); if (keyword) params.set('q', keyword); history.replaceState(null, '', '?' + params.toString()); } function restoreFromUrl() { const params = new URLSearchParams(window.location.search); currentTag = params.get('tag') || '全部'; keyword = params.get('q') || ''; }URLSearchParams是浏览器的原生查询字符串解析器,set方法自动处理编码问题,toString()生成内容安全的标准查询串。history.replaceState在地址栏写入新 URL 但不产生历史记录,所以每次筛选变化不会堆一堆撤销记录。初始化时调用restoreFromUrl,在renderGrid前把搜索框的值也同步过去,这样带参数打开的页面直接呈现用户想要的结果。这个模式同样适合做翻页和排序状态记忆,比引入路由库的成本低得多,但效果已经很接近一个单页应用的体验了。
本文还有配套的精品资源,点击获取