满屏 div 为什么搜索引擎抓不准:语义化标签该怎么选

活动页上线一周后,SEO 那边的同事发来一份抓取报告,说搜索引擎几乎没能识别出页面的文章列表和导航结构,收录效果比预期差一截。打开 Elements 面板查这个页面的 DOM 树,从 <body> 到最深的叶子节点,除了 imga,几乎全是 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 控制;timedatetime 属性写标准格式给程序读,标签内容写人类习惯的格式给人看,两者互不影响。这几处改动加起来,整段代码的行数几乎没有变化,但结构信息量完全不是一个量级。

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>

这里 sectionarticle 表达内容结构,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>

侧边栏本身是和主内容相关但又相对独立的一块信息,asidesection 更贴切。h2visually-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 调整这个 h2h3font-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 之后,整个页面的地标结构就完整了:headernavmainasidefooter 各司其职,谁都不需要靠 class 名去猜。

这一层地标结构补齐之后还有个连带的好处:团队内部单页应用做路由切换时,只要保证每次切换后新内容依然渲染在同一个 main 里,页面骨架的其余部分(顶部导航、页脚)就不需要重新渲染,这跟组件层面做的路由级别缓存是两回事,但从结构角度先把主内容区域圈定清楚,是做这类优化之前的一个前提条件。

表单控件的语义比想象中丰富

活动页里还有一个筛选表单,字段之间用 divclass="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>

labelfor 属性指向 selectid,两者绑定之后,点击文字本身也能让输入框获得焦点,这是原生就带的能力,不用额外写事件。这个细节在移动端尤其明显——移动端的可点击区域本来就小,label 把可点区域从"只有那个下拉框"扩大到"文字加下拉框",体验上有实打实的改善。

表单里的分组信息也经常被 div 吞掉。比如筛选表单里"活动类型"和"活动状态"如果本身是一组相关字段,规范里对应的标签是 fieldsetlegend

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>

如果内容本质上是行列对应的表格数据,就应该用 tabletheadtbodythtd 这一套标签,而不是靠 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>

thscope 属性说明这个表头对应的是一整列还是一整行,这个信息在数据量大、需要按行按列理解数字含义时非常关键。用 div 模拟出来的表格,样式上能做到一模一样,但没有任何机制告诉别的程序(或者别的开发同事读代码时)"第二行第三列这个数字对应的是暑期大促的转化率",只能靠数格子去数。

这里也有一个容易被误用的反例:早年很多页面用 table 来做整体布局排版,这是语义化标签被用反了的例子——布局用的是表格标签,但内容本身根本不是表格数据。这一年这种写法已经很少见了,但排查老项目的时候偶尔还能碰到,遇到时应该按内容本质换成 div + Flex/Grid,而不是继续沿用表格布局。

图片说明用 figure 会更完整

活动页的 banner 图下面配了一行说明文字,写法是这样:

1<img src="/banner.png" alt="轮播图1" />
2<div class="banner-desc">暑期大促,全场满减</div>

图片和它的说明文字之间同样没有结构上的关联,banner-desc 单看只是一段普通文字,没法确定它是在描述上面那张图。规范里给图片配文字说明准备的标签是 figurefigcaption

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 加合理的标题层级,相当于直接告诉爬虫这个页面在讲什么、各部分主次关系是什么;articletimenav 这些标签能帮它判断哪块是正文、哪块是发布时间、哪块只是导航链接,不需要正文内容。

这跟 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 时代的实现方式,触发器和选项大量用 divonclick 拼出来,样式好看,但标签选择基本没考虑过语义。这种情况下能做的事有限——不太可能为了语义去改第三方组件库的内部实现,但至少可以在自己项目里包一层,把关键的交互属性补上:

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 命名,永远是安全选项,不需要为了凑一个"更语义"的标签而生搬硬套。比如一个纯粹的骨架屏加载占位,找不到任何语义标签能表达"这是加载中的占位符",用 divaria-hidden 就足够清楚,不需要纠结。

落到评审清单里才算真正生效

这次评审之后,我把这几条判断标准整理成了一份简短的自查项,加进了团队前端合并请求模板里,不要求每次都逐条对照,但作为评审时的参照:结构容器是不是优先用了 header/nav/main/aside/footer 这类地标标签而不是清一色 div;列表数据是不是用了 ul/ol/li 而不是若干个并列的 div;标题层级是不是跟内容层级一致、没有为了字号跳级;可点击元素是不是根据"跳转还是触发动作"分清了 abuttonform 里的按钮是不是都显式写了 type;表单字段是不是用 label 关联了对应的输入控件;行列对应的数据是不是用了 table 而不是拿 Grid 布局出来的 div 模拟。

这份清单没有强制卡评审通过与否,更多是提醒——语义化标签选错,不会让功能跑不起来,问题往往要等到页面需要被别的程序理解、或者项目规模大到需要维护的人接手时才会暴露出来。评审当下多花两分钟把标签换对,比等到规模上来之后再大改,成本要低得多。

一位同事看到这份清单后补了一条建议:新项目起步阶段就把这几条写进脚手架自带的页面模板里,比如团队内部的中后台页面骨架默认就带好 header/nav/main/footer 这层结构,业务代码往里面填内容,而不是每个人从一个空的 div 开始搭。这条建议后来被采纳进了内部脚手架的下一个版本,新建页面时地标结构默认就是对的,省掉了每次评审都要提醒一遍的重复劳动。

这版活动页最终按这个顺序改完,diff 里改动的几乎都是标签名和少量属性,样式代码一行没动,评审也在这一轮之后顺利通过。

一位资深同事在评论区留了一句问法:"以后写结构前是不是先问一句这块东西本来是什么,比先想怎么摆样式更重要?" 这条问法被我们直接搬进了新人入职的前端规范文档里,作为写页面结构之前的第一步提醒,排在选组件、写样式这些步骤之前。