ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

纯前端点餐系统DEMO:HTML+CSS+JS实现数据流与本地缓存

2026/9/15 4:11:50 拓冰建站 浏览量
纯前端点餐系统DEMO:HTML+CSS+JS实现数据流与本地缓存 简介这是一份基于HTML5、JavaScript与CSS构建的APP点餐系统前端示例工程面向移动端网页订餐场景适合前端学习者、初级开发者以及有H5小工具开发需求的人群参考。压缩包内含93个文件整体约1.23MB其中23个HTML页面承担首页、菜单、购物车、订单、评价等结构7个CSS文件负责公共样式与各模块独立样式7个JS脚本实现滑动切换、触屏交互、购物车增减和订单计算等逻辑另含多张png/jpg/gif图片素材用于界面装饰与菜品展示目录按页面功能拆分便于对照学习。目前已有778人浏览该资源。通过完整代码可直观掌握点餐类H5应用从页面组织到交互反馈的落地方式包括菜品分类展示、购物车数量联动、下单流程和自适应布局对独立开发同类型轻应用很有帮助。1. 点餐系统DEMO不简单HTMLCSSJS的边界在哪里搜索框里敲“APP点餐系统HTML, Js, css,H5。DEMO-HTML .5代码类资源”下到的往往是一堆散落的html、css、js文件。真正的问题不是代码量而是这些文件串起来之后要像一个能演示的点餐系统菜单能按分类切、搜索能过滤、加购能算总价、刷新页面购物车不丢。这也是这篇要拆的东西——用纯HTMLCSSJS/H5把点餐DEMO的前端主链路搭起来不依赖框架、不依赖构建工具、不需要后端接口。点餐系统DEMO的核心不是页面好看而是数据流菜单数据、购物车状态、本地缓存三者怎么在js函数之间流转。适合正在做网页制作作业、给客户演示H5原型、或想搞清前端DOM操作和事件绑定的人往下看。代码类资源不缺缺的是把它们按职责拆好、按顺序加载、在边界处做兜底的那层工程意识。2. 选型与结构纯H5点餐DEMO为什么不需要框架拿到需求先别急着写界面。常见做法是直接上Vue或者React但“点餐系统DEMO .5代码资源”这类需求往往出现在三种场景里课程作业的网页制作、给甲方演示H5原型的临时页面、以及前端练手的第一个完整小项目。这三种场景共同特点是要快、要能单文件跑、要在别人电脑上也能双击打开。引入构建工具反而增加讲解成本npm install跑不完后面所有动作都卡住这个DEMO连演示机会都没有。纯HTMLCSSJS单页方案仍然是最可靠的起点。数据用一个js文件存数组渲染用函数把数组变成DOM购物车用对象存数量再用localStorage做持久化。这套组合不挑运行环境扔到任何静态目录都能跑起来后续要接真实后端时也只需要改数据加载那一段。2.1 用表格先划清纯前端的落点边界把需求逐条摆出来哪些能落、哪些只能模拟一开始就对客户说清楚DEMO阶段才不会做过头。功能需求纯前端能否实现常见做法菜单展示、分类切换、关键词搜索能本地数组 渲染函数购物车增删改、金额合计能JS对象存数量 reduce汇总订单提交只能模拟本地存一份订单 成功提示登录鉴权、支付回调、后厨接单不能DEMO里留fetch占位函数表格里最后一行常常被忽略。别人拿DEMO去谈需求时如果发现支付是假的会觉得被糊弄但如果你一开始就标清楚“这里留了接口占位”反而显得对边界有数。纯前端点餐系统本来就不是一个完整业务系统它是交互原型是主流程的可执行说明书。2.2 最小工程目录与文件职责划分目录结构我会这样搭四个文件各管一件事order-demo/ ├── index.html ├── css/ │ └── style.css ├── js/ │ ├── menu.js # 菜单数据独立成文件 │ ├── cart.js # 购物车状态与本地存储 │ └── main.js # 渲染与事件绑定入口 └── assets/ └── images/ # 菜品图占位无图也能跑先说加载顺序index.html里先引入menu.js再引入cart.js最后main.js。因为menu.js定义全局菜单数组cart.js里的购物车函数要引用菜单数据做金额计算main.js要调用前两个文件里的函数绑定事件。js捆绑打包是后话DEMO阶段手动控制script顺序最直观也最好排错。菜单数据单独放一个文件是给将来接接口留的口子——今天改数组明天改JSON响应渲染层不用动。2.3 用HBuilderX配置本地预览避免双击文件踩坑很多新手直接双击index.html结果图片加载不出来路由也乱。因为file://协议下部分浏览器对本地资源的限制比http严格。我一般用HBuilderX这类编辑器自带的本地服务器跑DEMOHBuilderX配置html、css、javascript都很顺手右键项目目录选择“运行到浏览器”即可它会起一个本地静态服务。不想装编辑器的话在项目根目录执行下面命令也行cd order-demo python3 -m http.server 9000浏览器打开http://localhost:9000/index.html效果等价。用这类方式跑起来之后css、js、图片的加载全都走相对路径跨域问题也被本地服务规避掉了。index.html骨架就是这个样子!DOCTYPE html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title点餐系统 DEMO/title link relstylesheet hrefcss/style.css /head body header idtopBar h1点餐系统 DEMO/h1 input typesearch idsearchInput placeholder搜索菜品 /header main idmenuContainer/main footer idcartBar button typebutton idcartBtn购物车/button span idtotalPrice¥ 0.00/span button typebutton idsubmitBtn去结算/button /footer script srcjs/menu.js/script script srcjs/cart.js/script script srcjs/main.js/script /body /htmlhtml骨架里两个细节值得留意。一是meta viewport必须写否则在手机上预览时页面会按980px宽度缩放字小到没法看。二是按钮都标了typebutton防止它们触发隐式的表单提交行为这是html网页制作里最不起眼但最常见的坑。head里只放cssscript全部放body底部这样DOM解析完再执行js不用等DOMContentLoaded事件。header和footer用position: fixed固定上下两端的样式实际样式放style.css里即可。提示HBuilderX有内置浏览器预览也可以把网址发到手机同一WiFi下测试这时用的是真机H5环境viewport和点击效果会直观暴露桌面浏览器看不出的问题。3. 菜单数据与页面渲染js函数组织和DOM取舍页面要做的第一件事是把菜单渲染出来。这个环节的决定会影响后面所有代码结构数据放哪、渲染函数怎么写、点击事件绑在哪。菜单数据量小常见做法是直接用一个独立的js文件定义数组配合js函数做渲染不需要请求后端。3.1 菜单数据用独立js文件js函数职责先分清楚menu.js里存分类和菜品两条数组。分类数组用于顶部切换菜品数组是菜单渲染的唯一数据源。字段我这样设计// menu.js —— 菜单数据独立成文件后续可替换为接口响应 const CATEGORIES [热销, 汉堡, 小吃, 饮品]; const MENU_ITEMS [ { id: b01, name: 香辣鸡腿堡, category: 汉堡, price: 18.5, // 原价用于划线展示 sellPrice: 15.9, // 实际售价购物车算钱用它 tag: 热卖, stock: 20 // 库存下单时做数量校验 }, { id: s01, name: 薯条, category: 小吃, price: 9.0, sellPrice: 7.5, tag: , stock: 50 }, { id: d01, name: 柠檬茶, category: 饮品, price: 8.0, sellPrice: 6.9, tag: 第二杯半价, stock: 30 } ];id用字符串而不是数字而且带了分类前缀。因为b01和01这样的纯数字在js对象做key时可能被隐式转成数字购物车和菜单映射时容易出类型不匹配的问题。price和sellPrice分开存是为了显示划线价时还能还原原价如果只有一个price字段促销价效果就做不了。stock字段现在只做展示后面接后端时拿来校验“加购数量不能大于库存”。字段命名和数据来源分离是这份DEMO往后扩展的底气。3.2 渲染菜单卡片用事件委托getElementById(demo)之外的选择渲染函数写在main.js里入参是菜单数组输出是HTML字符串插进列表容器。核心点是不直接操作每个按钮而是让容器统一响应点击// main.js —— 渲染菜单卡片 function renderMenu(list) { const container document.getElementById(menuContainer); if (!list.length) { container.innerHTML p classempty没有找到匹配的菜品/p; return; } const cards list.map(item section classmenu-card>// 事件委托监听菜单容器统一处理加入购物车 document.getElementById(menuContainer).addEventListener(click, event { const button event.target.closest(.add-btn); if (!button) return; const card button.closest(.menu-card); const itemId card.dataset.id; addToCart(itemId); }); // renderMenu(list) 接收数组并渲染 renderMenu(MENU_ITEMS);event.target.closest(.add-btn)先判断点击目标是否落在按钮上不是就直接返回。这样分类标签、价格区域点击都不会误触。很多人习惯用getElementById(demo)一个节点一个节点地取页面结构简单时没问题但菜单卡片一旦循环生成多个实例逐个绑监听既啰嗦又容易绑错。事件委托是解决这类动态列表点击问题的最省心写法。取DOM方式适用场景注意点getElementById(demo)取唯一节点id重复时只返回第一个querySelector(.menu-card)取单个元素选不到返回nullquerySelectorAll(.menu-card)批量取静态节点动态渲染后要重新取event.target.closest(.add-btn)事件委托场景从点击目标向上冒泡查找菜单卡片的鼠标移入动效用css做比js监听更划算。CSS动画完全不用从入门到精通全啃下来DEMO阶段会transition加transform两个属性就够/* 卡片移入效果用 :hover 处理鼠标移入事件 */ .menu-card { transition: transform 0.2s ease, box-shadow 0.2s ease; } .menu-card:hover { transform: translateY(-4px); box-shadow: 0 6px 12px rgba(0, 0, 0, 0.12); }:hover在触屏设备上表现不同真机演示时点按会停留在按下状态这个效果不影响主流程但要知道原因。CSS动画的终态要写在:hover里写在transition里不生效这是新手最容易搞反的地方。3.3 搜索过滤用includes做js判断字符串是否包含搜索框的即时过滤是点餐系统的常用交互。输入关键词菜单列表实时刷新。这里用一个简单filter配合includes解决// 搜索过滤js判断字符串是否包含忽略大小写 const searchInput document.getElementById(searchInput); searchInput.addEventListener(input, () { const keyword searchInput.value.trim().toLowerCase(); const filtered MENU_ITEMS.filter(item { return item.name.toLowerCase().includes(keyword) || item.category.toLowerCase().includes(keyword); }); renderMenu(filtered); });trim()去掉首尾空格防止用户输空格后什么都搜不到toLowerCase()把关键词和匹配字段都转小写做到大小写不敏感。includes是ES6方法语义直观判断字符串是否包含时首选它老版本浏览器兼容性要求高时改成indexOf(keyword) ! -1效果一样。这个函数返回布尔值正好符合filter回调的预期。菜单项少时每次输入都全量过滤性能完全够。等菜单量上了千条再考虑节流或防抖也来得及。这里的代价点在于每次input事件触发都会重建整个卡片列表如果卡片里有图片会出现闪烁因为img元素被重新创建了。实际问题留给第5章排错时一起看。4. 购物车逻辑与本地存储刷新不丢才敢称DEMO菜单能点、能搜只是静态展示点餐系统真正的业务逻辑在购物车。这一层决定了DEMO能不能流畅地给别人演示也是“前端业务代码”和“纯静态页面”的分水岭。4.1 购物车数据结构用id做key不用数组购物车的第一原则同一菜品不能出现多行。常见做法是用对象来存菜品id作为key数量作为value。对比数组结构对象找菜品时间复杂度是O(1)而且天然保证id唯一// cart.js —— 购物车状态管理与本地存储 const CART_KEY order_cart_v1; let cart {}; // 结构{ b01: 2, s01: 1 } function addToCart(itemId) { cart[itemId] (cart[itemId] || 0) 1; saveCart(); renderCart(); } function changeQty(itemId, delta) { const qty (cart[itemId] || 0) delta; if (qty 0) { delete cart[itemId]; // 数量归零直接删key } else { cart[itemId] qty; } saveCart(); renderCart(); } function saveCart() { localStorage.setItem(CART_KEY, JSON.stringify(cart)); } function loadCart() { try { const raw localStorage.getItem(CART_KEY); cart raw ? JSON.parse(raw) : {}; } catch (e) { cart {}; // 隐私模式下localStorage可能抛异常 } }(cart[itemId] || 0) 1是加购的惯用写法第一次点击时cart里没有这个key取默认值0再加1后续点击在已有数量上累加。changeQty里用delta做增量参数可以传1也可以传-1这样加和减复用一个函数不用写两个几乎一样的操作方法。数量归零时用delete真正移除key而不是把值设成0或undefined——设成0会让这个菜品在结算时被遍历到undefined则会在JSON序列化时消失行为不可预期。提示每次修改购物车后先saveCart()再renderCart()。顺序不要反因为renderCart里可能要用到localStorage里刚存的数据做展示。4.2 购物车渲染重新渲染比局部改DOM更省心renderCart是购物车面板的渲染函数做法和菜单卡片一致把cart对象映射成HTML一次性替换购物车列表内容。这里有个原则每次增删数量之后整体刷新而不是找到那一行的DOM去改数字。列表只有几行时重新渲染的代价可忽略但代码复杂度大幅下降// 渲染购物车列表与结算金额 function renderCart() { const cartList document.getElementById(cartList); const totalEl document.getElementById(totalPrice); const itemIds Object.keys(cart); if (!itemIds.length) { cartList.innerHTML p classempty-cart购物车是空的/p; totalEl.textContent ¥ 0.00; return; } cartList.innerHTML itemIds.map(id { const item MENU_ITEMS.find(menuItem menuItem.id id); if (!item) return ; // 菜单里已不存在的菜品直接跳过 return div classcart-item span${item.name}/span button typebutton>// 汇总购物车总金额 function calcTotal() { return Object.keys(cart).reduce((sum, id) { const item MENU_ITEMS.find(menuItem menuItem.id id); if (!item) return sum; return sum item.sellPrice * cart[id]; }, 0); }reduce的初始值0一定要给。不给的话第一次回调会把sum当成数组第一项的值拿它和一个数字做加法结果就错了。计算时用的是sellPrice而不是price这跟菜单卡片的展示逻辑保持一致如果这里误用原价会出现“页面显示促销价、购物车按原价算钱”的尴尬。第4.1和4.2节里涉及的操作接口整理成表方便后面接代码时对着看函数入参副作用使用时机addToCart菜品id字符串数量1写入localStorage刷新购物车菜单卡片点击加入购物车changeQty菜品id增量数字数量增减归零自动删除key购物车加减按钮calcTotal无无纯计算renderCart内部调用loadCart无读取本地缓存到cart对象页面启动时第一次渲染前saveCart无把cart写入localStorage每次数量变更后4.3 本地缓存的三件事存、取、失败兜底localStorage看起来简单实际使用中有三件事必须处理。第一件是key命名带版本号。CART_KEY写成order_cart_v1而不是cart这样以后购物车数据结构升级比如要加备注字段直接在代码里换成v2旧数据自然作废不会被JSON.parse出结构不兼容的脏数据。第二件事是读取时做异常兜底。loadCart里的try/catch不是摆设Safari隐私模式下localStorage.setItem会直接抛QuotaExceededError某些WebView环境下getItem也会报SecurityError。DEMO在客户手机上演示时最容易踩这个坑不包try/catch页面会白屏包了之后至少还能用内存变量支撑当前会话。第三件事是清空时机。下单成功之后要清除购物车缓存function clearCart() { cart {}; localStorage.removeItem(CART_KEY); renderCart(); }如果用localStorage.setItem(CART_KEY, {})而不是removeItem会留下一条永远存在的空记录下一次loadCart时解析得到空对象虽然没毛病但属于没必要的残留。removeItem更干净。5. H5适配与排错DEMO交付前的最后两小时DEMO做到能点能吃已经算完成但要拿到客户面前演示还差最后一步适配工作。移动端手感和桌面一致的DEMO会比只能电脑上跑的原型高一个可信度。下面这三个点是最常见的坑。5.1 input居中和移动端缩放两个高发的H5瑕疵搜索框在header里居中新手写css时习惯给input加text-align: center结果只有文字在input内居中input本身没居中。正确做法是用flex布局让input在容器中居中#topBar { display: flex; align-items: center; justify-content: center; gap: 12px; } #topBar input { width: 100%; max-width: 320px; font-size: 16px; line-height: 44px; box-sizing: border-box; }input的line-height设44px是为了满足移动端最小点击区域font-size设16px还有另一层原因iOS Safari会在聚焦时自动放大字号小于16px的input用户点一下搜索框整个页面就放大了体验很差。16px及以上才不会触发这个行为。css中怎么把input居中的答案其实是“看想让谁居中”内容居中用text-align元素居中用flex或margin: 0 auto。5.2 html一键返回顶部算法与底部浮动操作栏菜单列表长的时候返回顶部是刚需。有些老代码用document.documentElement.scrollTop逐帧递减现在有更干净的原生方案。html一键返回顶部算法现在只需要三步监听滚动、条件显示、平滑滚动button idbackTopBtn typebutton hidden回到顶部/button// main.js —— 滚动超过400px显示返回顶部按钮 const backTopBtn document.getElementById(backTopBtn); window.addEventListener(scroll, () { const scrollY window.scrollY || document.documentElement.scrollTop; backTopBtn.hidden scrollY 400; }, { passive: true }); backTopBtn.addEventListener(click, () { window.scrollTo({ top: 0, behavior: smooth }); });scrollY和documentElement.scrollTop是兼容写法取两者较大值覆盖不同浏览器。hidden属性控制显隐注意这个属性有用前提是按钮没有在css里写display: block——如果写了display优先级会盖过hidden按钮永远显示。passive: true告诉浏览器这个监听器不调用preventDefault滚动性能更好。smooth让滚动平滑老浏览器不支持时直接跳到顶部也算优雅降级。底部浮动操作栏用fixed固定在视口底部上面是购物车按钮和总价点击结算时把订单写到localStorage然后弹出提交成功提示。按钮底部预留安全距离防iPhone底部横条遮挡#cartBar { position: fixed; bottom: 0; left: 0; right: 0; padding-bottom: env(safe-area-inset-bottom); }env(safe-area-inset-bottom)是iOS 11.2之后的H5标准写法没写的话底部按钮会被Home Indicator压住。加了这行上真机才不至于看起来像没对齐。5.3 演示前速查5个最多见的故障点现象大概率原因处理方式页面空白控制台报错js文件路径写错或加载顺序颠倒F12看Network里js文件状态改成相对路径点“加入购物车”没反应动态渲染后事件绑定在旧DOM上改用事件委托监听menuContainer手机上输入框聚焦页面放大input字号小于16px字号调到16px及以上刷新后购物车丢失隐私模式或localStorage被禁try/catch降级成内存变量演示时用正常模式图片不显示路径含中文或空格file://协议限制用本地静态服务器运行图片文件去掉中文空格最后一句经验给到具体操作要让这份DEMO以后还能接上真实后端renderMenu就别写死数据源。今天传MENU_ITEMS明天传fetch(/api/menu).then(res res.json())的返回值渲染部分一行不用改。数据分离做到位再接真实接口就只是一个函数替换的事。本文还有配套的精品资源点击获取