上周帮一个刚转前端的同学看代码,他做了张商品价格对照表,表头那一行写的是<tr><td><b>名称</b></td><td><b>价格</b></td>...。我问他为什么不用th,他回了一句:"th不就是让字变粗变居中吗,我自己写 CSS 也能做到啊。"再问他thead是什么,他说那是"表格开头的意思吧"。这个回答其实很典型——把thead和th当成两个"让表头好看一点"的标签,是绝大多数人初学 HTML 表格时踩的第一个认知坑。这两个东西压根不在一个层级上,也压根不是用来解决同一个问题的:thead是给表格划片区的结构容器,th是给单元格贴身份的语义标签。前者管"这一块区域是什么",后者管"这一格描述的是谁"。搞清楚这个分工,你才能明白为什么有些表格打印出来第二页就没表头了、为什么爬虫抓下来的列名是一串 0、1、2、为什么屏幕阅读器念数据时前言不搭后语、为什么粘性表头怎么写都不生效。这篇就把这两个标签从结构、语义、渲染、实操到排坑完整拆一遍,不管你是刚写完第一个静态页面的新手,还是天天封装表格组件的老手,里面应该都有能直接抄走的东西。
1. 把两个标签放回表格结构里,关系一下就清楚了
1.1 一张语义完整表格的骨架长什么样
很多人写表格是"想到哪写到哪",先写table,再往里塞tr,塞完发现要表头,就在第一行里放几个td加粗。这种写法浏览器不会报错,页面也能正常显示,但 DOM 结构里是残缺的。先把完整的骨架摆出来,你对照一下就明白层级差异有多大了。
<table> <caption>某季度各区域销量(单位:件)</caption> <colgroup> <col> <col span="2"> </colgroup> <thead> <tr> <th scope="col">区域</th> <th scope="col">一季度</th> <th scope="col">二季度</th> </tr> </thead> <tbody> <tr> <th scope="row">华东</th> <td>1200</td> <td>1380</td> </tr> <tr> <th scope="row">华南</th> <td>960</td> <td>1105</td> </tr> </tbody> <tfoot> <tr> <th scope="row">合计</th> <td>2160</td> <td>2485</td> </tr> </tfoot> </table>看层级:table下面挂thead,thead下面挂tr,tr下面才是th。thead和th中间隔了一层tr,它们根本不存在"用哪个"的选择题。thead的合法子元素只有tr(严格来说还有脚本支持元素),th的父元素只能是tr。你把th直接塞进thead里,浏览器会帮你"纠正",但纠正出来的结构未必是你想要的,后面查问题会很痛苦。
顺带说一句tfoot的位置。现行的 HTML 规范里,tfoot允许写在tbody之前,也允许写在之后,两种都合法;但早期版本的规范要求它必须放在tbody之前。所以你在老项目里可能会看到tfoot夹在thead和tbody中间,那不是写错了,是历史遗留。新写的话放最后更符合直觉,反正渲染出来的顺序不受影响。
1.2thead是"分区容器",th是"单元格身份"
我用一个生活化的类比把这两个概念钉死。想象你在整理一份纸质报表:thead相当于你在这一摞纸的顶部夹了一个标签页,上面写着"这是表头区,别跟数据区混在一起";th相当于你在某一个格子里写了一行加粗的说明文字,表示"这一格是标题,它管的是下面这一列"。
标签页和格子里的文字,功能完全不重叠。你把标签页撕了,格子里的字还在;你把字擦了,标签页也还在。所以:
- 只写
thead不写th,结构分区有了,但每个单元格没表明"我是标题",辅助技术只知道这里有个区域,不知道里面每一格的语义。 - 只写
th不写thead,每个单元格的身份清楚了,但整块区域没被划出来,打印分页、CSS 分组、解析库的区域识别都会受影响。 - 两个都写,才是完整表达。
实际项目里我见过最多的错误写法是<thead><td>名称</td></thead>——thead直接套td,跳过了tr。浏览器会把td外面自动补一个tr,页面看着没毛病,但你的 CSS 选择器可能就对不上了,thead > td这种写法永远匹配不到任何元素,排查时能把人急死。
1.3 为什么浏览器能容忍省略thead,语义却照样丢光
HTML 的容错机制很宽容。你写<table><tr><td>...</td></tr></table>,浏览器会自动补一个tbody把tr包起来——这一点你在开发者工具的 Elements 面板里能亲眼看到,源码里没写的tbody在 DOM 里出现了。但thead不会被自动补。浏览器没法猜你哪一行是表头,它只能按你写的来。
这就导致一个隐蔽的后果:源码里看起来"我有表头啊,第一行不就是吗",但 DOM 里根本没有thead这个节点。所有依赖这个节点的能力全部失效,而且失效得静悄悄,页面上一点异常都看不出来。具体丢掉了什么,后面第 3 节会逐条展开。这里先记住一句话:省略thead不会让表格坏掉,只会让表格"降级"成一个纯视觉的排版工具。
2. 语义层面的分工:分组语义和单元格语义差在哪
2.1thead回答的是"这块区域是什么"
thead表达的是一个**行组(row group)**的概念。它告诉浏览器和辅助技术:"从这行到这行,属于表格的头部区域,是列标题的集合。"这句话听起来虚,但它带来三个实打实的能力。
第一个能力是打印分页。表格内容超过一页时,只要表头在thead里,绝大多数主流浏览器会在每一页的顶部重复渲染这段表头。我做过一个几十行的报价单,客户反馈"第二页开始就不知道哪列是哪列了",原因就是当初图省事没用thead。加上之后问题当场解决,一行 CSS 都没动。
第二个能力是区域识别。屏幕阅读器在浏览表格时,会把thead当作一个独立的导航单元,用户可以按键直接跳到"表头区域"或者"数据区域"。没有thead,整张表在辅助技术眼里就是一大坨平铺的行,只能一行一行挪。
第三个能力是 CSS 的锚点。thead对应display: table-header-group,这个值决定了它在表格布局中的特殊地位。你在做复杂样式的时候,有没有这个锚点,写法完全不一样。
需要强调一点:一张表只能有一个thead。规范如此。如果你有两层表头想拆成两个thead,那是错的,正确做法是在同一个thead里写多行tr。
2.2th回答的是"这一格在描述谁"
th表达的是表头单元格(header cell)。它跟td(数据单元格)是并列关系,区别只在于语义身份,不在于长相。th的含义是:"我这个格子的内容,是用来描述其他格子的。"
这个"描述关系"是有方向的,方向由scope属性指定。scope="col"表示我这一格管的是我下面这一整列;scope="row"表示我这一格管的是我右边这一整行。方向定对了,屏幕阅读器在念到某个数据格时,才能把对应的行标题和列标题一起念出来,形成"华东,一季度,1200 件"这样的完整句子。方向缺失或者定错,念出来的就是孤零零的一个"1200",用户根本不知道这是什么。
我做过一次盲测:把同一张表分别用全td和th+scope两种写法,丢进屏幕阅读器里听。全td的版本念到第四行的时候,我已经完全记不住当前在哪个列了;加了scope的版本,每个数字前面都会带上"华东""一季度"的上下文,理解成本低了一个量级。这个差距不是"体验稍好一点",是"能用"和"不能用"的区别。
顺便提醒:th不只能放在thead里。行标题(比如上面例子里的"华东""华南")通常放在tbody每一行的第一个位置,它也是th,只是scope="row"。所以不要把th和thead划等号,th可以出现在表格的任何区域。
2.3scope、headers、abbr三个属性的实际用法
这三个属性是th身上最有用、也最容易被全体忽略的部分。我用一张表把它们的分工讲清楚。
| 属性 | 作用 | 适用场景 | 踩坑提醒 |
|---|---|---|---|
scope | 声明这个th管的方向 | 常规的行列标题 | 只在简单表格里够用,跨行跨列多了会失效 |
headers | 用 id 精确指定这个td关联哪几个th | 复杂的多层表头 | 要手工维护 id,后期改动容易漏 |
abbr | 表头文字的缩写形式 | 窄屏、语音播报 | 支持度参差,不要指望它替代完整文字 |
scope的取值有四个:col、row、colgroup、rowgroup。前两个最常用,后两个专门用于合并单元格的场景——当一个th通过colspan横跨多列时,它管的就不是一列而是一组列,这时候应该用scope="colgroup"。
headers属性是复杂表格的兜底方案。当一个数据格同时受"第一季度"(列标题)和"华东"(行标题)以及"销售额"(分组标题)三个表头影响时,scope已经表达不清楚了,就得靠headers="id1 id2 id3"手工点名。代价是维护成本高,我一般只在表头超过两层时才动用它。
abbr属性给我留下的印象比较复杂。理论上它可以让"一季度销售额同比增长率"这种长标题在窄屏上显示成"Q1同比",但实际支持情况并不统一,主流浏览器对它的呈现策略并不一致。我的做法是:abbr写上(对辅助技术有好处),但视觉上的缩写效果还是靠 CSS 或者额外包一层元素来控制,不要把展示效果押在它身上。
3. 渲染、样式与导出:两者的实际差异实录
3.1th的加粗居中到底从哪来
这是最容易造成误解的一点。th默认是加粗且水平居中的,很多人因此认定"th就是一个样式标签"。这个结论对了一半——它确实携带默认样式,但默认样式是浏览器内置样式表给的,不是这个标签的本质。
主流浏览器的内置规则大致是这样:
th { font-weight: bold; text-align: center; vertical-align: middle; } td { text-align: start; vertical-align: inherit; }关键差异在text-align。th默认居中,td默认靠起始边(中文环境下就是左对齐)。这就埋了一个非常常见的视觉 bug:表头居中,下面的数据左对齐,视觉上完全对不齐。尤其是数字列,表头"销量"两个字在中间,下面一堆数字贴着左边,看起来就像排版坏了。
我一般的处理方式是把对齐方式统一掉,而不是让th保留它的默认值:
table { border-collapse: collapse; width: 100%; } th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e5e5e5; } th { font-weight: 600; background: #fafafa; color: #333; }注意这里我特意用了一个选择器组th, td来统一padding和text-align,这样th的居中默认值就被覆盖掉了。如果不做这一步,后面每个表格都得单独处理,长期下来是负担。
提示:
font-weight: bold和font-weight: 600在视觉上接近但不相同。中文字体在 600 字重下经常没有对应字形,浏览器会回退到 700,所以实际效果可能和bold一样。想让表头轻一点,用500更稳妥。
3.2thead几乎不带视觉样式,但它决定了三件事
thead本身在视觉上基本是"隐形"的——它没有背景、没有边框、没有内边距,你把它写上去页面上看不出任何变化。这也是新人觉得它"没用的标签"的原因。但它背后的三个机制一直在起作用。
第一是display: table-header-group。这个显示类型让它在表格布局算法里拥有特殊地位。你在做position: sticky的时候会直接撞上它,第 4.3 节会详细讲。
第二是打印重复。前面提过了,这是thead最实在的收益,尤其对长表格。
第三是样式的分区锚点。有了thead,你就可以写出thead th这种精确到区域的選擇器,而不用靠:first-child之类脆弱的方式去猜。我见过太多项目用tr:first-child td来选表头,一旦表格里插了个隐藏行或者用了colgroup,选择器立刻失效,然后就是一轮痛苦的排查。
3.3 用 CSS 选择器把两者串起来:常见写法与优先级陷阱
thead th是我在表格样式里用得最多的组合选择器,它的权重是 (0,0,2),比单独的th(0,0,1)高一级。这个权重差在覆盖默认样式时刚好够用,又不会高到难以覆盖。
/* 基础单元格 */ th { padding: 8px 12px; text-align: left; } /* 表头区域内的单元格,权重更高,优先生效 */ thead th { background: #f5f7fa; font-weight: 600; position: sticky; top: 0; }这里有个细节要注意:如果你在thead里也用了行标题(scope="row"的th),它同样会被thead th命中。这种情况在"左上角空白格"或者"分组首列"里会出现,样式可能不是你想要的。我一般的处理是给这类特殊格加个类名单独覆盖,而不是靠:first-child去区分,因为:first-child在colgroup存在时的行为容易让人困惑。
还有个小陷阱:nth-child系列选择器数的是同级元素的位置,在tr内部数的是th和td混在一起的位置。如果你第一列是th,第二列是td,那td:nth-child(2)能命中,但td:nth-child(1)命不中——因为第一个孩子是th不是td。这个坑我在 review 代码时见过不止一次。
4. 动手写:从结构到粘性表头的完整落地
4.1 第一步:把结构搭对,附一份自检清单
结构这步没有捷径,就是按规范一层层写。我给自己定了一份清单,每次写完表格过一遍,能挡掉八成问题:
table内是否有且仅有一个thead,且它在tbody之前;thead的直接子元素是否全部是tr,没有裸奔的th或td;- 每个
tr内的标题格是否用th,数据格是否用td; - 列标题的
th是否带scope="col",行标题的th是否带scope="row"; - 是否存在跨行跨列,如果有,对应的
scope是否升级成colgroup或rowgroup; - 表格是否有
caption说明用途。
第 6 条经常被跳过,但它的性价比很高。caption是表格的标题元素,屏幕阅读器进入表格时会先播报它,用户立刻知道"这是一张什么表"。写法上它必须是table的第一个子元素:
<table> <caption>2024 年各区域销量明细</caption> <thead>...</thead> <tbody>...</tbody> </table>如果视觉上不想显示这个标题,用 CSS 做视觉隐藏就行,别直接删掉:
caption { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }4.2 第二步:合并单元格时thead和th的配合
双层表头是thead和th配合最容易出错的场景。举个例子:上面一行是"上半年""下半年"两个大分组,下面一行是四个季度。
<thead> <tr> <th rowspan="2" scope="col">区域</th> <th colspan="2" scope="colgroup">上半年</th> <th colspan="2" scope="colgroup">下半年</th> </tr> <tr> <th scope="col">一季度</th> <th scope="col">二季度</th> <th scope="col">三季度</th> <th scope="col">四季度</th> </tr> </thead>这里的几个点值得说清楚。
第一,左上角那个"区域"格用了rowspan="2"纵向跨两行,因为它的语义是"这一列管的是区域",纵向跨过两个表头行才对齐。同时它的scope仍然是col,因为它管的还是列。
第二,"上半年"和"下半年"用了colspan="2",各自横跨两列,scope升级成colgroup。这一步很多人会漏,漏了之后辅助技术就分不清它是管一列还是管一组列。
第三,两行表头必须都在同一个thead里。我见过有人写两个thead分别包一行,那是无效结构,浏览器解析时会把它拆散,行为不可预期。
第四,这种双层表头一旦出现,scope的表达能力就到了边界。如果再加一层(比如"线上""线下"再分),最稳妥的做法是切换到headers属性手工关联:
<td headers="h-h1 h-q1">1200</td>其中h-h1是"上半年"的 id,h-q1是"一季度"的 id。麻烦是麻烦,但语义准确度是最高的一档。
4.3 第三步:粘性表头的正确写法,以及为什么写在thead上没用
这是我被问得最多的一个问题:为什么thead { position: sticky; top: 0; }不生效?
原因在于thead的显示类型是table-header-group,而position: sticky在表格行组元素上的行为在各家浏览器里并不一致,有些情况下它压根不会产生粘性效果。正确做法是把sticky加在thead里的th上,因为th的显示类型是table-cell,粘性定位在单元格上的支持是稳定的。
<div class="table-wrap"> <table> <thead> <tr> <th scope="col">区域</th> <th scope="col">一季度</th> </tr> </thead> <tbody>...</tbody> </table> </div>.table-wrap { max-height: 420px; overflow: auto; } .table-wrap thead th { position: sticky; top: 0; z-index: 2; background: #fff; box-shadow: inset 0 -1px 0 #e0e0e0; }几个必须注意的细节:
z-index不能省。滚动时下面的数据行会盖住表头,加个z-index把它顶上来。如果表格里还有粘性的首列,z-index要分层管理,左上角那个交叉格需要比两者都高。
背景色不能省。没有背景色,滚动时数据会从表头底下透出来,视觉上一团糊。用#fff还是页面背景色,取决于你的设计。
别用border-bottom,用box-shadow。在border-collapse: collapse的表格里,单元格的边框会被合并处理,滚动过程中这条边框会"掉"下来,粘住的只有内容没有线。用box-shadow: inset 0 -1px 0 #e0e0e0画内阴影,效果等价且不会脱落。这是我在三个项目里连续踩坑之后才固化的写法。
外层容器必须有确定的高度和overflow。粘性定位是相对最近的滚动容器生效的。如果.table-wrap没有max-height或者没设overflow,那滚动的是整个页面,top: 0会相对视口生效,表头会粘在页面顶部而不是表格里——这通常不是你想要的效果。
4.4 第四步:响应式改造时别把语义丢掉
窄屏下表格必然要改造,主流方案有两种:横向滚动和卡片化。横向滚动最省事,包一层overflow-x: auto就行,语义完全保留。卡片化视觉上更好看,但极容易把语义丢干净——很多人直接重写成div列表,th和td全没了。
我的做法是保留原表结构,用 CSS 把每个tr变成一块卡片,然后靠><td>@media (max-width: 640px) { thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } tr { display: block; padding: 12px; border-bottom: 1px solid #eee; } td { display: flex; justify-content: space-between; } td::before { content: attr(data-label); font-weight: 600; color: #666; } }
这里有个取舍要讲清楚:小屏下我把thead做了视觉隐藏而不是删除。这么做是为了让 DOM 结构保持完整——屏幕阅读器在读数据格时,仍然能通过scope关联到对应的th,念出完整上下文。视觉上看不到表头,但语义链没断。如果直接把thead从 DOM 里移除,或者重写成div,这条链就断了。这也是"thead和th到底差在哪"在响应式场景下最直观的一次体现:一个负责让视觉上的表头消失,一个负责让语义上的表头留下。
5. 常见问题与排查技巧实录
5.1 一张速查表,覆盖八成现场
下面这张表是我这几年攒下来的,基本覆盖了thead和th相关的典型症状、成因和处理方式。遇到问题时可以先扫一遍。
| 现象 | 根本原因 | 处理方式 |
|---|---|---|
| 打印时第二页开始没有表头 | 表头行直接写在tbody或者没分区 | 把标题行移进thead |
| 抓取页面表格时列名变成 0、1、2 | 表头用的是td,解析库识别不到标题语义 | 标题格换成th,并放进thead |
| 粘性表头不生效 | sticky加在了thead上 | 挪到thead th上,补z-index和背景 |
| 表头滚动时下边框消失 | border-collapse: collapse下边框被合并 | 改用box-shadow画线 |
| 屏幕阅读器念数据没有上下文 | th缺scope,或者整个用了td | 补scope="col"/scope="row" |
| 表头文字居中、数据左对齐,视觉错位 | th的浏览器默认居中没被覆盖 | 统一th, td的text-align |
| 复制到电子表格软件后表头识别不出来 | 表头不是th,语义缺失 | 换th,确保在thead内 |
| 双层表头被拆散 | 写了两个thead | 合并成一个,内部写两行tr |
| 表头文字过长撑破布局 | 没有做换行或缩写策略 | 用abbr+ CSS 控制宽度 |
| 表格里第一列的样式选不中 | :nth-child把th和td混在一起数 | 用类名定位,别依赖位置 |
5.2 几个容易被忽略的连带影响
有几个影响是超出"页面显示"范围的,但实际工作中很要命,值得单独拎出来说。
内容抓取。用 Python 的表格解析库去读网页表格时,解析器是靠结构判断哪一行是表头的。页面上有thead且标题格是th,读出来的结果就是带列名的规整数据;页面上全是td,读出来就是一坨无名字段,还得手工对齐。我做过一次数据核对,同一批页面两套模板,一套语义规整一套乱写,前者的提取脚本十分钟写完,后者花了大半天在猜列序。
复制粘贴。把网页表格全选复制,粘到电子表格软件里,语义清晰的表头会被识别成标题行,粘过去自动加粗并锁定在顶部(如果软件支持)。全td的表格粘过去就是普通数据,没有任何标题特征。这个小差别在需要频繁搬运数据的场景里,体验差距非常明显。
深色模式。如果你的表头设置了浅色背景,在深色主题下会变成一块突兀的白斑。我的处理是用 CSS 变量控制背景色,在prefers-color-scheme里换一套值。这里要注意th的默认背景是透明的,所以如果你只给thead设背景是不会有视觉效果的——必须落到th或者td上,因为背景在表格单元格上的绘制规则跟普通元素不一样。
结构化数据。表格的语义化对搜索引擎理解内容有帮助,但别指望它能带来排名跃升。真正有用的部分是:当别人抓取你的数据时,能不能准确还原出"哪一列是什么"。这属于数据可用性的范畴,不是优化技巧。
5.3 我这几年的实操心得
最后分享几个踩过坑才固化下来的习惯,算不上什么高深技巧,但能省不少事。
先写结构,后写样式,中间别开预览。我早期习惯边写边看效果,结果就是结构永远围着视觉效果妥协。后来改成先把thead、tbody、th、td、scope全部写完,再开浏览器补样式,出来的代码干净得多,后期改版也不用推倒重来。
封装表格组件时,把thead单独做成插槽。在组件化开发里,很多人图省事把表头和数据揉成一个数组循环渲染,结果表头样式和数据样式全缠在一起。把表头区单独抽成一个具名插槽,让调用方自己写thead里的内容,职责会清楚很多。这样粘性表头、排序图标、筛选器这些附加功能,都可以在插槽内部自由组合,不用改组件本身。
列数超过七八列,先想想是不是该换展示形式。这是产品层面的判断,不是技术问题。表格的优势在于"逐项对比",列数太多之后,横向滚动本身就是一种体验损耗。这时候考虑拆成主表加详情、或者换成卡片列表,往往比硬撑着做一张宽表更合适,语义化的压力也小了。
写完之后用键盘走一遍,再用开发者工具看一眼 DOM。键盘 Tab 走一遍能发现焦点顺序和可访问性的问题;开发者工具 Elements 面板看一眼能确认thead是不是真的存在、tbody是不是被浏览器自动补上了。这两个动作加起来不超过一分钟,但能挡住大部分低级问题。
我个人在实际操作中的体会是:thead和th这两个标签,技术难度几乎为零,难的是"意识到它们存在并且有必要写"。绝大多数表格相关的线上问题——打印断头、数据错位、辅助技术读不出上下文、抓取结果混乱——追溯到最后,都是当初少写了那么几行结构。等到出问题再回头补,成本比一开始写对要高得多,因为那时候样式、脚本、数据流都已经缠在上面了。所以我的建议很简单:下次写表格,哪怕只有三行,也把thead和scope老老实实写上。这几秒钟的投入,后面会一直在替你省时间。