纯前端三件套实战:小米商城首页与登录页静态项目全解析
简介一份基于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 classprice${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返回nullJSON.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()。这是一个看起来微不足道、实际上决定整个表单校验功能是否成立的细节。如果你的表单里面按钮没有指定typebutton点击后也会触发表单提交记得给按钮显式设置typesubmit或typebutton按实际需求来。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之间比较自然太快了看不出效果太慢了用户会觉得卡。骨架块的尺寸要和真实内容尽量一致否则切换时有明显跳变体验反而更差。做法是直接量真实商品卡的宽高骨架块按比例缩小一点、保持整体对齐。这个技巧在我自己做的静态项目里几乎每次都会用上成本极低、效果明显。做完这一步你的首页就不会再给人「硬邦邦、一下全出来」的感觉而是有了节奏和呼吸感。回想我自己第一次上手这个项目时最大的教训就是过早追求效果忽略了「反馈感」——用户操作了就该有响应页面加载了就该有过渡。希望这一篇能帮你少走我当初绕过的弯路也希望你可以把它当成一个起点把登录校验、购物车、轮播这些模块再拆开做深一点收获会更大。希望帮到你。本文还有配套的精品资源点击获取