满屏 div 为什么搜索引擎抓不准:语义化标签该怎么选
活动页上线一周后,SEO 那边的同事发来一份抓取报告,说搜索引擎几乎没能识别出页面的文章列表和导航结构,收录效果比预期差一截。打开 Elements 面板查这个页面的 DOM 树,从 <body> 到最深的叶子节点,除了 img 和 a,几乎全是 div。导航是 div,文章列表是一串 div,侧边栏也是 div,连页面主体和页脚的分界都要靠 class 名 .wrap-bottom 才能猜出来。样式和交互都是对的,页面在浏览器里看着挑不出毛病,问题出在浏览器之外——搜索引擎爬虫、屏幕阅读器这类不直接"看"页面的访问者,只能靠标签本身的语义去理解结构,而这版代码把这条信息全部抹掉了。
把这份报告转给写这版页面的同事,对方的第一反应很典型:"样式一样、功能一样,标签换了有什么区别?" 这个问题其实问到了点子上——如果只看视觉效果,div 确实什么都能干。但 HTML 从来不只是给 CSS 摆放盒子用的骨架,它同时也是页面内容的结构说明书,浏览器、辅助设备、搜索引擎爬虫,都要靠这份说明书理解页面讲的是什么。这份说明书写得好不好,平时看不出来,但等页面复杂到一定规模、或者需要被别的程序读取时,差别就会集中爆发。
先看这版代码具体错在哪
这版活动页的导航部分大致是这样写的:
1<div class="top-bar"> 2 <div class="logo">活动中心</div> 3 <div class="menu"> 4 <div class="menu-item">首页</div> 5 <div class="menu-item">进行中</div> 6 <div class="menu-item">已结束</div> 7 </div> 8</div>
单看这段代码,样式跑起来没问题,点击事件也绑得上。但把它读给一个没见过设计稿的人听,谁也说不出这是导航还是别的什么容器,因为 div 本身不携带任何含义,它是最纯粹的通用容器。换成语义标签之后,同样的结构变成:
1<nav class="top-bar" aria-label="主导航"> 2 <a class="logo" href="/">活动中心</a> 3 <ul class="menu"> 4 <li><a href="/">首页</a></li> 5 <li><a href="/ongoing">进行中</a></li> 6 <li><a href="/ended">已结束</a></li> 7 </ul> 8</nav>
这里做了两处改动:外层容器换成 nav,直接告诉浏览器和爬虫"这是一组导航链接";每个菜单项换成 a,因为点击后确实是跳转到别的路由,而不是触发页面内的某个动作。菜单项外面套一层 ul/li,是因为它们本质上是一组并列的条目,用列表标签比一堆 div 更准确地表达了"这是同一组东西"这件事。样式部分几乎不用改,.top-bar、.menu、.logo 这些 class 照常挂在新标签上,选择器该怎么写还怎么写。
文章列表那块问题更典型。原来的结构是:
1<div class="post-list"> 2 <div class="post-item"> 3 <div class="post-title">Vite 2.0 值得现在切吗</div> 4 <div class="post-meta">2021-08-01</div> 5 </div> 6</div>
这段代码的问题不在于它跑不起来,而在于它把好几层信息都压扁成了同一种标签。一篇文章应该是相对独立的一块内容,标题应该表达标题层级,发布时间是一段机器可以理解的日期,而不是普通文本。改写之后是这样:
1<ul class="post-list"> 2 <li> 3 <article class="post-item"> 4 <h2 class="post-title">Vite 2.0 值得现在切吗</h2> 5 <time class="post-meta" datetime="2021-08-01">2021 年 8 月 1 日</time> 6 </article> 7 </li> 8</ul>
article 表示这是一块可以独立拿出来阅读的内容;h2 承担的是标题层级,不是字号大小,字号完全交给 CSS 控制;time 的 datetime 属性写标准格式给程序读,标签内容写人类习惯的格式给人看,两者互不影响。这几处改动加起来,整段代码的行数几乎没有变化,但结构信息量完全不是一个量级。
div 不是原罪,滥用才是
评审时同事又问了一句:是不是以后都不能用 div 了?不是。div 本身没有任何问题,它是一个通用容器,专门用来处理"这块区域没有具体语义、纯粹是为了布局或者分组样式"的情况。真正的问题是把它当成万能替代品,用来承担所有本该由具体标签表达的结构。
一个健康的做法是让语义标签负责表达"这是什么",div 负责表达"这些东西要放在一起摆好看":
1<section> 2 <h2>推荐活动</h2> 3 <div class="activity-grid"> 4 <article class="activity-card">...</article> 5 <article class="activity-card">...</article> 6 </div> 7</section>
这里 section 和 article 表达内容结构,activity-grid 这个 div 只负责把几张卡片排成网格,它不需要有语义,因为它确实不代表任何具体内容,纯粹是布局需要。这种分工一旦清楚,评审的时候一眼就能看出哪块代码在管结构,哪块代码在管样式,不需要靠猜。
section 不是 div 的高级替身
评审进行到一半,diff 里导航那块的 div 换成了 nav,但侧边栏那块直接套了一层 section,注释里写的理由是"听起来更语义化"。这是我见过最常见的过度纠正——很多人一旦知道要少用 div,就把 section 当成万金油到处套,反而制造了新的问题。
section 在规范里的定义是一个有主题的内容分组,理想情况下应该配一个标题(h1~h6 之一)。如果一块区域只是纯粹的布局容器,或者压根没有一个能概括它的标题,套 section 并不会让语义变得更清楚,反而会在无障碍树里多出一个没有名字的 region。这一点在语义标签和无障碍表达之间是有交叉的:标签选对了,浏览器会自动生成对应的角色信息,但这属于另一个话题的范畴,这里只讨论标签本身该怎么选。判断标准很简单:这块内容能不能用一句话起个小标题?能就用 section 并且真的写上标题;不能就老实用 div。
侧边栏那版最后改成了这样:
1<aside aria-label="相关推荐"> 2 <h2 class="visually-hidden">相关推荐</h2> 3 <ul class="related-list"> 4 <li><a href="/posts/1">Element Plus 上手体验</a></li> 5 <li><a href="/posts/2">webpack 5 升级踩坑记录</a></li> 6 </ul> 7</aside>
侧边栏本身是和主内容相关但又相对独立的一块信息,aside 比 section 更贴切。h2 用 visually-hidden 这类只隐藏视觉、不隐藏语义的方式处理,是因为设计稿上这块区域确实不需要显示标题文字,但结构上仍然需要有一个标题来说明这个区块是什么。
导航标签也有类似的坑:页脚那组"关于我们 / 联系方式 / 隐私政策"的链接,直接又套了一层 nav。这没问题,但一个页面里如果出现多个 nav,最好用 aria-label 把它们区分开:
1<nav aria-label="主导航">...</nav> 2<nav aria-label="页脚导航">...</nav>
不然任何依赖标签结构去列举页面地标的工具,都只能看到两个都叫 "navigation" 的区块,没法区分谁是谁。
按钮和链接不能混着用
评审这版代码时,最容易被略过的一处是"查看更多"那个交互。这段代码写的是:
1<div class="load-more" onclick="loadMore()">查看更多</div>
这个交互不涉及页面跳转,纯粹是触发一段 JavaScript 去加载下一页数据,所以它本质上是一个按钮,不是链接,也不该是裸 div:
1<button type="button" class="load-more">查看更多</button>
这里有一条判断标准基本可以覆盖大部分场景:会改变当前页面地址或者跳到另一个资源的,用 a;只是触发页面内某个动作的,用 button。这条标准在实际项目里经常被打破的反例是拿 <a href="#"> 当按钮用,点击后配合 onClick 阻止默认行为。这种写法看着能跑,但地址栏会留下一个 #,浏览器历史记录也会被污染,用户点返回键时行为会很奇怪。需要一个动作就直接用 button,不要拿一个空的 href 凑数。
表单里的按钮还有一个容易被忽略的细节,就是显式写 type:
1<button type="button">重置</button> 2<button type="submit">提交</button>
省略 type 的按钮在 form 里默认是 submit。我们组之前有个后台筛选表单,"重置"按钮没写 type,QA 测出来一个诡异问题:每次点重置,整个表单都跟着提交一次,页面莫名其妙刷新一遍。排查了一阵才定位到就是这个默认值在作怪。从那以后,凡是写在 form 里的按钮,团队约定必须显式标注 type,宁可啰嗦也不留这个隐患。
这版活动页里恰好也有一个"筛选"按钮写在 form 内部,没有加 type,我在评审里顺手把这条也标了出来,算是把这个坑提前拦掉一次。
原生标签省下的成本,往往超出预期
评审快结束时,同事问了一个更实际的问题:"如果结构上确实需要一个可点击的东西,但设计稿要求的样式跟浏览器默认的 button 完全不一样,是不是不如直接用 div 好改样式?"
这个问题背后其实是把"改样式的成本"和"标签选择"混在了一起。原生 button 自带一整套行为:键盘可以聚焦,回车和空格能触发,disabled 属性能同时处理视觉禁用和交互禁用。如果换成 div 来模拟,这些行为都得自己补:
1<div 2 role="button" 3 tabindex="0" 4 aria-disabled="false" 5 onclick="onSave()" 6 onkeydown="handleKey(event)" 7> 8 保存 9</div>
补全这些代码只是复刻了原生 button 自带能力的一部分,aria-disabled="true" 也不会像原生 disabled 那样自动拦截点击事件,还得在事件处理函数里手动判断一次。更省事的方向是反过来:用原生 button,把默认样式清空,需要多少自定义样式就加多少,行为完全交给浏览器处理:
1button { 2 appearance: none; 3 border: none; 4 background: none; 5 padding: 0; 6 font: inherit; 7 cursor: pointer; 8}
样式上能做到和设计稿完全一致,键盘、焦点、禁用这些默认能力也都保留了下来,不需要额外维护一套交互逻辑。这条判断放在评审里说,比空口讲道理更容易服人——同一个视觉效果,两种实现方式,代码量的差距摆在那。
标题层级要跟内容对齐,不是跟字号对齐
这版代码里还有一处细节:活动详情页的正文标题用的是 h4,理由是"这个位置的字号本来就该小一点"。这也是一个常见的误区——标题标签首先表达的是文档结构的层级,不是视觉上的字号大小,字号完全应该交给 CSS。
一个页面通常只需要一个核心 h1,下面的分区用 h2,分区里的小节再用 h3,层层递进:
1<h1>暑期大促活动</h1> 2 3<section> 4 <h2>活动规则</h2> 5 <h3>参与门槛</h3> 6</section>
如果某处视觉上需要更小的字号,用 CSS 调整这个 h2 或 h3 的 font-size 就够了,不需要为了字号跳到 h4 甚至 h5。层级跳跃对屏幕阅读之外的场景同样有影响——很多做内容抓取和目录生成的工具,都是直接读标题层级来生成大纲,层级一旦乱掉,生成出来的目录也会跟着乱。
我自己排查这类问题时,会在控制台里跑一段脚本,把当前页面的标题层级按缩进打印出来:
1document.querySelectorAll('h1,h2,h3,h4,h5,h6') 2 .forEach(function (h) { 3 var level = +h.tagName[1]; 4 console.log(' '.repeat(level - 1) + h.tagName + ' ' + h.textContent.trim()); 5 });
如果打印结果的缩进是平滑递进的,说明层级是对的;如果忽大忽小,就说明哪里跳级了。组件库里复用的卡片标题、弹窗标题也容易踩这个坑——同一个标题组件被复用到不同上下文里,写死一个 h3 未必每次都合适。后来我们把这类标题组件改成支持传入 level 参数,具体渲染成 h2 还是 h3,由使用方根据它所在的位置决定,组件本身不写死层级。
main 标签容易被漏掉
评审到中间,我顺着 DOM 树往上翻,发现整个页面从 body 直接到 .top-bar、.content、.footer,没有任何一层标注出"这是页面主内容"。这是另一个很常见的疏漏——大家会记得给导航加 nav,给文章加 article,却漏掉最外层的 main。
main 的作用是标出页面里唯一的、真正承载核心内容的区域,跳过页头、导航、侧边栏这些重复出现在每个页面上的部分。它的价值不只是语义完整,在实际使用中,很多依赖标签结构做"跳转到主内容"功能的场景都是靠它定位:
1<body> 2 <header class="site-header">...</header> 3 <nav aria-label="主导航">...</nav> 4 <main> 5 <h1>暑期大促活动</h1> 6 ... 7 </main> 8 <aside aria-label="相关推荐">...</aside> 9 <footer class="site-footer">...</footer> 10</body>
一个页面里 main 只能出现一次(如果某个区域被 hidden 属性隐藏,不算在内)。补上 main 之后,整个页面的地标结构就完整了:header、nav、main、aside、footer 各司其职,谁都不需要靠 class 名去猜。
这一层地标结构补齐之后还有个连带的好处:团队内部单页应用做路由切换时,只要保证每次切换后新内容依然渲染在同一个 main 里,页面骨架的其余部分(顶部导航、页脚)就不需要重新渲染,这跟组件层面做的路由级别缓存是两回事,但从结构角度先把主内容区域圈定清楚,是做这类优化之前的一个前提条件。
表单控件的语义比想象中丰富
活动页里还有一个筛选表单,字段之间用 div 加 class="form-item" 分组,标签文字和输入框之间没有任何关联,纯粹靠 CSS 摆在一起:
1<div class="form-item"> 2 <div class="form-label">活动类型</div> 3 <select class="form-input"> 4 <option value="all">全部</option> 5 <option value="discount">满减</option> 6 </select> 7</div>
这里的问题是 form-label 那个 div 和它旁边的 select 在结构上没有任何关系,纯粹靠视觉上"挨得近"来表达关联。换成 label 之后,这层关联会变成结构层面真实存在的东西:
1<div class="form-item"> 2 <label for="activity-type" class="form-label">活动类型</label> 3 <select id="activity-type" class="form-input"> 4 <option value="all">全部</option> 5 <option value="discount">满减</option> 6 </select> 7</div>
label 的 for 属性指向 select 的 id,两者绑定之后,点击文字本身也能让输入框获得焦点,这是原生就带的能力,不用额外写事件。这个细节在移动端尤其明显——移动端的可点击区域本来就小,label 把可点区域从"只有那个下拉框"扩大到"文字加下拉框",体验上有实打实的改善。
表单里的分组信息也经常被 div 吞掉。比如筛选表单里"活动类型"和"活动状态"如果本身是一组相关字段,规范里对应的标签是 fieldset 加 legend:
1<fieldset> 2 <legend>按条件筛选</legend> 3 <div class="form-item"> 4 <label for="activity-type">活动类型</label> 5 <select id="activity-type">...</select> 6 </div> 7 <div class="form-item"> 8 <label for="activity-status">活动状态</label> 9 <select id="activity-status">...</select> 10 </div> 11</fieldset>
fieldset 不常被提起,是因为大部分表单字段不多、不需要分组,但一旦表单里出现"收货地址"和"发票信息"这种明显该分组的区块,fieldset 比一堆嵌套 div 更准确地表达了"这几项属于同一组"。
表格不是用来对齐文字的工具
这版代码里没有表格,但评审顺带聊到了另一个团队的历史遗留问题——用一堆 div 模拟出表格的视觉效果,用来展示活动数据报表:
1<div class="report"> 2 <div class="report-row report-head"> 3 <div class="report-cell">活动名称</div> 4 <div class="report-cell">参与人数</div> 5 <div class="report-cell">转化率</div> 6 </div> 7 <div class="report-row"> 8 <div class="report-cell">暑期大促</div> 9 <div class="report-cell">12,340</div> 10 <div class="report-cell">8.3%</div> 11 </div> 12</div>
如果内容本质上是行列对应的表格数据,就应该用 table、thead、tbody、th、td 这一套标签,而不是靠 CSS Grid 或者 Flex 把 div 排成表格的样子:
1<table class="report"> 2 <thead> 3 <tr> 4 <th scope="col">活动名称</th> 5 <th scope="col">参与人数</th> 6 <th scope="col">转化率</th> 7 </tr> 8 </thead> 9 <tbody> 10 <tr> 11 <th scope="row">暑期大促</th> 12 <td>12,340</td> 13 <td>8.3%</td> 14 </tr> 15 </tbody> 16</table>
th 的 scope 属性说明这个表头对应的是一整列还是一整行,这个信息在数据量大、需要按行按列理解数字含义时非常关键。用 div 模拟出来的表格,样式上能做到一模一样,但没有任何机制告诉别的程序(或者别的开发同事读代码时)"第二行第三列这个数字对应的是暑期大促的转化率",只能靠数格子去数。
这里也有一个容易被误用的反例:早年很多页面用 table 来做整体布局排版,这是语义化标签被用反了的例子——布局用的是表格标签,但内容本身根本不是表格数据。这一年这种写法已经很少见了,但排查老项目的时候偶尔还能碰到,遇到时应该按内容本质换成 div + Flex/Grid,而不是继续沿用表格布局。
图片说明用 figure 会更完整
活动页的 banner 图下面配了一行说明文字,写法是这样:
1<img src="/banner.png" alt="轮播图1" /> 2<div class="banner-desc">暑期大促,全场满减</div>
图片和它的说明文字之间同样没有结构上的关联,banner-desc 单看只是一段普通文字,没法确定它是在描述上面那张图。规范里给图片配文字说明准备的标签是 figure 和 figcaption:
1<figure> 2 <img src="/banner.png" alt="暑期大促活动主视觉,展示满减折扣信息" /> 3 <figcaption>暑期大促,全场满减</figcaption> 4</figure>
figure 表示这是一个可以独立引用的内容单元(图片、图表、代码示例都适用),figcaption 明确表示这段文字是对 figure 内容的说明。改完之后,顺手把 alt 也从占位的"轮播图1"换成了描述图片实际内容的文案——这一条前面已经提过,这里是同一处代码里连着改掉的两个问题。
这些改动跟 SEO 的关系比想象中直接
同事最后提了一句:这些改动会不会只是"看着规范",对实际效果没什么影响?这一年 Google 从 5 月开始把 Core Web Vitals(LCP、FID、CLS 这三项指标)正式纳入搜索排名信号,团队这半年一直在盯首屏加载速度和布局稳定性这些指标,语义化这块很容易被当成和排名无关的"代码洁癖"。但爬虫理解页面内容的方式跟浏览器解析结构的方式是同一套逻辑——一个清楚的 h1 加合理的标题层级,相当于直接告诉爬虫这个页面在讲什么、各部分主次关系是什么;article、time、nav 这些标签能帮它判断哪块是正文、哪块是发布时间、哪块只是导航链接,不需要正文内容。
这跟 Core Web Vitals 衡量的东西不在同一个维度,但都属于页面质量的一部分:一个是加载和交互体验,一个是内容能不能被准确理解,两者都会影响页面在搜索结果里的表现。团队这半年做迁移评估时,也顺带把这版活动页面的语义结构一起理清了,算是同一批工作里顺手做的事,不是额外加的任务。
图片的 alt 属性也是同一类问题。它首先是提供给辅助设备用的文字描述,但同时也是爬虫理解图片内容的依据,写成空字符串或者"图片1"这种占位内容,等于把这份信息白白扔掉。这版活动页的 banner 图 alt 写的正是"轮播图1",评审里也一并改成了描述实际内容的文案。
组件库带来的语义丢失是另一类问题
这版活动页里还有几处标签选得不对,根源不在这版页面自己写的代码,而在团队用的组件库。项目里用的下拉菜单组件,渲染出来的 DOM 大致是这样:
1<div class="dropdown"> 2 <div class="dropdown-trigger" onclick="toggle()">选择活动类型</div> 3 <div class="dropdown-panel"> 4 <div class="dropdown-option">全部</div> 5 <div class="dropdown-option">满减</div> 6 </div> 7</div>
这类组件库在 2021 年这个时间点上参差不齐,一部分沿用 Element UI 时代的实现方式,触发器和选项大量用 div 加 onclick 拼出来,样式好看,但标签选择基本没考虑过语义。这种情况下能做的事有限——不太可能为了语义去改第三方组件库的内部实现,但至少可以在自己项目里包一层,把关键的交互属性补上:
1<div class="dropdown"> 2 <button type="button" class="dropdown-trigger" aria-haspopup="listbox"> 3 选择活动类型 4 </button> 5 <ul class="dropdown-panel" role="listbox"> 6 <li class="dropdown-option" role="option">全部</li> 7 <li class="dropdown-option" role="option">满减</li> 8 </ul> 9</div>
触发器换成真正的 button,选项列表换成 ul/li,这两处改动组件库本身的视觉效果完全不受影响,因为改动只发生在标签名和少数属性上,class 和样式规则一个没动。这是评审时经常出现的争论点:有人会说"这是组件库的问题,不该我们改",但落到具体页面的产出物上,用户和爬虫看到的就是最终渲染出来的 DOM,组件库内部怎么实现是另一回事,页面层能做的语义修正还是应该做。
评审这版代码时我们顺带定了一条团队约定:往后凡是基于这类组件库二次封装的通用组件(下拉、弹层、标签页),封装层要负责把交互元素的标签和基本属性补齐,不能把组件库原样透传出去。这条约定后来写进了组件开发的评审清单里,变成了新组件合并请求必检的一项。
判断该用哪个标签,有一套可以复用的顺序
评审结束前,我把整体思路归纳成一套判断顺序,发在评论区留档,后面新人接手类似需求时可以直接参考:先看这块内容是不是页面主内容,是的话考虑 main;是不是一组导航链接,考虑 nav;是不是一块可以独立拿出来阅读的内容,考虑 article;是不是有明确主题、能起个小标题的分组,考虑 section(并真的加上标题);是不是和主内容相关但独立的辅助信息,考虑 aside;是不是会触发页面内动作,用 button;是不是会跳转到另一个资源,用 a;如果只是纯布局包裹、没有具体语义,用 div 完全没问题。
这套顺序熟悉了以后,写结构的速度并不会变慢,反而能省掉后面排查测试选择器、补齐地标结构这些返工。团队里后来上了 Testing Library 做组件测试,它的查询 API(比如 getByRole('button', { name: '提交' }))本质上就是逼着你把标签写对——按钮真的是 button、列表项真的在 ul 里,测试用例几乎不需要额外的 data-testid 就能写出来。这算是语义化之外一个没有特意去追求、却顺带拿到的收益。
这套判断顺序也不是死规则,遇到规范里没有明确对应标签的内容,退回 div 加恰当的 class 命名,永远是安全选项,不需要为了凑一个"更语义"的标签而生搬硬套。比如一个纯粹的骨架屏加载占位,找不到任何语义标签能表达"这是加载中的占位符",用 div 加 aria-hidden 就足够清楚,不需要纠结。
落到评审清单里才算真正生效
这次评审之后,我把这几条判断标准整理成了一份简短的自查项,加进了团队前端合并请求模板里,不要求每次都逐条对照,但作为评审时的参照:结构容器是不是优先用了 header/nav/main/aside/footer 这类地标标签而不是清一色 div;列表数据是不是用了 ul/ol/li 而不是若干个并列的 div;标题层级是不是跟内容层级一致、没有为了字号跳级;可点击元素是不是根据"跳转还是触发动作"分清了 a 和 button,form 里的按钮是不是都显式写了 type;表单字段是不是用 label 关联了对应的输入控件;行列对应的数据是不是用了 table 而不是拿 Grid 布局出来的 div 模拟。
这份清单没有强制卡评审通过与否,更多是提醒——语义化标签选错,不会让功能跑不起来,问题往往要等到页面需要被别的程序理解、或者项目规模大到需要维护的人接手时才会暴露出来。评审当下多花两分钟把标签换对,比等到规模上来之后再大改,成本要低得多。
一位同事看到这份清单后补了一条建议:新项目起步阶段就把这几条写进脚手架自带的页面模板里,比如团队内部的中后台页面骨架默认就带好 header/nav/main/footer 这层结构,业务代码往里面填内容,而不是每个人从一个空的 div 开始搭。这条建议后来被采纳进了内部脚手架的下一个版本,新建页面时地标结构默认就是对的,省掉了每次评审都要提醒一遍的重复劳动。
这版活动页最终按这个顺序改完,diff 里改动的几乎都是标签名和少量属性,样式代码一行没动,评审也在这一轮之后顺利通过。
一位资深同事在评论区留了一句问法:"以后写结构前是不是先问一句这块东西本来是什么,比先想怎么摆样式更重要?" 这条问法被我们直接搬进了新人入职的前端规范文档里,作为写页面结构之前的第一步提醒,排在选组件、写样式这些步骤之前。