简介:这是一份基于HTML、CSS与JavaScript打造的叮咚买菜动态页面设计源码,适合前端初学者及电商页面设计练习者用于模拟实战。压缩包内共98个文件,含91个PNG素材图片、2个CSS样式文件、1个HTML页面、1个JavaScript脚本、1个GIF动图及1个ICO图标,整体约4.54MB,目录划分清晰,便于按需查看。源码完整呈现商品滑动展示、购物车动态更新、搜索建议弹出等常见电商交互效果;HTML搭建页面骨架,CSS控制视觉与响应式排版,JavaScript负责事件响应与动态渲染,三者结合可作为学习前端协作的参考范例。目前已有112人学习浏览,适合希望提升动态页面开发能力、快速上手电商类界面设计的读者参考。
1. 为什么一套 97 个文件的电商页面源码值得拆一遍
这套基于HTML、CSS、JavaScript的叮咚买菜动态页面设计源码,打开后第一感受是资源多、代码少:97 个文件里 91 张 PNG、1 张 GIF、1 个 ICO,真正负责功能的只有一个 index.html、两个 CSS 和一个 JS 文件。页面模拟了生鲜电商首页那套高频交互——搜索建议实时弹出、Banner 自动轮播、购物车数量与总价即时变化,都是真实项目里的前端基本功,没引入框架和构建工具,适合逐行读完。
适合准备课设的学生,以及写惯了 Vue、React、想补原生 DOM 操作的开发者;拆完会发现,难的从来不是某个语法,而是三个文件之间的协作边界。
2. index.html 骨架与 base.css、index.css 的样式分工
2.1 语义化标签把首页拆成五个功能区
打开 index.html 先看 DOCTYPE 和头部。文件首行是<!DOCTYPE html>,配合<html lang="zh-cn">告诉浏览器按标准模式解析,并声明页面语言为简体中文,这对字体渲染和搜索引擎语义识别都有实际影响。meta charset 指定 UTF-8 编码,下面再用两个 link 把 favicon.ico 和两份 CSS 引入。这里有个细节值得注意:favicon.ico 即使不写<link rel="icon">,浏览器也会默认去站点根目录请求,但显式声明可以把图标路径固定下来,项目拷贝到子目录部署时不会因为路径变化而丢图标。
页面主体按语义化标签切成头部、导航、Banner、商品主体和页脚五个功能区。下面这个骨架就是按常见电商首页结构整理的,实际源码里的 class 名称可能会略有不同,但模块划分思路一致:
<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="UTF-8"> <title>叮咚买菜</title> <link rel="icon" href="favicon.ico" type="image/x-icon"> <link rel="stylesheet" href="css/base.css"> <link rel="stylesheet" href="css/index.css"> </head> <body> <header class="header"> <div class="logo"> <img src="images/下载.png" alt="叮咚买菜"> </div> <nav class="nav"> <ul> <li><a href="#">新鲜蔬菜</a></li> <li><a href="#">时令水果</a></li> <li><a href="#">肉禽蛋品</a></li> </ul> </nav> <div class="search"> <input type="text" id="searchInput" placeholder="搜点什么"> <button type="button">搜索</button> </div> </header> <main> <section class="banner" id="banner"> <div class="track"> <div class="slide"><img src="images/pic_008.png" alt="banner1"></div> <div class="slide"><img src="images/pic_024.png" alt="banner2"></div> </div> <button class="banner-prev">‹</button> <button class="banner-next">›</button> </section> <section class="goods-list" id="goodsList"><!-- 商品列表 --></section> </main> <footer class="footer">叮咚买菜动态页面练习</footer> <script src="js/index.js"></script> </body> </html>这段结构里有两个常被忽略的点。第一,CSS 全部放在 head 里,JS 放在 body 末尾,是因为样式表先加载可以减少页面闪烁,脚本后执行则避免阻塞 DOM 解析;如果 JS 必须放 head,那就得等 DOMContentLoaded 事件触发后再操作节点。第二,main标签内部用section做功能分区,每个section配一个 id,给 JS 提供稳定的查询入口。像搜索框命名 searchInput,Banner 容器叫 banner,商品列表叫 goodsList,这种“语义化 + 功能命名”的方式,后续维护时比用一串div套起来容易定位得多。
2.2 base.css 负责底子,index.css 负责脸面
源码里两份 CSS 文件的分工是典型的“公共样式与页面样式分离”思路。base.css 解决不同浏览器默认样式不一致和全局变量统一的问题,index.css 只写首页自己用到的布局与组件样式。两者叠加才组成最终渲染效果,如果只引入其中一个,页面要么样式错乱,要么布局全丢。
base.css 常见内容:
/* base.css:重置默认样式并定义主题变量 */ * { margin: 0; padding: 0; box-sizing: border-box; } :root { --primary-color: #13b42b; /* 生鲜电商的绿色主色 */ --price-color: #ff5000; /* 价格强调色 */ --text-main: #323233; /* 正文颜色 */ --bg-gray: #f5f6f8; /* 页面底色 */ } ul, li { list-style: none; } a { text-decoration: none; color: inherit; } img { display: block; max-width: 100%; }写在这里的每条规则都有明确目的:box-sizing: border-box让 padding 和 border 不再撑大元素宽度,这是做响应式布局时最省心的盒模型设置;:root里定义 CSS 变量,后续在 index.css 里直接用var(--primary-color)取值,改主题色时只动这一处;img { display: block }消除图片下方的间隙,否则单张商品图底下总会多出几个像素。a { color: inherit }则避免每个导航链接都去重写颜色。
index.css 常见内容:
/* index.css:页面级布局与组件样式 */ .header { display: flex; align-items: center; justify-content: space-between; height: 56px; padding: 0 16px; background: #fff; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); position: sticky; top: 0; z-index: 100; } .banner { overflow: hidden; } .banner img { width: 100%; height: 300px; object-fit: cover; } .goods-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; padding: 16px; }这段样式里重点看两个属性。position: sticky使头部在页面滚动时固定在视口顶部,这比position: fixed更合适——sticky 不脱离文档流,父容器范围未结束前自然参与布局,不会出现 fixed 遮挡内容的问题;object-fit: cover让高度不一的商品图统一裁切成 300px 高的容器且不变形。商品列表用 grid 的repeat(auto-fill, minmax(240px, 1fr))实现响应式换行,屏幕越宽一行展示越多商品,不需要写媒体查询就能适配常见分辨率。
两份 CSS 的分工可以梳理成一张表:
| 文件 | 关注点 | 典型内容 |
|---|---|---|
| base.css | 重置与全局约定 | 盒模型、列表去点、链接去下划线、CSS 变量 |
| index.css | 当前页面布局与组件 | header 定位、banner 尺寸、商品网格、按钮样式 |
2.3 选择器组织与层级关系
写页面样式时最头疼的是类名冲突。这套源码的类名逻辑可以总结为“块-元素”命名:header、nav、search 是块,goods-list、goods-item、add-btn 是块与元素的组合。父子选择器尽量只隔一层,例如.goods-list > .goods-item明确限定直接子级,而不是在 CSS 里写长串后代选择器。伪类选择器用得最多的是:hover和:nth-child,鼠标移入商品卡片时改变阴影和边框,这部分直接对应很多人搜的“css 鼠标移入事件”——其实它不是事件,而是:hover的样式切换,真正的事件绑定在 JS 侧完成。如果发现鼠标移上去没有反馈,优先检查的不是 JS,而是选择器是否被更高优先级的规则覆盖,或者.goods-item是否真的触发了 hover 的父级条件。
动手验证时,用浏览器开发者工具勾选规则,看每一行样式实际作用在哪个元素上。改乱时直接撤销一行规则,再刷新页面,就能快速体会这两份 CSS 的边界在哪里。
3. JavaScript 交互:搜索建议、轮播与购物车动态更新
3.1 事件监听与查询方式
js/index.js 是这套动态页面的“发动机”。围绕电商首页,脚本要解决三类问题:找元素、绑事件、改内容。原生 DOM 里找元素有两套 API,document.getElementById按 id 精确查找,document.querySelector则可以接受任意 CSS 选择器。实际项目中我更常用 querySelector,因为一个方法能覆盖类名、属性、伪类多种查找;但高频循环里 getElementById 会更快,常规页面用哪种都没有感知差异。
| 事件 | 触发时机 | 典型场景 |
|---|---|---|
| click | 鼠标单击元素 | 添加购物车、切换 Banner 箭头 |
| mouseenter | 鼠标移入元素且不冒泡 | 商品卡片浮层、菜单展开 |
| input | 表单值变化 | 搜索建议、价格区间即时筛选 |
| scroll | 滚动位置变化 | 返回顶部按钮显隐 |
注册监听时,如果商品列表是动态渲染出来的,直接给每个按钮绑 click 事件会在数据刷新后失效,常见做法是交给外层容器做事件委托:监听.goods-list的 click,再通过e.target.closest('.goods-item')判断实际点击的节点。事件委托让脚本只注册一次监听,无论商品数量是 10 个还是 100 个,代码量都保持不变。
3.2 Banner 轮播:索引、取模与定时器
轮播是这类动态页面最常见的视觉组件。实现思路是把所有轮播图放在一个横向轨道里,每次滚动一张图的宽度,核心是维护当前索引,并通过取模运算防止索引越界:
let currentIndex = 0; const banner = document.querySelector('#banner'); const track = banner.querySelector('.track'); const itemWidth = banner.clientWidth; function goTo(index) { const maxIndex = track.children.length - 1; // 索引小于 0 时跳到末张,超过最大下标时回到 0 currentIndex = index < 0 ? maxIndex : index % track.children.length; track.style.transform = 'translateX(' + (-currentIndex * itemWidth) + 'px)'; } // 每 3000ms 自动切换下一张 setInterval(() => { goTo(currentIndex + 1); }, 3000); document.querySelector('.banner-next').addEventListener('click', function () { goTo(currentIndex + 1); });这里的几个参数决定了轮播行为:itemWidth必须等于可视区宽度,否则轨道位移会错位;3000是自动播放间隔,单位毫秒,改小则切换更快,但过快的轮播会干扰用户点击;index % track.children.length是取模运算,当索引到达最后一张时回到 0,实现无限循环。手动点击和自动播放共用 goTo,避免两套逻辑各写一份导致索引不同步。建议再补充 mouseenter 暂停定时器、mouseleave 恢复,防止鼠标停留在图上时被自动切换打断阅读。
3.3 购物车数量与总价计算
购物车交互一定包含加号、减号和总价联动。实现时有几个容易出错的细节:从 DOM 里取出的数量是字符串,直接相加会变成1 + 1 = "11";价格乘以数量后可能出现浮点误差;负数数量要钳制到 1。下面这段是典型的处理方式:
function changeCount(step, currentCount) { currentCount += step; // 数量下限为 1,防止用户减出 0 或负数 return currentCount < 1 ? 1 : currentCount; } document.querySelector('.goods-list').addEventListener('click', function (e) { const btn = e.target; if (!btn.classList.contains('add-btn') && !btn.classList.contains('reduce-btn')) { return; } const item = btn.closest('.goods-item'); const countEl = item.querySelector('.count'); const next = changeCount(btn.classList.contains('add-btn') ? 1 : -1, Number(countEl.textContent)); countEl.textContent = next; updateTotal(); }); function updateTotal() { let total = 0; document.querySelectorAll('.goods-item').forEach(function (item) { const price = Number(item.dataset.price); const count = Number(item.querySelector('.count').textContent); total += price * count; }); document.querySelector('.total-price').textContent = total.toFixed(2); }这段代码最值得学习的是事件委托的写法:监听范围是商品列表容器,只判断按钮有无 add-btn 或 reduce-btn 类,不影响列表内其他元素的点击;item.closest('.goods-item')向上找最近的商品卡片,使得每行数据天然隔离;dataset.price对应 HTML 里>const goodsData = [ { name: '有机西红柿', tags: ['蔬菜', '爆款'] }, { name: '山东红富士', tags: ['水果', '新品'] }, { name: '土鸡蛋', tags: ['禽蛋', '早餐'] } ]; document.querySelector('#searchInput').addEventListener('input', function () { const keyword = this.value.trim(); const suggestBox = document.querySelector('#suggestBox'); if (!keyword) { suggestBox.style.display = 'none'; return; } const matched = goodsData.filter(function (item) { return item.name.includes(keyword); }); suggestBox.innerHTML = matched .map(function (item) { return '<li>' + item.name + '</li>'; }) .join(''); suggestBox.style.display = 'block'; });
trim()去掉首尾空格,避免用户只敲一个空格也触发请求;filter加includes做包含匹配,只要商品名中出现关键词就算命中;渲染时用字符串拼接生成li,最后统一join('')塞进 innerHTML,数据量在几十条内没有任何问题。要注意 innerHTML 拼接如果来源是用户输入,会存在 XSS 注入风险,这里的搜索数据来自本地常量数组所以没问题;真实项目中建议先做 HTML 转义再渲染。输入为空时用style.display = 'none'隐藏建议框,比反复删除子元素更轻量。
上面四段代码覆盖了动态页面最常见的交互模块。调试时优先打开浏览器控制台,脚本运行时报错会直接给出错误行号;看到TypeError: Cannot read properties of null,八成是 querySelector 的选择器在 HTML 里找不到对应节点,先回 index.html 确认 id 和 class 是否一致。
提示:把用户输入的内容直接拼进 innerHTML 会引入 XSS 风险,这里的搜索数据来自本地常量数组所以没问题;真实项目中建议先做 HTML 转义再渲染。
4. 图片素材组织:PNG、ICO、GIF 在页面里的真实作用
4.1 images 目录的路径规划与引用
这套源码的素材文件全部集中在 images 目录下,页面里引用时用的是相对路径。所谓相对路径,是以 index.html 所在目录为基准去描述目标文件位置:images/pic_001.png表示当前目录下 images 文件夹里的 pic_001.png;如果页面文件在子目录,路径就要写成../images/pic_001.png。带着 90 多张图片的项目,路径写错是加载失败的头号原因,而且这类错误不会在控制台报 JavaScript 异常,只会显示图片裂图,排查时要先在 Network 面板看失败请求的完整路径。
文件名大小写也是一个隐蔽的坑。云服务器和 Linux 环境下文件名区分大小写,Pic_001.png和pic_001.png是两个文件;在 Windows 本地预览时看不出来,一旦打包上传到 Linux 部署,图片就会突然丢失。拿到这份源码后,建议先检查 HTML 里的引用与实际文件名是否完全一致,顺手把所有路径列出来核对一遍:
<!-- 相对路径引用示例 --> <img src="images/pic_001.png" alt="时令蔬菜"> <img src="images/pic_095.png" alt="招牌商品"># 列出源码中实际存在的图片文件,与 HTML 引用做对比 ls images/ | sort > actual.txt grep -o "images/[^\"']*\.\(png\|gif\)" index.html | sort > used.txt diff actual.txt used.txt两条命令在 Linux、macOS 和 Windows 的 Git Bash 里都能直接执行。ls images/导出磁盘上真实存在的文件名,grep -o只提取 HTML 里引用的图片路径,diff对比两个文件的差异,多出来的行就是路径写错或引用了不存在图片的地方。这种用命令行做静态资源巡检的思路,比逐个睁眼看 90 张图高效得多。
注意:Windows 本地双击 index.html 虽然也能预览,但涉及 fetch 或 ES Module 时会受到 file:// 协议限制。建议用第 5 章的本地静态服务器方式打开。
4.2 PNG、ICO、GIF 各自的选型理由
91 张 PNG 是绝对主力,覆盖商品缩略图、Logo、按钮图标和装饰元素。PNG 采用无损压缩,放大缩小不产生锯齿,最关键的是支持透明通道,电商页面里商品图去掉背景直接叠在白色卡片上,视觉效果比 JPG 的白色底块干净。代价是文件体积大,一张高分辨率 PNG 经常上几百 KB,所以真实项目里商品大图会转成 WebP 或 AVIF。这套源码用 PNG 是为了教学场景下素材通用、浏览器兼容性最好,不必为格式兼容做额外处理。
favicon.ico 是唯一的浏览器标签页图标,尺寸通常只有 16×16 或 32×32,却承担着品牌识别功能。浏览器加载页面时会自动请求/favicon.ico,如果服务器上不存在这个文件,控制台会多一条 404 记录。源码把 favicon.ico 放在项目根目录并用link显式声明,就是为了避免这个多余的请求。
GIF 是这些图片里唯一支持逐帧动画的格式,主要用于促销角标、加载动画这类小而短的效果。GIF 的限制是只能显示 256 色,彩色写实图片用它做动画会明显失真,但如果只是让一个小元素动起来,GIF 比用 CSS 写动画省事得多,也不需要额外的 JS。
| 格式 | 压缩方式 | 透明通道 | 动画 | 这套源码里的用途 |
|---|---|---|---|---|
| PNG | 无损 | 支持 | 不支持 | 商品图、Logo、按钮图标 |
| ICO | 容器格式,内含多尺寸位图 | 视内部编码而定 | 不支持 | 浏览器标签页图标 |
| GIF | 无损但仅 256 色 | 支持 | 支持逐帧 | 促销动图、加载动画 |
4.3 图片加载失败时的容错与占位
即使路径全部正确,网络波动或文件被误删也可能导致图片裂图。给 img 标签写 alt 是必须的,在图片无法加载时显示替代文字,同时提升无障碍访问体验。更进一步可以用 onerror 事件做降级处理,让裂图自动替换成占位图:
<img src="images/pic_028.png" alt="新鲜蔬菜" onerror="this.onerror=null; this.src='images/placeholder.png';">onerror属性里先执行this.onerror = null,这是防递归的关键:如果占位图本身也不存在,onerror 会再次触发,把 onerror 置空后第二次加载失败就不会继续循环请求。this.src直接替换当前节点的图片地址,页面结构不会改变,商品卡片布局也不会因为换图而抖动。真实项目中这种做法更多会放到一个统一的错误图片处理函数里,避免每张图都写一段内联事件。
4.4 用懒加载缓解 90 张图的压力
图片多到 90 张时,首次打开页面如果把全部图片都请求一遍,移动端会明显卡顿。原生方案是给 img 加loading="lazy"属性,浏览器会自己判断图片离视口多远时开始加载,零 JS 成本;但需要更精细控制时,通常配合 IntersectionObserver 实现进入视口再赋值:
<img>const observer = new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; observer.unobserve(img); } }); }); document.querySelectorAll('.lazy').forEach(function (img) { observer.observe(img); });这里的思路是先把真实图片地址存在>const goodsList = [ { id: 1, name: '有机西红柿 500g', price: 8.9, img: 'images/pic_028.png' }, { id: 2, name: '山东红富士 1kg', price: 12.8, img: 'images/pic_095.png' }, { id: 3, name: '土鸡蛋 30枚', price: 19.9, img: 'images/pic_045.png' } ]; function renderGoods() { const wrap = document.querySelector('#goodsList'); wrap.innerHTML = goodsList.map(function (item) { return '<div class="goods-item">const defaultConfig = { autoplay: true, interval: 3000, loop: true }; const customConfig = { interval: 5000 }; // 只覆盖需要变的值 const config = Object.assign({}, defaultConfig, customConfig); // 结果:{ autoplay: true, interval: 5000, loop: true }
Object.assign 第一个参数是空对象,后面的配置依次覆盖同名属性,customConfig 没写的字段保留默认值。改造轮播代码时,把 setInterval 的时间参数换成 config.interval,关闭自动播放的判断换成if (config.autoplay),以后调整偏好就只改一处。
验证这套改造不需要额外工具。在项目根目录开一个静态服务,避免 file:// 协议下部分浏览器对模块脚本和 fetch 的限制:
# 任选其一 python3 -m http.server 8080 # 或 npx serve -l 8080 .浏览器访问 http://localhost:8080,检查商品列表是否按数组顺序渲染、加减号是否正常更新总价、轮播速度是否变成了 5 秒。用 HBuilderX 打开项目后点击运行,本质上也是起一个本地静态服务,效果相同。控制台切到 Network 面板刷新一次,确认图片只有进入视口的被请求,说明懒加载配置没有和原有逻辑冲突;再把 goodsList 数组里某个 price 改成 99.9,页面刷新后价格立即变化,而 HTML 文件一行都不用动,到这里数据驱动改造才算闭环。
本文还有配套的精品资源,点击获取