简介:围绕HTML网页综合项目实战整理的压缩包,面向刚接触前端、希望从标签语法走向完整页面的学习者,通过多个页面实例解决结构搭建、布局组织与样式控制等入门问题。包内共12个文件,以3个HTML页面和9张JPG素材为主,压缩后约1.2MB,HTML承载页面结构与交互元素,JPG提供案例效果参考及配图资源,便于对照设计稿完成页面还原。已有567人学习,说明其适合作为课堂练习或自学小项目。内容覆盖标题、段落、链接、图像、表格、列表、表单、按钮与输入框等常用元素,并延伸到内联样式、内部样式表和外部样式表对颜色、字体、尺寸与位置的调整;同时强调图片路径引用与资源管理,帮助读者理解页面从结构到视觉呈现的完整流程,积累可复用的实战经验。
1. 先把「能跑起来的多页面站」当成验收标准
HTML 网页综合项目实战最容易走偏的地方,是把“综合”理解成堆页面数量:首页、列表、详情、关于、联系,五张页面各写各的,样式复制粘贴,导航改一次要动五个文件,最后谁也不敢碰。我带过的几个模拟项目里,真正卡住进度的从来不是某个标签怎么写,而是上线之后二级页面图片全裂、手机上一打开就横向滚动、表单点一下整页刷新。这些问题有个共同点:它们在写下第一行标签之前就已经埋进去了。
所以这个标题下的实战,验收标准应该定得非常朴素——一套目录结构、一份公共骨架、一套断点、一份交互状态约定,任何人拿到仓库能在本地三条命令内跑起来,改一处全站生效,320px 宽的手机上不出现横向滚动条。下面按这个标准走:先用目录和骨架把“多页面”这件事定死,再谈语义化、响应式、原生交互,最后落到上线前那段能替你挡住低级的自检脚本。
2. 目录结构与多页面骨架:先定导航再写第一行标签
动手写<head>之前,先在纸上画一遍页面清单和导航树。原因很实在:多页面站的目录层级直接决定 URL 结构,而 URL 结构决定你后面所有相对路径怎么写、导航的当前页怎么判、部署到子目录会不会全站 404。这三个问题里任意一个返工,代价都远超前期画十分钟图。
一个能撑住五到十张页面的最小站,我一般会按“页面跟目录走、资源按类型分”来排。页面放根目录还是子目录,只取决于它有没有独立的下级页面;有就从目录开始,没有就单文件。比如产品列表下面还有详情,那就建products/目录,里面放index.html和detail.html。这样 URL 是/products/和/products/detail.html,读起来有层次,将来要做筛选参数也挂得上去。
2.1 静态站的目录分层与命名约定
site/ ├── index.html # 首页 ├── about.html # 单页,无下级 ├── products/ │ ├── index.html # 列表页 │ └── detail.html # 详情页 ├── assets/ │ ├── css/ │ │ ├── base.css # 变量、重置、排版 │ │ ├── layout.css # 容器、栅格、页头页脚 │ │ └── components.css # 按钮、卡片、表单、导航 │ ├── js/ │ │ ├── nav.js # 导航交互与当前页标记 │ │ └── list.js # 列表渲染与筛选 │ ├── img/ │ └── data/ │ └── products.json # 静态数据,供 fetch 使用 └── README.md这份结构里有两个决定值得解释。第一,子目录下用index.html而不是list.html,因为绝大多数静态服务器会把目录请求默认映射到index.html,URL 能少一段尾巴,也省掉一次重定向。第二,CSS 按“基础 / 布局 / 组件”拆三份而不是一份大文件,是因为多页面站最容易出现的情况是“改一个按钮把首页带崩”,拆开之后每份文件的职责边界清楚,出问题能快速定位到是哪一层。
命名上我坚持两条:文件名全小写、用连字符不用下划线;数据文件用复数名(products.json),页面用单数语义(detail.html)。这不是洁癖,是因为很多静态托管对大小写敏感,本地 Windows 上跑得好好的Detail.html,部署到区分大小写的环境上就是一个 404。这类踩坑一次就够记一辈子。
assets/data这个目录要单独说。很多人做综合项目时把数据直接写死在 HTML 里,列表页复制十份卡片标签。这样做的后果是改一条价格要改十处,而且没法做筛选和排序。把数据抽成 JSON,页面里只留一个空容器和一段渲染逻辑,是综合项目和“练习页面”的分水岭。
2.2 公共区块的复制与同步策略
多页面站没有模板引擎,页头页脚怎么办?业内常见三种做法,各有适用边界。
第一种是纯复制。每张页面里把<header>和<footer>原样写一遍,用注释标记区块起止。这是最土的做法,但也是最稳的:不依赖 JavaScript、不影响首屏、搜索引擎和屏幕阅读器都能直接读到。五到八张页面的规模,我一般就用这个,配合注释标记和后面的校验脚本,维护成本完全可控。
<!-- #region site-header --> <header class="site-header"> <a class="skip-link" href="#main">跳到主要内容</a> <nav class="site-nav" aria-label="主导航"> <a href="/index.html">首页</a> <a href="/products/">产品</a> <a href="/about.html">关于</a> </nav> </header> <!-- #endregion site-header -->第二种是运行时注入:页面里留一个空<div id="header">,用fetch拉partials/header.html塞进去。好处是改一处全站生效,坏处也很明显——脚本失败或用户禁用脚本时,页面顶部就是一片空白,导航彻底消失;而且注入发生在 DOMContentLoaded 之后,首屏会看到一次明显的跳动。
第三种是构建时包含,用任意静态站点生成器在打包阶段把片段拼进去,产出的仍然是纯 HTML。这是规模上去之后的正解,但对“综合项目实战”这个场景来说,引入构建链会让新手在环境配置上先卡两天。
提示:八张页面以内用复制加注释标记,超过十五张再考虑构建时包含。中间这段灰色地带,用注入方案的人多半会在上线后后悔,因为导航消失的投诉永远比维护成本来得快。
复制方案的关键不在复制,而在“同步”这件事有没有兜底。我的做法是:所有公共区块用统一的一对注释包住,写一个小脚本对比各页面的区块内容是否一致,不一致就报错。这段脚本很短,放在最后一章讲。
2.3 用最小命令把本地预览跑起来
新手最常问的“为什么我的fetch报错”“为什么 CSS 没加载”,九成出在直接用文件管理器双击 HTML 打开。这时地址栏是file:///...,浏览器的同源策略会把本地 JSON 请求直接拦掉,以/开头的绝对路径会被解析到磁盘根目录。这些问题在本地看起来像玄学,其实是协议差异。
起一个本地静态服务器就行:
# 在 site 目录下执行,监听 8080 端口 python3 -m http.server 8080 --bind 127.0.0.1 # 端口被占用时换一个 python3 -m http.server 8081 --bind 127.0.0.1参数说明:--bind 127.0.0.1限制只允许本机访问,避免同一局域网内其他人扫到你的开发目录;端口号选 8000 以上是因为 1024 以下是特权端口,普通用户绑定会失败。起来之后访问http://127.0.0.1:8080/,此时页面里的绝对路径/assets/css/base.css才会被正确解析到站点根,fetch('/assets/data/products.json')也不会被同源策略拦。
如果本机装了 Node 环境,npx serve也能起同样的服务,还会自动把目录请求映射到index.html。无论用哪个,判断标准只有一条:地址栏必须是http://开头。做不到这一条,后面所有关于路径和请求的调试都是在错误的前提上折腾。
2.4 当前页导航的标记方式
多页面站有个小细节容易被忽略:用户点进“产品”之后,导航里的“产品”应该高亮。常见做法是给每个页面的导航项手写一个active类,五张页面就是五处硬编码,加一张页面就要回头改五处。
更省事也更语义化的做法是用aria-current,让脚本按路径自动匹配:
// assets/js/nav.js const navLinks = document.querySelectorAll('.site-nav a'); // 取当前路径,去掉末尾的 index.html,方便比较 const current = location.pathname.replace(/index\.html$/, ''); navLinks.forEach((link) => { const target = new URL(link.href).pathname.replace(/index\.html$/, ''); // 首页单独处理:空路径或根路径都算首页 const isHome = target === '/' && (current === '/' || current === ''); const isMatch = target !== '/' && current.startsWith(target); if (isHome || isMatch) { link.setAttribute('aria-current', 'page'); } });逻辑上有三个点值得说。一是先做路径归一化,把末尾的index.html去掉,否则/products/和/products/index.html会被判成两个不同的页面。二是用startsWith而不是全等,这样详情页/products/detail.html也能让“产品”这一项保持高亮,符合信息架构的层级直觉。三是标记用aria-current="page"而不是只加类名,视觉高亮交给 CSS 的[aria-current="page"]选择器去做,语义和样式各管一头,将来换皮不用动脚本。
CSS 那边对应写一条就够:
.site-nav a[aria-current="page"] { color: #1a1a1a; font-weight: 600; border-bottom: 2px solid currentColor; }到这一步,一个能跑的骨架就有了:目录定死、公共区块有标记、本地能起服务、导航能自动高亮。接下来才是往骨架里填内容。
3. 语义化结构与可访问性:无样式时也要读得通
判断一份 HTML 写得好不好,有个很省事的办法:把 CSS 全关掉,看页面还能不能读。能读,说明结构是对的;读起来像一大段没有层次的白字,说明你在用div拼外观。这个测试之所以有效,是因为搜索引擎、屏幕阅读器和各种内容抽取工具看到的,本质上就是那个“没有 CSS 的版本”。
语义化不是给标签改个名字的仪式,它直接决定了三件实际的事:键盘用户能不能跳到正文、屏幕阅读器能不能按区块跳读、浏览器在脚本加载失败时能不能给出合理的默认行为。对于综合项目这种要展示“完整度”的作业,这部分往往比视觉效果更能拉开差距。
3.1 用 landmark 标签替掉 div 堆叠
先看一段典型的反例,这种写法在初学项目里非常普遍:
<!-- 反例:全靠类名表达结构 --> <div class="header"> <div class="nav">...</div> </div> <div class="content"> <div class="article">...</div> <div class="sidebar">...</div> </div> <div class="footer">...</div>这段代码在视觉上没问题,但对辅助技术来说,整页就是一堆没有名字的容器。屏幕阅读器用户想跳到正文,只能从头开始一个链接一个链接地听。
改成 landmark 元素之后,结构信息就进了 DOM:
<!-- 正例:结构与外观分离 --> <header class="site-header"> <nav class="site-nav" aria-label="主导航">...</nav> </header> <main id="main"> <article> <h1>产品列表</h1> <section aria-labelledby="filter-title"> <h2 id="filter-title">筛选条件</h2> </section> </article> <aside aria-label="相关推荐">...</aside> </main> <footer class="site-footer">...</footer>这里有几条硬性规则。<main>一个页面只能有一个,而且它不能嵌在<article>或<aside>里面。<header>和<footer>可以出现多次,放在<article>内部时表示这篇文章自己的头尾,不参与页面级 landmark。<section>只有在带标题时才值得用,没有标题的section和一个div没有区别,反而会让大纲多出一层空洞。<nav>也要节制,页脚的“友情链接”不一定要用nav,因为nav是给主要导航区块用的,用滥了反而稀释了它的价值。
aria-label在这里的作用是给同名 landmark 做区分。一个页面里出现两个nav(主导航、面包屑),屏幕阅读器的 landmark 列表里就会出现两个“导航”,用户不知道点哪个。加上aria-label="主导航"和aria-label="面包屑"之后,跳转列表里就是可辨别的两个名字。
3.2 表单的 label、fieldset 与错误提示
综合项目里几乎必然会有一个表单页:联系表单、下单表单、筛选表单。表单是 HTML 里语义最密集的部分,也是最容易写残的部分。一个字段要写全,需要 label 关联、合适的 type、自动填充提示、错误提示的关联,四样缺一样都会掉分。
<form id="contact-form" novalidate> <fieldset> <legend>联系方式</legend> <div class="field"> <label for="phone">手机号</label> <input id="phone" name="phone" type="tel" inputmode="numeric" autocomplete="tel" aria-describedby="phone-hint phone-error" aria-invalid="false" required> <p id="phone-hint" class="hint">用于接收订单通知,不会对外展示</p> <p id="phone-error" class="error" hidden>请输入 11 位手机号</p> </div> <div class="field"> <label for="email">邮箱</label> <input id="email" name="email" type="email" autocomplete="email" aria-describedby="email-error" aria-invalid="false" required> <p id="email-error" class="error" hidden>邮箱格式不正确</p> </div> <button type="submit">提交</button> </fieldset> </form>逐项说明。novalidate加在form上,关掉浏览器自带的原生气泡提示,不是不要校验,而是要把提示的呈现方式收到自己手里——原生气泡的样式无法定制、位置无法控制、在移动端还经常遮住输入框。type="tel"让移动端弹出数字键盘,inputmode="numeric"是给那些不认tel的浏览器兜底。autocomplete="tel"和autocomplete="email"让浏览器能自动填充,这对转化率的影响比任何花哨的动效都大。aria-describedby同时指向提示文案和错误文案,读屏时会连着输入框一起读出来。aria-invalid初始为false,校验失败时由脚本改成true,这是给辅助技术的明确信号。
fieldset加legend用于把一组相关字段圈起来,比如“联系方式”“收货信息”。很多人觉得它只是多一层边框,实际上它解决的是“读屏时不知道这个手机号属于哪一组”的问题。如果视觉上不想要那圈边框,用 CSS 重置掉就行,语义保留、样式去掉,这才是分离的正确姿势。
错误提示的显示逻辑要配合hidden属性而不是只改透明度。display: none或hidden的元素不会被读屏读出,也不会被 Tab 聚焦,这正是错误提示“需要时才出现”的正确行为。
3.3 图片、图标与替代文本的取舍
alt的写法有一个简单判断:这张图如果被拿掉,信息有没有损失。没有损失就是装饰性,写alt="",让读屏直接跳过;有损失就写内容,而且写的是“这张图传达了什么事”,不是“一张图片”。
<!-- 装饰性背景图:空 alt,读屏跳过 --> <img src="/assets/img/banner-bg.jpg" alt="" width="1440" height="480"> <!-- 信息性图片:描述内容 --> <img src="/assets/img/plug-type.jpg" alt="国标三脚插头正面视图" width="480" height="320" loading="lazy"> <!-- 功能性图标:用 aria-label 表达动作,图标本身隐藏 --> <button type="button" aria-label="关闭弹窗"> <svg aria-hidden="true" focusable="false" width="20" height="20"> <use href="#icon-close"></use> </svg> </button>三个细节。width和height属性一定要写,哪怕你后面用 CSS 改了尺寸。它们的真正作用不是控制显示大小,而是让浏览器在图片下载完成前就能按比例预留出空间,避免布局抖动——这个在慢速网络下的体验差异非常明显。loading="lazy"用在首屏以下的图片上,首屏图和 logo 不要加,加了反而拖慢首屏。功能性的图标按钮,动作写在按钮的aria-label上,svg本身标aria-hidden="true",否则读屏会把路径里的内容念一遍,听起来像乱码。
3.4 标题层级与跳转链接
标题层级只有一条规则:不要跳级,h1到h2到h3,靠层级表达从属关系,不要靠字号。一个页面只应该有一个h1,它就是这个页面的主题。列表页的每张卡片用h2,卡片内部的子标题用h3。如果视觉上觉得h3太大,用 CSS 调,别降级标签。
跳转链接是成本最低、收益最明显的可访问性改进:
<a class="skip-link" href="#main">跳到主要内容</a>.skip-link { position: absolute; left: -9999px; top: 0; background: #fff; padding: 0.75rem 1rem; z-index: 100; } .skip-link:focus { left: 0; }键盘用户按第一下 Tab 就能看到它,按回车直接跳到main,不用把导航里十几个链接挨个过一遍。默认隐藏在屏幕外、聚焦时滑入,视觉上完全不影响设计。
4. 响应式布局与交互状态:从 320px 到 1440px 都不塌
布局这一块,新手最常见的翻车姿势是先按 1440px 的设计稿写完,最后在手机上打开发现整页能左右滑。回头加媒体查询,越加越乱,最后变成一堆针对具体机型的修补。问题出在顺序:响应式应该从最小的屏开始往上写,而不是从最大的屏往下砍。
移动优先不只是个口号,它有个很实际的好处:你的基础样式只处理最窄的情况,天然不会写出“必须横向滚动才能看全”的结构;媒体查询只用来在空间变大时增加东西(多列、更大间距、侧边栏),逻辑是加法,改起来可控。
4.1 移动优先的断点与容器宽度
断点不要照着设备型号定,按内容开始变得难看的宽度定。我一般用两个:48em(约 768px)和64em(约 1024px)。前者是单列变双列的临界,后者是出现侧边栏的临界。用em而不是px,是因为它跟随用户的字号设置,用户把默认字号调大时断点会跟着移动,不会出现“字变大了但布局还按小屏算”的错位。
/* assets/css/layout.css */ :root { --container-max: 72rem; /* 约 1152px */ --gutter: clamp(1rem, 4vw, 2rem); /* 随视口宽度在 16px 到 32px 之间 */ --radius: 10px; } .container { width: 100%; max-width: var(--container-max); margin-inline: auto; padding-inline: var(--gutter); }clamp(1rem, 4vw, 2rem)这一行解决的是“小屏太空、大屏太挤”的老问题。视口 400px 时4vw是 16px,取到下限;视口 800px 时是 32px,取到上限;中间区间跟着视口线性变化,不需要为每种宽度写一个媒体查询。margin-inline和padding-inline是逻辑属性,在从右往左书写的语言环境下会自动镜像,养成习惯之后不用再单独处理。
4.2 Grid 搭列表,Flex 排组件内部
这里有个分工原则:页面级的二维排布用 Grid,组件内部的一维排布用 Flex。卡片列表是典型的二维网格,用 Grid 写起来最短也最稳。
/* 卡片列表:自动填充列,窄屏自动降到一列 */ .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr)); gap: 1.25rem; } /* 卡片内部:标题、描述、操作按钮纵向排布 */ .card { display: flex; flex-direction: column; gap: 0.75rem; border: 1px solid #e5e5e5; border-radius: var(--radius); overflow: hidden; } /* 卡片底部的按钮永远贴在底部,不管描述多长 */ .card__actions { margin-top: auto; padding: 0 1rem 1rem; }minmax(min(100%, 16rem), 1fr)这个写法是防横向滚动的关键。如果不套min(),直接写minmax(16rem, 1fr),当容器宽度小于 256px 时,列的最小宽度撑破了容器,页面就出现横向滚动条。套上min(100%, 16rem)之后,最小值不会超过容器本身的宽度,窄屏下自动退化成单列,不需要额外写媒体查询。
margin-top: auto是 Flex 里的一个小技巧,把卡片底部的操作区推到最下面,这样多张卡片并排时,按钮的位置是对齐的,不会因为描述文字多一行就参差不齐。这类细节看起来不起眼,但它是“看起来像正经产品”和“看起来像课后作业”的分界线。
4.3 移动端导航抽屉:CSS 断点与 JS 状态类怎么配合
移动端导航是响应式和交互的交汇点,也是最容易写乱的地方。常见的失败写法是用 JS 直接改style.display,结果窗口拉宽之后菜单还停在那里,或者打开菜单后按 Esc 关不掉、焦点跑到背后的内容上。
正确的分工是:CSS 负责在不同宽度下决定菜单是“横向排列”还是“抽屉”,JS 只负责切换一个状态属性,并处理焦点。
<header class="site-header"> <a class="brand" href="/index.html">模拟项目</a> <button class="nav-toggle" type="button" aria-expanded="false" aria-controls="site-nav"> <span class="sr-only">打开导航</span> </button> <nav id="site-nav" class="site-nav" aria-label="主导航"> <ul> <li><a href="/index.html">首页</a></li> <li><a href="/products/">产品</a></li> <li><a href="/about.html">关于</a></li> </ul> </nav> </header>/* 移动优先:默认是抽屉形态,藏在视口外 */ .nav-toggle { display: inline-flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border: 1px solid #ddd; border-radius: 8px; background: #fff; } .site-nav { position: fixed; inset: 0 0 0 auto; /* 贴右侧 */ width: min(80vw, 20rem); padding: 4.5rem 1.5rem 1.5rem; background: #fff; transform: translateX(100%); transition: transform 0.25s ease; z-index: 50; } .site-nav[data-open="true"] { transform: translateX(0); } /* 桌面端:抽屉退出,变成横向排列,按钮隐藏 */ @media (min-width: 48em) { .nav-toggle { display: none; } .site-nav { position: static; width: auto; padding: 0; transform: none; background: transparent; } .site-nav ul { display: flex; gap: 1.5rem; } }// assets/js/nav.js(接前文的当前页标记) const toggle = document.querySelector('.nav-toggle'); const nav = document.querySelector('#site-nav'); if (toggle && nav) { toggle.addEventListener('click', () => { const open = nav.dataset.open === 'true'; nav.dataset.open = String(!open); toggle.setAttribute('aria-expanded', String(!open)); // 展开后把焦点送进菜单,收起时还回按钮 if (!open) { nav.querySelector('a')?.focus(); } else { toggle.focus(); } }); // 按 Esc 关闭,符合键盘用户直觉 document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && nav.dataset.open === 'true') { nav.dataset.open = 'false'; toggle.setAttribute('aria-expanded', 'false'); toggle.focus(); } }); }几个必须点出来的地方。状态存在>// assets/js/list.js const grid = document.querySelector('.card-grid'); const filterForm = document.querySelector('#filter-form'); const STORAGE_KEY = 'products-filter'; // 从本地存储恢复上次的筛选条件 const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); const state = { keyword: saved.keyword || '', sort: saved.sort || 'default' }; async function loadProducts() { const res = await fetch('/assets/data/products.json'); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } function render(list) { if (!list.length) { grid.innerHTML = '<p class="empty">没有符合条件的结果,试着换个关键词。</p>'; return; } grid.innerHTML = list .map((item) => ` <article class="card"> <img src="${item.cover}" alt="${item.name} 产品图" width="480" height="320" loading="lazy"> <h2 class="card__title">${item.name}</h2> <p class="card__desc">${item.summary}</p> <div class="card__actions"> <a class="btn" href="/products/detail.html?id=${encodeURIComponent(item.id)}"> 查看详情 </a> </div> </article>`) .join(''); } function apply(data) { let list = data.filter((item) => item.name.toLowerCase().includes(state.keyword.toLowerCase()) ); if (state.sort === 'price-asc') { list = [...list].sort((a, b) => a.price - b.price); } render(list); } filterForm?.addEventListener('input', (e) => { const form = e.currentTarget; state.keyword = new FormData(form).get('keyword')?.toString() ?? ''; state.sort = new FormData(form).get('sort')?.toString() ?? 'default'; localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); }); loadProducts() .then(apply) .catch((err) => { console.error(err); grid.innerHTML = '<p class="empty">数据加载失败,请刷新重试。</p>'; });
逻辑分三层。第一层是数据获取,fetch之后先判res.ok,因为fetch只有在网络层失败时才 reject,404 和 500 都会走进 then,不判状态码就会在解析 JSON 时抛出莫名其妙的语法错误。第二层是过滤与排序,filter不修改原数组,sort会修改,所以排序时先展开一份副本,避免同一份数据被反复排序导致顺序不可预期。第三层是渲染,用模板字符串拼 HTML,注意插入href的id要经过encodeURIComponent,否则 id 里出现&或#会把参数截断。
参数方面,STORAGE_KEY要带业务前缀,因为localStorage是按域名共享的,同一个本地端口下如果放了两个练习项目,不带前缀的键会互相覆盖。localStorage只存轻量状态(筛选词、排序方式),不要存整份列表数据,一是容量有限,二是数据更新后缓存会变成过期内容,用户看到的价格对不上。
还有一点值得强调:innerHTML拼接在这里是安全的,因为数据来自自己的静态 JSON。但如果将来数据来自用户输入或第三方接口,就必须改成 DOM 创建或做转义,否则就是一个注入漏洞。这个区别在综合项目里经常被含糊过去,实际工作中是要出事的。
5. 避坑与常见问题排查:五个上线后才发现的坑
下面这五条都是我在模拟项目里真实遇到过、而且事后回看完全可以在写代码阶段就规避的问题。每条按“现象、原因、解决”的顺序写,如果你正在做的项目出现了同样的现象,可以直接对号入座。
5.1 部署到子目录后整站 404
现象:本地http://127.0.0.1:8080/一切正常,部署到某个子路径(比如/project/demo/)之后,首页能开,但点任何导航都是 404,CSS 和图片全部裂开。
原因:页面里的引用写成了以/开头的绝对路径。/assets/css/base.css在本地服务器上指向站点根,看起来没问题;一旦站点被放到子目录下,/指向的仍然是域名根,而不是子目录根,路径就全错了。
解决:静态站优先用相对路径,或者所有内部链接统一从页面自身位置出发写。同级页面用index.html、./about.html,跨目录用../assets/css/base.css。如果站点规模大到相对路径难以维护,就引入一个base标签或者构建时的路径前缀变量,但绝对不要在每张页面里手写几十个/开头的地址——那是给未来的自己埋雷。
5.2 表单按钮点一下就刷新页面
现象:表单填完点“提交”,页面闪一下回到初始状态,输入的内容全没了,控制台也没有明显报错。
原因:<button>在表单内部的默认type是submit。如果你的按钮只是用来触发一段 JS(比如“获取验证码”“展开更多”),没写type,它就会触发表单的默认提交行为,也就是刷新当前页面。
解决:所有非提交用途的按钮,显式写type="button"。真正用于提交的那个按钮才写type="submit",并在提交事件里e.preventDefault()自己接管。这个习惯要落到肌肉记忆里,因为在只写了一行代码的情况下,它几乎不会引起注意,直到某天用户投诉“填了半小时的表单一提交就没了”。
5.3 移动端 :hover 粘滞与点击延迟
现象:手机上下拉页面或者点过一个卡片之后,那个卡片一直保持着悬停状态的高亮和位移,点击其它地方也不消失;另一个表现是点击响应明显慢半拍。
原因:触摸屏没有“指针悬停”这个概念,浏览器为了让:hover样式在触摸设备上也能触发,会在点击后把元素保持在悬停状态,直到点下一个元素。同时老式浏览器为了判断用户是单击还是双击缩放,会在click之前等约 300ms。
解决:所有悬停效果用媒体查询包起来,只在真正支持悬停的设备上生效:
@media (hover: hover) and (pointer: fine) { .card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); } }300ms 延迟的问题,现代浏览器在设置了正确的viewport之后基本已经消除了:
<meta name="viewport" content="width=device-width, initial-scale=1">只要这行写了,就不需要再做什么touch-action之类的额外处理。反过来,如果这行没写,页面会被缩放到 980px 宽再显示,那才是移动端样式完全不对的头号原因。
图片是所有静态资源里最容易把首屏拖垮的一项,而且它的坑往往不在文件大小上,而在加载时机和尺寸声明上。
5.4 图片不写宽高,加载时整页跳动
现象:网速慢的时候打开列表页,先看到文字,图片加载完成后页面猛地往下推一大截,用户点按钮的瞬间按钮跑了,点到了别的东西上。
原因:没有给图片预留空间。浏览器在图片下载完成前不知道它多大,只能按 0 高度排版,图片到了之后再撑开。
解决:img标签上写width和height属性,同时在 CSS 里让它自适应:
img { max-width: 100%; height: auto; }现代浏览器会根据width和height的比值自动推导出宽高比,在图片下载前就占好位置,这项能力通常叫宽高比预留。CSS 里height: auto保证缩放时比例不变。两件事一起做,布局抖动基本就消失了。
5.5 一个页面多个 h1,屏幕阅读器读乱
现象:用无障碍检测工具扫一遍,提示“页面存在多个一级标题”或“标题层级跳跃”。视觉上看不出任何问题。
原因:为了控制字号,给每个区块的标题都套了h1加一个类名;或者反过来,为了视觉层级跳着用,h1底下直接跟h4。
解决:一个页面只保留一个h1,作为页面主题;往下严格按h2、h3递进,不跳级;视觉字号完全交给 CSS 类去控制。检查方法很简单:在浏览器控制台跑一行,把页面所有标题打出来,看层级是不是一棵正常的树。
// 在控制台执行,检查标题层级 [...document.querySelectorAll('h1,h2,h3,h4,h5,h6')] .map((h) => `${' '.repeat(Number(h.tagName[1]) - 1)}${h.tagName}: ${h.textContent.trim()}`);如果输出里出现两个h1,或者某个h3前面没有h2,那就是结构问题,改标签而不是改样式。
6. 上线前自检:一段扫死链和缺失 alt 的脚本
人工点一遍所有链接、逐张图检查alt,在五张页面的时候还行,到十几张必然漏。我的习惯是把这类检查固化成一段无依赖的脚本,提交代码前跑一次,退出码非零就不许合入。
// tools/check.js —— 在项目根目录执行:node tools/check.js const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const htmlFiles = []; // 1) 递归收集所有 html 文件,跳过依赖目录和隐藏目录 (function walk(dir) { for (const name of fs.readdirSync(dir)) { if (name.startsWith('.') || name === 'node_modules') continue; const full = path.join(dir, name); if (fs.statSync(full).isDirectory()) walk(full); else if (name.endsWith('.html')) htmlFiles.push(full); } })(ROOT); let issues = 0; for (const file of htmlFiles) { const html = fs.readFileSync(file, 'utf8'); const rel = path.relative(ROOT, file); // 2) img 必须带 alt(装饰图写 alt="" 也算通过) for (const m of html.matchAll(/<img\b[^>]*>/g)) { if (!/\balt\s*=/.test(m[0])) { console.log(`[ALT] ${rel} -> ${m[0].slice(0, 60)}`); issues++; } } // 3) 本地 href/src 指向的文件必须存在 for (const m of html.matchAll(/\b(?:href|src)\s*=\s*"([^"]+)"/g)) { const url = m[1]; if (/^(https?:|mailto:|tel:|#|data:|\/\/)/.test(url)) continue; const clean = url.split('#')[0].split('?')[0]; if (!clean) continue; const target = path.resolve(path.dirname(file), clean); if (!fs.existsSync(target)) { console.log(`[404] ${rel} -> ${url}`); issues++; } } } console.log(issues ? `发现 ${issues} 处问题` : '检查通过'); process.exit(issues ? 1 : 0);逻辑说明:第一步建索引,把所有 HTML 文件收进一个数组,跳过node_modules和以点开头的目录,避免扫到构建产物。第二步用正则匹配所有img开标签,检查有没有alt=,注意这里只判“有没有”,不判内容质量,内容质量靠人工。第三步匹配所有href和src,先用正则排除外链、锚点、mailto:、tel:和data:,剩下的按 HTML 文件所在目录解析成绝对路径,再判断文件是否存在。查询串和锚点要在判断前切掉,否则detail.html?id=1会被当成文件名去找,必然报错。
参数说明:process.exit(issues ? 1 : 0)是这段脚本的价值所在——退出码非零,CI 流水线才会失败。如果只是把问题打印到控制台,没人会看。想扩展的话有两个方向:一是加一条检查,把每个页面的header区块内容做哈希比对,解决前面说的“公共区块不同步”问题;二是检查main元素的数量和h1的数量,一条正则就够。
我自己的习惯是把这个脚本接在本地提交之前,先跑一遍再推代码。有一次我在模拟项目里改导航链接,手滑把about.html写成了about-us.html,本地页面看起来毫无异样,是这段脚本在扫描时打出了一行 404 才拦住的。静态站没有后端兜底,链接错了就是错了,用户看到的是一个白底黑字的错误页,连个道歉都没有。这种活儿交给脚本,比交给自己那点记忆靠谱,希望帮到你。
本文还有配套的精品资源,点击获取