纯前端构建可离线菜谱知识库:HTML+CSS+JS实战

发布时间:2026/9/14 22:01:34
纯前端构建可离线菜谱知识库:HTML+CSS+JS实战 简介这是一套基于纯前端技术栈HTML5、CSS3、JavaScript构建的响应式菜谱百科APP源码面向Web前端初学者与移动端开发入门者解决美食类信息平台从零搭建的学习需求。项目共98个文件含19个HTML页面结构文件、17个CSS样式文件含Bootstrap及自定义主题、9个JavaScript交互脚本涵盖数据加载、分类筛选、详情展示等核心逻辑以及44张高清菜谱图标与界面素材PNG整体压缩包仅1.52MB轻量易部署。已有333人下载学习适合作为HTML/CSS/JS综合实训项目提供完整目录结构、清晰模块划分如category.html菜品分类页、detail.html单菜详情页、favorites.html收藏功能、配套资源文件config.xml配置、字体图标woff/eot/svg及可直接运行的静态站点。1. 这不是“做个网页”——CookWiki 是用纯前端三件套构建可离线、可搜索、带交互逻辑的菜谱知识库很多人看到“HTML、CSS、JavaScript”就默认是静态页面但 CookWiki 的本质是一个结构化数据驱动的单页应用SPA雏形它不依赖后端接口所有菜谱数据以 JSON 形式内嵌或本地加载用户能按食材、菜系、难度三级筛选点击卡片展开详细步骤动画收藏夹状态持久化到 localStorage搜索框支持模糊匹配如输“番茄”命中“番茄炒蛋”“番茄牛腩汤”且结果实时高亮关键词。它面向的是家庭烹饪者、新手厨师、饮食健康关注者——这些人不需要复杂账户体系但极度依赖信息准确、操作零学习成本、手机横竖屏切换不崩。技术上它绕开了框架生态包袱用原生 DOM 操作事件委托CSS 自定义属性实现主题切换深色/浅色模式用IntersectionObserver实现菜谱卡片懒加载用localStorage模拟“用户态”。这不是教学 Demo而是能真正在 Chrome、Edge、Safari 移动版稳定运行的轻量级知识工具。2. 从零搭建 CookWiki 的核心骨架HTML 结构语义化 CSS 响应式布局 JS 数据驱动渲染CookWiki 的生命力始于 HTML 的语义层级设计。它不用div classcontainer堆砌而是严格遵循 WAI-ARIA 规范主内容区用main rolemain每个菜谱卡片用article aria-labelledbyrecipe-title-1搜索输入框绑定aria-controlsrecipe-list。这种结构让屏幕阅读器能准确播报“您正在浏览川菜类目共 12 道菜谱”也为后续 SEO 和无障碍访问打下基础。关键在于head中的元信息必须完整——meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno锁定移动端缩放meta namedescription contentCookWiki 是一个开源的中文菜谱百科涵盖家常菜、宴客菜、素食、快手菜等全品类做法提供搜索引擎摘要而link relmanifest href/manifest.json则为 PWA 安装能力埋点。2.1 HTML 的最小可行结构用template预置动态模板避免 innerHTML 拼接风险CookWiki 的菜谱列表不是写死的 HTML而是通过template标签预定义可复用的 DOM 片段。这样既避免了字符串拼接导致的 XSS 漏洞又提升了渲染性能!-- 在 body 底部声明模板 -- template idrecipe-card-template article classrecipe-card>/* 第一行主题变量 */ :root { --bg-primary: #ffffff; --text-primary: #333333; --card-bg: #f9f9f9; --border-color: #e0e0e0; --accent-color: #ff6b35; } [data-themedark] { --bg-primary: #1a1a1a; --text-primary: #e0e0e0; --card-bg: #2d2d2d; --border-color: #444; --accent-color: #ff8c52; } /* 第二行布局系统 */ .recipe-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5rem; padding: 1rem; } media (max-width: 768px) { .recipe-grid { grid-template-columns: 1fr; padding: 0.5rem; } } /* 第三行组件样式 */ .recipe-card { background: var(--card-bg); border-radius: 0.5rem; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); transition: transform 0.2s ease, box-shadow 0.2s ease; } .recipe-card:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0,0,0,0.12); }注意grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))是 CookWiki 响应式网格的核心。它确保在桌面端每行至少 2 张卡片300px 最小宽度在平板端自动收缩为单列且不会因内容高度差异导致错行——这是纯float或旧式inline-block无法稳定实现的。2.3 JavaScript 的数据驱动逻辑用模块化函数封装渲染、搜索、收藏三大能力CookWiki 的 JS 不是脚本堆砌而是按功能拆分为data.js数据管理、ui.jsUI 渲染、search.js搜索逻辑、storage.js本地存储四个模块。其中ui.js的renderRecipeList()函数是核心枢纽// ui.js export function renderRecipeList(recipes) { const container document.getElementById(recipe-list); const template document.getElementById(recipe-card-template).content; // 清空容器避免重复渲染 container.innerHTML ; recipes.forEach(recipe { const clone template.cloneNode(true); // 替换模板占位符 clone.querySelector(.recipe-title).textContent recipe.title; clone.querySelector(.recipe-desc).textContent recipe.description; clone.querySelector(.recipe-tag).textContent recipe.category; clone.querySelector(.recipe-time).textContent ⏱ ${recipe.time} 分钟; clone.querySelector(.recipe-level).textContent ${recipe.level}; clone.querySelector([data-id]).setAttribute(data-id, recipe.id); // 设置收藏按钮初始状态 const favBtn clone.querySelector(.btn-fav); if (isFavorited(recipe.id)) { favBtn.setAttribute(aria-pressed, true); favBtn.innerHTML svg classicon-heart-filled viewBox0 0 24 24path dM12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z//svg; } container.appendChild(clone); }); }该函数接收recipes数组来自data.js遍历生成 DOM 节点并注入容器。关键点在于不操作 innerHTML 字符串而是操作 DocumentFragment收藏状态通过isFavorited()函数查询localStorage所有事件监听器在initEventListeners()中统一绑定使用事件委托避免内存泄漏。3. 让 CookWiki 真正可用实现模糊搜索、本地收藏、离线缓存三大硬需求CookWiki 的“百科”属性体现在信息检索效率上。用户输入“豆腐”不仅要匹配标题含“豆腐”的菜谱还要命中“麻婆豆腐”“家常豆腐”“豆腐羹”等变体甚至容忍“豆付”“豆付”等拼音近似词。这不能靠indexOf()简单实现而需结合 Levenshtein 距离算法与分词预处理。3.1 模糊搜索的轻量级实现用fuse.js库替代正则暴力匹配CookWiki 选用fuse.jsv6.6.2作为搜索引擎因其体积仅 12KB支持权重配置和异步搜索。初始化时构建索引// search.js import { Fuse } from https://cdn.jsdelivr.net/npm/fuse.js6.6.2/dist/fuse.esm.min.js; const fuseOptions { keys: [title, ingredients, steps], // 在标题、食材、步骤中搜索 threshold: 0.3, // 允许 30% 的字符差异 ignoreLocation: true, includeScore: true, minMatchCharLength: 2 // 至少输入 2 字才触发搜索 }; let fuseInstance null; export function initSearchIndex(recipes) { fuseInstance new Fuse(recipes, fuseOptions); } export function searchRecipes(query) { if (!fuseInstance || !query.trim()) return []; return fuseInstance.search(query).map(result result.item); }参数说明threshold: 0.3是关键调节点——值越小匹配越严格0.1 仅匹配错 1 字越大越宽松0.5 可匹配“番茄”→“番石榴”。CookWiki 设为 0.3在“青椒肉丝”搜“青椒”能 100% 命中“青交肉丝”也能以 0.28 分返回而“青菜”则被过滤。minMatchCharLength: 2防止用户输单字“蛋”时返回全部含“蛋”的菜谱约 200 条提升体验。3.2 本地收藏的持久化用localStorage封装增删查兼容 Safari 无痕模式CookWiki 的收藏功能不依赖登录直接写入localStorage。但 Safari 无痕模式会拒绝写入需降级为内存存储// storage.js export function getFavorites() { try { const data localStorage.getItem(cookwiki_favorites); return data ? JSON.parse(data) : []; } catch (e) { // Safari 无痕模式抛出 QuotaExceededError降级为内存存储 console.warn(localStorage not available, using memory storage); return window.__favorites__ || []; } } export function addFavorite(id) { const favorites getFavorites(); if (!favorites.includes(id)) { favorites.push(id); saveFavorites(favorites); } } export function removeFavorite(id) { const favorites getFavorites().filter(item item ! id); saveFavorites(favorites); } function saveFavorites(favorites) { try { localStorage.setItem(cookwiki_favorites, JSON.stringify(favorites)); } catch (e) { // 内存存储 fallback window.__favorites__ favorites; } }提示window.__favorites__是全局临时变量仅在 localStorage 不可用时启用。实际项目中可进一步封装为 Class但 CookWiki 保持函数式风格以降低复杂度。3.3 离线缓存的渐进增强用 Service Worker 缓存静态资源与菜谱数据CookWiki 通过service-worker.js实现 PWA 能力让用户首次访问后即可离线使用// service-worker.js const CACHE_NAME cookwiki-v1; const urlsToCache [ /, /index.html, /style.css, /script.js, /data/recipes.json, // 菜谱数据文件 /icons/icon-192.png, /icons/icon-512.png ]; self.addEventListener(install, event { event.waitUntil( caches.open(CACHE_NAME) .then(cache cache.addAll(urlsToCache)) ); }); self.addEventListener(fetch, event { event.respondWith( fetch(event.request) .catch(() caches.match(event.request)) ); });注册 Service Worker 需在index.html的 JS 中执行// main.js if (serviceWorker in navigator) { window.addEventListener(load, () { navigator.serviceWorker.register(/service-worker.js) .then(registration console.log(SW registered: , registration.scope)) .catch(error console.error(SW registration failed: , error)); }); }注意urlsToCache必须包含/data/recipes.json——这是 CookWiki 的核心数据源。若用户首次访问时网络中断Service Worker 会返回缓存的 JSON保证菜谱列表仍可加载。但新增菜谱需手动更新urlsToCache并更改CACHE_NAME版本号否则缓存不会刷新。4. 解决 CookWiki 开发中的高频痛点移动端适配、字体渲染、事件委托陷阱CookWiki 在真实设备上常遇到三类典型问题iOS Safari 下position: sticky失效导致分类导航条不跟随滚动Android Chrome 中中文字体渲染发虚以及快速点击收藏按钮时因事件冒泡导致重复触发。这些问题不写进文档新手极易卡住数小时。4.1 iOS Safari 的 sticky 兼容方案用transform: translateY()替代原生 stickyiOS 15.4 之前position: sticky在overflow: auto容器中失效。CookWiki 的解决方案是放弃 CSS sticky改用IntersectionObserver监听导航区域位置动态添加fixed类// ui.js function initStickyNav() { const nav document.querySelector(.category-nav); const observer new IntersectionObserver( ([entry]) { nav.classList.toggle(nav-fixed, !entry.isIntersecting); }, { threshold: [0, 1] } ); observer.observe(document.querySelector(.category-section)); } // 对应 CSS .nav-fixed { position: fixed; top: 0; width: 100%; z-index: 100; background: var(--bg-primary); box-shadow: 0 2px 6px rgba(0,0,0,0.08); }验证方法在 iOS 设备 Safari 中打开 CookWiki滚动页面观察导航条是否始终吸附顶部。若失效检查IntersectionObserver是否被 polyfilliOS 12.2 原生支持无需额外引入。4.2 中文字体渲染优化强制启用 subpixel antialiasing 并指定 fallback 字体栈CookWiki 的标题字体在部分 Android 设备上显示模糊根源在于系统禁用了亚像素抗锯齿。通过 CSS 强制开启并指定字体栈可解决/* style.css */ body { -webkit-font-smoothing: subpixel-antialiased; -moz-osx-font-smoothing: grayscale; font-family: PingFang SC, Hiragino Sans GB, Microsoft YaHei, sans-serif; } .recipe-title { font-weight: 600; line-height: 1.3; /* 关键禁用 text-rendering: optimizeLegibility它在移动端反而降低清晰度 */ }参数说明-webkit-font-smoothing: subpixel-antialiased强制 WebKit 内核使用亚像素渲染比antialiased更锐利font-family中PingFang SCiOS优先于Microsoft YaHeiWindows避免跨平台字体回退失真line-height: 1.3防止中英文混排时行高塌陷。4.3 事件委托的防抖实践用event.stopPropagation()隔离按钮点击与卡片点击CookWiki 的菜谱卡片同时响应“查看详情”和“收藏”两个操作若用event.target判断易出错// 错误写法依赖 target.className container.addEventListener(click, e { if (e.target.classList.contains(btn-detail)) { viewRecipe(e.target.closest([data-id]).dataset.id); } else if (e.target.classList.contains(btn-fav)) { toggleFavorite(e.target.closest([data-id]).dataset.id); } }); // 正确写法用事件委托 stopPropagation container.addEventListener(click, e { const card e.target.closest(.recipe-card); if (!card) return; const detailBtn e.target.closest(.btn-detail); const favBtn e.target.closest(.btn-fav); if (detailBtn) { e.stopPropagation(); // 阻止冒泡到 card click viewRecipe(card.dataset.id); } else if (favBtn) { e.stopPropagation(); toggleFavorite(card.dataset.id); } });为什么必须stopPropagation()当用户点击收藏按钮时事件会先触发btn-fav的 handler再向上冒泡触发recipe-card的 click如果存在。若未阻止可能同时执行“查看详情”和“收藏”两个动作。closest()方法比className判断更健壮能正确识别子元素如 SVG 图标内的path。5. CookWiki 的进阶技巧用 CSSlayer管理样式优先级、用Intl.DateTimeFormat格式化时间、用ResizeObserver动态调整网格列数CookWiki 的代码规模增长后CSS 优先级冲突和日期格式硬编码成为新瓶颈。这些技巧不改变功能但大幅提升可维护性与国际化能力。5.1 用 CSSlayer显式声明样式层终结!important滥用当 CookWiki 新增“打印样式”或“高对比度模式”时传统 CSS 优先级规则易失控。layer提供显式层级控制/* style.css */ layer base, components, utilities; layer base { * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: system-ui, -apple-system, sans-serif; } } layer components { .recipe-card { background: var(--card-bg); border-radius: 0.5rem; } } layer utilities { .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } } /* 打印样式单独成层自动获得最低优先级 */ layer print { media print { .recipe-actions { display: none; } .recipe-card { box-shadow: none; border: 1px solid #000; } } }优势layer声明的顺序即优先级顺序base components utilities无需计算选择器特异性。新增.print-hidden工具类时直接写入layer utilities它天然高于layer components中的.recipe-card避免!important。5.2 用Intl.DateTimeFormat替代new Date().toLocaleDateString()支持多语言日期CookWiki 的“更新时间”字段若用new Date().toLocaleDateString()在中文环境显示“2023年10月5日”但在英文环境却显示“10/5/2023”不符合“中文菜谱百科”的定位。Intl.DateTimeFormat可强制指定 locale// utils.js export function formatDate(dateString) { const date new Date(dateString); return new Intl.DateTimeFormat(zh-CN, { year: numeric, month: 2-digit, day: 2-digit, hour: 2-digit, minute: 2-digit }).format(date); } // 使用示例 console.log(formatDate(2023-10-05T14:30:00)); // 2023年10月05日 14:30参数说明zh-CN强制使用中文格式{ year: numeric }等选项精确控制输出粒度。相比toLocaleDateString()Intl.DateTimeFormat支持更多 locale如zh-Hans简体中文、zh-Hant繁体中文且性能更优可复用 formatter 实例。5.3 用ResizeObserver动态调整网格列数比媒体查询更精准CookWiki 的grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))在窄屏下仍可能显示半张卡片。ResizeObserver可监听容器宽度动态设置列数// ui.js function initResponsiveGrid() { const container document.querySelector(.recipe-grid); const observer new ResizeObserver(entries { const width entries[0].contentRect.width; let columns 1; if (width 768) columns 2; if (width 1024) columns 3; if (width 1440) columns 4; container.style.gridTemplateColumns repeat(${columns}, 1fr); }); observer.observe(container); }验证方法在 Chrome DevTools 中切换设备模拟器拖动窗口宽度观察.recipe-grid的gridTemplateColumnsCSS 属性是否实时变化。此方案比媒体查询更灵活——它响应的是容器宽度而非视口宽度对嵌入 iframe 的场景更鲁棒。本文还有配套的精品资源点击获取