简介:一份面向前端初学者和作品集创作者的迷你实战案例,由Haiyong创建并提供技术支持,目标是使用HTML、CSS与JavaScript构建一个响应式、可过滤的游戏与工具展示页面。该页面以收录100个小游戏和实用工具为方向,采用卡片式布局展示各项目,并借助CSS3媒体查询让布局在手机、平板与桌面屏幕上自动调整;同时通过JavaScript监听用户点击分类或输入关键词,利用filter()方法筛选目标项目,再用DOM操作实时更新展示内容,形成流畅的浏览体验。压缩包为ZIP格式,仅含1个HTML文件,大小3KB,代码体量小但逻辑完整,清晰区分结构、样式与交互三层职责,适合直接阅读和修改。通过它能直观理解媒体查询断点设置、事件绑定、数组过滤、节点更新等前端核心知识点,也能为日后搭建个人作品集提供参考。目前已有711人学习下载,对于想在短时间内掌握响应式页面与前端交互开发流程的读者来说,是一份性价比很高的学习样板。
1. 响应式可过滤展示页:先定数据模型,再谈视觉
使用 HTML、CSS 和 JS 创建响应式可过滤的游戏与工具展示页面,核心难点从来不在“把卡片排成一行”,而在于三条线的交汇:数据模型能不能支撑任意筛选条件的组合,布局能不能随视口连续变化,渲染函数每次调用能不能保持稳定。如果一上来就写 HTML 标签,后面每加一个项目就手改三处,过滤逻辑全挂在 DOM 上,这个页面从第一天起就在欠技术债。
常见做法是先抽象出一条卡片的完整数据形状,再决定 CSS 怎么写、JS 怎么筛。原生三件套完全能对付这个规模,不需要引入框架;倒是 Vue3 响应式里 proxy 拦截加依赖收集的思路,能解释清楚纯 JS 方案中状态更新要手动触发视图重绘这件事为什么容易漏。这套做法适合想把静态页面做成可维护小工程的开发者,也适合给日后接框架留一个干净的起点。
2. 用 HTML 数据抽象搭卡片骨架:字段设计、模板渲染与事件委托
2.1 先定义字段,再写第一个 div
展示页的内容分成游戏和工具两大类,但它们的共性远大于差异:都有一句名称、一段描述、一个二级分类、若干标签。把字段固定下来,页面结构才不会被反复推翻。我一般用这样一组字段:
| 字段名 | 类型 | 用途 | 筛选参与 |
|---|---|---|---|
| id | string | 唯一标识,事件委托定位用 | 否 |
| title | string | 卡片标题 | 关键字搜索 |
| category | 'game' | 'tool' | 大类 | 分类按钮 |
| tags | string[] | 如 '射击'、'截图管理'、'系统优化' | 标签过滤 |
| status | 'new' | 'hot' | 'normal' | 角标与状态筛选 | 状态过滤 |
| desc | string | 列表页可见的简述 | 关键字搜索 |
tags 用数组而不是逗号分隔字符串,是为了过滤时能直接走 Array.prototype.includes,避免做分隔符拼接和拆解。status 给一个默认值 normal,让后续渲染逻辑不用每次判断字段是否存在。desc 控制在 50 字以内,否则卡片在网格里高度参差,整体节奏会乱。
2.2 渲染函数:输入数据,输出 HTML,绝不掺状态
渲染层最忌讳在自己的函数体里读 DOM 上的当前选中项。常见做法是把 render 写成纯粹的函数:给它什么数组,它就渲染什么数组。
const root = document.querySelector('#card-grid'); function render(items) { root.innerHTML = items.map(item => ` <article class="card">document.querySelector('.filter-bar').addEventListener('click', (e) => { const btn = e.target.closest('[data-filter]'); if (!btn) return; const key = btn.dataset.filter; // category / status / tag const value = btn.dataset.value; // game / tool / new / hot ... state[key] = value; refreshButtonState(key, value); applyFilter(); });closest 写法的意义在于,按钮内部的文字、图标等子元素被点击时同样能命中父按钮,不用在 target 上反复判断节点类型。dataset.filter 和 dataset.value 构成一条通用过滤指令,新增筛选维度时不需要写新的事件函数,只要在 HTML 上补>function render(items) { const fragment = document.createDocumentFragment(); items.forEach(item => { fragment.appendChild(createCardElement(item)); }); root.replaceChildren(fragment); }
replaceChildren 是比 innerHTML 友好的替代:它只替换容器下的子节点,不会改动容器本身的属性与监听器。createCardElement 方法内部用 createElement 逐层构造节点,或者返回一个 克隆出来的片段,代价是模板代码更长,收益是 DOM 节点被复用,节点引用和动画补间不会因重建而断开。真正的取舍点在于页面规模是否值得这份代码量:不值得就用模板字符串,数据量上来再优化也不迟,但方案要提前设计好。
3. CSS 响应式布局:Grid auto-fill、窄屏筛选栏与容器查询
3.1 用 auto-fill 的 Grid,不写死列数
响应式布局常见的错误是写 .card-grid { columns: 3 },再用三个媒体查询改成 2 列、1 列,断点一变所有列数都要跟着调。我一般这样写:
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 20px; }repeat(auto-fill, minmax(260px, 1fr)) 的含义是:每列下限 260px,上限由浏览器按容器宽度平均分配。260px 是一个比较稳的基础值,能保证卡片内部放得下标题和两三个标签。如果改成 320px,桌面端想显示 3 列,容器就得预留足够宽度。配合 auto-fill 之后,媒体查询只需要处理外边距、内边距这类全局参数,不需要管列数。
3.2 窄屏筛选栏:横向滚动而不是竖堆
游戏加工具可筛选的入口数量通常有六到十个,在手机上竖着堆会吃掉首屏。常见做法是保持一行可横向滚动:
.filter-bar { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; } .filter-bar::-webkit-scrollbar { display: none; } .filter-bar__btn { flex: 0 0 auto; }overflow-x: auto 配上隐藏滚动条,视觉是一行干净的按钮,手指能滑动切换。有两个细节容易踩:一是 flex 子项默认 flex-shrink: 1,不设 flex: 0 0 auto 会被压扁,最后一个按钮可能截断;二是要留一点 padding-bottom,否则某些浏览器里 focus 环会被滚动容器裁掉,键盘高亮看不见。窄屏筛选栏本质是一个单手操作的横向轮播,比多行换行更贴合手机拇指热区。
3.3 容器查询:让卡片的内部布局跟随自身宽度
视口媒体查询适合解决整页结构问题,但它回答不了“同一张卡片在侧边栏和全屏区时,内部要不要换行”。容器查询给组件级响应提供了原生方案:
.card { container-type: inline-size; } @container (max-width: 320px) { .card__head { flex-direction: column; align-items: flex-start; } }给 .card 加上 container-type: inline-size 之后,该元素成为自己的查询容器,@container 里的规则以它自身的宽度为基准。inline-size 只跟踪内联轴(横向)的尺寸,避免建立完整格式化上下文带来的布局开销。兼容性在现代浏览器里可以放心用;不支持的浏览器会忽略 @container,组件走默认的单行布局,效果不会崩。
3.4 字体、间距与两行截断:用 clamp 调节奏
响应式不止列数,字级与间距也要相对化。给根元素设一个随视口变化的字号,卡片内部用 rem 跟随:
:root { --font-md: clamp(0.875rem, 0.8rem + 0.4vw, 1rem); --space-card: clamp(16px, 2vw, 24px); } .card { padding: var(--space-card); font-size: var(--font-md); }clamp 接收最小值、首选值、最大值三个参数。中间项 0.8rem + 0.4vw 是关键,vw 部分让字号随视口连续增长,而不是媒体查询里的跳变。另一种和响应式强相关的细节是描述两行截断,用行数限制而不是定高:
.card__desc { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }这套 -webkit-box 写法在 Chromium、Safari 和 Firefox 都稳定。line-clamp 限定两行,配合 overflow: hidden 实现“超出两行省略”,不会像 height 写死那样把第三行半个字裁切在卡片里。
4. JS 过滤引擎:state 合并筛选条件,防抖搜索与结果计数
4.1 用一个 state 对象管理所有筛选项
分类、状态、标签、关键字四个维度如果各自维护变量,再两两组合,判断逻辑会膨胀成四层嵌套。常见做法是让所有筛选项进同一个对象,过滤函数只读 state,不读 DOM:
const state = { category: 'all', status: 'all', selectedTag: '', keyword: '' }; function getFilteredItems() { return ITEMS.filter(item => { if (state.category !== 'all' && item.category !== state.category) return false; if (state.status !== 'all' && item.status !== state.status) return false; if (state.selectedTag && !item.tags.includes(state.selectedTag)) return false; if (!state.keyword) return true; const kw = state.keyword.toLowerCase(); return item.title.toLowerCase().includes(kw) || item.desc.toLowerCase().includes(kw); }); }early return 的写法比逐步累加 matchCategory、matchStatus 布尔变量更直观。每一层只回答“这一项要不要被排除”,所有条件同时满足才留下,这正是组合过滤的语义。四五个筛选条件不会带来性能压力,即使数据量过万,这个 filter 也只需几毫秒。
| 组合方式 | 语义 |
|---|---|
| category 与 status 叠加 | 同时满足才通过 |
| selectedTag 与 keyword 叠加 | 标签与文本是同层交集 |
| 重置按钮 | 四个条件恢复默认后重新渲染 |
4.2 关键字搜索:防抖与 includes 的边界
搜索框的 input 事件会在每次按键时触发,敲“截图”会依次过滤“截”“截图”两次,中文输入法还会多出拼音中间态。常见做法是 200ms 防抖:
let timer = null; searchInput.addEventListener('input', (e) => { clearTimeout(timer); timer = setTimeout(() => { state.keyword = e.target.value.trim(); render(getFilteredItems()); }, 200); });防抖核心是 clearTimeout 加 setTimeout 的组合:连续输入时,上一次定时器被清掉,只有停顿超过 200ms 才真正执行筛选。trim() 去掉首尾空格,避免用户只敲空格触发一次性空过滤。includes 是子串匹配,对“截图”能命中“截图工具”,却不一定能命中“屏幕捕获”。要不要做别名匹配,取决于内容是否自带 tags;只要每个工具都有合适标签,title 和 desc 的 includes 已经够用,不必引入分词库。
4.3 按钮激活态与 state 同步,避免 UI 和数据脱节
筛选按钮最常用的交互是单选切换:点分类里的“游戏”时,其他分类取消激活。如果每个按钮的激活态只靠 add/remove class,而 state 没有被同步更新,刷新或重新渲染后 UI 会和数据脱节。我一般这样组织:
function refreshButtonState(key, value) { document.querySelectorAll(`[data-filter="${key}"]`).forEach(btn => { btn.classList.toggle('is-active', btn.dataset.value === value); }); }classList.toggle 的第二个参数接收布尔值:state 里存的是什么,按钮上就显示什么。点击来源无论是鼠标、键盘还是脚本重置,最后都会回到与 state 一致的状态。对 tag 来说,还要支持“再点一次取消选择”,这时的逻辑是:如果点击的 tag 等于 state.selectedTag,就把 selectedTag 置为空字符串。这个分支建议单独处理,不写进通用点击函数。
4.4 空结果与计数反馈
筛选结果为零时,卡片区空白会让人怀疑页面是否出错。需要一个专门的空状态组件,在渲染结果长度为零时显示:
<div class="empty-state" hidden> <p>没有匹配的项目,试试其他筛选条件</p> <button type="button" id="btnReset">重置全部条件</button> </div>渲染函数更新卡片之后,同步更新空状态的 hidden 属性与结果计数:
function render(items) { // 渲染卡片逻辑 countEl.textContent = `共 ${items.length} 个项目`; emptyEl.hidden = items.length > 0; }重置按钮做的事是恢复 state 默认值,再调用 refreshButtonState 和 render,而不是刷新页面。关键点是筛选结果计数由渲染函数统一计算,不额外遍历一次 DOM,避免两个数据源不一致。
4.5 渲染与状态解耦:filter 和 render 分开
组合筛选逻辑写进 getFilteredItems,渲染只接收数组,这个拆分看起来多余,却是整个 JS 部分最重要的结构决策。后续任何交互——按钮点击、搜索输入、重置——都只做两件事:改 state,调 render(getFilteredItems())。如果后续接框架,Vue 的 computed 恰好对应 getFilteredItems 的角色,React 的 useMemo 也承担同样的计算位置。原生写法里没有依赖收集,每次手动调用的都是全量计算,这个规模下几乎不会成为性能瓶颈。
5. 交付前的小技巧:CSS 变量收敛主题,hover 动效与验收点
5.1 类别主题用 data 属性加 CSS 变量
游戏和工具两类卡片如果要做视觉区分,不需要写 .card--game 和 .card--tool 两套独立颜色规则。把差异收敛成 CSS 变量,挂在卡片自身的 data 属性上:
.card[data-category="game"] { --accent: #6366f1; --badge-bg: rgba(99, 102, 241, .12); } .card[data-category="tool"] { --accent: #22c55e; --badge-bg: rgba(34, 197, 94, .12); } .card__badge { background: var(--badge-bg, #f3f4f6); color: var(--accent, #374151); }新增“资源”分类时,只需要补一段 [data-category="resource"] 规则,角标、标题强调线、hover 边框都会自动跟随,不需要改 HTML 结构。
5.2 hover 动效要过三层检查
展示页观感问题不少出在动效上:触屏设备 hover 粘滞、无 hover 能力的设备上过渡无意义、动画干扰卡片的可读性。三层约束分别是设备能力、系统偏好和性能:
@media (hover: hover) { .card:hover { transform: translateY(-4px); box-shadow: 0 12px 24px -8px rgba(0, 0, 0, .15); } } @media (prefers-reduced-motion: reduce) { .card { transition: none; transform: none; } }@media (hover: hover) 把动效限制在真正有悬停能力的设备上,避免手机点击后卡片一直停留在“悬浮”状态。prefers-reduced-motion 尊重系统减弱动效的偏好。这两层判断加完,代码不再需要“移动端禁用特效”这类 hack。
5.3 验收清单
最后对着清单过一遍:
- 视口从 320px 拖到 1440px,卡片栅格列数平滑变化,没有突破容器宽度的跳变。
- 窄屏下筛选栏横向滚动顺畅,最后一个按钮不会被 flex 压缩截断。
- 分类、状态、关键字任意组合筛选后,取消任一条件,结果回到正确的子集,而不是保留上一次的交集。
- 键盘 Tab 能走通筛选区,focus 环没有被滚动容器裁掉,搜索框防抖生效。
- 性能面板里快速连点 10 次筛选按钮,监听器数量没有线性增长,渲染耗时没有累加。
这些项目每一条都对应一个具体代码位置,排查时不需要从头读整个文件。把清单放进仓库 README,后续维护和接手的人可以直接照着验证。
本文还有配套的精品资源,点击获取