ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

H5商城纯前端静态页面拆解:移动端适配与购物车实现

H5商城纯前端静态页面拆解:移动端适配与购物车实现

简介:这套H5商城静态页面以必要APP为原型,面向希望练习移动端商城界面搭建、参考页面组织与交互实现的前端学习者及开发者,提供一套可直接浏览、便于二次修改的纯静态HTML模板。压缩包共133个文件、约2.02MB,其中34个HTML页面串联起个人中心、商家入口、商品分类、商品详情、购物车、订单、登录注册、收货地址和提现等完整购物流程,另有11个JS脚本负责交互逻辑、3个CSS文件组织公共样式,并配备PNG、JPG图片及字体图标资源;页面以手写为主、局部引入jQuery插件,代码清晰简洁。已有1228人学习下载。读者可从中了解商城类H5的目录划分、页面跳转和公共样式复用方式,参考移动端菜单、提示弹窗、表单与列表布局等常见做法,并按需替换图片文案,用于课程作业、原型演示或项目练手,减少从零搭建静态骨架的时间。

1. 先看它能干什么:一个压缩包里的手机端商城

运营临时要在活动页挂一个手机端商城,后台排期赶不上,设计只给了几张图,商品表还是表格。把 H5商城.zip 解压后,入口是一个 index.html,手机浏览器打开就是商品列表、详情、购物车和个人中心,商品数据写在本地 JSON,购物车落在 localStorage,整套东西是纯前端静态页面,不依赖数据库和接口。

我拿到这类包先做的事不是改页面,而是起一个本地静态服务,把首页、详情、加购、返回刷新这条链路走一遍,确认图片路径、数据字段和跳转参数都对得上。它适合前端新手练移动端适配、事件委托和本地状态,也适合外包先做可点击原型;不适合直接当生产收单系统,因为订单、支付、库存校验都不在包里。

如果你想要一个能快速换皮肤、换商品数据、在手机浏览器里直接演示的 H5 商城,这个包的价值在“结构清楚、依赖少、改起来不牵一发动全身”。下面按目录、核心模块、购物车、避坑和二次开发顺序拆。

2. 目录结构与技术选型:先判断这个包能不能直接跑

2.1 解压后的目录与文件职责

这类纯前端静态页面通常不会上构建工具,目录也比较直白。下面是我拿到包后会先对照的结构,实际压缩包可能多几个页面或少几个文件,但职责大体一致:

H5商城/ ├── index.html # 首页:轮播、分类入口、商品列表 ├── detail.html # 商品详情:参数、加购按钮 ├── cart.html # 购物车:数量、勾选、合计 ├── user.html # 个人中心:静态入口、订单占位 ├── css/ │ ├── base.css # reset、通用变量、工具类 │ ├── page.css # 页面级布局 │ └── theme.css # 主题色、圆角、字号 ├── js/ │ ├── rem.js # 移动端根字号适配 │ ├── request.js # fetch 封装、错误处理 │ ├── common.js # 公共头尾、返回、Toast │ ├── goods.js # 首页商品渲染 │ ├── detail.js # 详情页参数与渲染 │ └── cart.js # 购物车读写与计算 ├── data/ │ ├── goods.json # 商品数据 │ └── banners.json # 轮播数据 └── images/ ├── goods/ # 商品图 └── banner/ # 轮播图

文件职责可以用一张表快速核对,改错文件是静态包最常见的翻车点:

文件职责改动频率
index.html首页骨架、挂载点低
detail.html详情页骨架低
cart.html购物车骨架低
css/theme.css主题色、圆角、间距高
data/goods.json商品名称、价格、图片、库存高
js/goods.js列表渲染、跳转拼参中
js/cart.js加购、数量、合计中

页面级多入口的好处是详情页可以直接分享链接,缺点是公共头尾要复制或靠 JS 注入。常见做法是在 common.js 里渲染底部导航,避免每个 HTML 都写一遍。改之前先备份整个目录,尤其是 images 和 data,后面换图换价不容易乱。

启动不要双击 HTML。进入解压后的根目录,用静态服务跑起来:

cd H5商城 python -m http.server 8080 # 电脑浏览器访问 http://localhost:8080 # 真机访问把 localhost 换成电脑局域网 IP,端口保持 8080

端口被占用就换 8081,根目录必须能直接看到 index.html。真机访问要求手机和电脑在同一局域网,防火墙别拦端口。这个步骤看起来简单,但后面 JSON 加载、详情页参数、图片相对路径都依赖它。

提示:先跑通再改代码,先改数据再改样式,最后动 JS。顺序反了,排查成本会翻倍。

2.2 技术栈:HTML5 + CSS3 + 原生 JavaScript 的适配组合

这个包的核心技术栈就是 HTML5、CSS3、原生 JavaScript,没有框架运行时,也没有打包产物。移动端适配常见两种方案:rem 根字号缩放,或者 vw 视口单位。两者选一种,不要混用。混用后页面在部分手机上会忽大忽小,后面避坑章节会细说。

viewport 一般写在 head 里:

<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">

width=device-width让布局视口等于设备宽度,initial-scale=1禁止初始缩放,viewport-fit=cover主要处理刘海屏安全区。user-scalable=no在商城里常见,但会牺牲可访问性,如果项目有无障碍要求,可以去掉最大缩放限制。

rem 适配脚本通常放在 head 里、CSS 之前,避免首屏闪动:

(function () { var docEl = document.documentElement; var designWidth = 750; // 设计稿宽度,常见 750 或 375 function setRem() { var width = Math.min(docEl.clientWidth, 750); // 平板不无限放大 docEl.style.fontSize = (width / designWidth) * 100 + 'px'; } setRem(); window.addEventListener('resize', setRem); window.addEventListener('pageshow', setRem); })();

designWidth是设计稿宽度,100是基准系数,意思是 750 设计稿下 1rem 等于 100px 设计尺寸。CSS 里写width: 3.45rem就对应 345px 设计稿。Math.min限制平板宽度,避免大屏上字大得离谱。pageshow是给返回缓存补一次计算。

主题色建议收进 CSS 变量,不要满文件搜颜色:

:root { --brand: #ff5a3c; --text: #222; --muted: #888; --bg: #f5f5f5; --card: #fff; --radius: 8px; --page-pad: 12px; } .goods-card { border-radius: var(--radius); background: var(--card); padding: var(--page-pad); }

换品牌色只改--brand,换圆角只改--radius。老设备不支持 CSS 变量时要有兜底色,但这类 H5 商城通常面向较新的手机浏览器,问题不大。布局用 Flex 足够,商品卡片两列用display:flex; flex-wrap:wrap,每个卡片width: calc(50% - 6px),比 float 稳。

为什么不用框架?静态包体积小、零构建、丢到任意静态服务器就能开,适合演示和活动页。代价是状态管理弱、公共组件靠手工维护、页面多了容易复制出多份头尾。我的做法是先把数据层收口到 request.js,把公共 UI 收口到 common.js,再考虑要不要接构建工具。先跑通,再谈工程化。

执行步骤可以按这个顺序:起服务打开首页,确认商品图和价格;点进详情,确认 URL 上有 id;加购后去购物车,确认数量和合计;返回首页再进详情,确认参数没丢。四步都过,再改 theme.css 和 goods.json。

3. 核心模块拆解:首页商品流、详情页参数与轮播交互

3.1 商品数据与列表渲染

商品数据一般放在data/goods.json,字段不会太复杂。价格我建议用“分”存整数,避免0.1 + 0.2这种浮点玄学:

[ { "id": "g_1001", "name": "轻薄连帽外套", "price": 12900, "originPrice": 19900, "cover": "images/goods/1001.webp", "tags": ["新品", "包邮"], "stock": 20, "desc": "春秋款,宽松版型" }, { "id": "g_1002", "name": "日常帆布托特包", "price": 5900, "cover": "images/goods/1002.webp", "tags": ["热卖"], "stock": 8, "desc": "大容量,通勤可用" } ]

price为 12900 表示 129.00 元,cover用相对路径,id必须唯一,详情页和购物车都靠它关联。stock在纯前端只能做展示和前端拦截,不能当真实库存。tags取第一个做角标即可,别在模板里写太多判断。

首页渲染走 fetch 加模板字符串,核心代码如下:

// js/goods.js const listEl = document.querySelector('[data-goods-list]'); function escapeHtml(str) { return String(str).replace(/[&<>"']/g, (s) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[s])); } function formatPrice(fen) { return (Number(fen) / 100).toFixed(2); } async function renderGoods() { try { const res = await fetch('data/goods.json', { cache: 'no-store' }); if (!res.ok) throw new Error('商品数据加载失败'); const goods = await res.json(); listEl.innerHTML = goods.map((item) => ` <a class="goods-card" href="detail.html?id=${encodeURIComponent(item.id)}"> <img src="${escapeHtml(item.cover)}" alt="${escapeHtml(item.name)}" loading="lazy"> <h3>${escapeHtml(item.name)}</h3> <p class="price">¥${formatPrice(item.price)}</p> ${item.tags && item.tags.length ? `<span class="tag">${escapeHtml(item.tags[0])}</span>` : ''} </a> `).join(''); } catch (err) { listEl.innerHTML = '<p class="empty">商品加载失败,请检查本地服务与 data/goods.json</p>'; console.error(err); } } renderGoods();

listEl是首页的商品容器,HTML 里写<div>// js/detail.js const params = new URLSearchParams(location.search); const id = params.get('id'); const cacheKey = 'h5mall:lastGoodsList'; async function getGoodsById(goodsId) { let list = []; try { const cached = sessionStorage.getItem(cacheKey); if (cached) list = JSON.parse(cached); } catch (e) { list = []; } if (!Array.isArray(list) || !list.length) { const res = await fetch('data/goods.json', { cache: 'no-store' }); if (!res.ok) throw new Error('详情数据加载失败'); list = await res.json(); sessionStorage.setItem(cacheKey, JSON.stringify(list)); } return list.find((item) => item.id === goodsId); } async function renderDetail() { if (!id) { document.body.innerHTML = '<p class="empty">缺少商品参数</p>'; return; } try { const goods = await getGoodsById(id); if (!goods) { document.body.innerHTML = '<p class="empty">商品不存在或已下架</p>'; return; } document.querySelector('[data-detail-name]').textContent = goods.name; document.querySelector('[data-detail-price]').textContent = '¥' + (Number(goods.price) / 100).toFixed(2); document.querySelector('[data-detail-desc]').textContent = goods.desc || ''; document.querySelector('[data-detail-cover]').src = goods.cover; } catch (err) { document.body.innerHTML = '<p class="empty">详情加载失败,请返回重试</p>'; console.error(err); } } renderDetail();

params.get('id')拿到的是解码后的值,传参时用encodeURIComponent,取参时不用再解。sessionStorage缓存商品列表,减少从详情返回再进详情的重复请求,但缓存可能过期,所以只做兜底,不做唯一数据源。找不到商品时给明确空态,比控制台报错友好。

详情页加购按钮不要直接内联onclick,用事件监听并带上 id:

document.querySelector('[data-add-cart]').addEventListener('click', () => { if (!id) return; addToCart(id, 1); // 这里只做轻提示,不跳转,避免打断浏览 showToast('已加入购物车'); });

addToCart来自 cart.js,后面会讲。加购后不强制跳购物车,手机端更顺。按钮要加防重复点击,至少 300ms 内只执行一次,否则用户手抖会加多次。库存展示只做前端提示,真实扣减必须在服务端,这一点在纯前端静态页面里要认清。

3.3 轮播与手势:点击和滑动要分开

首页轮播常见是自动播放加手动滑动。不引入插件也能做,关键是区分横向滑动和纵向滚动,别让用户滑页面时轮播抢事件:

// js/banner.js const track = document.querySelector('[data-banner-track]'); let startX = 0; let startY = 0; let deltaX = 0; track.addEventListener('touchstart', (e) => { const t = e.touches[0]; startX = t.clientX; startY = t.clientY; deltaX = 0; }, { passive: true }); track.addEventListener('touchmove', (e) => { const t = e.touches[0]; deltaX = t.clientX - startX; const deltaY = t.clientY - startY; if (Math.abs(deltaX) > Math.abs(deltaY)) { e.preventDefault(); // 横向滑动时阻止页面纵向滚动 } }, { passive: false }); track.addEventListener('touchend', () => { if (Math.abs(deltaX) > 40) { // 根据 deltaX 正负切换上一张或下一张 // 切换后重置 translateX,并重置自动播放定时器 } });

40是滑动阈值,太小容易误触,太大手感迟钝。passive: true用于 touchstart 提升滚动性能,touchmove 里要preventDefault就不能 passive。自动播放定时器在页面隐藏时清掉,visibilitychange里恢复,不然手机切后台回来会连跳好几张。

轮播图数据建议也放 JSON,字段用image和link,首页渲染时拼<img>。点击区域和滑动区域重叠时,touchend 里判断Math.abs(deltaX) < 10才算点击,否则只做切换。这个细节不做,用户想滑下一张却跳进了活动页,体验很糟。

改这块的步骤:先换data/banners.json里的图片路径,确认图片尺寸一致;再改自动播放间隔;最后在真机上试横向滑、纵向滚、点击跳转三种操作。桌面浏览器模拟触摸不准,真机试一次最稳。

4. 购物车与本地存储:纯前端状态怎么不乱

4.1 只存必要字段:id、数量、勾选

纯前端购物车没有服务端托底,localStorage 里不要存整个商品对象。商品价格、名称、图片都可能变,存快照会导致购物车和详情页不一致。只存 id、数量、勾选状态:

字段类型说明
idstring对应 goods.json 的 id
countnumber数量,建议 1 到 99
checkedboolean是否勾选结算

读写加一层 try/catch,隐私模式下 localStorage 可能直接抛错:

// js/cart.js const CART_KEY = 'h5mall:cart:v1'; function readCart() { try { const raw = localStorage.getItem(CART_KEY); const data = raw ? JSON.parse(raw) : []; return Array.isArray(data) ? data : []; } catch (e) { console.warn('购物车读取失败', e); return []; } } function writeCart(list) { try { localStorage.setItem(CART_KEY, JSON.stringify(list)); } catch (e) { console.warn('购物车写入失败', e); } } function addToCart(id, count = 1) { const list = readCart(); const item = list.find((it) => it.id === id); if (item) { item.count = Math.min(item.count + count, 99); } else { list.push({ id, count: Math.min(count, 99), checked: true }); } writeCart(list); }

CART_KEY带v1是为了以后结构变化时迁移,不要用cart这种太通用的键,同一域名下容易和其他页面冲突。Math.min(..., 99)防误点刷数量,真实库存校验仍然要在服务端。checked默认 true,加购后用户去购物车能直接看到合计。

如果以后要把购物车结构从数组改成对象,读旧数据时按版本号迁移:读到v1就转换后写入v2,不要把旧数据直接丢掉。这个习惯在纯前端项目里很值,用户清缓存之前,数据就在他手机里。

4.2 结算计算:用分计算,勾选过滤,空态

购物车页要先拿商品数据,把 id 映射成商品对象,再算合计。价格统一用分:

function calcCart(list, goodsMap) { const checked = list.filter((it) => it.checked); let totalFen = 0; let count = 0; checked.forEach((it) => { const goods = goodsMap[it.id]; if (!goods) return; // 商品下架或数据缺失,跳过 totalFen += Number(goods.price) * it.count; count += it.count; }); return { count, totalFen, totalText: (totalFen / 100).toFixed(2), empty: checked.length === 0 }; }

goodsMap可以用goods.reduce((map, item) => (map[item.id] = item, map), {})构建。checked.length === 0时展示空态,不要显示0.00还让点结算。商品缺失时跳过而不是报错,避免一个下架商品把整个购物车打崩。

数量加减和删除用事件委托,别给每个按钮绑一次:

document.querySelector('[data-cart-list]').addEventListener('click', (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const row = btn.closest('[data-id]'); const id = row.dataset.id; const action = btn.dataset.action; // 根据 action 执行 plus、minus、remove,然后重算并渲染 });

结算按钮要防重复点击:点击后立刻disabled = true,渲染完成或提示后再恢复。纯前端没有真实下单,但演示时连点弹两次提示也很出戏。

4.3 跨页面同步与返回刷新

购物车在 cart.html 改完,返回首页或详情页,数量角标要更新。同页操作直接重渲染,跨标签页用 storage 事件:

window.addEventListener('storage', (e) => { if (e.key === CART_KEY) { renderCartPage(); renderCartBadge(); } }); window.addEventListener('pageshow', (e) => { if (e.persisted) { renderCartPage(); renderCartBadge(); } });

storage事件只在其他标签页修改时触发,当前页修改不会触发,所以当前页要主动调渲染。pageshow的persisted处理从缓存恢复的页面,手机浏览器返回时很常见。没有这段,用户从购物车返回首页,角标还是旧数字,看起来像没加上。

测试步骤:开两个标签页,一个在首页,一个在购物车;购物车里加数量,首页角标应变化;手机浏览器前进后退,购物车合计应刷新;清掉 localStorage 再进购物车,应走空态而不是报错。三步都过,购物车状态基本稳。

5. 避坑与排查:纯前端静态页面从能开到能用的5个坎

5.1 双击 index.html 打开一片空白:file:// 下 fetch 被拦

现象:解压后直接双击 index.html,页面结构出来了,但商品列表空白,控制台报跨域或 fetch 失败。

原因:浏览器对file://协议限制很严,fetch 本地 JSON 会被拦,图片和脚本的相对路径也可能和 HTTP 服务下不一致。纯前端静态页面不是“双击就能跑”,它仍然需要一个静态服务器。

解决:进入项目根目录起服务,python -m http.server 8080,访问http://localhost:8080。确认根目录下有 index.html,路径不要多套一层文件夹。真机访问用电脑局域网 IP,端口别被防火墙拦。改完 JSON 后强制刷新一次,避免缓存。

注意:不要用绝对路径/data/goods.json去修 file:// 的问题,换到子目录部署会再次翻车。统一用相对路径。

5.2 图片一半不显示:路径大小写、中文空格与相对层级

现象:商品列表有的图显示,有的裂开;本地看着正常,传到服务器后全裂。

原因:常见三类,一是文件名大小写不一致,Goods/1001.jpg写成goods/1001.jpg;二是文件名带中文空格,URL 编码后对不上;三是相对层级写错,详情页在根目录,图片却按../images找。

解决:统一图片目录和命名,商品图用goods/1001.webp这种纯小写英文数字。JSON 里的cover和实际文件逐条核对,批量替换时先备份。部署到子目录时,所有路径用相对当前 HTML 的写法,不要以/开头。浏览器控制台看 Network 面板,哪个 404 改哪个,比猜快。

5.3 手机端点按钮没反应:300ms 延迟、点击穿透与遮罩层

现象:加购按钮点了没反应,或者点遮罩关闭弹层时,底下的商品卡片也被点开了。

原因:老移动浏览器有 300ms 点击延迟;遮罩层用 click 关闭时,事件可能穿透到底层;有的按钮被绝对定位的角标盖住,实际点不到。

解决:viewport 加width=device-width后大多数现代浏览器不再有 300ms 延迟;遮罩关闭用touchstart并preventDefault,或者判断点击目标是不是遮罩本身。按钮加大点击区域,至少 40px 高。调试时用真机,别只看桌面模拟器,层级和触摸事件差异很大。

5.4 rem 和 vw 混用后页面忽大忽小:根字号被二次设置

现象:同一页面在两种手机上间距比例不一致,刷新后字号跳一下,弹层打开后页面整体变大。

原因:rem 依赖根字号,vw 依赖视口宽度,两套比例同时作用在同一个容器上,基准不一致。还有的包在 body 后又引了一次 rem.js,或者 resize 时重复设置根字号。

解决:全站只选一种适配方案。用 rem 就固定设计稿宽度和基准系数,所有尺寸走 rem;用 vw 就把根字号写死,尺寸走 vw。检查 rem.js 是否只引入一次,resize 加防抖,pageshow 只做补算不重复初始化。改完在两种宽度下对比首屏和弹层。

5.5 改了数据刷新没变化:缓存、JSON 尾逗号与字段类型

现象:goods.json 里改了价格,页面还是旧价;或者整页空白,控制台报 JSON 解析错误。

原因:浏览器缓存了 JSON;JSON 最后一个字段多了逗号;价格从数字改成字符串,计算时出现NaN;id 重复导致详情页永远显示第一个。

解决:开发期 fetch 加cache: 'no-store',或者强制刷新。JSON 用编辑器校验,别手写尾逗号。价格统一用数字分,id 保证唯一。改完先看控制台,再看 Network 里的响应内容,最后才怀疑渲染代码。静态页面的黑匣子不多,多数问题在路径和数据格式。

6. 二次开发:把静态包改成可持续维护的手机端商城

这个包继续往下改,最值得先动的是数据层。页面里散着 fetch 和路径,后面接真实接口会到处改。我一般抽一个js/api.js,页面只认方法名,不认 URL:

// js/api.js const API_BASE = './data'; export async function getGoodsList() { const res = await fetch(`${API_BASE}/goods.json`, { cache: 'no-store' }); if (!res.ok) throw new Error('goods list failed'); return res.json(); } export async function getBanners() { const res = await fetch(`${API_BASE}/banners.json`, { cache: 'no-store' }); if (!res.ok) throw new Error('banners failed'); return res.json(); }

页面脚本改成import { getGoodsList } from './api.js',HTML 里的 script 加type="module"。以后换接口只改API_BASE和返回字段映射,页面渲染逻辑不动。再往下可以把商品卡片、空态、Toast 抽成小函数,公共头尾用 common.js 注入,减少复制。

改造前后可以按这张表对照:

改造点改造前改造后
数据来源页面直接 fetch JSONapi.js 统一出口
主题颜色散在 CSStheme.css 变量
购物车各页读 localStoragecart.js 统一读写
校验控制台报错空态加错误提示

验证方法不复杂:首页首屏、详情参数、加购合并、返回刷新、弱网缓存,五条链路各走一遍。手机端再试横向滑动和纵向滚动,确认轮播不抢事件。做完这些再考虑接构建工具和真实接口,顺序别反。

血泪经验是:静态包最容易死在“看起来能开”上,路径、缓存、参数三个地方各埋一个雷。从那以后我每次改这类 H5 商城,都先起本地服务、真机走一遍加购链路,再动样式。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表