用HTML/CSS/JS构建响应式卡片墙:数据驱动与懒加载的摸鱼入口页 📅 发布时间:2026/9/15 0:50:20 👁 浏览次数: 简介这是一个结合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: 768pxiPad 竖屏三到四列字号回到 16pxmin-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 classcard href${item.url} target_blank relnoopener img src${item.icon} alt loadinglazy h3${item.title}/h3 p${item.desc}/p div classtags${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 classempty没有匹配的内容a href# idreset清除筛选/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加loadinglazy属性浏览器只在图片即将进入视口时才发起请求。但这在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)在加载完成后解除观察避免重复触发。如果项目截图都存在本地loadinglazy配合普通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(); /scriptdata.js里直接const projects [...]由于它是普通脚本而非模块projects会挂到全局作用域后续script能直接访问。这种拆法在开发时方便单独维护数据发布时再和主文件合并成一个 HTML 也行。如果图片资源也要全部内嵌可以把小图标转成 Base64 字符串作为icon字段但一百张图这样做体积会失控所以一般只对小于 5KB 的图标内嵌截图类资源保持外链引用。5. 用数据模型思维维护入口页新增项目、自动生成标签与验收清单5.1 给每条数据定一个不可省略的字段约束数据模型是唯一需要长期维护的东西。字段建议固定为id / title / desc / tags / icon / url其中id必须唯一tags至少一个元素其余字段可以为空但键不能丢。定好规则后新增一个游戏或工具只需要在数组末尾追加一条对象渲染和过滤逻辑完全不用改。下面是字段说明和校验逻辑。字段类型必填说明idstring是唯一标识用于定位、URL 锚点titlestring是卡片标题也是搜索的主要匹配字段descstring否一句话描述控制在 60 字内tagsarray是至少一个分类标签用于过滤按钮iconstring否图标或截图地址懒加载生效urlstring是落地地址新窗口打开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 classfilter-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前把搜索框的值也同步过去这样带参数打开的页面直接呈现用户想要的结果。这个模式同样适合做翻页和排序状态记忆比引入路由库的成本低得多但效果已经很接近一个单页应用的体验了。本文还有配套的精品资源点击获取