响应式 checkout 流程应使用 tables 还是 divs?
明确结论
不要把整个 checkout 流程都做成 table,也不要为了布局方便,把所有内容都换成没有语义的 div。
可以按下面的原则选择元素:
- 如果订单明细具有稳定的行列关系,用户需要按列比较价格、数量和小计,就使用
<table>。 - 如果商品项在移动端需要重新排成卡片,其中还有颜色选择、数量输入、删除等复杂控件,优先使用
<ul>、<li>、article、section等语义元素,并配合 CSS Grid 布局。 - 收货地址、配送方式和支付信息属于表单,应使用
<form>、<fieldset>、<legend>、<label>等元素。 <div>可以用于布局,但不能代替按钮、表单、列表或表格本身的语义。
判断标准不是简单地在 table 和 div 之间二选一,而是看内容是否具有真正的二维表格关系,以及移动端是否需要调整信息顺序。
为什么 table 在这类页面中容易失控
HTML table 适合表达行列数据。浏览器和辅助技术可以识别表头与数据单元格之间的关系,在桌面端也方便用户快速比较商品价格和小计。
但 checkout 中的商品行通常不只是数据,还会包含:
- 商品图片和名称
- SKU、规格、颜色等属性
- 数量输入框
- 库存或配送提示
- 删除、收藏等操作
- 单价、优惠和小计
这些内容在桌面端可以排成一行,到了移动端,往往需要重新组合图片、商品信息、数量和操作按钮。table 的布局模型可以调整列宽或支持横向滚动,却不适合随意改变单元格的顺序。
如果为了适配移动端,强行把 <tr>、<td> 设置为 display: block,视觉上虽然可能变成卡片,但表格原有的结构和可访问性会更难理解。不同浏览器和辅助技术对此类处理的表现也可能不同。
推荐的页面结构
checkout 可以按功能拆分,不必让整个页面只使用一种元素:
- 商品编辑区域使用语义列表或 CSS Grid。
- 订单金额汇总使用
<dl>,因为这里表达的是名称与数值之间的对应关系。 - 收货地址、支付方式等区域使用标准表单结构。
- 只读且需要按列比较的订单明细可以使用
<table>。 - 布局和响应式变化交给 CSS,不要用 table 模拟页面网格。
这样拆分,通常比维护多套略有差异的 table 组件更稳定。
适合复杂商品操作的实现
下面的结构把每件商品作为一个独立条目。桌面端显示为规则网格,移动端可以直接变成卡片,不需要复制两套 HTML。
<ul class="cart-items">
<li class="cart-item">
<a class="cart-item__product" href="/products/linen-shirt">
<img
class="cart-item__image"
src="/images/linen-shirt.jpg"
alt=""
width="96"
height="96"
>
<span class="cart-item__details">
<strong>Linen Shirt</strong>
<span>Color: Navy</span>
<span>Size: M</span>
</span>
</a>
<div class="cart-item__price">
<span class="field-label">单价</span>
<span>¥299.00</span>
</div>
<div class="cart-item__quantity">
<label for="quantity-product-123">数量</label>
<input
id="quantity-product-123"
name="items[123][quantity]"
type="number"
min="1"
max="10"
value="1"
inputmode="numeric"
>
</div>
<div class="cart-item__subtotal">
<span class="field-label">小计</span>
<strong>¥299.00</strong>
</div>
<button
class="cart-item__remove"
type="button"
aria-label="从购物车中删除 Linen Shirt"
>
删除
</button>
</li>
</ul>
.cart-items {
margin: 0;
padding: 0;
list-style: none;
}
.cart-item {
display: grid;
grid-template-columns:
minmax(16rem, 1fr)
minmax(6rem, auto)
minmax(7rem, auto)
minmax(7rem, auto)
auto;
grid-template-areas: "product price quantity subtotal remove";
gap: 1rem;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid #d9d9d9;
}
.cart-item__product {
grid-area: product;
display: flex;
gap: 1rem;
color: inherit;
text-decoration: none;
}
.cart-item__image {
flex: 0 0 auto;
object-fit: cover;
}
.cart-item__details {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.cart-item__price {
grid-area: price;
}
.cart-item__quantity {
grid-area: quantity;
}
.cart-item__quantity label,
.field-label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
color: #555;
}
.cart-item__quantity input {
width: 5rem;
}
.cart-item__subtotal {
grid-area: subtotal;
}
.cart-item__remove {
grid-area: remove;
}
@media (max-width: 767.98px) {
.cart-item {
grid-template-columns: 6rem minmax(0, 1fr);
grid-template-areas:
"product product"
"price subtotal"
"quantity quantity"
"remove remove";
align-items: start;
}
.cart-item__product {
min-width: 0;
}
.cart-item__remove {
justify-self: start;
min-height: 44px;
}
}
这里用 class 管理样式,DOM 的阅读顺序仍然合理。CSS Grid 只负责视觉排列,所以即使样式没有加载,用户依然能够读取内容和使用表单。
在实际项目中,还要根据业务规则决定数量变化后如何更新:立即更新、输入框失去焦点后更新,或者点击“更新购物车”后统一提交。不能只依赖 JavaScript 更新界面,服务端仍需验证数量、库存、价格和可购买状态。
金额汇总不需要 table
订单金额通常是标签与数值的对应关系,用 <dl> 更合适:
<dl class="order-summary">
<div>
<dt>商品小计</dt>
<dd>¥598.00</dd>
</div>
<div>
<dt>配送费</dt>
<dd>¥20.00</dd>
</div>
<div>
<dt>优惠</dt>
<dd>−¥50.00</dd>
</div>
<div class="order-summary__total">
<dt>应付总额</dt>
<dd>¥568.00</dd>
</div>
</dl>
.order-summary > div {
display: flex;
justify-content: space-between;
gap: 2rem;
padding: 0.5rem 0;
}
.order-summary dd {
margin: 0;
text-align: end;
white-space: nowrap;
}
.order-summary__total {
margin-top: 0.5rem;
border-top: 1px solid #999;
font-weight: 700;
}
仍然应该使用 table 的情况
如果页面展示的是只读订单明细,而且用户确实需要比较各列,table 仍然是合适的选择。移动端可以先使用横向滚动,不必破坏表格结构。
<div class="table-scroll" tabindex="0" aria-label="订单商品明细,可横向滚动">
<table class="order-table">
<caption>订单商品明细</caption>
<thead>
<tr>
<th scope="col">商品</th>
<th scope="col">单价</th>
<th scope="col">数量</th>
<th scope="col">小计</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Linen Shirt,Navy,M</th>
<td>¥299.00</td>
<td>2</td>
<td>¥598.00</td>
</tr>
</tbody>
</table>
</div>
.table-scroll {
max-width: 100%;
overflow-x: auto;
overscroll-behavior-inline: contain;
}
.order-table {
width: 100%;
min-width: 40rem;
border-collapse: collapse;
}
.order-table th,
.order-table td {
padding: 0.75rem;
text-align: start;
border-bottom: 1px solid #ddd;
}
.order-table td:last-child {
white-space: nowrap;
}
横向滚动并不适合所有场景,但对于列关系重要、主要用于读取的表格,它通常比强制把每个单元格改成块级元素更可靠。
表单区域应保持标准语义
地址或支付表单不要用 table 对齐标签。应使用 <fieldset> 和 <label>,再通过 Grid 控制布局:
<fieldset class="address-fields">
<legend>收货地址</legend>
<div class="form-field">
<label for="full-name">姓名</label>
<input id="full-name" name="fullName" autocomplete="name" required>
</div>
<div class="form-field">
<label for="postal-code">邮政编码</label>
<input
id="postal-code"
name="postalCode"
autocomplete="postal-code"
inputmode="numeric"
required
>
</div>
<div class="form-field form-field--wide">
<label for="street-address">详细地址</label>
<input
id="street-address"
name="streetAddress"
autocomplete="street-address"
required
>
</div>
</fieldset>
.address-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.address-fields legend,
.form-field--wide {
grid-column: 1 / -1;
}
.form-field label,
.form-field input {
display: block;
width: 100%;
}
@media (max-width: 575.98px) {
.address-fields {
grid-template-columns: 1fr;
}
}
实施时需要注意
- 不要分别维护桌面端 table 和移动端卡片。重复的 DOM 容易造成字段不同步、
id重复、焦点重复,以及辅助技术重复朗读等问题。 - 如果商品图片只是装饰,使用
alt=""。如果图片承担商品识别功能,则提供简洁、且不重复商品名称的替代文本。 - 每个输入控件都应关联
<label>,不能只依赖placeholder。 - 删除商品应使用
<button type="button">或明确的提交按钮,不要给<div>绑定点击事件来代替按钮。 - 不能只用颜色区分规格,还要显示颜色名称或其他文字说明。
- 删除、价格变化和库存不足等动态结果应提供清楚的反馈。必要时,可以使用经过测试的
aria-live区域。 - 移动端需要保证合理的点击区域,并检查键盘操作、焦点顺序、错误提示和屏幕阅读器输出。
- Bootstrap 可以处理断点、间距和基础表单样式,但 HTML 结构仍应根据内容语义确定。
- 不要只按设备名称设置断点。应在内容开始拥挤、控件难以操作的位置切换布局。
如果购物车条目包含大量可编辑控件,而且移动端需要明显调整排列顺序,语义列表配合 CSS Grid 通常更容易维护。真正需要按行列比较的只读数据则继续使用 table。混合使用这些结构,可以更好地兼顾语义、可维护性和易用性。
备注:内容仅供参考。
