简介:这套「html系列编程示例」面向刚接触网页开发的初学者与课堂教学场景,以可直接运行的源码案例帮助读者理解HTML文档结构、常用标签与页面组织方式,解决只看语法却缺少动手练习的问题。压缩包共14个文件,以12个html示例页为主,另含1个jpg和1个bmp图片素材,整体约52KB,体积轻便,适合随取随用。案例覆盖标题与段落、超链接、图像插入、无序与有序列表、表格、表单及表单组合、框架页面、引用块等常见知识点,并配有图片资源便于直接预览效果。已有156人学习,说明其作为入门练习材料具有一定参考价值。读者可据此逐页查看源码,对照浏览器效果理解标签嵌套、页面分区与内容呈现之间的关系,也可在此基础上改写结构、替换素材,完成自己的静态页面练习,为后续学习CSS样式与响应式布局打下基础。
1. 从一张被样式搞乱的页面说起
前段时间帮朋友看一个活动页,本地双击打开一切正常,传到服务器上就整体错位,按钮叠在文字上。翻他的工程,发现里面二三十个 html 文件全平铺在一个目录里,样式靠行内style硬写,图片路径有相对有绝对。我把这套「html系列编程示例」的源码按功能重新分了一遍目录,才定位到问题是资源路径和层叠顺序。这类示例集合的价值就在这儿:它是一套能对照的 HTML/CSS/JavaScript 样本,基础标签、表单、布局、DOM 事件、Canvas 动画各占一块,每个示例都能单独打开看效果,也能整段复制到自己的项目里改。适合刚入门前端、想补一遍基础结构的人,也适合做后台管理页要临时抄布局的老手——你要的不是框架文档,而是能跑、能改的源码。
2. 目录结构和技术栈:一套示例工程是怎么组织起来的
多数人拿到这种示例源码,第一反应是直接双击index.html看效果,看完就关。等到要改某个示例,才发现文件都在一个层级,找起来全靠文件名拼音,这就是最开始的痛点。一套合格的示例工程,目录本身就得替读者完成一半的索引工作,让「这个示例属于哪一类」在路径上就能读出来。
2.1 目录分层:把示例当项目来摆
我一般不会按demo1.html、demo2.html这种流水号去排,而是按知识块分组,组内再用两位数字前缀固定顺序。好处是文件管理器里排序稳定,浏览器地址栏也能直接手改编号跳转。目录大概长这样:
html-series-demos/ ├── index.html # 索引页,按分组列出所有示例链接 ├── 01-basic/ # 文档结构与基础标签 │ ├── 01-document-structure.html │ ├── 02-text-and-list.html │ └── 03-table-and-media.html ├── 02-form/ # 表单控件、约束校验、提交拦截 │ ├── 01-input-types.html │ ├── 02-validation.html │ └── 03-form-data.html ├── 03-layout/ # 三栏布局、Flex、Grid │ ├── 01-float-three-column.html │ ├── 02-flex-three-column.html │ └── 03-grid-three-column.html ├── 04-dom/ # 选择器、事件委托、动态列表 │ ├── 01-query-and-modify.html │ └── 02-event-delegation.html ├── 05-canvas/ # 绘图基础与动画循环 │ ├── 01-shape-basic.html │ └── 02-animation-loop.html ├── 06-responsive/ # 媒体查询与移动端适配 │ └── 01-media-query-breakpoint.html └── assets/ ├── css/base.css # 全站公共样式与 CSS 变量 ├── js/utils.js # 转义、请求封装等小工具 └── img/ # 示例配图,统一小写短横线命名这个结构有两个隐性约束。第一,分组目录只放 html,公共资源一律进assets,避免同一个reset.css在六个目录里各存一份,改一处忘五处。第二,文件名不带中文和空格,中文名在部分服务器上会被二次编码,地址栏里变成一长串%E4%B8%AD,复制给别人很容易断链。上面这些约束看着琐碎,但正是示例源码最容易忽略、二次开发时最容易吃亏的地方。
2.2 技术选型:零构建依赖,但必须有本地服务
这套示例的技术栈很朴素:原生 HTML5 + CSS3 + ES6 的 JavaScript,不引入打包器,不依赖任何框架。好处是任何一个文件都能直接读、直接改,读者不需要先理解webpack.config.js里几十行配置才能看到第一个页面。代价是模块化和资源请求会受限——这也是新手最容易踩的一脚。
直接双击01-document-structure.html,纯标签和样式没问题,但只要示例里出现fetch('./data/users.json')或者<script type="module">,浏览器控制台立刻报跨域或者模块加载失败。原因不在代码,在于file://协议下页面的源是null,浏览器把它当成不安全上下文。正确做法是在项目根目录起一个静态服务:
# 在 html-series-demos 根目录执行,Python 3 自带,不用装依赖 cd html-series-demos python3 -m http.server 8006 --bind 127.0.0.18006是我习惯用的端口,避开 8000、8080 这些常被其他服务占用的号;--bind 127.0.0.1限定只监听本机,避免同一局域网内被别人访问到你的工作目录。启动后浏览器打开http://127.0.0.1:8006/index.html,所有示例的相对路径都能正常解析,fetch也能拿到同源数据。
如果机器上没有 Python,用 Node 环境起一个同样简单:
npx serve . -l 8006 # -l 指定监听端口,serve 默认会打印出实际访问地址提示:服务必须在项目根目录启动,不能在
04-dom/子目录里启动。否则 html 里写的../assets/js/utils.js会跳到服务根目录之外,浏览器直接 404。
2.3 公共资源与命名约定:让示例能被复制走
示例源码真正的使用场景不是「看」,而是「抄一段到自己项目里」。所以要保证每一段被抄走的代码都是自洽的:它引用的样式变量、工具函数、图片路径,在复制时能一起被识别出来。
公共样式集中放在assets/css/base.css,用 CSS 变量收敛颜色和间距:
/* base.css:只放跨示例复用的部分,单个示例的特有样式仍写在页内 */ :root { --color-primary: #2f6fed; --color-text: #1f2329; --color-border: #d9dee7; --space-sm: 8px; --space-md: 16px; --radius-md: 6px; } body { margin: 0; font: 14px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--color-text); } .u-hidden { display: none !important; }font简写里14px/1.6表示字号 14、行高 1.6;system-ui让页面在 macOS、Windows 上自动匹配系统字体,省掉引入字体文件的请求。工具函数放assets/js/utils.js,用普通脚本而非 ES module 暴露到全局,这样老示例里<script src>直接引用就能用:
// utils.js:挂在 window 上,方便非模块化的示例直接调用 window.$utils = { // 把用户输入转义后再拼进 innerHTML,防注入 escapeHtml(str) { return String(str).replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); }, // 按毫秒等待,配合 async/await 做节流或演示加载态 sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } };escapeHtml的正则用字符类一次匹配五个危险字符,替换函数里用对象查表,比一串if-else好读也好扩展。sleep返回 Promise,是为了让示例里能写await $utils.sleep(800)演示加载态,而不是用setTimeout套回调把代码拉成一层层嵌套。这两个函数几乎每个示例都会用到,所以值得单独抽出来;其余只被单个示例使用的小逻辑,就留在页面内,不做过度抽象。
3. 核心示例拆解:DOM 操作与事件链路
DOM 这一组示例是整套源码里被翻阅最多的部分,因为几乎所有人的第一个真实需求都是「点一下按钮,列表里多一行 / 少一行」。这段逻辑写顺了,后面做表格、做树形菜单、做标签页都是同一套思路。下面拆三个示例,分别对应事件绑定、表单取值和异步渲染。
3.1 事件委托:一个列表的增删操作
最直觉的写法是给每个删除按钮单独addEventListener,列表项一多,监听器数量就跟着涨,动态新增的项还得记得补绑。示例里用的是事件委托,绑在父容器上,靠事件冒泡统一处理:
<ul id="todo-list"> <li class="item"><form id="signup" novalidate> <label for="email">邮箱</label> <input id="email" name="email" type="email" required placeholder="you@example.com" autocomplete="email"> <label for="age">年龄</label> <input id="age" name="age" type="number" min="18" max="99" required> <button type="submit">提交</button> </form> <script> const form = document.getElementById('signup'); form.addEventListener('submit', (event) => { event.preventDefault(); // 拦掉默认提交,改由脚本控制 if (!form.checkValidity()) { form.reportValidity(); // 让浏览器在第一个不合法字段旁弹出原生提示 return; } const data = Object.fromEntries(new FormData(form).entries()); console.log('提交数据', data); }); </script>novalidate加在<form>上,是为了关掉浏览器「提交时才校验」的默认时机,把控制权交给脚本;type="email"、required、min、max这些约束属性仍然生效,只是触发的入口变了。checkValidity()返回布尔值,不弹提示;reportValidity()才会把提示画到界面上,两者搭配就是「先静默判断,再决定是否提示」。new FormData(form)会自动收集所有带name的控件,Object.fromEntries把迭代器转成普通对象,省掉逐个querySelector取值。
参数上需要留意type="number"的两个坑:用户输入非数字时value是空字符串,不是NaN;min/max只约束提交,不阻止用户敲进去。所以示例里在提交前又加了一层Number(data.age)的判断,这段在源码里是注释掉的,取消注释就能看到完整写法。
3.3 异步渲染:fetch 取数据并处理三种状态
列表渲染示例演示的是最典型的三态:加载中、成功、失败。写法上用一个>async function loadUsers(url, container) { container.dataset.state = 'loading'; container.textContent = '加载中…'; try { const res = await fetch(url, { headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error('HTTP ' + res.status); // 4xx/5xx 不会自动抛错 const list = await res.json(); container.innerHTML = list .map((u) => '<li>' + $utils.escapeHtml(u.name) + '</li>') .join(''); container.dataset.state = 'done'; } catch (err) { container.dataset.state = 'error'; container.textContent = '加载失败,请稍后重试'; console.error('[loadUsers]', err); // 保留原始错误,方便排查 } }
fetch只有在网络层失败时才 reject,服务端返回 404、500 时res.ok是false,必须手动抛。res.json()也要await,忘了写就会拿到一个 Promise,list.map直接报错。拼接 HTML 时套了$utils.escapeHtml,因为示例数据里可能有<、&这类字符,不转义会破坏结构,真实项目里这就是一个注入点。
配合>#user-list[data-state="loading"] { color: #8a919e; } #user-list[data-state="error"] { color: #d93025; } #user-list[data-state="done"] { color: inherit; }
这种写法的好处是状态只有一种来源,排查时在开发者工具里看>/* float 版:需要清除浮动,且中间栏必须写在最后 */ .layout-float { overflow: hidden; } /* 触发 BFC,包住浮动子元素 */ .layout-float .side { float: left; width: 200px; } .layout-float .main { float: left; width: calc(100% - 400px); } .layout-float .side-right { float: right; }
overflow: hidden在这里不是为了裁剪,而是创建块级格式化上下文,让父元素把浮动子元素的高度算进去,否则父容器高度会塌成 0。calc(100% - 400px)减去左右两栏宽度,中间栏才不会换行。
Flex 版是现在最常用的写法,代码量最少:
.layout-flex { display: flex; min-height: 100vh; } .layout-flex .side { flex: 0 0 200px; } /* 不放大、不缩小、基准 200px */ .layout-flex .main { flex: 1 1 auto; min-width: 0; }flex: 0 0 200px三个值分别是grow、shrink、basis,侧栏固定宽度就靠它。中间栏的min-width: 0是关键——flex 子项的默认最小宽度是内容宽度,里面放一个white-space: nowrap的长表格,整个布局就被撑破,加上这句才允许它收缩。
Grid 版最适合「先定区域再填内容」的思路:
.layout-grid { display: grid; grid-template-columns: 200px minmax(0, 1fr) 200px; grid-template-areas: "left main right"; gap: 16px; } .layout-grid .side-left { grid-area: left; } .layout-grid .main { grid-area: main; } .layout-grid .side-right { grid-area: right; }minmax(0, 1fr)和上面的min-width: 0是同一个问题的两种解法,作用都是允许中间列被压缩到 0,防止内容撑破网格。grid-template-areas用字符串画出布局草图,改结构时只动这一行,比调整 DOM 顺序省事。
三种写法的取舍很明确:新项目直接 grid 或 flex,float 只在维护老代码时看一眼;需要一维排列用 flex,需要行列同时控制用 grid。
4.2 媒体查询断点与移动端视口
响应式示例的 html 头部第一行必须是视口声明,否则手机上所有媒体查询都会失效:
<meta name="viewport" content="width=device-width, initial-scale=1">width=device-width让布局视口等于设备宽度,initial-scale=1禁止初始缩放。少了这一行,手机浏览器会按 980px 的虚拟宽度渲染,页面被整体缩小,点按钮要点两次才中——这就是老生常谈的移动端点击「迟钝」。
断点写法在示例里只保留两档,够用且好记:
/* 默认按移动优先写:先写窄屏样式 */ .card-list { display: grid; grid-template-columns: 1fr; gap: 12px; } /* 平板及以上:两列 */ @media (min-width: 768px) { .card-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } } /* 桌面:三列 */ @media (min-width: 1200px) { .card-list { grid-template-columns: repeat(3, minmax(0, 1fr)); } }移动优先的意思是基础样式服务窄屏,宽屏靠min-width往上叠,这样样式覆盖关系是单向的,不会出现「写了 max-width 又被大屏规则盖回去」的来回打架。断点数值 768 和 1200 是常见分界,改的时候建议全站只维护这两个数,别在十几个文件里各写一个 769、770。
4.3 Canvas 动画:高清屏缩放与 requestAnimationFrame
Canvas 示例有两个容易被忽略的细节:高清屏发虚和后台空转。先把画布按设备像素比放大:
const canvas = document.getElementById('stage'); const ctx = canvas.getContext('2d'); const rect = canvas.getBoundingClientRect(); // CSS 尺寸不变,把画布位图按 dpr 放大,再缩放绘图坐标系 const dpr = window.devicePixelRatio || 1; canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr); ctx.scale(dpr, dpr); let x = 0; let rafId = null; function frame() { ctx.clearRect(0, 0, rect.width, rect.height); // 清空上一帧 ctx.fillStyle = '#2f6fed'; ctx.fillRect(x, 40, 60, 60); x = (x + 2) % Math.max(rect.width - 60, 1); // 到边后回到起点 rafId = requestAnimationFrame(frame); } frame(); // 标签页切到后台时暂停,回来再续,避免无意义的计算 document.addEventListener('visibilitychange', () => { if (document.hidden) { cancelAnimationFrame(rafId); rafId = null; } else if (rafId === null) { frame(); } });canvas.width是位图宽度,style上的宽高才是显示尺寸,两者不一致时浏览器会拉伸,画面就糊。所以先把位图设成CSS 宽 × dpr,再ctx.scale(dpr, dpr),之后所有绘图坐标仍然按 CSS 像素写,心智负担最小。clearRect的参数用的也是 CSS 尺寸,因为坐标系已经被缩放过了。
动画循环用requestAnimationFrame而不是setInterval,前者跟随屏幕刷新率,切到后台自动降频;再加一层visibilitychange手动取消,是给长时间运行的页面留个保险。rafId初始为null也是刻意的,用它区分「当前没在跑」,避免重复启动两个循环。
5. 避坑与排查:HTML 示例跑不通的五个现场
示例源码最大的问题是「看起来太简单」,简单到出错了会怀疑是代码本身有问题。实际排查下来,八成问题集中在协议、编码、时序和优先级这四件事上。
5.1 资源加载与编码类问题
现象一:双击打开页面,控制台报跨域或者Failed to fetch。原因在协议——file://下页面的源被视为不透明,fetch、XMLHttpRequest、type="module"的脚本都会被拦。解决办法是在项目根目录起静态服务,用http://127.0.0.1:8006访问,命令见第 2 章的python3 -m http.server。判断依据很简单:地址栏以file://开头,就别指望异步请求能通。
现象二:页面里的中文变成问号或者方块。两种可能,一是 html 头部缺<meta charset="utf-8">,浏览器按系统默认编码猜;二是声明了 utf-8,但文件本身是用 GBK 保存的,声明和实际不符。解决方式是先补一行声明放在<head>最前面:
<head> <meta charset="utf-8"> <title>示例</title> </head>再去编辑器里把文件另存为 UTF-8(不带 BOM)。带 BOM 的文件在<head>之前会引入三个不可见字节,某些情况下会让页面顶部多出一条空白,所以选「无 BOM」更稳。
5.2 脚本时序与样式覆盖类问题
现象三:控制台报Cannot read properties of null (reading 'addEventListener')。原因十有八九是<script>写在<head>里,脚本执行时目标元素还没被解析出来,document.getElementById拿到null。两种改法:把脚本挪到</body>之前,或者加defer:
<script src="../assets/js/app.js" defer></script>defer表示「下载不阻塞解析,DOM 解析完再按顺序执行」,比async更适合有依赖关系的示例脚本,因为async不保证执行顺序。
现象四:改了 CSS,刷新页面没变化。先别怀疑代码,打开开发者工具看 Elements 面板右侧的 Styles,被划掉的规则就是被更高优先级覆盖了。常见来源三个:行内style优先级最高、#id选择器比.class高、后引入的样式表覆盖前面的。缓存问题也好判断——Network 面板里该 CSS 文件的 Status 显示304或from disk cache。临时验证可以在地址后面加版本参数:
<link rel="stylesheet" href="../assets/css/base.css?v=20240601">参数值随便改,目的是让浏览器认为是新地址。长期做法是构建时自动生成 hash,但纯静态示例里手改就够了。
5.3 移动端与画布的适配问题
现象五:手机上按钮要点两下才响应,或者 1px 边框看起来特别粗。前者是缺 viewport 声明,浏览器按 980px 虚拟宽度渲染后整体缩放,点击判定区域和视觉位置错位;后者是高清屏下 1px 的 CSS 像素对应 2 到 3 个物理像素。第一个问题的解法就是第 4 章那行<meta name="viewport">;第二个问题在示例里用伪元素加transform: scale处理:
.hairline { position: relative; } .hairline::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--color-border); transform: scaleY(0.5); /* 高清屏下压到半像素视觉厚度 */ transform-origin: bottom center; }另外 Canvas 在那台高分屏上发虚也是同一类问题,忘了乘devicePixelRatio,位图被拉伸了。这类适配没有玄学,全是像素账,算清楚就不翻车。
6. 二次开发:把示例拆成自己能用的组件
示例看懂了,下一步是把它变成项目里能复用的东西。我的做法是先抽样式变量,再抽结构,最后留一份自查清单。样式变量在base.css的:root里已经定义好了,新页面直接引用var(--color-primary),换主题时只改一处。结构上以卡片列表为例,把 html 骨架、css 布局、js 渲染拆成三块,js 里只保留数据入口:
// card-list.js:只负责把数据渲染成节点,不关心数据从哪来 export function renderCardList(container, items) { if (!Array.isArray(items) || items.length === 0) { container.innerHTML = '<p class="empty">暂无数据</p>'; return; } container.innerHTML = items.map((item) => ` <article class="card" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />