纯静态HTML5购物网站带登录:localStorage全流程实战

发布时间:2026/10/27 8:01:08
纯静态HTML5购物网站带登录:localStorage全流程实战 简介这份HTML5购物网站案例面向前端初学者、课程设计或毕业项目参考者围绕带登录入口的静态购物网站展开帮助读者理解从页面搭建到购物车交互的完整实现思路。压缩包共48个文件以4个HTML页面、5个CSS样式表、8个JavaScript脚本及30余张PNG/JPG图片为主并含1个JSON配置整体约5.28MB覆盖页面结构、样式美化、轮播与三级菜单、登录验证和购物车逻辑等模块。已有1043人学习下载。读者可从中参考HTML5表单与客户端存储、CSS3过渡动画和响应式布局、jQuery事件处理与动态DOM操作以及localStorage保存购物车商品、实时计算总价等做法适合用来拆解静态电商站点的目录组织与交互实现思路。1. 只学过前端三件套能不能交出一个 html5 购物网站带登录课程设计选题一下来十个里有六个是电商站但真正能跑起来的没几个大部分人卡在同一个地方只学了 HTML、CSS、JS 和一点 jQuery没有后端接口于是登录怎么存、购物车怎么记、刷新之后数据还在不在全靠现场编。这套 html5 购物网站带登录 的方案就是奔着这个卡点去的——纯静态文件双击index.html就能打开注册登录、商品列表、分类筛选、加入购物车、确认订单、我的订单全流程闭环数据用 localStorage 落地不依赖服务器和数据库。它适合两类人一类是要交毕设或课设、需要一套结构完整、讲得清楚设计取舍的方案另一类是页面已经写得挺熟、但没做过带状态的小项目想照着走一遍完整流程的开发者。下面按需求、架构、实现、避坑、验收的顺序推进每一章都能对应到具体的文件和代码。2. 需求拆解与技术选型四条主线、七个页面和一份目录约定动手写第一行 HTML 之前先把要做成什么样钉死。静态购物站最容易犯的错不是技术难而是范围失控——一会儿想加秒杀一会儿想加优惠券最后连登录都没跑通。我的习惯是先切出四条主线每条主线对应几个页面、几张数据表剩下的功能一律进二期。2.1 功能边界登录、商品、购物车、结算四条主线第一条是账号主线包含注册、登录、退出、登录态保持、未登录拦截。第二条是商品主线包含列表展示、分类筛选、关键词搜索、详情查看。第三条是购物车主线包含加入、改数量、删除、勾选、合计。第四条是交易主线包含确认订单、填写收货信息、生成订单号、订单列表。这四条主线之间存在明确的依赖关系购物车要读登录态决定存哪个 key结算要读购物车决定算多少钱订单要读登录态决定归属谁。把依赖关系画清楚后面写代码时就不会出现购物车页要引三个 js 还互相打架的情况。页面文件核心功能依赖数据首页index.html轮播、推荐位、分类入口goods商品列表list.html分类筛选、搜索、分页、加购goods、cart商品详情detail.html图文详情、数量选择、加购goods、cart购物车cart.html改数量、删除、勾选、合计cart、goods登录注册login.html双 Tab 切换、表单校验、回跳users、current_user确认订单order.html收货信息、金额核对、提交cart、orders个人中心mine.html用户信息、我的订单列表current_user、orders七个页面里有五个需要登录态所以守卫脚本要放在公共位置不能每个页面复制一遍。这一点在后面第 4 章会展开。2.2 技术选型原生 JS 和 jQuery 不是二选一是分工很多人纠结都 2024 年了还用不用 jQuery。在这个项目里答案不是用不用而是各干各的活。原生 JS 负责三件事localStorage 的读写与序列化、JSON.parse/stringify的容错、ES6 模板字符串拼 HTML。这三件事 jQuery 封装的收益很低而且localStorage本身就是同步 API套一层$.ajax式的思维反而容易写错。jQuery 负责另外三件事DOM 批量操作$wrap.html(html)、事件委托.on(click, .btn-add, fn)、以及少量动画加购飞入、提示条淡出。尤其是事件委托商品卡片是动态渲染出来的如果用document.querySelectorAll(.btn-add).forEach(btn btn.onclick ...)每次重新渲染都得重新绑一遍漏一次就是点了没反应。用$(document).on(click, .btn-add, fn)绑在父级上渲染多少次都不用管。提示不引入 Vue/React 不是技术保守是答辩现场的现实约束。静态文件加 CDN 引入 jQuery双击就能跑一旦上构建工具现场换个电脑、断个网node_modules没装全演示直接卡住。选型的第一原则是交付场景能跑。jQuery 用 3.7 的压缩版通过script srcjs/jquery-3.7.1.min.js/script本地引入不走外链 CDN理由同上——断网也能演示。2.3 页面清单与目录约定目录结构提前定好后面加文件就不会乱。我一般按页面平铺 资源分目录的方式组织好处是每个 HTML 的引用路径都是同级的css/、js/不会出现../../这种一看就头疼的相对路径。shopping-site/ ├── index.html # 首页轮播 推荐位 分类入口 ├── list.html # 商品列表分类、搜索、分页 ├── detail.html # 商品详情图文 数量选择 加购 ├── cart.html # 购物车改数量、删除、勾选、合计 ├── login.html # 登录 / 注册双 Tab ├── order.html # 确认订单 提交 ├── mine.html # 个人中心 我的订单 ├── css/ │ ├── base.css # CSS 变量、重置、通用按钮与提示条 │ └── page.css # 各页面样式 媒体查询 ├── js/ │ ├── db.js # localStorage 统一封装 │ ├── data.js # 商品初始数据常量数组非 json 文件 │ ├── guard.js # 登录态守卫 │ ├── cart.js # 购物车增删改与金额计算 │ ├── goods.js # 商品渲染、筛选、搜索 │ └── order.js # 下单与订单列表 └── img/ # 商品图与占位图这里有个关键决定商品数据放在js/data.js里作为一个常量数组而不是data/goods.json。原因在避坑那一章会细说简单讲就是本地file://协议下读 json 会被拦而script引入的 js 文件不受影响。CSS 变量统一放在base.css的:root里主色、圆角、间距各定一套后面调风格只改一处/* base.css —— 全局变量与重置 */ :root { --brand: #e93b3d; --text: #333; --text-light: #999; --bg: #f5f5f5; --radius: 8px; --gap: 12px; } * { margin: 0; padding: 0; box-sizing: border-box; } body { background: var(--bg); color: var(--text); font-size: 14px; } .btn { border: none; border-radius: var(--radius); cursor: pointer; }box-sizing: border-box这一行必须放在最前面不然后面所有带 padding 的卡片宽度都会超出容器移动端一测就露馅。3. 总体架构与数据模型三层结构、四张表和一条渲染链路静态站没有后端但它仍然需要分层。分层不是为了好看是为了让数据从哪来、在哪改、怎么显示三件事不搅在一起。我见过太多课设把 localStorage 的读写散落在七八个文件里最后购物车金额对不上查两小时发现是某处JSON.parse没加 try-catch。3.1 分层视图层、状态层、数据层的边界整个站点按三层切视图层就是七个 HTML 页面加上page.css只负责结构和样式不写业务逻辑。页面里最多出现一个script用来调初始化函数。状态层是guard.js、cart.js、goods.js、order.js这四个文件负责当前登录的是谁、购物车里有什么、订单列表长什么样。所有业务判断都在这层页面拿到的永远是已经算好的结果。数据层是db.js唯一一个直接碰localStorage的文件。其他任何文件都不允许出现localStorage.getItem只能调DB.get()/DB.set()。这条规矩听起来死板但它解决的是最大的一个坑key 拼写不一致。今天写cart明天写shop_cart后天写cartList数据就丢了而且丢得无声无息。统一入口之后key 前缀只在一处定义。// db.js —— 唯一的 localStorage 出入口 const DB { prefix: shop_, // 读取并反序列化解析失败时返回默认值而不是抛异常 get(key, def null) { try { const raw localStorage.getItem(this.prefix key); return raw null ? def : JSON.parse(raw); } catch (e) { console.error([DB] 解析失败:, key, e); return def; } }, // 写入前统一序列化调用方不用关心 JSON 细节 set(key, val) { localStorage.setItem(this.prefix key, JSON.stringify(val)); }, remove(key) { localStorage.removeItem(this.prefix key); } };get里的try-catch不是防御性编程的摆设。用户手动在 DevTools 里改过值、或者上一版数据结构变更导致旧数据格式不匹配JSON.parse都会直接抛错整个页面白屏。有了兜底最差情况是数据回到初始状态而不是页面打不开。3.2 数据模型用户、商品、购物车、订单四张表没有数据库但脑子里得有表。四张表加一个会话对象字段设计如下表名key关键字段说明用户表shop_usersid, username, password, phone, createTimepassword 存哈希值不存明文商品表shop_goodsid, name, price, originPrice, cover, category, stock, sales, descprice 单位为「分」购物车表shop_cart_goodsId, count, checked按用户隔离未登录用 guest订单表shop_ordersorderNo, userId, items, total, address, status, createTimetotal 单位为「分」会话对象shop_current_userid, username只存最小必要信息三个设计点值得单独说。第一价格用整数「分」存储。129.00 元存成12900展示时再除以 100。这不是洁癖是必须——0.1 0.2 0.30000000000000004在购物车合计里会实打实地显示出来验收的时候老师一眼就能看见。第二购物车 key 带用户 ID。同一个浏览器里A 登录加了三个商品退出B 登录购物车必须是空的。如果用固定 key两个人的购物车会混在一起这是最容易被追问的点。第三订单里存商品快照而不只是 goodsId。商品价格改了、下架了历史订单的金额不能跟着变。items里存{ goodsId, name, price, count }这样订单详情页不用回查商品表也能完整展示。商品表的初始数据写成常量数组// data.js —— 商品初始数据首次运行时写入 localStorage const GOODS_SEED [ { id: 1001, name: 无线蓝牙耳机, price: 12900, originPrice: 19900, cover: img/p1.jpg, category: 数码, stock: 50, sales: 328, desc: 半入耳设计单次续航 6 小时充电仓总计 24 小时。 }, { id: 1002, name: 纯棉基础款T恤, price: 5900, originPrice: 9900, cover: img/p2.jpg, category: 服饰, stock: 120, sales: 1024, desc: 260g 重磅纯棉圆领不易变形四色可选。 }, { id: 1003, name: 便携保温杯 500ml, price: 8900, originPrice: 12900, cover: img/p3.jpg, category: 家居, stock: 80, sales: 217, desc: 316 不锈钢内胆保温 12 小时一键弹盖。 } ];真实项目里这个数组会有几十条建议至少准备 12 条、覆盖 4 个分类这样分页和筛选才有东西可演示。3.3 一次渲染的完整链路从 localStorage 到商品卡片从数据到页面走的是取数 → 过滤 → 拼串 → 注入 → 委托五步。以列表页为例// goods.js —— 商品渲染主流程 // 1) 取数首次运行把种子数据落库之后都从 localStorage 读 function initGoods() { const saved DB.get(goods); if (saved saved.length) return saved; DB.set(goods, GOODS_SEED); return GOODS_SEED; } // 2) 转义商品名可能含 等字符直接拼进模板会破坏结构 function escapeHtml(str) { return String(str).replace(/[]/g, m ({ : amp;, : lt;, : gt;, : quot;, : #39; })[m]); } // 3) 渲染拼串 一次注入比循环 append 快得多 function renderGoods(list, $wrap) { if (!list.length) { $wrap.html(li classempty没有找到相关商品/li); return; } const html list.map(g li classgoods-card>!-- login.html —— 双 Tab 结构 -- div classtabs button classtab active>// 用户名3-16 位中英文、数字、下划线 const USERNAME_RE /^[a-zA-Z0-9_\u4e00-\u9fa5]{3,16}$/; // 注册 $(#register-form).on(submit, function (e) { e.preventDefault(); const username $.trim($(#reg-username).val()); const password $(#reg-password).val(); const phone $.trim($(#reg-phone).val()); if (!USERNAME_RE.test(username)) return toast(用户名需 3-16 位中英文、数字、下划线); if (password.length 6) return toast(密码至少 6 位); if (phone !/^1\d{10}$/.test(phone)) return toast(手机号格式不正确); const users DB.get(users, []); // 重名校验要忽略大小写否则 Test 和 test 是两个人 if (users.some(u u.username.toLowerCase() username.toLowerCase())) { return toast(该用户名已被注册); } users.push({ id: Date.now(), username, password: hash(password), phone, createTime: new Date().toLocaleString() }); DB.set(users, users); toast(注册成功请登录); $(.tab[data-tablogin]).trigger(click); });toast是一个自己写的轻提示函数三行就够创建一个固定定位的 div塞文字setTimeout两秒后移除。不用引任何 UI 库。重名校验的toLowerCase()是血泪经验。如果不加用户注册了Admin再有人注册admin也能成功登录的时候两个账号没法区分测试时一测就出问题。4.2 会话保持与页面守卫current_user 加回跳地址登录成功后往shop_current_user里写{ id, username }。为什么不全量存用户对象因为密码哈希不该在会话里多待一份而且用户改了昵称之后会话里的旧数据会跟用户表不一致。真正体现设计水平的是守卫。最朴素的做法是进订单页时判断没登录就跳首页问题是用户登录完回不到刚才想去的页面。正确做法是把当前地址编进redirect参数// guard.js —— 页面守卫页面顶部同步调用 function requireLogin() { const user DB.get(current_user); if (user) return user; // 记住当前页含查询串登录后原路返回 const current location.pathname.split(/).pop() location.search; const redirect encodeURIComponent(current); location.href login.html?redirect redirect; return null; } // 登录成功后读取回跳地址 function backAfterLogin() { const redirect new URLSearchParams(location.search).get(redirect); location.href redirect ? decodeURIComponent(redirect) : index.html; }pathname.split(/).pop()是为了兼容file://打开时pathname带完整磁盘路径的情况只取文件名部分。encodeURIComponent必须加因为list.html?cat数码这种带中文的地址不编码会丢参数。页面里的用法是先守卫、后初始化// order.html 的入口脚本 $(function () { const user requireLogin(); // 未登录会直接跳走后面不执行 if (!user) return; initOrderPage(user); });注意requireLogin之后必须return。location.href赋值是异步跳转后面的代码在当前事件循环里还会继续执行不加return会出现跳转瞬间闪一下订单内容的现象。4.3 购物车增删改与金额计算以「分」为单位的整数方案购物车的核心不是增删改是同一件商品加两次不能出现两条记录和合计金额必须精确。// cart.js —— 购物车核心 const Cart { // 未登录用 guest 车登录后用 cart_uid天然隔离 key() { const u DB.get(current_user); return u ? cart_ u.id : cart_guest; }, list() { return DB.get(this.key(), []); }, // 加购已存在则累加数量不存在则新增 add(goodsId, count 1) { const list this.list(); const item list.find(i i.goodsId goodsId); if (item) { item.count count; } else { list.push({ goodsId, count, checked: true }); } DB.set(this.key(), list); this.badge(); }, // 改数量数量小于 1 时按删除处理 update(goodsId, count) { let list this.list(); const item list.find(i i.goodsId goodsId); if (!item) return; if (count 1) { list list.filter(i i.goodsId ! goodsId); } else { item.count count; } DB.set(this.key(), list); }, // 合计全程整数分运算最后一步才转元 total(list) { const goods DB.get(goods, []); const cents list .filter(i i.checked) .reduce((sum, i) { const g goods.find(x x.id i.goodsId); return g ? sum g.price * i.count : sum; }, 0); return (cents / 100).toFixed(2); }, // 角标只要已加购的总件数不受勾选影响 badge() { const n this.list().reduce((s, i) s i.count, 0); $(.cart-badge).text(n).toggle(n 0); } };total里先price * count累加成整数分再统一/100转元全程没有小数参与运算浮点误差从源头被掐断。如果反过来先转成元再乘再累加59.00 * 3就可能变成177.00000000000003。购物车页的渲染要点是删除用过滤、勾选用标记// 删除某一项filter 生成新数组避免 splice 在循环里改索引 $(#cart-list).on(click, .btn-del, function () { const id Number($(this).data(id)); const list Cart.list().filter(i i.goodsId ! id); DB.set(Cart.key(), list); renderCart(); });filter而不是splice是因为splice会改变原数组长度如果外面还套着一层forEach索引会错位表现就是删一个少两个。这个 bug 排查起来极费时间直接用不可变写法就没这问题。结算的时候把购物车按checked过滤出来写进订单// order.js —— 提交订单 function submitOrder(user, address) { const goods DB.get(goods, []); const cart Cart.list().filter(i i.checked); if (!cart.length) return toast(请先勾选要结算的商品); // 快照把商品名和当时的价格一起存进订单防止后续改价影响历史订单 const items cart.map(i { const g goods.find(x x.id i.goodsId); return { goodsId: g.id, name: g.name, price: g.price, count: i.count }; }); const total items.reduce((s, i) s i.price * i.count, 0); const orders DB.get(orders, []); orders.unshift({ orderNo: createOrderNo(), userId: user.id, items, total, address, status: 待付款, createTime: new Date().toLocaleString() }); DB.set(orders, orders); // 只清掉已结算的未勾选的留在购物车 DB.set(Cart.key(), Cart.list().filter(i !i.checked)); location.href mine.html; }订单号用时间戳加三位随机数保证同一秒内多次下单也不会重复function createOrderNo() { const d new Date(); const p n String(n).padStart(2, 0); const stamp ${d.getFullYear()}${p(d.getMonth() 1)}${p(d.getDate())} ${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}; return stamp Math.floor(Math.random() * 900 100); }5. 避坑与常见问题排查静态站最容易翻车的五个点下面这五条全是静态购物站的高频事故。每一条我都见过至少两次症状相似但原因不同按现象 → 原因 → 解决记下来出问题时能省掉大半排查时间。5.1 刷新之后购物车清零现象加购三件商品角标显示 3按 F5 刷新角标消失购物车页显示空。原因数据只存在内存里。很多人把购物车写成let cart []这个模块级变量push完就直接渲染从没调过DB.set或者调了但 key 写成了cart读的时候用的是cart_ uid读写不一致。解决坚持写操作必落库这一条纪律任何修改购物车的函数末尾都要有一行DB.set(this.key(), list)。排查时打开 DevTools 的 Application → Local Storage看shop_cart_xxx这个 key 到底存不存在、内容对不对。如果 key 存在但内容是旧值说明写入被覆盖了如果 key 根本不存在说明写入路径没走到。5.2 合计金额出现 0.30000000000000004现象两件 0.1 元的商品合计显示成0.30000000000000004。原因JavaScript 的 Number 是 IEEE 754 双精度浮点十进制小数在二进制里无法精确表示加减乘除都会累积误差。解决所有价格字段以「分」为整数存储运算全程用整数只在最后展示时(cents / 100).toFixed(2)。如果数据源已经是小数比如从别处导入先在入口处Math.round(price * 100)转成分再进入计算链路。这条改起来工作量不大但必须一开始就定中途改会牵动所有读写价格的地方。5.3 动态渲染出来的按钮点不动现象商品列表第一次加载能加购切换分类重新渲染后按钮完全没反应控制台也不报错。原因事件绑在了被替换掉的 DOM 节点上。$(.btn-add).on(click, fn)这种写法只对执行那一刻存在的元素生效$wrap.html(html)一注入旧节点被销毁新节点上没有任何监听器。解决一律用事件委托绑在不会被替换的祖先元素上——$(#goods-list).on(click, .btn-add, fn)。第二个参数是选择器jQuery 会在事件冒泡到#goods-list时判断event.target是否匹配匹配才触发。判断标准很简单如果这行绑定代码执行时目标元素还没出现在 DOM 里就必须用委托。5.4 退出登录后按返回键还能看到订单现象点退出跳回首页按浏览器后退订单页的内容又显示出来了。原因只清了界面没清状态或者清了但页面没做进入时的校验。location.href跳转不等于销毁页面浏览器前进后退会走缓存$(function(){})里的初始化代码不一定重跑。解决两道保险。第一退出登录时不仅要DB.remove(current_user)还要把依赖登录态的界面元素一起清掉第二所有需要登录的页面在入口处同步调requireLogin()未登录直接跳走。跳转用location.replace()而不是location.href这样当前页不会留在历史记录里。另外要在pageshow事件里再校验一次应对浏览器从往返缓存恢复页面的情况// 从 bfcache 恢复时再校验一次登录态 window.addEventListener(pageshow, function (e) { if (e.persisted !DB.get(current_user)) { location.replace(login.html); } });5.5 双击打开页面商品列表一片空白现象用编辑器内置服务器跑得好好的直接双击index.html打开商品区空白控制台报跨域错误。原因file://协议下fetch、$.getJSON、XMLHttpRequest读本地 json 文件都会被同源策略拦掉。这是浏览器的安全限制不是代码写错了。解决商品初始数据改成js/data.js里的常量数组用script srcjs/data.js/script引入。script 标签加载本地文件不受这个限制双击能跑拷到 U 盘里换台电脑也能跑。这一条对答辩场景特别重要——你永远不知道答辩机房的网络和环境是什么状态。注意换电脑演示前先手动清一次 localStorage。旧电脑上的测试数据尤其是脏的用户表和订单表跟着浏览器走不到新机器但如果是在同一台机器上反复演示残留数据会让注册重名订单列表一堆测试单这类问题冒出来。6. 验收与答辩五步自检清单和三个必答问题项目写完不等于能交。我一般会按下面这张清单走一遍全程用刚拿到电脑的人的视角操作不跳步。步骤操作通过标准1清空 localStorage双击 index.html首页正常商品有数据无控制台报错2未登录直接访问 order.html跳到登录页地址带 redirect 参数3注册新账号并登录登录成功自动回到 order.html4加购三件、改数量、删一件、取消勾选一件角标与合计同步变化刷新后不变5提交订单退出登录换账号登录新账号购物车为空看不到上一个账号的订单第 5 步最关键它同时验证了购物车隔离和订单归属。很多人卡在这里才发现购物车 key 没带用户 ID。答辩时被问得最多的三个问题提前准备好答案没有后端登录有什么意义答这套设计把数据访问收敛在db.js一层所有业务代码只调DB.get/set不直接碰存储。后续接真实后端时只需要把db.js换成 ajax 封装业务层代码基本不动。分层的目的就是让换存储这件事只影响一个文件。密码存 localStorage 安全吗答不安全也不假装安全。当前存的是轻量哈希只为了避免明文直接躺在 DevTools 里被一眼看到真实项目必须由服务端做加盐慢哈希。这一点在代码注释里写清楚了答辩时主动说出来比被问出来好。数据量大了怎么办答localStorage 单域名大约 5MB商品数据在千条级别、图片走文件路径不占空间的情况下完全够用。真到瓶颈第一件事是把商品列表改成分页拉取第二件事是换 IndexedDB。当前分页已经做了每页 8 条所以演示时不会卡。最后一件事演示前把浏览器缩放调到 100%窗口拉成手机宽度再走一遍——响应式网格如果在窄屏塌了前面所有功能做得再好视觉分会掉一大截。我最早做这类项目的时候功能全写完才想起测刷新结果发现购物车压根没落库返工改了小半天。从那以后我每次写完一个写操作都强制走一遍改数据 → F5 → 看还在不在的流程三秒钟的事能挡掉后面几小时的排查。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询