thead与th区别:HTML表格语义化、粘性表头与排坑
上周帮一个刚转前端的同学看代码他做了张商品价格对照表表头那一行写的是trtdb名称/b/tdtdb价格/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 span2 /colgroup thead tr th scopecol区域/th th scopecol一季度/th th scopecol二季度/th /tr /thead tbody tr th scoperow华东/th td1200/td td1380/td /tr tr th scoperow华南/th td960/td td1105/td /tr /tbody tfoot tr th scoperow合计/th td2160/td td2485/td /tr /tfoot /table看层级table下面挂theadthead下面挂trtr下面才是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 分组、解析库的区域识别都会受影响。两个都写才是完整表达。实际项目里我见过最多的错误写法是theadtd名称/td/thead——thead直接套td跳过了tr。浏览器会把td外面自动补一个tr页面看着没毛病但你的 CSS 选择器可能就对不上了thead td这种写法永远匹配不到任何元素排查时能把人急死。1.3 为什么浏览器能容忍省略thead语义却照样丢光HTML 的容错机制很宽容。你写tabletrtd.../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属性指定。scopecol表示我这一格管的是我下面这一整列scoperow表示我这一格管的是我右边这一整行。方向定对了屏幕阅读器在念到某个数据格时才能把对应的行标题和列标题一起念出来形成华东一季度1200 件这样的完整句子。方向缺失或者定错念出来的就是孤零零的一个1200用户根本不知道这是什么。我做过一次盲测把同一张表分别用全td和thscope两种写法丢进屏幕阅读器里听。全td的版本念到第四行的时候我已经完全记不住当前在哪个列了加了scope的版本每个数字前面都会带上华东一季度的上下文理解成本低了一个量级。这个差距不是体验稍好一点是能用和不能用的区别。顺便提醒th不只能放在thead里。行标题比如上面例子里的华东华南通常放在tbody每一行的第一个位置它也是th只是scoperow。所以不要把th和thead划等号th可以出现在表格的任何区域。2.3scope、headers、abbr三个属性的实际用法这三个属性是th身上最有用、也最容易被全体忽略的部分。我用一张表把它们的分工讲清楚。属性作用适用场景踩坑提醒scope声明这个th管的方向常规的行列标题只在简单表格里够用跨行跨列多了会失效headers用 id 精确指定这个td关联哪几个th复杂的多层表头要手工维护 id后期改动容易漏abbr表头文字的缩写形式窄屏、语音播报支持度参差不要指望它替代完整文字scope的取值有四个col、row、colgroup、rowgroup。前两个最常用后两个专门用于合并单元格的场景——当一个th通过colspan横跨多列时它管的就不是一列而是一组列这时候应该用scopecolgroup。headers属性是复杂表格的兜底方案。当一个数据格同时受第一季度列标题和华东行标题以及销售额分组标题三个表头影响时scope已经表达不清楚了就得靠headersid1 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)比单独的th0,0,1高一级。这个权重差在覆盖默认样式时刚好够用又不会高到难以覆盖。/* 基础单元格 */ th { padding: 8px 12px; text-align: left; } /* 表头区域内的单元格权重更高优先生效 */ thead th { background: #f5f7fa; font-weight: 600; position: sticky; top: 0; }这里有个细节要注意如果你在thead里也用了行标题scoperow的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是否带scopecol行标题的th是否带scoperow是否存在跨行跨列如果有对应的scope是否升级成colgroup或rowgroup表格是否有caption说明用途。第 6 条经常被跳过但它的性价比很高。caption是表格的标题元素屏幕阅读器进入表格时会先播报它用户立刻知道这是一张什么表。写法上它必须是table的第一个子元素table caption2024 年各区域销量明细/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 rowspan2 scopecol区域/th th colspan2 scopecolgroup上半年/th th colspan2 scopecolgroup下半年/th /tr tr th scopecol一季度/th th scopecol二季度/th th scopecol三季度/th th scopecol四季度/th /tr /thead这里的几个点值得说清楚。第一左上角那个区域格用了rowspan2纵向跨两行因为它的语义是这一列管的是区域纵向跨过两个表头行才对齐。同时它的scope仍然是col因为它管的还是列。第二上半年和下半年用了colspan2各自横跨两列scope升级成colgroup。这一步很多人会漏漏了之后辅助技术就分不清它是管一列还是管一组列。第三两行表头必须都在同一个thead里。我见过有人写两个thead分别包一行那是无效结构浏览器解析时会把它拆散行为不可预期。第四这种双层表头一旦出现scope的表达能力就到了边界。如果再加一层比如线上线下再分最稳妥的做法是切换到headers属性手工关联td headersh-h1 h-q11200/td其中h-h1是上半年的 idh-q1是一季度的 id。麻烦是麻烦但语义准确度是最高的一档。4.3 第三步粘性表头的正确写法以及为什么写在thead上没用这是我被问得最多的一个问题为什么thead { position: sticky; top: 0; }不生效原因在于thead的显示类型是table-header-group而position: sticky在表格行组元素上的行为在各家浏览器里并不一致有些情况下它压根不会产生粘性效果。正确做法是把sticky加在thead里的th上因为th的显示类型是table-cell粘性定位在单元格上的支持是稳定的。div classtable-wrap table thead tr th scopecol区域/th th scopecol一季度/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补scopecol/scoperow表头文字居中、数据左对齐视觉错位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老老实实写上。这几秒钟的投入后面会一直在替你省时间。