
简介基于HTML5、JavaScript与CSS的模拟联想购物商城完整项目适合初中级前端开发者对照学习动态网页构建与数据交互。资源含185个文件压缩包约7.97MB以78张JPG、54张PNG图片素材为主另有19个JS、15个HTML、14个CSS以及2个PSD、1个ICO、1个GIF和1个DOC文档。系统完整实现动态浏览、关键词检索、用户注册登录与商品增删改利用AJAX异步刷新数据CSS3媒体查询保证响应式布局HTML5语义化标签优化结构。DOC文档详述运行配置步骤便于快速部署可深入理解商城前端功能模块与交互逻辑并锻炼表单验证与数据检索的编程思维。现已有3787人学习/下载对入门商城开发与前端实践有参考价值。1. 拿 HTML5JavaScriptCSS 手搓商城为什么不推荐一上来就上框架很多人一听到「商城系统」第一反应是 Vue、React 配一套 Node 后端再挂个数据库。但如果你手里这份资源标题写的是 html5javascriptcss 商城模拟联想购物系统说明它定位的是纯前端、可静态托管、以模拟数据驱动的那一类项目——这种资源对入门者和前端基础不牢的从业者反而更有价值。它解决的核心问题不是「高并发下单」而是让你在一套纯 HTML/CSS/JS 的体系里完整跑通注册登录、动态浏览、信息检索、增删改查这一整条链路。适合谁适合正在做课设、想补齐原生 JS 基本功、或者需要在没有后端的情况下快速搭一个可演示购物系统的人。下面我按拆解资源的思路把这套系统的实现路径、参数设置和隐性坑逐个说清楚。2. 拆解「模拟联想」购物系统的三层架构数据层、渲染层、交互层2.1 为什么要用“模拟数据 本地存储”而不是真实后端这套系统的核心是「模拟」。所谓模拟联想通俗讲就是所有商品数据、用户数据、购物车数据都不走服务器而是落在本地存储或者写死的 JS 数组里。常见做法是维护一个db.js里面放商品列表、用户列表、分类字典然后用localStorage做持久化。我一般会在动手前先明确一个原则数据层决定了整套系统的复杂度上限。如果你准备用真实后端那注册登录要管会话、要管密码加密、要管接口鉴权这套资源的主题就变味了。而纯前端模拟的好处是——你把注意力全部集中在 DOM 操作、事件绑定、数据流管理这些前端基本功上。等以后切到 Vue 或者 React你会发现「数据驱动视图」的思路完全能迁移过去。// db.js - 模拟数据源 const mockDB { users: [ { id: 1, username: demo, password: 123456, email: demotest.com } ], products: [ { id: 101, name: 无线机械键盘, category: 外设, price: 399, stock: 50, img: ./img/kb.jpg }, { id: 102, name: 4K 显示器, category: 显示, price: 1999, stock: 12, img: ./img/monitor.jpg }, { id: 103, name: 人体工学椅, category: 家具, price: 1499, stock: 8, img: ./img/chair.jpg } ], cart: [] };这段代码的逻辑很直白users存账号products存商品cart存当前用户的购物车。注意cart放在mockDB里只是临时占位真正持久化要用localStorage.setItem(cart_, JSON.stringify(cartArr))这种方式按用户隔离。参数上price建议用整数单位元避免浮点误差stock库存字段在模拟系统里常被忽略但加上之后商品详情页的「已售罄」状态才能正常展示。2.2 商品渲染的两种方案字符串拼接 vs 模板克隆动态浏览是这套系统的重头戏。商品列表从数组渲染到页面上新手最容易写成innerHTML字符串拼接数据一变就整块重绘。这种方式不是不行但会有两个隐患一是商品卡片里如果嵌了用户输入内容容易出现 XSS二是整块重绘会让图片重新加载页面闪烁明显。更稳的方案是「模板克隆」提前在 HTML 里写好一个template标签JS 里通过document.importNode复制节点再填充数据。这样做的好处是 DOM 结构清晰事件监听可以绑定到容器上做事件委托性能也更好。// renderProducts.js - 模板克隆渲染商品列表 const template document.getElementById(product-template); const container document.querySelector(#product-list); function renderProducts(list) { container.innerHTML ; // 清空容器 list.forEach(item { const node template.content.cloneNode(true); node.querySelector(.product-name).textContent item.name; node.querySelector(.product-price).textContent ¥ item.price; node.querySelector(.product-stock).textContent 库存 item.stock; node.querySelector(.add-cart-btn).dataset.id item.id; container.appendChild(node); }); }这段代码里cloneNode(true)的true表示深克隆子节点全部复制。textContent赋值比innerHTML安全因为商品名里如果意外带上了script标签它会被当作纯文本展示而不是执行。dataset.id是 HTML5 标准的数据传递方式在事件委托里用event.target.closest(.add-cart-btn).dataset.id就能拿到商品 ID。注意一个参数如果你用的是innerHTML拼模板记得对用户可控字段做转义这个后面避坑章会细说。2.3 注册登录模块密码处理与会话标记注册登录看起来简单但「模拟」不等于「随便写」。真正用心做的资源会在密码处理上至少做一层btoa编码或简单的哈希而不是明文存储。这里我会用 Web Crypto API 的crypto.subtle.digest做 SHA-256 哈希虽然它要求 HTTPS 或 localhost 环境但本地打开 HTML 文件时浏览器通常允许。// auth.js - 注册登录逻辑 async function hashPassword(password) { const encoder new TextEncoder(); const data encoder.encode(password); const hash await crypto.subtle.digest(SHA-256, data); return Array.from(new Uint8Array(hash)).map(b b.toString(16).padStart(2, 0)).join(); } async function register(username, password, email) { const users JSON.parse(localStorage.getItem(users) || []); if (users.find(u u.username username)) { return { success: false, msg: 用户名已存在 }; } users.push({ id: Date.now(), username: username, password: await hashPassword(password), email: email }); localStorage.setItem(users, JSON.stringify(users)); return { success: true }; } function login(username, password) { const users JSON.parse(localStorage.getItem(users) || []); const user users.find(u u.username username); if (!user) return { success: false, msg: 用户不存在 }; // 登录时同步比对哈希值不能直接比明文 hashPassword(password).then(hash { if (user.password hash) { sessionStorage.setItem(currentUser, JSON.stringify({ username: user.username })); return { success: true }; } return { success: false, msg: 密码错误 }; }); }这段代码有个值得注意的点crypto.subtle.digest返回的是 Promise所以register和login都不是同步函数。你在实际调的时候如果写的是const result login(...)然后立刻判断result.success大概率拿到的会是undefined。我一般会在封装层面统一用async/awaitUI 层也同步改成异步流程。Date.now()做用户 ID 在本地场景够用但如果你后续要导入导出用户数据建议改成自增计数器存在localStorage里避免多标签页同时注册产生 ID 冲突。3. 信息检索与商品管理从检索逻辑到增删改查的完整闭环3.1 搜索过滤的关键参数关键词、分类、价格区间信息检索在商城系统里对应的是「搜索 筛选」功能。这里有两个常见设计一是搜索框输入关键词二是在侧边栏点分类或价格区间。实现上本质都是对products数组做过滤。// filter.js - 多条件过滤 function getFilteredProducts({ keyword , category , minPrice 0, maxPrice Infinity, sortBy default }) { let result mockDB.products.filter(item { const matchKeyword item.name.toLowerCase().includes(keyword.toLowerCase()); const matchCategory category ? item.category category : true; const matchPrice item.price minPrice item.price maxPrice; return matchKeyword matchCategory matchPrice; }); if (sortBy price-asc) { result.sort((a, b) a.price - b.price); } else if (sortBy price-desc) { result.sort((a, b) b.price - a.price); } return result; }这里的参数设置有几个细节keyword用toLowerCase()做大小写归一避免用户搜「KEYBOARD」搜不到「keyboard」分类过滤用全等比较所以分类名称必须跟db.js里完全一致一个空格都不能差——这是很常见的翻车点价格区间用Infinity做默认上限比999999这种魔法数字更优雅。排序函数里a.price - b.price升序b.price - a.price降序如果后续要加「销量排序」或「最新上架」直接在这个sortBy分支里扩展就行。3.2 商品增删改表单校验与列表联动更新管理员维护商品这块需要做到「改完数据页面立刻同步」。很多人习惯改动数组后重新调localStorage.setItem但容易忘记重新渲染。我常用的模式是「中央数据源 订阅渲染」——所有增删改操作统一走updateProducts(newList)函数函数内部先写存储再触发渲染。// admin.js - 商品增删改 function saveProduct(product) { const products JSON.parse(localStorage.getItem(products) || []); if (product.id) { // 编辑根据 id 找到索引替换 const idx products.findIndex(p p.id product.id); if (idx ! -1) products[idx] { ...products[idx], ...product }; } else { // 新增生成新 id product.id Date.now(); products.push(product); } localStorage.setItem(products, JSON.stringify(products)); renderProducts(products); return product.id; } function deleteProduct(id) { let products JSON.parse(localStorage.getItem(products) || []); products products.filter(p p.id ! id); localStorage.setItem(products, JSON.stringify(products)); renderProducts(products); }参数说明编辑时用{ ...products[idx], ...product }做对象合并好处是表单里没填的字段能保留原值。新增时用Date.now()做 ID但要注意同一毫秒内连续新增两个商品会 ID 冲突稳妥做法是维护一个nextId计数器。deleteProduct里不能直接let products mockDB.products然后splice因为这样会改动原数组引用。表单提交前要校验必填字段和价格格式我习惯在submit事件里e.preventDefault()阻止默认提交然后手动读取FormData。3.3 购物车与订单流程数量加减、总价计算、库存校验购物车是商城系统的「心脏」。这里核心是管理cart数组每项包含productId、quantity、selected三个字段。数量加减要触发总价重算同时校验库存上限结算时要判断是否勾选商品和库存是否足够。// cart.js - 购物车核心操作 const cart JSON.parse(localStorage.getItem(cart_ currentUser.username)) || []; function addToCart(productId, quantity 1) { const product mockDB.products.find(p p.id productId); const cartItem cart.find(i i.productId productId); if (cartItem) { if (cartItem.quantity quantity product.stock) { alert(库存不足当前库存 product.stock); return false; } cartItem.quantity quantity; } else { cart.push({ productId, quantity, selected: true }); } saveCart(); return true; } function updateQuantity(productId, newQuantity) { const cartItem cart.find(i i.productId productId); const product mockDB.products.find(p p.id productId); if (newQuantity product.stock) { newQuantity product.stock; // 强制上限 } if (newQuantity 0) { removeFromCart(productId); return; } cartItem.quantity newQuantity; saveCart(); }这段代码容易被忽略的是「更新数量时不能直接改输入框的 value」——比如用户手输 999必须做 clamp 到库存上限。selected字段是为了支持「勾选部分结算」在计算总价时记得filter(i i.selected)而不是把全部购物车项加起来。saveCart()内部要做的是localStorage.setItem(cart_ username, JSON.stringify(cart))这个 key 带用户名才能做到多用户购物车隔离。很多资源在这块偷懒用同一个 keyA 用户加购的商品 B 用户登录也能看到属于明显 bug。4. 布局与交互细节响应式样式、事件委托与本地存储的边界4.1 响应式布局媒体查询的断点设置购物系统的页面通常包含顶部导航、商品推荐区、侧边筛选栏、购物车抽屉。纯 CSS 做响应式关键在于断点的选择。我一般设三个断点768px平板竖屏、1024px平板横屏/小笔记本、1200px桌面宽屏。低于768px时侧边栏改为折叠菜单商品卡片网格从 4 列降为 1 列。/* style.css - 响应式核心设置 */ .product-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; padding: 16px; } media (max-width: 1200px) { .product-grid { grid-template-columns: repeat(3, 1fr); } } media (max-width: 1024px) { .product-grid { grid-template-columns: repeat(2, 1fr); } .sidebar { display: none; } /* 侧边栏隐藏改用顶部下拉筛选 */ } media (max-width: 768px) { .product-grid { grid-template-columns: 1fr; } .nav-cart { font-size: 14px; } }这里有个细节断点数值不是拍脑袋定的要结合你页面里最宽的元素来定。比如商品卡片如果最小宽度 240px那么grid-template-columns: repeat(4, 1fr)在 1024px 宽度下单张卡片剩余宽度只有 (1024 - 3*16) / 4 ≈ 244px刚好能放下如果再小就会溢出。1fr是等分剩余空间的意思比固定px更适合这种场景。还有一个容易踩的坑是gap属性在旧版 Safari 上不生效如果你要兼容改成在每个卡片上写margin-right和margin-bottom或者用padding方案。4.2 事件委托机制为什么不能给每个按钮单独绑事件商品列表里的「加入购物车」按钮可能有几十个如果每个按钮都addEventListener不仅内存占用高动态新增商品后还要重新绑事件。事件委托的做法是「把监听器绑在父容器上利用事件冒泡找到真正触发的按钮」。// event-delegation.js document.querySelector(#product-list).addEventListener(click, event { const addBtn event.target.closest(.add-cart-btn); if (!addBtn) return; // 点击的不是加购按钮 const productId Number(addBtn.dataset.id); const product mockDB.products.find(p p.id productId); if (product product.stock 0) { addToCart(productId); } else { alert(该商品暂时无货); } });closest方法会从当前元素向上查找匹配选择器的祖先节点即使你点到了按钮内部的span图标也能正确找到.add-cart-btn。Number(addBtn.dataset.id)这步很关键因为dataset拿到的永远是字符串如果不转数字后面find(p p.id productId)用的是严格全等字符串101不等于数字101会直接匹配失败。很多同学在这翻车排查半天发现是类型不一致。4.3 localStorage 的容量边界与多标签页同步localStorage 的存储上限通常 5MB 左右对图片以外的文本数据完全够用但有两个边界问题值得注意。一是不能存函数它只能序列化 JSON 支持的简单类型二是多标签页数据不同步。如果你打开两个商品管理页在 A 页改了商品价格B 页不会自动刷新。解决多标签页同步标准方案是监听storage事件window.addEventListener(storage, event { if (event.key products) { const updatedProducts JSON.parse(event.newValue); renderProducts(updatedProducts); } });注意这个事件只在「其他标签页」触发当前标签页自己setItem是不会触发监听回调的所以别指望它替代自己的渲染调用。event.newValue在removeItem时是null要做判空处理。这套机制适合商品管理这种低频写入场景如果购物车频繁变化我建议直接把渲染函数抽成公共方法在写入点手动同步调用。5. 避坑与常见问题排查六个高频翻车现场5.1 登录后刷新就掉线sessionStorage 依赖混乱现象登录成功后进入商品页一切正常刷新页面或跳转后登录态消失直接退回登录页。原因资源里用了sessionStorage保存当前用户信息但sessionStorage的生命周期是「标签页会话」你在同一个标签页刷新不会丢跳转到新页面如果开了新标签页或浏览器设置还原会话就会丢。更隐蔽的是项目里有的地方用localStorage读用户信息有的地方用sessionStorage两边不同步。解决统一用一个CURRENT_USER_KEY常量比如const CURRENT_USER_KEY current_user登录时sessionStorage.setItem(CURRENT_USER_KEY, JSON.stringify(user))在index.html的入口脚本最开始加一段「会话检查」读不到就location.href login.html。如果想做得更健壮改成localStorage持久化登录态并提供一个「记住我」勾选框控制。5.2 商品列表空白JSON 解析失败或路径错误现象页面打开后商品区域空白控制台报Unexpected token in JSON或者404 Not Found。原因前者是JSON.parse解析了 HTML 文件内容通常是db.js的script src路径写错服务器返回了index.html的 HTML 当作 JS 来解析后者是相对路径问题比如./img/kb.jpg在你把 HTML 文件放到子目录后失效。解决先用fetch(./data/products.json)的方式加载数据并打印response.text()看内容如果是数据文件检查src路径层级。我习惯把所有资源相对路径写成./开头并且只在项目根目录运行不搞多级子目录嵌套。静态图片建议用 CSP 白名单或统一加onerror回调显示占位图不要指望路径永远正确。5.3 加购数量变成 NaN 或负数parseInt 与输入框取值现象商品详情页输入数量 2点击加购后购物车里数量变成NaN或者手动清空输入框后数量变成 -1。原因输入框的值是字符串parseInt对空字符串返回NaN减号按钮没做最小值限制quantity - 1得到负数。解决加一个统一的parseQuantity函数function parseQuantity(inputValue, maxStock) { let qty parseInt(inputValue, 10); // 第二个参数必须传 if (isNaN(qty)) qty 1; qty Math.max(1, Math.min(qty, maxStock)); return qty; }这里parseInt(inputValue, 10)的第二个参数10是告诉浏览器按十进制解析否则老浏览器遇到08这种可能按八进制解析。Math.max(1, Math.min(qty, maxStock))是「双夹紧」小于 1 强制为 1大于库存强制为库存。我见过有人直接inputValue做隐式转换abc会得到NaN别用那套。5.4 修改商品后列表没变化数据源引用没更新现象管理员后台编辑了一个商品的价格保存后前台列表还是旧价格刷新页面后新价格才出来。原因renderProducts内部读的是mockDB.products但编辑时写的是localStorage没有把mockDB和localStorage同步或者saveProduct里products[idx] {...}创建了新对象但mockDB.products里还是旧对象引用。解决做一个统一的「同步源」函数编辑完成后重新把localStorage的内容灌回mockDBfunction reloadProducts() { mockDB.products JSON.parse(localStorage.getItem(products)) || []; renderProducts(mockDB.products); }所有数据变更点只写localStorage然后调用reloadProducts()。这套「单写单读」模式虽然笨但能杜绝双数据源不一致的毛病。如果觉得麻烦干脆连mockDB.products的同步读都省了渲染时直接从localStorage读数组。5.5 按钮点击无效事件重复绑定与 DOM 覆盖现象第一次点击加购按钮有效第二次页面刷新后按钮点击完全没反应。原因renderProducts每执行一次父容器innerHTML被清空重绘旧的事件委托绑定还在但页面上实际生效的监听器因为挂到了旧 DOM 节点上而「丢失」。解决事件委托绑定的是父容器不要绑在动态生成的子元素上。检查代码里有没有document.querySelectorAll(.add-cart-btn).forEach(btn btn.addEventListener(...))这类写法一旦列表重绘这些按钮全部是新的 DOM 节点旧的绑定自然无效。统一改成前面第 4 章的事件委托模式只在#product-list上绑一次。5.6 商品图片加载失败闪红叉懒加载与占位图方案现象商品区加载时图片区域出现大片空白或浏览器默认的坏图图标影响演示效果。原因模拟数据里的img路径是假的或图片不存在img标签加载失败后原样显示破图。解决给图片加onerror处理并做一个本地 SVG 占位图node.querySelector(.product-img).addEventListener(error, function() { this.src ./assets/placeholder.svg; this.alt 图片暂不可用; });在商品卡片渲染时给img加loadinglazy属性首屏之外的图片延迟加载。如果资源包里有现成图片素材检查图片文件名有没有中文、空格或特殊字符——这类字符在本地 file 协议下能打开部署到服务器后经常 404。我在一个模拟项目里就因为图片名带了个括号上线后整页红叉排查了半小时。6. 让购物系统的演示效果再上一个台阶商品推荐联动与一键重置数据当你的商城已经能正常完成注册、登录、浏览、搜索、加购、结算这一整套流程后剩下的工作是把「演示效果」打磨得更像真实产品。这里分享一个低成本高收益的技巧做「商品推荐联动」——根据用户最近浏览的商品分类在首页展示同类商品的推荐列表。// recommend.js - 基于浏览历史的简单推荐 function getRecommendations(productId) { const currentProduct mockDB.products.find(p p.id productId); if (!currentProduct) return []; // 记录浏览历史存最近 5 个商品 id let history JSON.parse(localStorage.getItem(view_history)) || []; history history.filter(id id ! productId); history.unshift(productId); localStorage.setItem(view_history, JSON.stringify(history.slice(0, 5))); // 推荐逻辑同分类随机取 4 个 const sameCategory mockDB.products.filter(p p.category currentProduct.category p.id ! productId); const shuffled sameCategory.sort(() Math.random() - 0.5); return shuffled.slice(0, 4); }这个函数在每次渲染商品详情页时调用把返回的数组传到已有的renderProducts里标题改成「看了又看」。view_history用数组头部插入的方式维护最近浏览slice(0, 5)控制长度。注意Math.random() - 0.5这种洗牌方式效率不高但推荐列表最多几十条数据性能完全够用不必追求 Fisher-Yates 洗牌。再给一个实用技巧准备一个「一键重置数据」按钮方便演示前把脏数据清干净。很多资源没有这个功能导致你演示到一半发现购物车里有上次测试的残留数据。function resetAllData() { // 保留初始用户清掉新增用户和所有业务数据 localStorage.setItem(users, JSON.stringify([{ id: 1, username: demo, password: e99a18c428cb38d5f260853678922e03, email: demotest.com }])); localStorage.setItem(products, JSON.stringify(mockDB.products)); localStorage.removeItem(cart_demo); localStorage.removeItem(view_history); location.reload(); }这个函数里只保留了 demo 用户的哈希密码其他测试产生的用户、购物车、浏览记录全部清除。注意location.reload()放在最后确保所有界面从干净状态重启。我在演示前都有一个强制习惯先开隐私窗口跑一遍完整流程看是否有缓存残留再点一次「重置数据」按钮确认列表全部恢复初始值。这套流程走完后再开始给同事或导师演示基本不会出现「哎购物车怎么有东西」的尴尬场面。希望帮到你。本文还有配套的精品资源点击获取