简介:一套面向网页设计初学者与前端入门学习者的HTML表格练习源码,围绕表格与表单的基础搭建展开,适合课堂作业、课后练习或自学时对照参考。压缩包共5个文件,体积约30KB,以1个html页面为主体,配合3张jpg图片素材和1份md说明文档,分别承担页面结构、配图素材与使用说明的作用,代码与素材分离存放,可直接在浏览器中打开查看效果并逐段修改。已有203人学习下载,可作为基础练习的参考样本。读者能拿到一份可运行的表格页面示例,据此理解表格行列组织、单元格合并、边框与间距设置等常见写法,并借助效果图与说明文档快速复现作业样式,在模仿中熟悉HTML标签结构与页面排版思路,为后续表单控件和简单布局练习打下基础。
1. 十分钟的活干了两小时:这份 HTML 表格源码卡在哪个环节
做后台页面的人大概都遇到过:需求文档上写着「加一个列表」,你估十分钟,结果调了一下午——表头跟内容错位半格,窄屏上表格把整页撑出横向滚动条,复制到 Excel 里表头丢了,给某一列加了固定宽度后长文本又挤成竖排汉字。压缩包html制作简单的表格源码.zip解决的就是最前面那段重复劳动:它给出的是一份结构完整、能直接双击在浏览器里看到效果的 HTML 表格源码,table、thead、tbody、th、td该有的层级一个不少,字段换成自己的就能用。适合三类人:刚学 HTML、需要一份能对照的正确写法的新手;要快速搭后台列表页、不想每次都从空白文件敲标签的前端;以及做数据展示页、需要一份可交付静态原型的从业者。它不承担业务逻辑,也不带任何框架和构建工具,纯静态结构——这点得先说清楚,否则你会指望它顺手把排序和分页也做了。
2. 拆骨架:table/thead/tbody 的四层语义与 colspan 网格算术
先别急着改颜色。表格出问题,十次里有七次不是 CSS 的锅,而是结构写歪了:少一个td、rowspan占位没扣、表头用了td加粗冒充。这一章把源码里那套骨架逐层拆开,讲清楚每一层为什么必须存在,以及合并单元格时那张看不见的网格该怎么算。
2.1 最小可用骨架:四层结构缺一层都会在别处还债
一份能长期维护的表格,结构上至少分成四块:caption说这张表是什么,thead放列标题,tbody放数据行,tfoot放合计或备注。浏览器对残缺结构很宽容,你不写tbody它会自动补一个,但自动补出来的东西你在调试时看不见,用脚本按table.rows取值时下标就会对不上。所以源码里显式写全,是给后面所有操作打底。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <!-- 窄屏适配的第一件事,不是 CSS,是这个 viewport --> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>设备巡检记录</title> </head> <body> <table class="data-table"> <!-- caption 必须是 table 的第一个子元素,位置错了会被浏览器挪走 --> <caption>第三季度设备巡检记录</caption> <thead> <tr> <th scope="col">序号</th> <th scope="col">设备编号</th> <th scope="col">巡检日期</th> <th scope="col">状态</th> </tr> </thead> <tbody> <tr> <td>1</td> <td>DEV-0142</td> <td>2024-07-03</td> <td>正常</td> </tr> <tr> <td>2</td> <td>DEV-0157</td> <td>2024-07-05</td> <td>待复检</td> </tr> </tbody> <tfoot> <tr> <!-- 合计行跨前三列,colspan 之和要跟表头列数对齐 --> <td colspan="3">合计</td> <td>2 台</td> </tr> </tfoot> </table> </body> </html>这段代码可以直接存成.html打开看。几个参数值得单独说:lang="zh-CN"决定读屏软件用中文语音朗读,也影响localeCompare之类的排序行为;scope="col"是给辅助技术看的,告诉它「这个 th 管的是下面这一整列」;tfoot写在tbody后面是常见写法,规范上也允许放在thead之后,但放前面会让部分老版本浏览器的打印分页变得奇怪,我一般统一放最后。
caption常被当成可有可无的装饰,实际上它是表格唯一的「名字」。屏幕阅读器进入表格时会先念 caption,再念列名。没有它,用户听到的是一串孤立的数字。样式上如果不想让它占一行,可以用caption-side: bottom挪到下面,或者视觉隐藏但保留在无障碍树里——千万别用display: none,那样读屏软件也读不到了。
2.2 th 与 scope:表头不是「加粗的单元格」
新手最常见的写法是把表头写成<td><b>设备编号</b></td>,视觉上看不出区别,代价是整张表在无障碍层面失去了列名。用th之后,浏览器和读屏软件都知道这是表头,scope再把「管哪一列、管哪一行」说清楚。
| scope 取值 | 用在哪 | 实际含义 |
|---|---|---|
col | thead里的th | 该表头管理它所在的那一列 |
row | 数据行首列的th | 该表头管理它所在的那一行 |
colgroup | 与colgroup分组配合 | 管理一组列,多级表头里用 |
rowgroup | 与tbody分组配合 | 管理一组行,较少用 |
scope不是必须写的,浏览器会做一定程度的推断,但推断规则在复杂表格里并不靠谱,尤其是存在合并单元格时。行列交叉的表格(第一列是行标题、第一行是列标题)最容易被读错,这种结构下首列要用<th scope="row">,交叉单元格本身则用<td>。
如果表格复杂到scope表达不了——比如多级表头盖住好几列且分组不规则——就得改用id+headers的方案:给每个表头单元格一个id,数据单元格用headers="a b"列出它对应的表头 id。这套写法维护成本高,一般只有在财务报表、课表这类结构时才会动用到,源码里默认不带,需要时再补。
2.3 colspan 与 rowspan:先在心里画网格,再写属性
合并单元格是表格错位的头号来源,原因几乎都是同一个:写的时候盯着「这一行有几个格子」,而不是「这一行在网格上还剩几个格子」。rowspan一旦用上,被它占掉的位置在后续行里就不能再写td了。
举个例子,三列的表,第一列是分组名,一个分组下挂两条记录:
<tbody> <!-- 第一行占用第 1 列(rowspan=2),后两列各占一格,共 3 格 --> <tr> <th scope="rowgroup" rowspan="2">A 组</th> <td>甲设备</td> <td>3</td> </tr> <!-- 第二行第 1 列已被上一行的 rowspan 占掉,所以只能写 2 个 td --> <tr> <td>乙设备</td> <td>5</td> </tr> </tbody>校验方法很简单,写在纸上或者注释里都行:每一行的「td 数量 + 各 td 的 colspan 之和 + 被上方 rowspan 占用的列数」必须等于总列数。三条加起来对不上,页面就会整行右移,而且浏览器不会报错,只会默默把多出来的格子挤到下一行。我在改别人源码时,养成的习惯是先数thead有几个th,再把tbody每一行按上面这个式子加一遍,比在浏览器里肉眼找错位快得多。
还有一个容易忽略的点:rowspan跨行之后,斑马纹和:nth-child的视觉节奏会断掉,被合并的那几行看起来像一整块。这种情况下要么放弃斑马纹改用border-bottom分隔,要么靠border-collapse的边框来划分区域,别硬撑。
3. 样式参数:border-collapse、斑马纹与 sticky 表头的四组取舍
结构对了,剩下的是参数。表格的 CSS 有几个「看似可选、实际必选」的属性:不设border-collapse你的边框就是双线,不设table-layout你的列宽就会随内容抖动,不把position: sticky写在正确的元素上表头就吸不住。这一章按这四个点逐个给参数和取舍理由。
3.1 border-collapse 与 border-spacing:两套边框模型不能混着用
表格有两套边框模型。默认是separate,每个单元格各画各的框,相邻两条边框中间还有border-spacing的间隙;collapse则把相邻边框合并成一条。绝大多数数据列表要的是后者,因为separate下你会得到 2px 的双线,视觉上格外粗糙。
table.data-table { width: 100%; border-collapse: collapse; /* 相邻单元格共享边框,双线变单线 */ border-spacing: 0; /* collapse 下此属性失效,写上只为将来切模型时不残留间隙 */ table-layout: fixed; /* 列宽由首行/colgroup 决定,长内容不再撑宽整张表 */ font-size: 14px; } /* 只画一条底线,外框交给 table 自己,避免四边边框粗细不一致 */ table.data-table th, table.data-table td { padding: 10px 12px; border-bottom: 1px solid #e5e6eb; text-align: left; /* th 默认居中,统一成左对齐更符合列表阅读习惯 */ vertical-align: middle; overflow-wrap: anywhere; /* 长编号、长 URL 强制换行,防止撑破列宽 */ } table.data-table { border: 1px solid #e5e6eb; }参数说明:border-collapse: collapse写在table上,不是写在td上——写在td上无效,这是很常见的翻车点。合并之后,td之间相邻的边框会由浏览器决定保留哪一条,规则大致是「宽者胜、样式优先者胜、颜色深者胜」,所以相邻两个单元格最好只让一个设边框,两边都设容易出现某一段边框颜色跟预期不符。border-spacing只在separate模型下生效,写在这里是防御性的。
overflow-wrap: anywhere是我在表格里最喜欢的一个属性。数据里一旦出现长设备编号、长 URL、没有空格的一长串字符,table-layout: auto会把整列撑到夸张的宽度,fixed下则会溢出到相邻单元格。加上它之后,长串会在任意位置折行,列宽保持不变。
3.2 斑马纹与 hover::nth-child 计数的是「兄弟」,不是「数据」
斑马纹的常规写法是tbody tr:nth-child(even)。这里有个前提容易被忽略::nth-child数的是同一父元素下的所有兄弟元素,只要tbody里混进了非数据行,奇偶就会整体错位。
哪些东西会混进来?空数据时的「暂无数据」占位行、模板渲染留在 DOM 里的<tr hidden>、分组小标题行。这三种在真实项目里都出现过。对策是给数据行加类名,把选择器限定在真正的数据行上:
/* 有数据行的类名就用类名,没有就退回到 nth-of-type */ tbody tr.data-row:nth-child(even) { background: #fafbfc; } /* hover 提权到 td 上,避免被单元格的内联背景色覆盖 */ tbody tr.data-row:hover td { background: #eef4ff; }nth-child和nth-of-type的区别值得记一下:nth-of-type按标签名筛选后再计数,所以在tbody里混了<tr>之外的元素(比如某些模板引擎塞的注释节点不算,但塞了<template>会算)时更稳;但如果你混进来的本来就是<tr>,两者是一样的,都得靠类名。结论:数据行一定加类名,选择器不要裸奔。
hover 的背景色写在第tr上还是td上,也有差别:写在tr上时,如果某个td自己有背景色(比如状态列染色),会把整行 hover 效果切断一截;写在td上则能统一覆盖。这是个细节,但做出来的成品观感差别很明显。
3.3 table-layout: fixed:什么时候必须锁死列宽
table-layout: auto是默认值,浏览器按内容分配列宽。后果是:数据一变列宽就跳,加载第二页时表格从 800px 突然变成 1200px,用户眼睛要重新找列。这种「跳一下」的问题在后台系统里尤其明显,因为那里的数据是异步刷新的。
fixed模式下,列宽由第一行的单元格(或者colgroup)决定,跟内容多少无关,渲染也更快——浏览器不需要先测量所有内容再排版。代价是内容不得不适应列宽,所以通常要配合colgroup或给th指定宽度:
<table class="data-table"> <!-- 按列声明宽度,顺序与表头一致 --> <colgroup> <col style="width: 64px"> <!-- 序号,窄 --> <col style="width: 160px"> <!-- 设备编号,固定 --> <col> <!-- 说明,吃掉剩余空间 --> <col style="width: 96px"> <!-- 状态 --> </colgroup> <thead>...</thead> <tbody>...</tbody> </table>注意两点。第一,fixed要生效,table本身必须有确定的宽度(width: 100%或具体像素值),没写宽度浏览器会退回auto。第二,未指定宽度的列平分剩余空间,所以顺序很重要——把最需要宽的那一列留空,剩下的自然被挤窄。
如果某一列必须单行显示不许换行(比如编号),用三件套:white-space: nowrap+overflow: hidden+text-overflow: ellipsis,三个缺一个都出不来省略号。这时还得给单元格加title属性,让用户能悬停看全文,否则被截断的内容就彻底看不到了。
3.4 sticky 表头:写对元素、链路干净、背景不透明
长表格滚动时表头吸顶,是这份源码里最实用的一段样式,也是最容易「写了没反应」的一段。三个条件缺一不可。
第一,position: sticky要写在th上,不要写在thead上。thead的 sticky 行为在部分渲染引擎里不生效,而th是稳定的。
第二,从th到最近的滚动祖先之间,不能有overflow: hidden。这一条最坑:很多布局组件为了裁剪圆角,会在卡片容器上写overflow: hidden,表头的 sticky 立刻失效,而且控制台不会有任何提示。排查方法是从th逐层往上翻计算样式,看overflow是不是visible。同样,祖先上的transform、filter、will-change会创建新的包含块,让 sticky 相对它定位,表现就是「吸在了错误的位置」。
第三,必须有背景色和不透明层级:
thead th { position: sticky; top: 0; z-index: 2; /* 要盖住数据行,但别大过弹层 */ background: #f7f8fa; /* 不写就会透出下面滚动的文字 */ box-shadow: inset 0 -1px 0 #e5e6eb; /* sticky 后 border-bottom 会被滚走,用内阴影补齐分割线 */ }box-shadow: inset这一手是为了补边框。在border-collapse: collapse模型下,th的下边框属于表格边框的一部分,元素吸顶后那条边框并不跟着走,视觉上表头和数据之间就少了一条线。用内阴影模拟一条底线,效果稳定。
4. 窄屏适配:横向滚动、卡片化重排、隐藏列三种方案的适用边界
表格天生不适合窄屏:列多、字密、必须对齐。源码默认给的是宽屏布局,拿到移动端会直接把页面撑开。三种主流方案各有用武之地,选错的代价是用户得多点两下才能看到关键信息。这一章把三种方案的代码、适用条件和失败模式都摆出来。
4.1 横向滚动:改动最小,但要求列宽有下限
最省事的做法是套一层滚动容器,让表格自己横向滚,页面不跟着滚。
<div class="table-wrap"> <table class="data-table">...</table> </div>.table-wrap { width: 100%; overflow-x: auto; /* 只在这里滚,页面主体不动 */ -webkit-overflow-scrolling: touch; /* 移动端惯性滚动,老写法,保留无副作用 */ } .table-wrap .data-table { min-width: 720px; /* 小于这个宽度就滚,而不是把列压扁 */ } /* 关键:滚动容器如果是 flex 子项,会被内容撑开导致滚不动,必须归零 min-width */ .flex-parent > .table-wrap { min-width: 0; }参数就一个核心:min-width。它决定了「什么时候开始滚」。设太小,列被压得每个字一行;设太大,用户在手机上要横滑半天。我的经验值是:列数 × 90px 左右,再加上固定列的实际宽度,宁可滚也不要压缩。
这套方案最适合后台管理系统——那些页面本来就在桌面用,移动端只是兜底。失败模式有两种:容器在 flex 或 grid 布局里没有min-width: 0,表格把容器撑到跟内容一样宽,滚动条压根不出现;或者表格自身没有min-width,于是列宽被压到极限,内容全部竖排,比滚动还难读。
4.2 卡片化重排:数据要能一行一条地读
面向手机用户的展示页——订单列表、成绩单、行程详情——横向滚动体验很差,用户看不清当前在哪一列。这时用媒体查询把表格「拆」成一条条卡片:
@media (max-width: 640px) { /* 表头视觉隐藏但保留在无障碍树里,不能用 display: none */ .data-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } .data-table, .data-table tbody, .data-table tr, .data-table td { display: block; width: 100%; } .data-table tr { border: 1px solid #e5e6eb; border-radius: 6px; margin-bottom: 12px; padding: 4px 0; } .data-table td { display: flex; justify-content: space-between; gap: 16px; border-bottom: 1px dashed #eef0f3; text-align: right; } /* 列名从><tr class="data-row"> <td>/* 推荐:用语义类名控制,而不是 nth-child 数序号 */ @media (max-width: 480px) { .data-table .col-secondary, .data-table td.col-secondary { display: none; } }不要用nth-child定位要隐藏的列。原因很实在:只要以后有人加了一列,所有序号往后挪一位,被隐藏的列就全错了,而且这种错误只在窄屏出现,桌面端测不出来。用类名,改动时跟着列一起动。
另外要意识到display: none的副作用:读屏软件也读不到这列内容,某些浏览器里连复制粘贴都取不到。如果被隐藏的列可能包含关键信息(比如备注里写了异常原因),就必须补一个「展开全部」的开关,把类名切掉。这是产品层面的取舍,不是纯样式问题,值得在交付时明确说明。
5. 五个翻车现场:从表头丢失到 innerHTML 拼串的排查记录
下面这五条是我改表格源码时反复遇到的。每条按「现象 → 原因 → 解决」写,照着对号入座基本能定位。
5.1 结构与表头类问题
第一条:整行右移一格,最后一列数据消失。现象是表格从某一行开始整体往右挪,最后一列被挤到表格外或者直接看不见。原因几乎都是某行的单元格数量写多了,多数情况下是上方有rowspan占位,写的时候忘了那一列已经被占掉,多写了一个td。解决方法是按 2.3 的公式数一遍:每行「td 数 + colspan 之和 + 上方 rowspan 占用列数 = 总列数」。在开发者工具里给td加临时描边(outline: 1px solid red)能一眼看出哪一行多出一格。
第二条:滚动后表头文字消失,只剩空白条。现象是表头吸顶了,位置对,但里面的文字或背景不见了。原因通常是表头没写成th,而是用td加font-weight: bold冒充,而 sticky 样式是针对th写的,自然不生效;另一种是把position: sticky写在了thead上,部分渲染引擎不认。解决是把表头统一改回thead > tr > th,sticky 规则落在th上,并检查祖先链路上有没有overflow: hidden。
5.2 样式与视觉类问题
第三条:边框变成双线,1px 看着像 2px。现象是每个单元格四周都有边框,相邻两条并排,整体显得笨重。原因是table上没有写border-collapse: collapse,浏览器用了默认的separate模型。解决是在table上补border-collapse: collapse,同时把边框收敛到一侧——只给td留border-bottom,外框交给table。另外要注意,在collapse模型下border-spacing无效,如果代码里两个都写了,别以为是border-spacing救了场。
第四条:斑马纹隔行错乱,某几行颜色连着。现象是本来该交替的底色,在某个位置断掉,出现连续两行同色或三行同色。原因有两个:tbody里混进了非数据行(「暂无数据」占位行、<tr hidden>的模板行),nth-child把它们也算进了计数;或者数据是异步追加的,追加时没重排索引,导致奇偶基准变了。解决是给真实数据行加>// 推荐:用 DOM API 建行,内容一律走 textContent,天然免疫标签注入 function appendRow(tbody, values) { const tr = document.createElement('tr'); tr.className = 'data-row'; values.forEach((v, i) => { const td = document.createElement('td'); td.textContent = String(v ?? ''); // 空值统一成空串,避免出现 "null" if (i === 0) td.dataset.label = '设备编号'; // 供卡片化布局取标签 tr.appendChild(td); }); tbody.appendChild(tr); }
这段代码的关键就一句:textContent不会解析 HTML。代价是不能再塞图标标签,需要图标时单独createElement一个span放进去,比拼接字符串麻烦一点,但省掉了后面所有的转义烦恼。
提示:改完表格后,用开发者工具的「无障碍」面板看一眼结构树。如果
th的scope显示为「列」、caption出现在表格名称位置,说明骨架是对的;如果表头显示成普通单元格,说明你漏了th。
6. 补一个排序函数再交付:上线前的三项验证动作
静态表格能看不能用,最常见的追加需求是点表头排序。不加库的情况下,二十行代码足够,核心在于排序后的行要「移动」而不是「重建」——appendChild一个已经存在的节点会把它从原位置摘下来,正好用来原地重排,事件监听也不会丢。
// 给表头绑定点击排序:单列排序,反复点击切换升降序 function enableSort(table) { const tbody = table.tBodies[0]; // 只取数据行,占位行和模板行不参与排序 const getRows = () => Array.from(tbody.querySelectorAll('tr.data-row')); table.querySelectorAll('thead th').forEach((th, index) => { let asc = true; th.style.cursor = 'pointer'; th.addEventListener('click', () => { const rows = getRows().sort((a, b) => { const x = a.cells[index]?.textContent.trim() ?? ''; const y = b.cells[index]?.textContent.trim() ?? ''; const nx = Number(x), ny = Number(y); // 两边都是有效数字(且非空)才按数值比,否则按中文本地化字符串比 const cmp = (x !== '' && y !== '' && !Number.isNaN(nx) && !Number.isNaN(ny)) ? nx - ny : x.localeCompare(y, 'zh-Hans-CN'); return asc ? cmp : -cmp; }); rows.forEach(r => tbody.appendChild(r)); // 已有节点会被移动,不会克隆 asc = !asc; th.dataset.order = asc ? 'asc' : 'desc'; // 留给箭头图标用 }); }); }参数和逻辑说明:a.cells[index]按列下标取单元格,所以表头顺序必须和数据列严格一致,这也是前面强调结构的原因;数值判断特意排除了空串,因为Number('')是 0,会让空单元格排到最前面;localeCompare带'zh-Hans-CN'参数,中文按拼音序而不是码点序,否则「张」会排到「李」前面。日期列如果是2024-07-03这种格式,字符串比较恰好等价于时间比较,不用额外转换;如果是2024/7/3这种不补零的写法,就必须先new Date()再比,这是我在一个项目里踩过的坑——七月的数据排到了十二月后面。
交付前我会固定做三件事。第一,把窗口拉到 320px 宽,看有没有横向溢出:判断标准不是「能不能滚」,而是「页面主体会不会跟着滚」,后者说明有个元素超宽了。第二,全选表格复制,粘到表格软件里,看列是否还对齐——用户对表格最真实的高频操作就是复制走,colspan写错在这一步暴露得最彻底。第三,用键盘 Tab 键走一遍,确认可点击的表头能获得焦点(必要时给th加tabindex="0"并监听回车),只用鼠标测是测不出来的。
从那以后,我每次拿到一份表格源码,都会先数清thead的列数、按公式核对每一行的格子、再把窄屏和复制粘贴这两步走一遍,最后才动样式。顺序反了,后面全是返工。希望帮到你。
本文还有配套的精品资源,点击获取