购物车页面,大概是前端练手项目里含金量被低估得最严重的一个。看起来平淡无奇,但真正动手去复刻一个接近主流电商App的购物车静态界面时,你会发现布局、语义、样式细节全都用得上。我最近的练习就是把这个页面从零写了一遍,没有框架,没有JavaScript,纯HTML和CSS,对照着App里的实际效果,一点一点把结构、间距、颜色、圆角对齐。这篇文章把我整个实现过程、踩过的坑,以及一些比较隐蔽的布局细节记录下来,希望对正在练前端的你有一点参考价值。
对刚学完HTML和CSS的同学来说,购物车是一个特别合适的“毕业设计”:它不像官网首页那么简单,也不像后端管理后台那么枯燥,既有列表、又有表单控件、还有固定在底部的操作栏,做一轮下来,你对Flex布局、定位、伪元素这些基础知识的理解会和之前完全不一样。
1. 项目概览与设计思路
1.1 我为什么偏偏选购物车作为练手项目
先说一个我自己的判断:前端练手,练的不应该是“写得出来”,而是“想得清楚”。所谓想得清楚,是指看到一个界面之后,能在脑子里把它拆成一个个模块,再进一步拆成一个一个的HTML节点和CSS规则。购物车页面在这一点上极其合适。它不像三栏布局那样简单到没有挑战,也不像数据大屏那样一上来就被各种不可控的图表占满注意力。购物车页面的复杂度刚刚好,而且结构和交互套路高度统一,做一次就能沉淀出一套可以复用在其他电商页面上的布局思路。
购物车页面的界面元素也足够丰富:顶部有导航栏,中间是商品列表,每个商品卡片里又有复选框、图片、标题、规格、价格、数量控件,底部还挂着一个固定结算栏。一个页面里几乎包含了常见的电商场景元素。尤其难得的是,这个页面里的所有元素都适合用布局手段来表现,不需要复杂动画,不需要大量的状态管理,只要HTML和CSS写得扎实,页面的还原度就能达到八九成。
另一个让我选择它的原因是可验证性。界面做得好不好,直接对照着手机里的电商App看一眼就知道。你不需要等后端接口,不需要调数据库,一个浏览器加一个编辑器就能完成整个验证闭环。这种即时反馈对养成“苛刻地看自己作品”的习惯很有帮助。
1.2 页面结构与核心模块拆解
我把这个静态页面拆成三个大区域,六个子模块,对应关系是这样的:
| 区域 | 子模块 | 涉及知识点 |
|---|---|---|
| 顶部导航 | 返回入口、页面标题、管理入口 | 三栏Flex布局、文字居中、sticky定位 |
| 商品列表 | 商品卡片容器 | 纵向列表组织、卡片间距控制 |
| 商品卡片内部 | 复选框、图片、信息区、数量控件 | Grid列布局、自定义复选框、步进器样式 |
| 底部结算栏 | 全选、合计金额、结算按钮 | fixed定位、z-index层级、按钮样式 |
这样拆完,页面就不再是一张图,而是一堆可以单独攻克的子问题。我实际做题的顺序也基本遵循这个拆法:先搭整体骨架,再填充各模块,最后统一调间距和颜色。
列表里商品卡片如果多的话,可以做成循环结构,但静态阶段我建议至少写两个商品卡片,一个选中、一个未选中,这样能同时验证两种状态下的样式。别小看这个细节,很多同学写第一个卡片的时候一切正常,复制出第二个卡片后就发现复选框、对齐方式开始出问题,原因通常就是某个固定尺寸写死了,没考虑到多个商品并排或叠加的情况。
1.3 技术选型:为什么坚持纯HTML和CSS
这个项目我坚持用纯HTML和CSS,原因主要有三个。第一,购物车静态界面的核心是布局和样式还原,不是交互逻辑,用不到JavaScript。第二,在一个页面里同时练到Flex、Grid、定位、伪元素,机会很集中,没必要把时间花在搭框架上。第三,先做静态界面可以为后面加JavaScript做准备——如果你连结构都写不顺手,直接上动态交互只会被DOM操作搅得焦头烂额。
需要说明的是,我这里的“静态”不是“简陋”的意思。静态界面同样要精确到像素级:复选框的勾选样式要好看,数量控件的按钮要对齐,价格数字的字体和大小要协调。这些细节做得到位,后面接上JS才真正像那么回事。
2. 从零搭建购物车页面的HTML骨架
2.1 头部导航区的结构设计与语义
购物车页面的头部,在主流电商App里基本都是一个套路:左边返回,中间标题,右边一个“管理”或者“编辑”入口。这种三栏结构用Flex来实现是最顺手的。
<header class="cart-header"> <button class="header-back" aria-label="返回"> <svg width="20" height="20" viewBox="0 0 20 20"> <path d="M12 4l-6 6 6 6" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/> </svg> </button> <h1 class="header-title">购物车</h1> <button class="header-edit">管理</button> </header>这里有一点要注意:很多同学会把返回箭头直接写死成一个字符,比如写成‹。这种方式在原型阶段完全可行,但如果要做到更接近真实效果,建议改用内联SVG或者icon字体。字符箭头在不同系统的字体渲染下,粗细和位置会有明显差异。我为了偷懒直接用了字符,结果在真机上看发现箭头明显偏上,最后还是换成了SVG。
标题用h1是语义上的考虑,购物车页面是整页唯一的内容主体,用h1表达最高层级标题,对屏幕阅读器也更友好。管理按钮用button而不是a标签,是因为它表达的是一个操作动作,而不是一个跳转链接。类似这种语义细节,在静态页面里很容易被忽略,但它是前端功底的一部分。
2.2 商品列表区的DOM组织与类名约定
商品列表是整个页面的重心。一个典型的商品卡片包含四块内容:选中复选框、商品图片、商品名称和规格信息、价格和数量控件。我把它们组织成一个grid容器,第一列是复选框,第二列是商品图片,第三列是商品信息区。信息区里面再分成标题、规格、底部价格数量行。
<section class="cart-list"> <div class="cart-item"> <label class="item-check"> <input type="checkbox" class="check-input" checked /> <span class="check-area"></span> </label> <div class="item-pic"> <img src="https://picsum.photos/200/200?random=1" alt="商品图片" /> </div> <div class="item-info"> <h2 class="item-name">经典黑白T恤 基础款 纯棉圆领短袖</h2> <p class="item-spec">颜色:黑色;尺码:M</p> <div class="item-bottom"> <span class="item-price">¥99.00</span> <div class="num-stepper"> <button class="num-btn num-minus" type="button">−</button> <input class="num-input" type="text" value="1" /> <button class="num-btn num-plus" type="button">+</button> </div> </div> </div> </div> <!-- 第二个商品卡片,未选中 --> <div class="cart-item"> <!-- 结构同上,input不写checked --> </div> </section>类名方面我习惯用类似BEM但更宽松的命名方式,不会让页面类名太长,但又能通过.cart-item这样一眼看出它属于哪个模块。这个习惯在纯静态阶段看起来无所谓,一旦后面引入JavaScript或者交给别人维护,命名规范的价值就会显现出来。
两个商品卡片之间,我建议用margin-bottom或gap来分隔。如果使用gap,记得把外层容器设为flex-column或者grid,单纯div之间的margin有时候会被折叠,处理起来比较烦。
2.3 底部结算栏的结构设计
底部结算栏的最大特点是永远固定在最下方,用户无论滚动到哪里都能看到。结构上分为左右两组:左边是全选入口,右边是合计金额和结算按钮。
<footer class="cart-footer"> <label class="footer-check"> <input type="checkbox" class="check-input" /> <span class="check-area"></span> <span class="footer-check-text">全选</span> </label> <div class="footer-right"> <span class="footer-total">合计:<em>¥99.00</em></span> <button class="footer-submit">结算(1)</button> </div> </footer>注意这里的合计金额我用的是em标签,因为金额是全选状态下页面里最有视觉强调需求的内容,em在语义上刚好合适。当然你也可以用strong,不同团队的习惯不同,但都要避免用一个没有语义的div加一堆类名去表达。
另外,在全选按钮和结算按钮之间,通常还会有一个删除按钮,在编辑模式下出现。静态界面阶段可以先不做,但设计结构时预留一个位置,后面加需求会轻松很多。
3. 核心CSS布局与样式细节
3.1 头部导航的Flex方案与标题居中问题
头部看起来简单,但真正写的时候有一个居中问题。我一开始是直接用flex的space-between来处理:左边按钮、中间标题、右边按钮,结果发现左右按钮的实际宽度并不相同。“管理”按钮有两个汉字,宽度接近50px,而返回按钮只有二十多像素,space-between根本不会让标题居中。
.cart-header { display: flex; align-items: center; justify-content: space-between; padding: 0 16px; height: 48px; border-bottom: 1px solid var(--border-color); background: #fff; position: sticky; top: 0; z-index: 10; } .header-title { font-size: 18px; font-weight: 600; position: absolute; left: 50%; transform: translateX(-50%); }解决办法是把标题绝对定位到中间。left:50%加上transform:translateX(-50%)是经典写法,transform平移的是元素自身宽度的一半,所以无论标题多长都能居中。左右按钮留在正常文档流里,靠flex的space-between落在两端。
有一点要注意:绝对定位的标题会脱离文档流,如果页面很宽,标题和左右按钮可能发生重叠。解决思路是给左右按钮设定一个最小宽度,或者把两边都做成固定宽度的容器。主流电商App的标题一般会用两个字的词,像“购物车”,只要左右按钮最小宽度不小于标题宽度,就不会有冲突。
我还给header加了position:sticky和top:0,让头部在列表滚动时也能保持可见。页面上商品一多,页面高度超过视口是必然的,这时候sticky头部和fixed结算栏搭配起来,体验就非常接近真实App了。
3.2 自定义复选框:三种方案对比与实现
原生checkbox在不同系统和浏览器上的外观差异实在太大了,直接拿来做界面会和旁边的圆角、字体风格完全脱节。这里通常有三种做法:第一种是保留原生input,用accent-color简单改颜色,省事但外观不可控;第二种是彻底隐藏input,用span或者伪元素绘制,兼容性最好;第三种是用第三方图标库配合label模拟,多一次依赖。我这次采用的是第二种,最稳也最干净。
.check-input { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; } .check-area { display: inline-block; width: 20px; height: 20px; border: 1px solid #ddd; border-radius: 50%; background: #fff; position: relative; transition: background 0.2s, border-color 0.2s; flex: none; } .check-input:checked + .check-area { background: var(--primary); border-color: var(--primary); } .check-input:checked + .check-area::after { content: ""; position: absolute; left: 6px; top: 3px; width: 5px; height: 10px; border: 2px solid #fff; border-top: none; border-left: none; transform: rotate(45deg); }有几个关键细节:第一,input不能直接display:none,因为这样在iOS Safari下label点击的焦点状态行为会异常,更可靠的方式是用绝对定位加opacity:0,把它挪出视觉范围但不移除。第二,选中状态的样式,主要通过input:checked + .check-area这个兄弟选择器来生效。第三,对勾用伪元素和旋转边框实现,宽度和厚度要协调好,20px的方框配2px边框、5px宽10px高的对勾,是我试过比较协调的比例。
这个复选框样式有一个好处:完全纯CSS,不需要JavaScript。静态界面里虽然点击没有实际联动,但样式已经具备了,后面写JS时只需要监听change事件去切换checked属性,样式会自动跟随更新。
3.3 商品卡片信息区的Grid布局
商品卡片内部,我采用的是grid三列布局,列宽分别对应复选框、图片和剩余空间,这样做的好处是三个区域的宽度控制非常直观。
.cart-item { display: grid; grid-template-columns: 24px 96px 1fr; gap: 12px; padding: 16px; align-items: start; background: #fff; border-radius: var(--radius-card); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); } .item-pic img { width: 96px; height: 96px; object-fit: cover; border-radius: 8px; background: var(--bg-page); } .item-bottom { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; }刚开始我也试过用flex嵌套来实现同样的效果,外层的flex需要给信息区设置flex:1,图片又要固定宽高,写起来不复杂,但每次调整间距都要多想一想哪个元素在伸缩。改成grid之后,24px、96px、1fr三列一眼看完,后续调整尺寸只需要改模板。
商品图片我用了固定96x96的尺寸,并且加了object-fit:cover,这样即使商品图本身不是正方形,也能在容器里被裁成整齐的正方形,不会把卡片撑高。给图片设置背景色也很重要,在图片加载失败或者还没加载出来时,这个底色能让占位区域看起来不那么突兀。
信息区内部,标题使用单行截断,规格信息单独一行放在标题下方,价格和数量控件放在最底部的flex行里,justify-content:space-between让价格在左、步进器在右。一个卡片内的内容层级就这么出来了。
3.4 数量控件(步进器)的细节实现
数量控件是购物车页面最容易出错的组件。常见的三个问题是:按钮和输入框挤在一起不好看、输入框宽度撑破布局、垂直方向对不齐。
.num-stepper { display: flex; align-items: center; border: 1px solid var(--border-color); border-radius: 16px; overflow: hidden; background: #fff; } .num-btn { width: 34px; height: 32px; background: #f7f7f7; font-size: 18px; line-height: 1; } .num-input { width: 40px; height: 32px; border: none; border-left: 1px solid var(--border-color); border-right: 1px solid var(--border-color); text-align: center; font-size: 14px; -moz-appearance: textfield; } .num-input::-webkit-outer-spin-button, .num-input::-webkit-inner-spin-button { -webkit-appearance: none; }我的做法是:用一个外层容器包裹按钮和输入框,容器设置border-radius和overflow:hidden,内部元素不再单独设圆角。这样整个步进器看起来像一个整体,边缘不会出现拼接的缝隙。输入框的上下滚动箭头要手动去掉,否则在Chrome下会显示两个小三角。
尺寸上,按钮宽34px、输入框宽40px、高度统一32px。输入框宽度太小,数字多时会溢出;太大又会把步进器拉到不平衡。40px配合font-size:14px和text-align:center,最多能显示两位数,对于购物车场景基本够用。
还有一点配色细节:按钮背景和输入框背景稍微区分一下,按钮用浅灰,输入框用白色,视觉上会清晰很多。如果全用同色,用户很难一眼找到可点击区域。
3.5 固定底部结算栏的定位与层级
底部结算栏肯定要用fixed,这个没什么可犹豫的,但fixed有一个容易忽略的问题:它会脱离文档流,导致页面底部内容被挡住。解决方法是给body加一个padding-bottom,数值要大于结算栏高度,最好再富裕出一点空间。
body { padding-bottom: calc(var(--footer-height) + 16px); } .cart-footer { position: fixed; left: 0; right: 0; bottom: 0; height: var(--footer-height); display: flex; align-items: center; justify-content: space-between; padding: 0 16px; background: #fff; box-shadow: 0 -1px 4px rgba(0, 0, 0, 0.05); z-index: 100; padding-bottom: env(safe-area-inset-bottom); }我用了阴影来模拟结算栏顶部的分隔线,box-shadow: 0 -1px 4px rgba(0,0,0,0.05),这条阴影很淡,靠近看才有存在感。如果你想要更明显的分隔线,也可以加border-top,但阴影和边框不要同时用,否则视觉会很脏。
z-index设置为100,已经足够避开绝大多数内容层级。如果页面里有弹窗、抽屉等其他浮层,再单独约定层级。
4. 像素级还原:间距、颜色与字体
4.1 用CSS变量管理全页设计规范
当页面里的颜色和间距开始多起来,纯粹靠脑子记忆就很危险。比如主色在复选框、价格、结算按钮里各用了一次,如果三处分别写死,后面想调整品牌色就要逐个找。我这次在页面顶部定义了一组变量:
:root { --primary: #e93b3d; --border-color: #eee; --bg-page: #f5f5f5; --text-main: #333; --text-sub: #999; --radius-card: 12px; --radius-control: 16px; --footer-height: 56px; }用变量之后,调整设计统一性变得很快。比如发现按钮圆角太大了,只需要改一处。这种习惯在大型项目里几乎是一种强制规范,但即使在小型练手项目里提前用上,也能帮你建立视觉一致性的意识。
如果你用的是VSCode,装一个支持CSS变量跳转的插件,后面修改变量时会很舒服。不过这类工具属于锦上添花,就算不用,变量本身的收益也已经很可观了。
4.2 价格数字的排版技巧
价格是购物车页面里最重要的信息,但很多人会忽略它的排版。直接写¥99.00,人民币符号会跟数字一样大,视觉上很钝。我的做法是把符号缩小,并且让价格字体稍重一些。
.item-price { font-size: 18px; font-weight: 700; color: var(--price-color); } .footer-total em { font-style: normal; color: var(--price-color); font-size: 20px; font-weight: 700; }如果你用的是文本符号,可以用一些技巧来缩小符号,但有些字体渲染下¥字符的定位会不稳定。更稳妥的方案是用小号span包裹符号,或者直接使用一个等宽的数字字体栈。
数字字体方面,可以给价格设置font-variant-numeric: tabular-nums,让数字以等宽方式渲染,这样金额从99变成199时,数字不会左右晃动。如果用一个局部字体栈,比如Helvetica Neue配合sans-serif,数字的观感也会好很多。
顺便提一下,结算栏里的大金额文字节奏要拉开:“合计:”用小号浅色,后面的金额用大号红色加粗,一眼扫过去用户就知道自己需要付多少钱。
4.3 圆角、阴影和边框的使用分寸
卡片加圆角和阴影,能让页面层次感更强,但特别容易用过头。我的习惯是:卡片用12px圆角,阴影用0 2px 8px rgba(0,0,0,0.04),非常淡;分隔线用浅灰的border-bottom;按钮类控件圆角用16px,比卡片更圆一点,形成对比。
关于1px边框,还有一个小技巧:在移动设备的高分辨率屏上,普通border:1px实际上会显得偏粗。更精细的做法是用transform:scaleY(0.5)把伪元素边框压细,或者用背景渐变模拟0.5px的线。我这个静态页面主要做原型展示,所以用了普通边框,如果是正式上线的还原,建议在头部和卡片分隔线这些地方做处理。
还有一个容易踩的小坑:给卡片加box-shadow和border-top同时存在时,可能在顶部出现一条意外的亮边。因为阴影在顶部也会有少量投影,与边框叠加后看起来像双层。避免的方式是二者只取其一。
5. 实战中踩过的坑与排查技巧
5.1 复选框在Safari上不听话
这是我的第一个教训。一开始我给input直接写样式,Chrome里一切都好,但打开Safari一看,复选框还是系统原生的样子,怎么改都不动。原因是Safari对原生控件外观的自定义能力非常有限,只有少数几个属性生效。最后只能回归最稳妥的方案:彻底隐藏原生input,用span重绘。前面代码里已经写得很详细,这里不再重复。想提醒的是,调试的时候不要只在Chrome里看,至少用模拟器看一下Safari和安卓原生浏览器的效果。
另外,隐藏input的写法是opacity:0加绝对定位,不要随手给display:none。display:none会让点击事件和焦点行为变得奇怪,特别是在label包裹input的场景下。
5.2 在线图片加载导致布局抖动
测试时我用的是在线占位图,第一次打开页面时列表会出现一阵明显的跳动:图片还没加载时卡片高度很矮,图片加载后突然抽高,把下面卡片往下推。这种情况在静态界面里显得特别糙。解决办法就是提前给图片容器占好尺寸:固定width和height,或者用aspect-ratio声明比例。我采用的是固定96x96,加上object-fit:cover,无论原图比例如何都不会破坏布局。
如果你希望图片能适配不同屏幕,也可以用aspect-ratio: 1配合width:100%,这样容器会维持正方形比例。不过我这次更倾向于固定像素,因为购物车列表里的图片尺寸在主流App里基本都是定值,固定方式更接近真实。
5.3 fixed底部栏遮挡滚动内容
fixed底部栏脱离文档流后,页面最后一条商品很可能会被盖住。只给body加padding-bottom还不够,如果padding-bottom恰好等于footer高度,最后一条内容的底部会和footer贴得严严实实,视觉上像粘在一起。我建议padding-bottom比footer高度多出16px左右,留一点呼吸空间。
还有一个更细致的做法:在列表容器上设置margin-bottom,或者给结算栏留一个占位空div。但占位div多一个元素,我一般不推荐。padding-bottom是最直接的。
5.4 小屏适配与底部安全区域
在窄屏手机上,商品名称如果不做截断,会把整个卡片撑高,价格和数量控件甚至会挤到对齐错乱。所以我给.item-name加了单行截断。你也可以做成两行截断,用-webkit-line-clamp:2,但要注意配合overflow:hidden和display:-webkit-box才能生效。
至于iPhone底部的安全区域,结算栏需要处理env(safe-area-inset-bottom)。如果直接就写padding:0 16px,在全面屏手机上按钮会被底部手势条压住,点击区域也变小。我之前的做法是把footer的padding-bottom改成calc(8px + env(safe-area-inset-bottom)),这样底部间距在真机上才是安全的。
这一条在浏览器模拟器里看不到效果,需要真机测试。如果你手头没有iPhone,可以在Chrome的开发者工具里选择iPhone机型模拟,虽然不完全精确,但至少能验证方向。
5.5 相邻边框线的重影问题
这个页面里,步进器的按钮、输入框、外层容器之间涉及多条边框。如果不加处理,三条边框拼在一起会显得很重,甚至出现粗细不匀。我的做法是只在容器上画完整的border,内部元素只保留需要的分隔线:输入框加左右border,按钮不加边框,这样最终视觉上就是一条1px的线。
同样的道理也适用于卡片之间。如果两张卡片都有上下边框,中间会出现两条线,看起来比预期更粗。要么卡片之间用间距和阴影分隔,要么统一使用border-bottom并在末尾去掉最后一条。从主流App的视觉习惯看,间距加阴影的方式更干净。
6. 完整Demo代码:可直接运行的购物车静态界面
下面这套代码可以直接保存成一个html文件,浏览器打开就能看效果。商品图片用的是在线占位图,网络正常的情况下会自动加载。你也可以替换成你自己的图片地址。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>购物车静态界面Demo</title> <style> :root { --primary: #e93b3d; --price-color: #e93b3d; --border-color: #eee; --bg-page: #f5f5f5; --text-main: #333; --text-sub: #999; --radius-card: 12px; --radius-control: 16px; --footer-height: 56px; } * { margin: 0; padding: 0; box-sizing: border-box; } body { background: var(--bg-page); font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; color: var(--text-main); padding-bottom: calc(var(--footer-height) + 16px); } button { border: none; background: none; cursor: pointer; font: inherit; } /* header */ .cart-header { display: flex; align-items: center; justify-content: space-between; padding: 0 16px; height: 48px; border-bottom: 1px solid var(--border-color); background: #fff; position: sticky; top: 0; z-index: 10; } .header-back, .header-edit { display: flex; align-items: center; color: var(--text-main); font-size: 16px; } .header-title { font-size: 18px; font-weight: 600; position: absolute; left: 50%; transform: translateX(-50%); } /* list */ .cart-list { padding: 12px; display: flex; flex-direction: column; gap: 12px; } .cart-item { display: grid; grid-template-columns: 24px 96px 1fr; gap: 12px; padding: 16px; align-items: start; background: #fff; border-radius: var(--radius-card); box-shadow: 0 2px 8px rgba(0,0,0,0.04); } .item-pic img { width: 96px; height: 96px; object-fit: cover; border-radius: 8px; background: var(--bg-page); } .item-name { font-size: 15px; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .item-spec { font-size: 12px; color: var(--text-sub); margin-top: 6px; } .item-bottom { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; } .item-price { font-size: 18px; font-weight: 700; color: var(--price-color); } /* stepper */ .num-stepper { display: flex; align-items: center; border: 1px solid var(--border-color); border-radius: var(--radius-control); overflow: hidden; background: #fff; } .num-btn { width: 34px; height: 32px; background: #f7f7f7; font-size: 18px; line-height: 1; } .num-input { width: 40px; height: 32px; border: none; border-left: 1px solid var(--border-color); border-right: 1px solid var(--border-color); text-align: center; font-size: 14px; -moz-appearance: textfield; } .num-input::-webkit-outer-spin-button, .num-input::-webkit-inner-spin-button { -webkit-appearance: none; } /* custom checkbox */ .item-check, .footer-check { display: inline-flex; align-items: center; cursor: pointer; position: relative; } .check-input { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; } .check-area { display: inline-block; width: 20px; height: 20px; border: 1px solid #ddd; border-radius: 50%; background: #fff; position: relative; transition: background 0.2s, border-color 0.2s; flex: none; } .check-input:checked + .check-area { background: var(--primary); border-color: var(--primary); } .check-input:checked + .check-area::after { content: ""; position: absolute; left: 6px; top: 3px; width: 5px; height: 10px; border: 2px solid #fff; border-top: none; border-left: none; transform: rotate(45deg); } /* footer */ .cart-footer { position: fixed; left: 0; right: 0; bottom: 0; height: var(--footer-height); display: flex; align-items: center; justify-content: space-between; padding: 0 16px; background: #fff; box-shadow: 0 -1px 4px rgba(0,0,0,0.05); z-index: 100; padding-bottom: env(safe-area-inset-bottom); } .footer-check-text { margin-left: 6px; font-size: 14px; } .footer-right { display: flex; align-items: center; gap: 12px; } .footer-total { font-size: 14px; color: var(--text-sub); } .footer-total em { font-style: normal; color: var(--price-color); font-size: 20px; font-weight: 700; } .footer-submit { height: 36px; padding: 0 20px; border-radius: 18px; background: var(--primary); color: #fff; font-size: 14px; font-weight: 600; box-shadow: 0 4px 10px rgba(233,59,61,0.2); } </style> </head> <body> <header class="cart-header"> <button class="header-back" aria-label="返回"> <svg width="20" height="20" viewBox="0 0 20 20"> <path d="M12 4l-6 6 6 6" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/> </svg> </button> <h1 class="header-title">购物车</h1> <button class="header-edit">管理</button> </header> <section class="cart-list"> <!-- 商品1:选中状态 --> <div class="cart-item"> <label class="item-check"> <input type="checkbox" class="check-input" checked /> <span class="check-area"></span> </label> <div class="item-pic"> <img src="https://picsum.photos/200/200?random=1" alt="商品图片" /> </div> <div class="item-info"> <h2 class="item-name">经典黑白T恤 基础款 纯棉圆领短袖</h2> <p class="item-spec">颜色:黑色;尺码:M</p> <div class="item-bottom"> <span class="item-price">¥99.00</span> <div class="num-stepper"> <button class="num-btn num-minus" type="button">−</button> <input class="num-input" type="text" value="1" /> <button class="num-btn num-plus" type="button">+</button> </div> </div> </div> </div> <!-- 商品2:未选中状态 --> <div class="cart-item"> <label class="item-check"> <input type="checkbox" class="check-input" /> <span class="check-area"></span> </label> <div class="item-pic"> <img src="https://picsum.photos/200/200?random=2" alt="商品图片" /> </div> <div class="item-info"> <h2 class="item-name">运动水杯 便携大容量 户外健身吸管杯</h2> <p class="item-spec">颜色:蓝色;容量:750ml</p> <div class="item-bottom"> <span class="item-price">¥69.00</span> <div class="num-stepper"> <button class="num-btn num-minus" type="button">−</button> <input class="num-input" type="text" value="1" /> <button class="num-btn num-plus" type="button">+</button> </div> </div> </div> </div> </section> <footer class="cart-footer"> <label class="footer-check"> <input type="checkbox" class="check-input" /> <span class="check-area"></span> <span class="footer-check-text">全选</span> </label> <div class="footer-right"> <span class="footer-total">合计:<em>¥99.00</em></span> <button class="footer-submit">结算(1)</button> </div> </footer> </body> </html>运行之后,你应该能看到一个接近主流电商App观感的购物车列表:头部置顶、两张商品卡分别演示选中和未选中状态、底部结算栏固定不动,“合计”里的金额和“结算(1)”形成清晰的视觉重点。
如果想练练手,可以试着改几个地方:把勾选样式改成方形勾选框、把步进器改成输入框直接可编辑、或者给卡片加删除按钮的占位。每个改动都会牵动样式,足够你琢磨一阵了。
7. 从静态到动态:后续升级方向
7.1 全选和单选联动
静态界面完成后,最自然的升级就是加JavaScript。全选和单选联动是购物车交互里最经典的一块:点击全选按钮,所有商品的checked都变成true;取消其中一个,全选状态变成false。实现思路是监听所有checkbox的change事件,用一个方法扫描当前选中数量,只要有一个未选中,全选就取消勾选。
这一步会让前面的自定义复选框真正发挥价值,因为样式已经准备好,JS只需要操控checked属性,视觉会自动跟随。
7.2 数量变化与合计金额实时计算
数量步进器的交互也是下一步必做的:点击加号,数量加一;点击减号,数量减一;输入框直接输入数字时,还要处理边界值(不能小于1,不能大于库存)。每次数量变化,都需要重新计算当前商品的小计和整个列表的合计。
这里有个细节:数量输入框的值是字符串,做加法之前一定要用parseInt或者Number转换,否则会出现1加1的字符串拼接这种低级bug。另一个细节是input的change事件只在失焦时触发,如果你希望实时计算,监听input事件会更合适。
7.3 商品移除与本地存储
再往后做,可以给每个商品卡片增加移除按钮,以及把购物车数据存到localStorage。购物车的数据结构建议用一个数组,每个商品对象包含id、名称、规格、价格、数量、选中状态,页面的渲染就基于这个数组来完成。当用户刷新页面,从localStorage恢复数据,购物车就能保持连续性。
这一步其实就是从“静态界面”向“完整业务页面”跨进的关键。你会发现,静态阶段定义好的DOM结构和类名,会让后续的JS逻辑清晰很多。这也是我坚持先做静态页面的一个重要原因。
7.4 静态经验如何衔接到框架学习
如果你接下来打算学React或Vue,这个购物车页面也是很合适的移植对象。商品卡片可以抽成一个商品项组件,结算栏是一个独立组件,页面则是组装这些组件的容器。静态阶段写下的HTML结构和CSS类名,几乎可以一比一迁移到组件的模板中。
框架里的状态管理,其实就是把原来JS里维护的购物车数据数组,改成响应式的数据源。理解了静态界面和交互逻辑之间的边界,后面理解数据驱动视图会顺畅很多。
最后说点个人体会。我写这个静态购物车页面,最大的收获不是学会了某个属性,而是养成了先拆结构、再写代码、最后抠细节的习惯。很多同学上手就写,写到一半发现模块之间互相影响,再回头调整结构,耗时又容易乱。先从静止的HTML骨架开始,把结构定清楚,后面的CSS和JS都会顺很多。购物车这个页面虽然小,但足够让你完整走一遍这个流程。如果你也在练前端,建议真的动手写一遍,而不是只看别人的代码。