本文约 5,570 字,预计阅读 14 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:一篇文章 6000 字,读者找不到第二段在哪
技术文章最常见的问题不是写得浅,而是写得太长又没有路标。读者从搜索引擎点进来,关心的是某个具体报错的解法,结果页面往上滑三屏还在铺垫背景,于是直接关闭——跳出率拉满,页面停留时长被压到十几秒。国内不少教程站把长文拆成一页一页做分页,效果反而更差:多页阅读会稀释正文权重,第二页之后的页面基本不被收录。
在文章顶部自动插入一块目录(Table of Contents,简称 TOC),是成本最低的补救手段。它有三重收益:让读者一秒判断这篇值不值得读;Google 会把带锚点跳转的目录识别为”跳转链接(Jump links)”,在搜索结果里展示小标题级别的二级入口;同时目录里的 <h2>/<h3> 文本还能强化页面的语义结构。实现方式分两派:装插件,或者用二十来行 PHP 自己解析标题。下面把两条路都讲透。
原理分析:目录 = 解析标题 + 生成锚点 + 前端跳转
无论用什么方案,本质流程都一样,差别只在于谁来做和什么时候做。
| 环节 | 插件方案 | 代码方案 |
|---|---|---|
| 抓取时机 | the_content 过滤器,优先级 9~11 |
the_content 过滤器,自定义优先级 |
| 锚点来源 | 重写标题的 id 属性(slugify) |
用 sanitize_title() 生成 id |
| 层级控制 | 后台可选 h2~h6 组合 | 正则匹配指定标签数组 |
| 样式 | 自带 CSS + 主题 override | 完全自定义 CSS |
| 首屏性能 | 多一份 CSS/JS 请求(约 3–8 KB,可内联) | 零额外请求 |
关键点在锚点:WordPress 编辑器输出的 <h2> 默认不带 id,所以必须先给每个标题补 id 属性,目录才能跳。这一步做不好,就会出现”点目录没反应”的经典故障。
第二个要点是 the_content 的优先级。很多页面构建器和表格插件也在 the_content 上跑钩子,如果 TOC 的优先级设得太低(比如 20),解析到的可能是别人处理之后的 HTML;设得太高(比如 4),又可能在 wpautop 之前解析,导致标题标签判断失误。稳妥区间是 9 到 11。
延伸阅读:WordPress 短代码开发与区块化取舍
保姆级实操
步骤 1:插件方案选型(不想写代码走这条)
推荐 Easy Table of Contents(作者 Magazine3,wordpress.org 插件数安装量超过 30 万)。安装后进入「设置 → Table of Contents」,核心选项如下:
- Position:选 Before first heading(插到第一个标题之前),不要选 After first paragraph,否则段落会被目录硬生生截断。
- Headings:勾 h2 + h3 即可,勾满 h2~h6 会让三层以上的目录缩进失控。
- Smooth Scroll Offset:如果你的主题是 sticky header,这里填 header 高度(常见 60~90),否则点击后标题会被导航栏盖住。
- Show when:设为
post即可,别对page全局生效,落地页插目录会破坏转化路径。
LuckyWP Table of Contents 是另一个免费选择,它的优势是自带”appearence → container width”精细控制,适合侧边栏宽度受限的双栏主题。两者都遵循同一个坑:插件目录默认对所有文章类型生效,需要用插件后台的 Post Types 选项收窄。
步骤 2:关闭某一篇的目录
不需要在每篇文章都显示目录。插件支持两类关闭方式:文章编辑页右侧的 meta 框(Easy TOC 提供「Disable the automatic generation of the table of contents」复选框),或者用一行代码在主题层全局禁用某篇文章:
// functions.php:ID 为 1234 的文章不生成目录
add_filter( 'ez_toc_maybe_apply_the_content_filter', function( $apply ) {
if ( is_singular( 'post' ) && get_the_ID() === 1234 ) {
return false;
}
return $apply;
} );
步骤 3:无插件方案——完整可运行代码
把下面这段丢进主题的 functions.php,它会在正文第一级标题之前插入目录,并给每个 <h2>/<h3> 补上 id。代码用 DOMDocument 做解析,避免正则 Nested parse 出错:
function mft_build_toc( $content ) {
if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
return $content;
}
// allow_url 相关兼容:用 UTF-8 包裹,避免中文乱码
$html = '<?xml encoding="utf-8" ?>' . $content;
$dom = new DOMDocument();
libxml_use_internal_errors( true );
$dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
libxml_clear_errors();
$levels = array( '2', '3' );
$nodes = array();
foreach ( $levels as $lv ) {
foreach ( $dom->getElementsByTagName( 'h' . $lv ) as $node ) {
$nodes[] = array( 'lv' => (int) $lv, 'node' => $node );
}
}
if ( count( $nodes ) < 3 ) {
return $content; // 标题太少就不插目录
}
$toc = '<nav class="mf-toc" aria-label="文章目录"><p class="mf-toc-title">目录</p><ol>';
foreach ( $nodes as $item ) {
$text = trim( $item['node']->textContent );
$id = 'toc-' . sanitize_title( $text ) . '-' . md5( $text );
$item['node']->setAttribute( 'id', $id );
$toc .= sprintf(
'<li class="mf-toc-item mf-toc-lv%d"><a href="#%s">%s</a></li>',
$item['lv'], esc_attr( $id ), esc_html( $text )
);
}
$toc .= '</ol></nav>';
$out = $dom->saveHTML();
$out = preg_replace( '#<\?xml encoding="utf-8" \?>#', '', $out );
// 插到第一个 h2 之前
$first_h2 = $nodes[0]['node']->ownerDocument->saveHTML( $nodes[0]['node'] );
return str_replace( $first_h2, $toc . $first_h2, $out );
}
add_filter( 'the_content', 'mft_build_toc', 11 );
步骤 4:配套 CSS
.mf-toc {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 14px 18px;
margin: 0 0 24px;
background: #f9fafb;
}
.mf-toc-title { margin: 0 0 8px; font-weight: 600; font-size: 15px; }
.mf-toc ol { margin: 0; padding-left: 0; list-style: none; }
.mf-toc-lv3 > a { padding-left: 16px; font-size: 13px; color: #4b5563; }
.mf-toc a { display: block; padding: 4px 0; line-height: 1.7; text-decoration: none; }
.mf-toc a:hover { color: #c0392b; }
/* 解决 sticky header 遮挡:配合 html 的 scroll-padding-top */
html { scroll-padding-top: 90px; }
步骤 5:验证
随便打开一篇文章,右键查看源代码,确认 <h2> 标签带了 id="toc-xxxx";点目录任意一项,URL 末尾应出现 #toc-...;用浏览器开发者工具切到手机视图,确认目录在小屏没有溢出容器。
常见踩坑
- 点击目录无反应:八成是 id 没生成。原因通常是主题输出
<h2>时本身已被其他钩子改写,或者sanitize_title()对纯中文返回空串(旧版核心在某些字符集下会这样),此时必须靠 md5 兜底——上面代码里已经加了。 - 目录出现在 RSS 里:
the_content会同时作用于the_excerpt_rss之外的输出。加if ( is_feed() ) return $content;把 Feed 排除掉。 - 案例:某站长把 Easy TOC 的 Position 设成 After first paragraph,同时开启了 WP Rocket 的延迟 JS。结果目录在前 500 篇文章里全部压在图片下方,搜索引擎快照里满屏都是”1. 引言 2. 原理 3. 实操”,摘要被目录污染。改回 Before first heading、并在 PageSpeed 把
defer JS对该页面的toc.js加白名单之后才恢复。 - 和表格插件打架:TablePress 用
the_content输出表格,优先级冲突会导致目录出现在表格单元格里。把 TOC 钩子优先级调到 11,并把 TablePress 的输出放到wpautop之后。 - 中文标点导致锚点失效:标题里含
?、,时,urlencode之后锚点可能不再匹配。用sanitize_title()+ hash 的方式可以彻底规避。 - sticky header 遮住标题:光靠
scroll-padding-top有时不够,部分主题给body加了overflow-x: hidden,会让基于 html 的滚动属性失效,这种情况要在主题 CSS 里补body { scroll-padding-top: 90px; }。 - 目录被算进正文相关性:如果标题超过 40 个(比如长列表文章),目录文本量会很大。设置”少于 3 个标题不显示、超过 35 个标题折叠”,并给
<nav>加aria-label让辅助技术正确识别。
总结
判断标准很简单:内容层级稳定、编辑团队多人协作用插件,Easy TOC 胜在选项直观、能按文章单独关闭;追求极致轻量、文章结构固定用代码方案,二十多行即可零依赖实现。无论哪种,落地后请务必检查三件事:标题是否真的带上了 id、is_feed() 是否排除、sticky header 是否被 scroll-padding-top 补偿。至于悬浮式侧边目录,只在宽度 ≥1200px 的阅读型站点上有收益,营销落地页不要加。
相关配置:WordPress 站点缓存排除规则设置
