1. 项目概述:为什么选择“车之家”购物商城作为期末大作业
作为一个过来人,我太了解大学生期末大作业的痛点了 —— 既要体现技术广度,又要保证功能完整,还不能太复杂导致做不完。HTML+CSS+JavaScript 三件套是前端基础,但很多同学交上去的作业要么是静态页面拼凑,要么是功能残缺的“半成品”。这个“车之家”购物商城选题,我一开始就觉得挺聪明的:汽车相关品类自带“专业感”,能让老师眼前一亮,而且购物商城的核心流程(商品展示、购物车、结算)天然适合练手,正好覆盖了前端三大件的主要知识点。
这个项目本质上是一个纯前端模拟的购物商城,不需要后端支持,所有数据(商品信息、购物车状态)都存储在 JavaScript 的变量或 localStorage 中。适合刚学完 JS 基础、想做一个完整项目来巩固技能的同学。整个项目可以从零开始手写,不依赖任何第三方框架(如 Vue、React),让你彻底吃透原生 DOM 操作和事件处理。做完这个项目,你至少能掌握:语义化 HTML 结构、CSS 布局与动画(Flex、Grid、过渡)、JavaScript 数组方法与对象操作、事件冒泡与委托、本地存储(localStorage)的使用。这些知识点在后续的框架学习中都是基础中的基础。
我见过太多同学直接拷贝网上现成的商城模板,结果答辩时一问三不知。所以这篇博文会带你一步步自己构建,从页面设计到交互逻辑,把每个“为什么这样做”都讲清楚。你不需要有后端知识,只要懂一点 HTML 标签、CSS 选择器、JS 的基本语法,就能跟着做出来。当然,如果你已经有一些基础,可以直接跳到“核心交互实现”部分,参考购物车的逻辑设计。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体设计与技术选型:一个“车之家”商城应该长什么样
2.1 页面结构规划:从用户视角出发
一个购物商城,用户最关心的无非是“看商品”、“加购物车”、“下单”。所以我们的页面结构围绕这三个核心场景展开:
- 首页(商品列表页):展示所有汽车相关商品,包括图片、名称、价格、添加到购物车按钮。这里要体现视觉吸引力,因为汽车周边产品往往有酷炫的图片(比如轮毂、车模、机油、内饰件等)。
- 购物车页:展示已添加的商品,支持数量修改、删除、总价计算。这个页面是交互的核心,也是 JS 逻辑最密集的地方。
- 结算页(模拟):因为纯前端无法真正下单,用弹窗或页面显示“订单提交成功”的提示,并清空购物车。重点是模拟流程的完整性。
我选择将商品列表放在首页,购物车作为一个独立页面(或侧边栏)。考虑到期末作业的展示效果,我推荐使用“单页多视图”架构:所有内容都在一个 HTML 文件中,通过 CSS 控制显示/隐藏,或者用 JS 切换区域。这样代码更紧凑,也方便老师检查。当然,你也可以拆成多个 HTML 页面,但那样需要处理页面间数据传递(用 URL 参数或 localStorage),会增加复杂度。对于新手,单页应用更友好。
2.2 技术栈选型:为什么不用框架
- HTML5:语义化标签(header、nav、main、section、footer)让结构清晰,也有利于 SEO 和可访问性。对于期末作业,使用语义化标签是个加分项。
- CSS3:Flexbox 和 Grid 用于布局,配合过渡动画(hover 效果、购物车抽屉滑入)提升用户体验。不需要 Bootstrap 等框架,因为手写 CSS 能体现你对布局的理解。而且车之家主题可以设计深色/金属质感配色,更容易出彩。
- 原生 JavaScript(ES6+):使用 let/const、箭头函数、模板字符串、数组方法(map、filter、reduce)等,既现代又简洁。用 localStorage 持久化购物车数据,即使刷新页面,购物车内容也不会丢失。
不选择 Vue/React 的原因:期末大作业通常要求展示基础能力,框架会掩盖原生基本功。而且很多老师明确禁止使用框架,要求手写原生。但如果你有基础,想展示学习能力,可以额外加一个基于框架的版本作为对比,不过主体还是原生。
2.3 数据模型设计:用对象数组模拟数据库
在 JavaScript 中,我们用一个数组来存储所有商品信息。每个商品是一个对象,包含 id、名称、图片、价格、分类等属性。例如:
javascript复制const products = [
{
id: 1,
name: '赛车座椅靠垫',
image: 'images/seat.jpg',
price: 299,
category: '内饰'
},
{
id: 2,
name: '全合成机油 5W-30',
image: 'images/oil.jpg',
price: 198,
category: '保养'
}
// 更多商品...
];
购物车数据则是一个数组,每个元素包含商品 id、数量。计算总价时通过 id 从 products 中查找价格。这种设计让数据与视图分离,购物车只需存储 id 和数量,减少冗余。
javascript复制let cart = [
{ id: 1, quantity: 2 },
{ id: 3, quantity: 1 }
];
这样设计的好处是:当商品信息(如价格)变化时,购物车自动同步最新价格,不需要手动更新每个购物车项。而且容易扩展,比如添加商品介绍、库存等字段。
3. 核心细节解析与实操要点
3.1 HTML 结构搭建:从骨架到填充
先设计整体布局。我习惯用“头-主体-尾”结构,主体内分左右(或上下)区域。对于首页,商品列表用网格展示。购物车区域可以放在页面右侧固定位置,或者用按钮触发弹窗。
示例 HTML 结构(简化版):
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>车之家 - 汽车用品购物商城</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="logo">车之家</div>
<nav>
<ul>
<li><a href="#" data-page="home">首页</a></li>
<li><a href="#" data-page="cart">购物车 (<span id="cart-count">0</span>)</a></li>
</ul>
</nav>
</header>
<main>
<!-- 首页商品列表区域 -->
<section id="home-page" class="page active">
<div class="product-grid" id="product-grid">
<!-- 由 JS 动态生成 -->
</div>
</section>
<!-- 购物车页面区域 -->
<section id="cart-page" class="page">
<div id="cart-content">
<!-- 购物车列表 -->
</div>
<div id="cart-total">
总计:<span id="total-price">0</span> 元
<button id="checkout-btn">结算</button>
</div>
</section>
</main>
<footer>
<p>© 2025 车之家 期末大作业</p>
</footer>
<script src="script.js"></script>
</body>
</html>
注意点:
- 使用
data-page属性配合 JS 控制页面切换,比直接操作 href 更优雅。 - 购物车数量徽标(
#cart-count)实时更新,需要动态绑定。 - 商品列表区域留空,由 JS 渲染,避免硬编码重复 HTML。
3.2 CSS 布局与样式设计:让“车之家”有质感
汽车主题的视觉风格通常偏向硬朗、金属、深色,但作为大学生作业,也可以走简洁清爽路线,关键是要干净。我选择了深灰+橙色的配色方案,深灰作为背景,橙色作为按钮和强调色,呼应“车之家”的动感。
布局上,商品网格使用 CSS Grid:
css复制.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
padding: 20px;
}
这样能自适应屏幕宽度,每行最少 250px,自动填充。购物车页面则使用 Flex 布局,左边列表,右边结算区(或上下结构)。
为了让过渡更平滑,我添加了页面切换动画。当两个页面切换时,用 opacity 和 transform 实现淡入淡出效果。
css复制.page {
display: none;
opacity: 0;
transition: opacity 0.3s ease;
}
.page.active {
display: block;
opacity: 1;
}
注意:display 无法直接动画,所以这里用 display: none 隐藏,但 active 时用 block 显示,再用 opacity 过渡。不过更好的做法是用 visibility 和 opacity 配合,或者用 position: absolute 叠加。我倾向于用 display: none 加 opacity 的简单模式,因为对于期末作业,效果已经足够。
商品卡片样式设计:
css复制.product-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.15);
}
.product-card img {
width: 100%;
height: 200px;
object-fit: cover;
}
.product-card .info {
padding: 15px;
}
.product-card .info h3 {
margin: 0 0 10px;
font-size: 18px;
}
.product-card .info .price {
color: #e67e22;
font-size: 20px;
font-weight: bold;
}
.product-card .add-to-cart {
display: block;
width: 100%;
padding: 10px;
background: #e67e22;
color: #fff;
border: none;
border-radius: 0 0 8px 8px;
cursor: pointer;
transition: background 0.2s;
}
.product-card .add-to-cart:hover {
background: #d35400;
}
这里要注意:图片必须使用 object-fit: cover 来适应容器,避免图片变形。同时,每个商品卡片应该包含一个“加入购物车”按钮,按钮通过自定义属性 data-product-id 绑定商品 ID,方便 JS 事件委托。
3.3 JavaScript 核心逻辑:从数据渲染到交互
3.3.1 商品列表渲染
使用 document.getElementById('product-grid') 获取容器,然后遍历 products 数组,生成 HTML 字符串,一次性插入。避免频繁 DOM 操作导致性能问题。
javascript复制function renderProducts() {
const grid = document.getElementById('product-grid');
let html = '';
products.forEach(product => {
html += `
<div class="product-card" data-id="${product.id}">
<img src="${product.image}" alt="${product.name}">
<div class="info">
<h3>${product.name}</h3>
<p class="price">¥${product.price}</p>
</div>
<button class="add-to-cart" data-id="${product.id}">加入购物车</button>
</div>
`;
});
grid.innerHTML = html;
}
这里使用模板字符串(反引号),清晰易读。注意:data-id 属性是后续事件绑定的关键。
3.3.2 加入购物车功能
使用事件委托,在 product-grid 上监听点击事件,通过 event.target.closest('.add-to-cart') 判断是否点击了按钮。然后获取按钮的 data-id,调用 addToCart(id) 函数。
javascript复制document.getElementById('product-grid').addEventListener('click', function(e) {
const btn = e.target.closest('.add-to-cart');
if (!btn) return;
const id = parseInt(btn.dataset.id);
addToCart(id);
});
function addToCart(productId) {
// 查找购物车中是否已有该商品
const existingItem = cart.find(item => item.id === productId);
if (existingItem) {
existingItem.quantity++;
} else {
cart.push({ id: productId, quantity: 1 });
}
// 更新购物车显示
updateCartUI();
// 保存到 localStorage
saveCart();
}
注意:find 方法返回第一个匹配项,如果不存在则 undefined。这里用 push 添加新对象。另外,购物车数组在内存中,需要同步更新视图和本地存储。
3.3.3 购物车页面渲染
购物车页面需要展示每个商品的详细信息(通过 id 到 products 中查找)、数量、小计,以及总计。渲染函数:
javascript复制function renderCart() {
const cartContent = document.getElementById('cart-content');
if (cart.length === 0) {
cartContent.innerHTML = '<p>购物车还是空的,快去逛逛吧!</p>';
document.getElementById('total-price').textContent = '0';
return;
}
let html = '';
let total = 0;
cart.forEach(item => {
const product = products.find(p => p.id === item.id);
if (!product) return; // 防止商品数据被删除导致异常
const subtotal = product.price * item.quantity;
total += subtotal;
html += `
<div class="cart-item" data-id="${item.id}">
<img src="${product.image}" alt="${product.name}" width="80">
<div class="item-info">
<h4>${product.name}</h4>
<p>单价:¥${product.price}</p>
<div class="quantity-control">
<button class="decrease" data-id="${item.id}">-</button>
<span>${item.quantity}</span>
<button class="increase" data-id="${item.id}">+</button>
</div>
<p class="subtotal">小计:¥${subtotal}</p>
</div>
<button class="remove-item" data-id="${item.id}">删除</button>
</div>
`;
});
cartContent.innerHTML = html;
document.getElementById('total-price').textContent = total.toFixed(2);
}
注意:每个商品项中,数量增减按钮和删除按钮都绑定了 data-id,方便事件委托。同时,total.toFixed(2) 保留两位小数,避免浮点数精度问题。
3.3.4 购物车数量增减与删除
同样使用事件委托,在 cart-content 上监听点击,根据 target 的类名判断操作。
javascript复制document.getElementById('cart-content').addEventListener('click', function(e) {
const target = e.target;
const id = parseInt(target.dataset.id);
if (target.classList.contains('increase')) {
const item = cart.find(item => item.id === id);
if (item) item.quantity++;
updateCartUI();
} else if (target.classList.contains('decrease')) {
const item = cart.find(item => item.id === id);
if (item) {
item.quantity--;
if (item.quantity <= 0) {
// 数量为0时移除该商品
cart = cart.filter(item => item.id !== id);
}
}
updateCartUI();
} else if (target.classList.contains('remove-item')) {
cart = cart.filter(item => item.id !== id);
updateCartUI();
}
});
注意:减少数量时,如果变为0,则自动从购物车中移除。这是一种常见的交互设计,也可以保留数量为0时显示“已失效”,但简化版直接移除更清晰。
3.3.5 本地存储持久化
每次购物车变化时,调用 saveCart() 将 cart 数组转为 JSON 字符串存入 localStorage。页面加载时,从 localStorage 读取并还原。
javascript复制function saveCart() {
localStorage.setItem('carShopCart', JSON.stringify(cart));
}
function loadCart() {
const saved = localStorage.getItem('carShopCart');
if (saved) {
try {
cart = JSON.parse(saved);
} catch (e) {
cart = [];
}
} else {
cart = [];
}
}
注意:JSON.parse 可能抛出异常,如果数据被篡改,需要捕获并重置。另外,loadCart() 应在页面初始化时最早调用,以便恢复购物车状态。
3.3.6 页面切换与导航高亮
使用 data-page 属性控制页面显示。点击导航链接时,切换 active 类。
javascript复制document.querySelectorAll('nav a[data-page]').forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const pageId = this.dataset.page;
// 隐藏所有页面
document.querySelectorAll('.page').forEach(page => page.classList.remove('active'));
// 显示目标页面
document.getElementById(pageId + '-page').classList.add('active');
// 如果切换到购物车页面,重新渲染购物车
if (pageId === 'cart') {
renderCart();
}
});
});
注意:切换时,需要重新渲染购物车页面,因为数据可能已经变化。另外,当前页面高亮效果可以通过给导航链接添加 active 类实现,此处略。
3.4 数据与视图同步:避免常见 bug
前端开发中,数据与视图不同步是新手最容易犯的错误。比如,购物车数量变化后,忘记更新页面上的总价和徽标。我的做法是:将所有更新 UI 的操作集中到一个函数 updateCartUI() 中,该函数负责:
- 更新导航栏购物车徽标数量(
cart.reduce((sum, item) => sum + item.quantity, 0)) - 如果当前显示的是购物车页面,则重新渲染购物车列表和总价
- 保存 localStorage
javascript复制function updateCartUI() {
// 更新徽标
const count = cart.reduce((sum, item) => sum + item.quantity, 0);
document.getElementById('cart-count').textContent = count;
// 如果当前页面是购物车,重新渲染
const cartPage = document.getElementById('cart-page');
if (cartPage.classList.contains('active')) {
renderCart();
}
saveCart();
}
这样,任何地方修改了购物车数据后,只需调用 updateCartUI() 即可确保所有视图一致。避免了重复代码和遗漏。
4. 实操过程与核心环节实现
4.1 准备素材与项目结构
首先,你需要准备商品图片。对于“车之家”,可以到免费图库(如 Unsplash、Pexels)搜索“car accessories”等关键词,下载几张图片,并重命名为 seat.jpg、oil.jpg、tire.jpg 等,放在 images 文件夹下。如果没有图片,也可以使用占位图服务(如 https://via.placeholder.com/300x200?text=Car+Seat),但建议使用真实图片,展示效果更好。
项目文件结构:
code复制car-shop/
├── index.html
├── style.css
├── script.js
└── images/
├── seat.jpg
├── oil.jpg
├── tire.jpg
└── ...
所有代码都在根目录,方便管理。
4.2 一步步实现:从空白到完整商城
我按照以下顺序编写代码,每完成一步测试一步,避免最后堆在一起找 bug。
第一步:HTML 骨架。先写一个最简单的页面,包含标题、导航、两个页面容器(商品列表和购物车),确保能正常显示。
第二步:CSS 基础样式。给页面设置背景色、字体、导航栏样式,然后实现商品卡片的基本样式,不需要太复杂,只要布局整齐即可。
第三步:JavaScript 数据定义。在 script.js 最顶部定义 products 数组和 cart 数组,然后调用 loadCart() 恢复购物车。
第四步:渲染商品列表。实现 renderProducts() 函数,在页面加载时调用,确保商品显示出来。
第五步:实现加入购物车功能。添加事件委托,测试按钮点击后,alert 或 console.log 检验是否成功获取 ID。然后逐步完善 addToCart 和 updateCartUI,观察徽标数字变化。
第六步:实现购物车页面渲染。导航链接绑定点击事件,切换到购物车页面,并调用 renderCart()。此时购物车页面应该显示已添加的商品(如果为空则显示提示)。
第七步:实现购物车内的数量增减与删除。添加事件委托,测试增减按钮和删除按钮正常工作,总价实时更新。
第八步:结算功能。点击“结算”按钮,弹出确认框或显示一个模态框,提示“订单提交成功”,然后清空购物车并更新 UI。注意:清空购物车后,需要调用 updateCartUI() 并且导航到首页(或保持当前页面)。
第九步:优化细节。添加页面切换动画、购物车徽标动画、输入验证(如数量不能为负数)、商品搜索或分类筛选(可选)。这些可以为你的作业加分。
4.3 关键代码片段与解释
下面给出完整 script.js 的核心部分,并附上注释说明。
javascript复制// ============== 数据定义 ==============
const products = [
{ id: 1, name: '赛车座椅靠垫', image: 'images/seat.jpg', price: 299, category: '内饰' },
{ id: 2, name: '全合成机油 5W-30', image: 'images/oil.jpg', price: 198, category: '保养' },
{ id: 3, name: '运动轮毂 18寸', image: 'images/wheel.jpg', price: 1280, category: '改装' },
{ id: 4, name: '车载吸尘器', image: 'images/vacuum.jpg', price: 159, category: '清洁' },
{ id: 5, name: '行车记录仪', image: 'images/dashcam.jpg', price: 399, category: '电子' },
{ id: 6, name: '汽车香水', image: 'images/perfume.jpg', price: 49, category: '内饰' }
];
let cart = [];
// ============== 本地存储操作 ==============
function loadCart() {
const saved = localStorage.getItem('carShopCart');
if (saved) {
try {
cart = JSON.parse(saved);
// 确保每个对象都有 id 和 quantity
cart = cart.filter(item => item.id && item.quantity > 0);
} catch (e) {
cart = [];
}
}
}
function saveCart() {
// 只保存有效的购物车项(数量>0)
const validCart = cart.filter(item => item.quantity > 0);
localStorage.setItem('carShopCart', JSON.stringify(validCart));
}
// ============== 渲染函数 ==============
function renderProducts() {
const grid = document.getElementById('product-grid');
let html = '';
products.forEach(product => {
html += `
<div class="product-card">
<img src="${product.image}" alt="${product.name}" onerror="this.src='https://via.placeholder.com/300x200?text=Car+Product'">
<div class="info">
<h3>${product.name}</h3>
<p class="price">¥${product.price.toFixed(2)}</p>
</div>
<button class="add-to-cart" data-id="${product.id}">加入购物车</button>
</div>
`;
});
grid.innerHTML = html;
}
function renderCart() {
const cartContent = document.getElementById('cart-content');
if (cart.length === 0) {
cartContent.innerHTML = '<p class="empty-cart">购物车还是空的,快去逛逛吧!</p>';
document.getElementById('total-price').textContent = '0.00';
return;
}
let html = '';
let total = 0;
cart.forEach(item => {
const product = products.find(p => p.id === item.id);
if (!product) return; // 如果商品不存在,忽略
const subtotal = product.price * item.quantity;
total += subtotal;
html += `
<div class="cart-item" data-id="${item.id}">
<img src="${product.image}" alt="${product.name}" width="80" onerror="this.src='https://via.placeholder.com/80?text=No+Img'">
<div class="item-info">
<h4>${product.name}</h4>
<p class="item-price">单价:¥${product.price.toFixed(2)}</p>
<div class="quantity-control">
<button class="decrease" data-id="${item.id}">-</button>
<span class="quantity">${item.quantity}</span>
<button class="increase" data-id="${item.id}">+</button>
</div>
<p class="subtotal">小计:¥${subtotal.toFixed(2)}</p>
</div>
<button class="remove-item" data-id="${item.id}">删除</button>
</div>
`;
});
cartContent.innerHTML = html;
document.getElementById('total-price').textContent = total.toFixed(2);
}
function updateCartUI() {
// 更新徽标
const count = cart.reduce((sum, item) => sum + item.quantity, 0);
document.getElementById('cart-count').textContent = count;
// 如果当前页面是购物车,重新渲染
const cartPage = document.getElementById('cart-page');
if (cartPage.classList.contains('active')) {
renderCart();
}
// 保存到 localStorage
saveCart();
}
// ============== 购物车操作函数 ==============
function addToCart(productId) {
const existingItem = cart.find(item => item.id === productId);
if (existingItem) {
existingItem.quantity++;
} else {
cart.push({ id: productId, quantity: 1 });
}
updateCartUI();
// 可选:添加反馈动画
showToast('商品已加入购物车');
}
function removeFromCart(productId) {
cart = cart.filter(item => item.id !== productId);
updateCartUI();
}
function changeQuantity(productId, delta) {
const item = cart.find(item => item.id === productId);
if (!item) return;
item.quantity += delta;
if (item.quantity <= 0) {
removeFromCart(productId);
} else {
updateCartUI();
}
}
// ============== 事件绑定 ==============
document.addEventListener('DOMContentLoaded', function() {
// 加载购物车数据
loadCart();
// 渲染商品列表
renderProducts();
// 更新UI(徽标等)
updateCartUI();
// 商品列表区域:加入购物车
document.getElementById('product-grid').addEventListener('click', function(e) {
const btn = e.target.closest('.add-to-cart');
if (!btn) return;
const id = parseInt(btn.dataset.id);
addToCart(id);
});
// 购物车区域:数量增减、删除
document.getElementById('cart-content').addEventListener('click', function(e) {
const target = e.target;
const id = parseInt(target.dataset.id);
if (target.classList.contains('increase')) {
changeQuantity(id, 1);
} else if (target.classList.contains('decrease')) {
changeQuantity(id, -1);
} else if (target.classList.contains('remove-item')) {
removeFromCart(id);
}
});
// 导航切换页面
document.querySelectorAll('nav a[data-page]').forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const pageId = this.dataset.page;
document.querySelectorAll('.page').forEach(page => page.classList.remove('active'));
document.getElementById(pageId + '-page').classList.add('active');
if (pageId === 'cart') {
renderCart();
}
});
});
// 结算按钮
document.getElementById('checkout-btn').addEventListener('click', function() {
if (cart.length === 0) {
alert('购物车为空,请先添加商品');
return;
}
// 模拟结算
const total = cart.reduce((sum, item) => {
const product = products.find(p => p.id === item.id);
return sum + (product ? product.price * item.quantity : 0);
}, 0);
if (confirm(`订单总金额:¥${total.toFixed(2)}\n确认提交订单?`)) {
// 清空购物车
cart = [];
updateCartUI();
// 切换到首页
document.querySelectorAll('.page').forEach(page => page.classList.remove('active'));
document.getElementById('home-page').classList.add('active');
// 提示成功
alert('订单提交成功!感谢在车之家购物。');
}
});
});
// ============== 辅助函数:简易 Toast 提示 ==============
function showToast(message) {
// 简单实现,也可以使用 alert 或自定义弹窗
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-visible');
setTimeout(() => {
toast.classList.remove('toast-visible');
setTimeout(() => toast.remove(), 300);
}, 2000);
}, 100);
}
4.4 样式完善:让页面“看起来”更专业
除了基础样式,还需要添加购物车页面样式、Toast 提示样式、响应式设计等。下面给出关键 CSS 片段。
css复制/* 全局样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f5f5f5;
color: #333;
}
header {
background: #2c3e50;
color: #fff;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}
.logo {
font-size: 24px;
font-weight: bold;
color: #e67e22;
}
nav ul {
list-style: none;
display: flex;
gap: 20px;
}
nav a {
color: #fff;
text-decoration: none;
font-size: 16px;
position: relative;
}
nav a[data-page="cart"] #cart-count {
background: #e67e22;
color: #fff;
border-radius: 50%;
padding: 2px 8px;
font-size: 12px;
margin-left: 5px;
}
main {
min-height: calc(100vh - 120px);
padding: 20px;
}
.page {
display: none;
}
.page.active {
display: block;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
footer {
background: #2c3e50;
color: #fff;
text-align: center;
padding: 15px;
font-size: 14px;
}
/* 商品网格 */
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 25px;
max-width: 1200px;
margin: 0 auto;
}
.product-card {
background: #fff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
}
.product-card img {
width: 100%;
height: 220px;
object-fit: cover;
}
.product-card .info {
padding: 15px;
}
.product-card .info h3 {
font-size: 18px;
margin-bottom: 8px;
}
.product-card .info .price {
color: #e67e22;
font-size: 22px;
font-weight: bold;
}
.add-to-cart {
width: 100%;
padding: 12px;
background: #e67e22;
color: #fff;
border: none;
cursor: pointer;
font-size: 16px;
transition: background 0.2s;
}
.add-to-cart:hover {
background: #d35400;
}
/* 购物车页面 */
#cart-page {
max-width: 800px;
margin: 0 auto;
}
.cart-item {
display: flex;
align-items: center;
background: #fff;
margin-bottom: 15px;
border-radius: 8px;
padding: 15px;
box-shadow: 0 1px 5px rgba(0,0,0,0.1);
}
.cart-item img {
border-radius: 5px;
margin-right: 15px;
}
.cart-item .item-info {
flex: 1;
}
.cart-item .item-info h4 {
margin-bottom: 5px;
}
.cart-item .item-price {
color: #888;
font-size: 14px;
}
.quantity-control {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0;
}
.quantity-control button {
width: 30px;
height: 30px;
border: 1px solid #ccc;
background: #f9f9f9;
border-radius: 4px;
cursor: pointer;
font-size: 18px;
line-height: 30px;
text-align: center;
}
.quantity-control button:hover {
background: #eee;
}
.quantity-control .quantity {
font-size: 18px;
min-width: 30px;
text-align: center;
}
.subtotal {
font-weight: bold;
color: #e67e22;
}
.remove-item {
background: #e74c3c;
color: #fff;
border: none;
padding: 8px 15px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-left: 10px;
}
.remove-item:hover {
background: #c0392b;
}
.empty-cart {
text-align: center;
font-size: 18px;
color: #999;
padding: 50px 0;
}
#cart-total {
text-align: right;
padding: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 5px rgba(0,0,0,0.1);
margin-top: 20px;
}
#cart-total span {
font-size: 24px;
font-weight: bold;
color: #e67e22;
}
#checkout-btn {
background: #27ae60;
color: #fff;
border: none;
padding: 12px 30px;
font-size: 18px;
border-radius: 5px;
cursor: pointer;
margin-left: 20px;
}
#checkout-btn:hover {
background: #219a52;
}
/* Toast 提示 */
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
background: #2c3e50;
color: #fff;
padding: 12px 24px;
border-radius: 5px;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 999;
}
.toast-visible {
opacity: 1;
}
这些样式足够让页面看起来像一个完整的商城。你可以根据喜好调整配色,比如改成蓝色调或红色调。
5. 常见问题与排查技巧实录
在实际开发中,我遇到过不少坑,也看到同学们经常在以下几个地方卡住。下面整理成问答形式,方便你快速定位问题。
5.1 购物车数量不更新或徽标显示错误
现象:点击加号或减号后,页面上的数量没有变化,或者导航栏的购物车数量总是显示0。
原因:最可能的原因是事件绑定没有正确指向购物车内容区域,或者 updateCartUI() 函数没有正确计算总数量。也可能是 cart 数组没有正确更新。
排查步骤:
- 在
addToCart或changeQuantity函数中,用console.log(cart)打印当前购物车数据,确认数据是否改变。 - 检查
updateCartUI中cart.reduce是否写对了,注意reduce的初始值是否为0。 - 检查事件委托的选择器,确保
cart-content上的点击事件能捕获到按钮。可以添加console.log(e.target)看点击目标是否被正确识别。 - 如果使用
closest方法,确认按钮的类名在 HTML 中正确,且没有拼写错误。
解决方案:按照上述步骤,基本能定位问题。我遇到最常见的是 closest 条件写错,例如 e.target.closest('.increase') 写成了 .increas。另外,注意 dataset.id 是字符串,需要 parseInt 转换为数字才能正确比较。
5.2 商品图片加载失败或显示alt文本
现象:商品图片显示不出来,只显示一个“×”或alt文本。
原因:图片路径错误,或者图片文件不存在。也可能是图片格式不支持(如.webp在旧浏览器上)。
解决方案:
- 检查
images文件夹下的图片文件名是否与script.js中定义的一致,注意大小写和扩展名。 - 图片路径相对于 HTML 文件,如果 HTML 在根目录,图片路径应为
images/seat.jpg。 - 我建议在
img标签上添加onerror属性,当图片加载失败时显示一个占位图,这样至少不会出现“裂图”影响美观。上面代码中已经添加了onerror处理,你可以替换成你自己的占位图URL。
5.3 页面切换时购物车内容仍保留,但总价不更新
现象:从首页切换到购物车页面时,购物车列表显示正确,但总价还是0,或者还是上次的值。
原因:在切换到购物车页面时,没有重新调用 renderCart() 函数。因为 renderCart 中会计算总价并更新 #total-price。
解决方案:在导航点击事件中,切换到购物车页面后,要调用 renderCart()。上面代码中已经做了,但如果你忘记,就会导致总价不更新。
5.4 结算时购物车清空,但刷新页面后购物车又恢复
现象:点击结算,购物车清空,页面显示正常。但刷新页面后,购物车又出现了之前的商品。
原因:在结算时,你清空了 cart 数组并调用了 updateCartUI(),但 updateCartUI 中调用了 saveCart(),而 saveCart 可能会将空数组写入 localStorage。但问题可能出在 saveCart 没有正确覆盖旧数据,或者 loadCart 在页面加载时从 localStorage 读取了旧数据。
解决方案:确保你的 saveCart 函数中,在写入 localStorage 之前,先过滤掉数量 <=0 的项。上面代码中 saveCart 已经做了 filter。另外,检查 loadCart 是否在 DOMContentLoaded 中最早调用,确保没有其他代码插入旧数据。还有一个常见错误:在结算函数中,使用了 cart = [] 但忘记更新 localStorage,导致页面刷新后重新加载了旧数据。所以务必在修改 cart 后调用 updateCartUI() 或直接调用 saveCart()。
5.5 页面在不同屏幕尺寸下布局错乱
现象:在大屏上布局正常,在手机或小窗口下,商品卡片挤在一起,或者购物车项目变形。
原因:没有使用响应式设计。商品网格使用 auto-fill 和 minmax 已经自适应,但其他元素(如导航栏、购物车项目)可能需要媒体查询调整。
解决方案:添加媒体查询,例如:
css复制@media (max-width: 768px) {
.product-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
.cart-item {
flex-direction: column;
text-align: center;
}
.cart-item img {
width: 100%;
max-width: 150px;
margin: 0 auto 10px;
}
.quantity-control {
justify-content: center;
}
header {
flex-direction: column;
gap: 10px;
}
nav ul {
gap: 10px;
}
}
这样在小屏下,购物车项目变为垂直排列,图片居中,按钮也居中,体验更好。
5.6 使用 localStorage 时出现跨域问题或数据丢失
问题:直接打开 index.html 使用 file:// 协议时,localStorage 无法正常工作(某些浏览器限制)。而且数据可能会因为浏览器清理缓存而丢失。
解决方案:
- 建议在本地搭建一个简单的 HTTP 服务器(如使用 VS Code 的 Live Server 插件,或者 Python 的
http.server模块),这样 localStorage 在http://协议下稳定工作。 - 数据丢失通常是用户主动清理缓存,无法避免。可以在代码中添加一个提示,告知用户购物车数据存储在本地浏览器中,清除缓存会丢失。
6. 进阶优化与扩展思路
如果你的期末作业想拿高分,或者你觉得当前功能太简单,可以尝试以下扩展,它们都能很好地展示你的编程能力。
6.1 商品搜索与分类筛选
在首页顶部添加一个搜索框,输入关键字后,实时过滤商品列表。使用 filter 方法,将商品名称或分类中包含关键字的产品显示出来。再添加分类按钮(如“全部”、“内饰”、“保养”、“改装”),点击后只显示对应分类的商品。
实现思路:在 renderProducts 函数中接受一个可选的 filteredProducts 参数,默认是 products。当搜索或分类变化时,重新调用 renderProducts 并传入过滤后的数组。
6.2 商品详情弹窗
点击商品卡片(不是购物车按钮),弹出一个模态框,显示商品的大图、详细描述、规格等。这样可以让页面看起来更专业。模态框可以用 CSS 实现,控制 display 和 opacity。
6.3 购物车全选/取消全选功能
在购物车页面头部添加一个“全选”复选框,勾选后,所有商品前面出现复选框,选中状态统一。然后可以批量删除或计算选中商品总价。这个功能需要修改购物车数据结构,为每个商品添加 checked 属性,并更新 renderCart 中的复选框状态。
6.4 使用 BOM 的 history 实现浏览器前进后退
利用 window.history.pushState 和 popstate 事件,使页面前进后退能切换首页和购物车页面,而不是仅仅靠点击导航。这样用户体验更接近真实应用。
6.5 增加动画效果
比如商品加入购物车时,有一个小动画飞向购物车图标(类似京东、淘宝的加入购物车动画)。这个需要用到 requestAnimationFrame 或 CSS 动画,配合获取按钮位置和购物车图标位置,计算飞行路径。虽然复杂,但做好了非常惊艳。
一些个人经验分享
做这个项目时,我最大的体会是:期末作业不在于功能多,而在于每个功能都做得扎实、稳定。很多同学喜欢堆砌很多看似高级的功能,但每个都有 bug,老师一眼就能看出来。相反,如果你把基础的展示、添加、删除、结算这四个核心功能做到极致——没有控制台报错、数据持久化正确、界面清爽、交互流畅,分数一定不会低。
另外,答辩时一定要能讲清楚你代码的逻辑。比如老师问“为什么购物车数据要存在 localStorage 而不是 sessionStorage?”你可以回答:因为 localStorage 的数据不会因为关闭标签页而丢失,即使用户误关页面,下次打开购物车还在,更符合购物车的使用场景。这样的回答能体现你思考过,而不是单纯抄代码。
最后,代码注释一定要写。虽然老师可能不看,但写注释能帮你理清思路,而且万一要检查代码,有注释的代码印象分会好很多。我习惯在关键函数前面用 /** ... */ 写清楚输入输出和功能描述。
如果你按照这篇博文一步步做下来,一个完整的“车之家”购物商城就完成了。记得把项目打包成 zip 提交,里面包含 index.html、style.css、script.js 和 images 文件夹。祝你的期末大作业顺利通过,如果能拿到高分,别忘了回来告诉我一声。
