本文约 6,719 字,预计阅读 17 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:站点层级做得很清楚,搜索引擎看不见
文章页顶部那行「首页 › WordPress › 主题 › 本文」对人类读者很友好——一眼知道自己在哪里,也能点上一级。但对搜索引擎来说,如果这行只是普通的 <a> 链接,它并不能确定这是「面包屑」,只能当成普通的内部链接处理。加上结构化数据之后,Google 才会在搜索结果里把这条路径显示成层级化的面包屑,点击率通常有明显提升。
很多站长依赖 Yoast SEO、Rank Math 或 Breadcrumb NavXT 生成面包屑。这些方案没问题,但有两个常见困扰:一是插件的 HTML 结构与主题样式对不上,改起来要覆写模板;二是想在自定义文章类型、自定义分类法上加面包屑时,插件的映射配置比较绕。自己写一份只有几十行,可控性反而更好。本文给出完整实现,并说明如何与已有插件和平共处。
原理分析:面包屑数据与面包屑外观是两件事
面包屑实际上是两个独立的东西:
| 维度 | 视觉面包屑 | 结构化数据面包屑 |
|---|---|---|
| 面向 | 人类读者 | 搜索引擎 |
| 形式 | HTML <nav> + 链接 |
JSON-LD <script type="application/ld+json"> |
| 要求 | 可点击、层级清晰 | 符合 schema.org BreadcrumbList 规范 |
| 一致性要求 | 两者必须表达同一条路径 |
schema.org 的 BreadcrumbList 结构是:一个 itemListElement 数组,每项是一个 ListItem,含 position(从 1 开始)、name、item(该层级的 URL)。最后一项(当前页面)按 Google 的规范可以省略 item,写上也不会报错。
数据生成时最难的是层级递归。分类是有父子关系的,文章页的面包屑要显示完整的分类链。WordPress 提供了 get_term_parents_list()(4.8 起),能直接返回带分隔符的父级链接串,省掉手写递归。对于自定义分类法同样适用,只要传对 $taxonomy。
输出位置的选择:wp_head 里输出 JSON-LD 最稳妥,Google 能稳定抓取。也可以放在页面底部,但不要放在需要 JS 执行后才插入的位置——部分抓取器不执行 JS。
保姆级实操
- 设计层级规则:首页 → 分类/归档 → 当前文章;页面类型走页面层级。
- 写面包屑数据函数:返回
name+url的数组,便于同时服务 HTML 和 JSON-LD。 - 递归分类父级:用
get_term_parents_list()或自己递归get_term()。 - 输出 HTML:
<nav aria-label="面包屑">+<ol>列表,语义化更好。 - 输出 JSON-LD:在
wp_head里用wp_json_encode()输出,注意转义。 - 验证:用 Google 富媒体搜索结果测试工具或 schema.org 校验器检查。
- 与插件共存:已装插件时关掉自己的输出,避免两份 JSON-LD。
<?php
// 子主题 functions.php:生成面包屑数据(同时供 HTML 与 JSON-LD 使用)
function mimangfei_breadcrumb_items() {
$items = array();
$items[] = array(
'name' => get_bloginfo( 'name' ),
'url' => home_url( '/' ),
);
if ( is_front_page() ) {
return $items;
}
if ( is_archive() || is_single() ) {
$tax = is_single() ? 'category' : get_queried_object()->taxonomy;
$term = null;
if ( is_single() ) {
$terms = get_the_terms( get_the_ID(), 'category' );
if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
$term = $terms[0];
}
} else {
$term = get_queried_object();
}
if ( $term && ! is_wp_error( $term ) ) {
// 先补齐所有父级分类
$parents = get_term_parents_list(
$term->term_id,
$tax,
array( 'separator' => '', 'link' => false, 'format' => 'slug' )
);
$slugs = array_filter( explode( '/', trim( (string) $parents, '/' ) ) );
$slugs[] = $term->slug;
foreach ( $slugs as $slug ) {
$t = get_term_by( 'slug', $slug, $tax );
if ( $t && ! is_wp_error( $t ) ) {
$items[] = array(
'name' => $t->name,
'url' => get_term_link( $t ),
);
}
}
}
}
if ( is_single() ) {
$items[] = array(
'name' => get_the_title(),
'url' => get_permalink(),
);
}
if ( is_page() ) {
$ancestors = array_reverse( get_post_ancestors( get_the_ID() ) );
foreach ( $ancestors as $post_id ) {
$items[] = array(
'name' => get_the_title( $post_id ),
'url' => get_permalink( $post_id ),
);
}
$items[] = array(
'name' => get_the_title(),
'url' => get_permalink(),
);
}
return $items;
}
<?php
// 输出 JSON-LD:最后一项省略 item,符合 Google 规范
add_action( 'wp_head', 'mimangfei_breadcrumb_json_ld', 20 );
function mimangfei_breadcrumb_json_ld() {
// 已装 SEO 插件时直接让路,避免两份数据冲突
if ( defined( 'WPSEO_VERSION' ) || class_exists( 'RankMath' ) ) {
return;
}
$items = mimangfei_breadcrumb_items();
if ( count( $items ) < 2 ) {
return;
}
$list = array();
foreach ( $items as $i => $item ) {
$element = array(
'@type' => 'ListItem',
'position' => $i + 1,
'name' => $item['name'],
);
// 最后一项只给 name,不给 item
if ( $i < count( $items ) - 1 ) {
$element['item'] = $item['url'];
}
$list[] = $element;
}
$data = array(
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => $list,
);
echo '<script type="application/ld+json">'
. wp_json_encode( $data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES )
. '</script>' . "\n";
}
<?php
// 视觉面包屑:语义化列表 + 微数据无关,纯 HTML
function mimangfei_breadcrumb_html() {
$items = mimangfei_breadcrumb_items();
if ( count( $items ) < 2 ) {
return '';
}
$html = '<nav class="breadcrumb" aria-label="面包屑"><ol>';
$last = count( $items ) - 1;
foreach ( $items as $i => $item ) {
if ( $i === $last ) {
$html .= '<li aria-current="page">' . esc_html( $item['name'] ) . '</li>';
} else {
$html .= '<li><a href="' . esc_url( $item['url'] ) . '">'
. esc_html( $item['name'] ) . '</a></li>';
}
}
$html .= '</ol></nav>';
return $html;
}
.breadcrumb ol {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
list-style: none;
margin: 0 0 1rem;
padding: 0;
font-size: 0.85rem;
color: var( --muted, #656d76 );
}
.breadcrumb li + li::before {
content: "/";
margin-right: 6px;
color: var( --border, #d0d7de );
}
常见踩坑
- JSON-LD 里 URL 用了相对路径:
item必须是绝对 URL,否则校验器报错、Google 无法识别。 position从 0 开始:schema.org 的position从 1 开始,从 0 起会被判为无效。- 案例:某站面包屑在 Google 搜索结果里一直不显示,校验器却说「有效」。排查发现他们输出了两份 BreadcrumbList——主题一份、SEO 插件一份,且两家的层级不一致(主题按文章所属的第一个分类,插件按主分类)。Google 遇到同类型多份数据时可能全部忽略。最后关掉主题那份,统一由插件输出,两周后搜索结果里正常显示了面包屑。教训:同类型结构化数据只保留一份。
get_term_parents_list()的format参数用错:传'name'时返回的是名称而非 slug,再拿去get_term_by( 'slug' )会查不到。要么统一用 slug,要么直接用link => true拿现成的链接串。- 文章属于多个分类时取哪个不确定:
get_the_terms()返回的顺序不保证稳定,面包屑可能忽而走 A 分类忽而走 B 分类。指定主分类(Yoast 的primary_category或自己加个字段)更可控。 - 中文没有做转义:
wp_json_encode()默认会把中文转成\uXXXX,虽然有效但可读性差。加上JSON_UNESCAPED_UNICODE更直观。 wp_head里输出时没判断首页:首页也会生成只有一项的面包屑,属于冗余。加count() < 2的判断。- 面包屑出现在 404 页:404 页的层级无从谈起,输出会出错。用
! is_404()提前 return,404 页的做法另见主题 404 页面的自定义设计。
总结
面包屑的判断标准是:视觉路径与结构化数据必须表达同一条路径,且全站只输出一份 JSON-LD。行动清单:写一个同时服务 HTML 与 JSON-LD 的数据函数 → 用 get_term_parents_list() 递归分类父级 → wp_head 输出 JSON-LD,position 从 1 开始、末项省略 item → 已装 SEO 插件时主动让路 → 用富媒体测试工具验证 → 在 Google Search Console 的「面包屑」报告里观察几周。做完这项,可以接着处理 主题分页与 SEO:rel=next/prev 的现状,把列表页的结构化表达一并理顺。
