简介:一份基于HTML5、CSS3与JavaScript仿制小米商城首页及登录页面的前端静态页面资源,面向网页开发初学者及需要巩固前端基础的学习者,用于实践语义化标签、页面布局、表单交互与响应式适配。压缩包共126个文件,主要包含97个PNG、18个JPG及JPEG/WebP/GIF等图片素材,配合3个CSS样式表、2个HTML页面、1个JavaScript脚本与ICO图标,整体约2.4MB,文件按页面和样式拆分,结构清晰。已有4571人学习浏览,实战参考价值较高。代码覆盖HTML5语义化结构、CSS3 Flexbox/Grid布局、媒体查询适配、JavaScript表单校验及AJAX异步登录等要点,并关注浏览器兼容与无障碍优化;样式文件将基础重置、首页样式与登录页样式分离,便于理解CSS组织方式,读者可结合图片素材与页面代码逐步仿写商城首页和登录页,完整走一遍前端开发流程。
1. 这个标题到底在讲什么:先写一个能跑的静态商城项目
如果你搜到这个标题,多半是想用纯前端三件套把小米商城的首页和登录页复刻出来,而不是引入 Vue 或 React 那一套工程化方案。这个需求在面试作品集、课程大作业里出现频率很高,也有不少朋友是刚学完 JS 基础,想找个综合项目练手。用 HTML + CSS + JS 实现商城首页和登录页面,说白了就是不用框架、不依赖构建工具,打开浏览器就能跑的静态页面项目,数据交互用本地存储模拟。
这类项目的价值在于:它逼你把 CSS 布局、DOM 操作、事件处理、表单校验这些基础功扎扎实实过一遍,而且效果直观——写完就能看到接近真实商城的界面。对于想走前端方向的同学,这是性价比很高的练手项目;对于想快速搭一个演示 demo 的开发者,这个方案也能满足需求。
我见过不少人在这个项目上翻车,常见的是布局乱飞、登录校验形同虚设、页面刷新后状态丢失。这些坑都来自同一个根源:对静态页面的边界和手法不熟悉。这篇文章不打算带你一行行抄代码,而是按「先立架构、再实现、最后避坑」的思路,把整条链路拆给你看。
2. 首页拆解:从导航栏到商品卡片,一套适合静态页的布局方案
2.1 整体布局选型:固定宽度容器与 Flex 布局
动手写首页前,第一件事是确定布局策略。小米商城这类电商首页通常有两套经典方案:一套是全屏自适应,宽度跟着视口走;另一套是固定宽度居中,内容区域恒定 1226px(小米官网长期使用的宽度)。静态项目我建议用固定宽度居中,原因很简单:它好控制、好对齐,不需要花大量精力去处理响应式边界。你可以在外层套一个容器,设定width: 1226px; margin: 0 auto;,内部所有模块都以此为基准。
.container { width: 1226px; margin: 0 auto; /* 上下留 0,左右自动居中 */ }这段样式的逻辑是:给容器一个固定宽度,然后利用margin: 0 auto让它在水平方向居中。注意margin的简写顺序是「上 右 下 左」,这里只写了两个值,表示上下为 0、左右为 auto。它的优点是无论浏览器窗口多大,内容始终居中且宽度不变;缺点是窄屏下两侧会出现滚动条,不过对于静态演示项目,这个代价完全可接受。
有了容器之后,页面内部的模块推荐统一用 Flex 布局。电商首页的典型结构是:顶部导航栏、搜索区、商品分类导航、轮播图、商品卡片区、底部信息。导航栏用display: flex; justify-content: space-between;可以把左右两块撑开,左侧放 Logo 和分类链接,右侧放登录入口和购物车入口,这个布局方式不需要浮动,也不容易出现高度塌陷。
2.2 导航栏与轮播图:定位方式与层级管理
导航栏有一个常见的需求:滚动页面时它固定在窗口顶部。实现方式是用position: fixed,它的定位基准是浏览器视口,与文档流无关。需要注意,fixed定位的元素脱离了文档流,后续内容会顶上来,造成遮挡,所以通常要给主体内容加上margin-top或给导航栏预留占位元素,数值等于导航栏高度。
.header { position: fixed; top: 0; left: 0; width: 100%; height: 60px; background: #fff; z-index: 100; } .main { margin-top: 60px; }这里的z-index: 100是层级管理的关键。轮播图在切换时容易出现箭头按钮、图片指示器、下拉菜单互相遮挡的问题,统一约定:导航栏 100,轮播图控制按钮 10,弹层 200,这样层级关系清晰,不会出现按钮点不到的尴尬。
轮播图是整个首页最容易写崩的部分。静态页面的轮播图核心逻辑是:维护一个索引,控制图片列表的transform位移,配合定时器和手动切换按钮。早期的做法是把所有图片横排放在一个容器里,通过修改margin-left来切换位置;现在更推荐用transform: translateX(),因为它在动画平滑度和性能上更好。
function goToSlide(index) { const track = document.querySelector('.slider-track'); const width = document.querySelector('.slider').offsetWidth; // 左右边界约束,超出范围时回到边界 if (index < 0) { index = 0; } if (index >= track.children.length) { index = track.children.length - 1; } currentIndex = index; track.style.transform = 'translateX(' + -width * currentIndex + 'px)'; }这段代码里的offsetWidth是读取轮播容器当前的实际宽度,每次调用时实时获取,避免窗口大小变化后位移量不准。-width * currentIndex是向左移动,因为图片排列方向是水平向右,要展示第 N 张图就要把轨道整体左移 N 个图宽的距离。
2.3 商品卡片网格与组件化思维
商品区域是整个页面信息量最大、最容易出现样式错乱的地方。它的标准做法是网格布局:每一行放 4 个或 5 个商品卡片,每个卡片包含图片、标题、描述、价格。用 CSS Grid 是最直接的方式,grid-template-columns: repeat(4, 1fr)可以一行放四个等宽卡片,间距用gap控制,不用再去算 margin 负值。
.product-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; } .product-card { background: #fff; padding: 20px; text-align: center; transition: box-shadow 0.3s ease; } .product-card:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); }写到这里,你可能会发现一个问题:如果商品卡片数量、内容都在 HTML 里硬编码,那页面只要加一个商品就得复制一大段 HTML,维护成本很高。静态项目常见做法是用 JS 数组保存商品数据,循环生成卡片 DOM。这样数据与视图分离,后面想换商品、加跳转链接都方便。
const products = [ { name: '智能手环', desc: '心率监测', price: 199 }, { name: '蓝牙耳机', desc: '主动降噪', price: 349 }, { name: '台灯', desc: '护眼无频闪', price: 99 } ]; const grid = document.querySelector('.product-grid'); products.forEach(item => { const card = document.createElement('div'); card.className = 'product-card'; card.innerHTML = ` <h3>${item.name}</h3> <p>${item.desc}</p> <span class="price">${item.price} 元</span> `; grid.appendChild(card); });这里用innerHTML拼接模板是刻意简化,它的可读性好、适合静态项目;不过要注意数据来源是本地写死的数组,不是用户输入,才不会有注入风险。如果你打算加「价格筛选」「排序」之类的功能,products数组是最合适的操作对象,改数组再重新渲染,比一个个操作 DOM 节点省心得多。
3. 登录页面:表单校验与交互状态,把用户体验做细
3.1 登录框布局与输入框状态
登录页相对首页要简单很多,结构上就是一张卡片、两个输入框、一个按钮、若干提示文字。但越是简单的页面,细节越重要。布局上,通常用绝对定位或 Flex 把登录卡片放到页面中央。我的习惯是外层容器height: 100vh再配合 Flex 的justify-content: center; align-items: center;,这样无论屏幕尺寸如何,卡片都居中。
输入框的交互细节是很多人忽略的地方。一个合格的输入框至少有四种视觉状态:默认、聚焦、成功、错误。默认状态下边框颜色浅灰,聚焦时变成主题色,错误时变成红色并显示提示文字。实现方式是为不同状态定义样式类,然后通过 JS 切换类名。
.input-field { width: 100%; border: 1px solid #e0e0e0; border-radius: 4px; padding: 12px; transition: border-color 0.2s; } .input-field:focus { border-color: #ff6700; outline: none; } .input-field.error { border-color: #e53935; }重点说一下:focus和.error的优先级问题。两者同时存在时,如果.error在 CSS 中写在:focus之前,那么聚焦状态下仍然是橙色边框,错误状态被覆盖。解决办法是让.error的规则在:focus之后定义,或者给.error提升优先级,比如写成.input-field.error:focus。这种细节就是「看起来小、实际上影响观感」的典型。
3.2 用原生 JS 写校验规则,不引入 jQuery 校验插件
登录页的核心业务是校验。常见的校验规则有三条:手机号非空且格式正确、密码非空且长度不小于 8 位、两次密码一致(如果是注册场景)。原生写法不需要依赖任何插件,它的核心思路是:在表单提交事件里拦截默认行为,逐项检查输入值,把错误信息插入到对应的元素中。
const form = document.getElementById('login-form'); const phoneInput = document.getElementById('phone'); const passwordInput = document.getElementById('password'); const phoneError = document.getElementById('phone-error'); const passwordError = document.getElementById('password-error'); form.addEventListener('submit', function (event) { event.preventDefault(); // 阻止表单默认提交刷新页面 let valid = true; // 手机号校验 const phoneReg = /^1[3-9]\d{9}$/; if (!phoneReg.test(phoneInput.value.trim())) { phoneError.textContent = '请输入正确的手机号'; phoneInput.classList.add('error'); valid = false; } else { phoneError.textContent = ''; phoneInput.classList.remove('error'); } // 密码校验 if (passwordInput.value.length < 8) { passwordError.textContent = '密码长度至少 8 位'; passwordInput.classList.add('error'); valid = false; } else { passwordError.textContent = ''; passwordInput.classList.remove('error'); } if (valid) { // 校验通过,跳转到首页并且携带登录状态 loginAction(); } });正则^1[3-9]\d{9}$的含义是:以 1 开头,第二位是 3 到 9 之间的数字,后面还有 9 位数字,总共 11 位。用它做手机号格式校验在大多数场景下够用了。event.preventDefault()是表单提交最关键的一行代码,不写的话页面会刷新,本地的 JS 状态和提示信息全部丢失,这是新手最容易踩的坑。
表单校验的触发时机也值得讲究。只做表单提交瞬间的校验,体验是不够的。像我一般会在input事件里做实时校验——密码输入长度够了立刻消掉错误提示,不需要等提交才给反馈。做法很简单,把上面校验逻辑抽成一个函数,然后在blur(失焦)和input两个事件里调用它。
3.3 记住账号:localStorage 的存取细节
登录页还有一个高频需求:记住手机号。实现方式是点击「记住我」复选框后,把手机号存到localStorage,下次打开页面自动回填。这个功能写起来只有几行代码,但有一个容易忽略的坑:存储内容务必做「取出时的存在性判断」,否则localStorage首次访问时取到null,直接赋值给输入框会报错。
const rememberMe = document.getElementById('remember-me'); const savedPhone = localStorage.getItem('rememberedPhone'); if (savedPhone) { phoneInput.value = savedPhone; rememberMe.checked = true; } form.addEventListener('submit', function () { if (rememberMe.checked) { localStorage.setItem('rememberedPhone', phoneInput.value.trim()); } else { localStorage.removeItem('rememberedPhone'); } });localStorage.setItem的键名建议用固定字符串,比如rememberedPhone,不要每次拼接时间戳,否则会生成大量冗余键值。另外,localStorage只能存字符串,如果后面想存对象,比如用户信息{name: 'test', phone: '138...'},必须先JSON.stringify再存,取出来后再JSON.parse。这个转换过程忘了做,取出来的是[object Object]这类字符串,页面展示就会出错。很多同学在这里翻车,把对象直接传给setItem,浏览器会强制转成字符串,数据就丢了。
4. 页面之间的数据打通与本地存储实践
4.1 登录状态如何跨页面传递
首页和登录页是两个独立的 HTML 文件,它们之间天然隔离,不能互相访问对方的变量。要实现「登录后跳转首页,并展示用户昵称」,最常见的做法是用sessionStorage或localStorage保存登录态。两者的区别在于:sessionStorage在标签页关闭后清空,localStorage会一直保留。我的建议是登录态用sessionStorage,记住手机号用localStorage,各司其职更合理。
// 登录页:校验通过后写入登录态 sessionStorage.setItem('isLogin', 'true'); sessionStorage.setItem('userName', '小米用户'); // 跳转到首页 window.location.href = './index.html';// 首页:页面加载时读取登录态 const isLogin = sessionStorage.getItem('isLogin'); const userName = sessionStorage.getItem('userName'); const loginEntry = document.querySelector('.login-entry'); if (isLogin === 'true' && userName) { loginEntry.textContent = '你好,' + userName; } else { loginEntry.textContent = '点击登录'; }这里有一个很常见的坑:sessionStorage.getItem('isLogin')返回的是字符串,如果在登录页写入的是布尔值true,取出来会是字符串'true'。所以判断时不要写if (isLogin)这种依赖真值判断的写法,应该明确写if (isLogin === 'true'),把类型也说清楚。否则遇到存储内容异常的情况,判断结果会变得不可预测。
4.2 购物车角标数量统计
首页导航栏通常有一个购物车图标,右侧带一个红色数字角标。静态页面里,购物车数据通常也是用 localStorage 模拟的——把用户添加的商品放进一个数组,再存到 localStorage,页面加载时读取数组长度、更新角标。
// 添加购物车的方法 function addToCart(productId) { let cart = JSON.parse(localStorage.getItem('cart')) || []; // 如果商品已存在,则数量加 1;否则新增 const existItem = cart.find(item => item.id === productId); if (existItem) { existItem.count += 1; } else { cart.push({ id: productId, count: 1 }); } localStorage.setItem('cart', JSON.stringify(cart)); updateCartBadge(); } function updateCartBadge() { const cart = JSON.parse(localStorage.getItem('cart')) || []; const totalCount = cart.reduce((sum, item) => sum + item.count, 0); const badge = document.querySelector('.cart-badge'); badge.textContent = totalCount > 99 ? '99+' : totalCount; }JSON.parse(localStorage.getItem('cart')) || []这个写法非常关键:首次访问时getItem返回null,JSON.parse(null)也会返回null,如果不加|| [],后面调用cart.find会直接报错。reduce在这里把每个商品的数量累加,得到总件数。角标超过 99 显示99+是电商通用惯例,直接显示三位数会让 UI 变形。
4.3 用 hash 实现简单的页面切换
如果你不想做多个 HTML 文件之间的跳转,也可以用 hash 路由在一个页面里模拟「首页 → 登录页」的切换。这个做法的核心是监听window.location.hash的变化,然后切换显示不同的区块。它比多页面更适合演示场景,因为切换不刷新页面,状态不丢。
function renderPage() { const hash = window.location.hash || '#home'; const homePage = document.getElementById('page-home'); const loginPage = document.getElementById('page-login'); if (hash === '#login') { homePage.style.display = 'none'; loginPage.style.display = 'block'; } else { homePage.style.display = 'block'; loginPage.style.display = 'none'; } } window.addEventListener('hashchange', renderPage); renderPage();这里用display: none和display: block切换区块的可见性,优点是实现简单;缺点是每个区块的初始化逻辑都集中在一起,代码量大了会乱。对于当前这个规模的项目,这种写法完全够用。需要注意,hashchange事件只在 hash 变化时触发,首次加载页面时 hash 为空,所以要主动调用一次renderPage()做初始化。很多人会漏掉这一行,结果第一次打开页面两个区块重叠显示,样式全乱。
5. 避坑记录:静态页面最常见的 5 个翻车现场
5.1 图片加载不了,布局整体塌掉
现象:写首页时用了本地图片路径,结果商品卡片文案还在,图片位置是空白小图标,整个网格高度也缩水了。 原因:<img>标签的src路径写错,或者图片文件没有放到项目目录下。浏览器加载失败时不会报 JS 错误,只在 Network 面板里显示 404。 解决:排查路径时,优先确认项目目录结构。如果页面在html/文件夹里、图片在images/文件夹里,相对路径要从 HTML 文件所在目录开始算。一个稳妥习惯是统一用./images/xxx.jpg这种显式相对路径,不要用images/xxx.jpg少了./也没有大问题,但容易在嵌套目录里搞混。另外给每个<img>设置width和height属性占位,图片加载失败也有固定布局空间。
5.2 JS 代码放在 head 里,报错找不到 DOM 元素
现象:脚本执行报错Cannot read property 'addEventListener' of null,点击按钮没有反应,页面交互完全失效。 原因:<script>标签放在<head>里,此时浏览器从上往下解析 HTML,还没有渲染到 body 里的目标元素,所以document.getElementById取到null。 解决:把<script>标签挪到</body>之前,这是静态项目最推荐的位置。或者保留在 head 里,把代码包在window.onload或DOMContentLoaded事件回调里。注意window.onload要等图片等资源全部加载完才触发,而DOMContentLoaded只要 DOM 结构出来就触发,后者的加载感知更流畅。
5.3 Flex 布局嵌套过深,子元素被压缩变形
现象:商品卡片里的文字被挤成多行,图片被压扁,价格数字溢出容器。 原因:Flex 容器默认的flex-shrink是 1,当容器空间不足时子元素会被压缩。如果内容区域设置了固定宽度但子元素总宽超过它,压缩就会发生。 解决:给不希望被压缩的元素设置flex-shrink: 0;,比如商品卡片里的图片容器。另外,如果遇到文本溢出,可以用white-space: nowrap; overflow: hidden; text-overflow: ellipsis;组合让文本单行省略号展示,而不是换行撑破布局。
5.4 表单提交后页面刷新,错误提示一闪而过
现象:输入错误手机号点登录,页面瞬间刷新,所有输入内容清空,校验提示根本看不到。 原因:表单的默认提交行为把页面重新加载了,而 JS 校验逻辑写在submit事件里却没有调用event.preventDefault()。 解决:在submit事件回调第一行执行event.preventDefault()。这是一个看起来微不足道、实际上决定整个表单校验功能是否成立的细节。如果你的表单里面按钮没有指定type="button",点击后也会触发表单提交,记得给按钮显式设置type="submit"或type="button",按实际需求来。
5.5 localStorage 里存对象,取出来变成了字符串
现象:登录成功后存储用户信息localStorage.setItem('user', userObj),页面读取时显示乱码或[object Object]。 原因:localStorage只支持字符串存储,对象传入时会调用默认的toString()方法,转换结果就是[object Object]。 解决:存储前用JSON.stringify()把对象转为 JSON 字符串,读取后先用JSON.parse()解析,再访问对象的属性。有一个对应的小坑:JSON.parse遇到非法字符串会抛异常,程序直接中断。稳妥做法是把解析放在try...catch里,或者先用函数判断字符串是否合法。简单项目我一般封装一个小函数,把解析和默认值放在一起,例如function getStorage(key) { try { return JSON.parse(localStorage.getItem(key)); } catch (e) { return null; } }。
6. 让项目更有质感:在静态页面里做一个骨架屏加载效果
很多静态项目给人「很业余」感觉的根源,不是布局不对,而是缺少加载过程中的反馈。页面无论多大,资源都是秒开,用户看到的直接就是最终结果,缺少了「等待-出现」的节奏感。一个低成本、高观感的手段是骨架屏——用灰色块先占住位置,数据渲染时平滑替换成真实内容。
骨架屏的 CSS 核心是用渐变动画模拟加载的光影扫过效果。给每个占位块加一个background: linear-gradient,配合background-position的动画,就能模拟出「数据在加载中」的视觉反馈。下面这段代码定义了两个关键帧,让高光区域从左往右移动:
.skeleton { background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 37%, #f0f0f0 63%); background-size: 400% 100%; animation: skeleton-loading 1.4s ease infinite; } @keyframes skeleton-loading { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }然后给商品卡片准备一套骨架版本,比如标题栏、图片区、价格区各一个div,用.skeleton类控住样式。页面加载时默认显示骨架,等商品数据数组渲染完成后,把骨架容器替换成真实卡片。用 JS 控制,能非常自然地模拟出数据加载流程:
window.addEventListener('DOMContentLoaded', function () { // 模拟网络请求耗时,1.2 秒后渲染真实数据 setTimeout(function () { renderProducts(products); const skeletonWrap = document.querySelector('.product-skeleton'); skeletonWrap.style.display = 'none'; }, 1200); });setTimeout在这里的作用是模拟异步请求的耗时。实际项目中,这个位置是fetch请求返回后的回调,真实数据到达后隐藏骨架。骨架屏的动画时长可以微调:网络好时希望它快一点,网络差时它一直在转也没关系。一般设置在1.2s到1.6s之间比较自然,太快了看不出效果,太慢了用户会觉得卡。骨架块的尺寸要和真实内容尽量一致,否则切换时有明显跳变,体验反而更差。做法是直接量真实商品卡的宽高,骨架块按比例缩小一点、保持整体对齐。
这个技巧在我自己做的静态项目里几乎每次都会用上,成本极低、效果明显。做完这一步,你的首页就不会再给人「硬邦邦、一下全出来」的感觉,而是有了节奏和呼吸感。回想我自己第一次上手这个项目时,最大的教训就是过早追求效果,忽略了「反馈感」——用户操作了就该有响应,页面加载了就该有过渡。希望这一篇能帮你少走我当初绕过的弯路,也希望你可以把它当成一个起点,把登录校验、购物车、轮播这些模块再拆开做深一点,收获会更大。希望帮到你。
本文还有配套的精品资源,点击获取