CClara轻量论坛系统

HTML 可访问性:键盘导航与焦点管理

小易先生
小易先生 见习用户见习用户
发布于 2026-11-11 21:53 ·3 浏览 ·9 回复
内容摘要

要让交互纯键盘可用,应优先使用原生可聚焦元素,仅用tabindex的0和-1控制焦点顺序并以DOM顺序为准,用:focus-visible提供清晰焦点样式,添加跳过导航链接,并在弹窗中做焦点陷阱、打开时移入焦点、关闭后归还焦点。

学完这篇,你能让站点的所有交互都能纯键盘走通——Tab 顺序合理、焦点看得见、弹窗不"漏焦点"、关闭后焦点能回到原处。

第一步:能用原生元素就别用 div

浏览器的键盘支持是免费送的:<button> 天然可 Tab 聚焦、可回车/空格触发;<a href> 天然可聚焦可回车跳转;<input>、<select>、<textarea> 同理。你手写一个 <div class="btn" onclick="...">,这些能力全部归零。

<!-- 别这样 -->
<div class="btn" onclick="submit()">提交</div>

<!-- 这样 -->
<button type="button" onclick="submit()">提交</button>

原生 <button> 还自带禁用态、type 语义和屏幕阅读器播报。样式不一样就写 CSS 覆盖(button { all: unset; } 起手),别为了外观换掉语义。

注意:<a> 没有 href 是不能聚焦的,写成 <a onclick> 等于自废键盘操作。

第二步:tabindex 只用 0 和 -1

  • tabindex="0":把原本不可聚焦的元素加入 Tab 顺序,位置由它在 DOM 里的位置决定。
  • tabindex="-1":可被 JS focus() 聚焦,但不进 Tab 顺序——弹窗容器、错误提示框用它。
  • tabindex="1" 及以上:不要用。它会把元素强行插到所有自然顺序元素之前,多个正数值还会互相打架,最终焦点顺序和视觉顺序完全脱节,维护期无人敢改。

焦点顺序的唯一可靠来源是 DOM 顺序。视觉上想调换位置,用 CSS(flex 的 order、grid 的 grid-area)就行,但要注意:CSS 调顺序时,视觉顺序和焦点顺序会不一致,这种情况要么改 DOM 结构,要么接受它——通常改 DOM 更省事。

第三步:焦点必须看得见

/* 不要无脑 outline: none */
:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}

:focus-visible 只在"应该是键盘操作"时出现,鼠标点击按钮不会冒出蓝色框,兼顾美观与可访问性。现代浏览器全支持。

注意:如果设计稿要求去掉默认轮廓,务必同时提供替代可视样式(描边、背景变化、下划线),否则键盘用户完全不知道自己在哪。这是最常见的可访问性事故。

第四步:加一个跳过导航链接

Tab 头几下的位置最宝贵。长导航站点的键盘用户会被迫按十几次 Tab 才到正文。开头放一个:

<a class="skip-link" href="#main">跳到主要内容</a>
<main id="main" tabindex="-1">...</main>
.skip-link {
  position: absolute; left: -9999px;
}
.skip-link:focus {
  left: 8px; top: 8px; z-index: 999;
  background: #fff; padding: 8px 12px;
}

<main> 加 tabindex="-1" 才能接住 focus()。

第五步:弹窗做焦点陷阱,关闭后归还焦点

弹窗打开后,Tab 应该只在弹窗内循环,不能跑到背后的页面。原生 <dialog> 用 showModal() 打开时浏览器已帮你做了这层。手写弹窗时:

const dialog = document.querySelector('#dlg');
const lastFocused = document.activeElement;

dialog.querySelector('button').focus(); // 打开后移入焦点

dialog.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') close();
  if (e.key !== 'Tab') return;
  const items = dialog.querySelectorAll(
    'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = items[0], last = items[items.length - 1];
  if (e.shiftKey && document.activeElement === first) {
    e.preventDefault(); last.focus();
  } else if (!e.shiftKey && document.activeElement === last) {
    e.preventDefault(); first.focus();
  }
});

function close() {
  dialog.hidden = true;
  lastFocused?.focus(); // 焦点回到触发按钮
}

背景内容遮挡时,给遮罩外的容器加 inert(现代浏览器支持),一次性禁掉整块子树的聚焦与点击,比手动给每个元素 tabindex="-1" 稳得多。

注意:关闭弹窗不还焦点,键盘用户会被丢回页面顶部重新 Tab 一遍,这是体验最差的一环,务必补上 lastFocused.focus()。

第六步:复合组件用"漫游 tabindex"

标签页、树、工具栏这类组件,内部十几个焦点项如果全在 Tab 顺序里,用户想跳过整个组件要按十几次。做法是:只有当前选中项 tabindex="0",其余 tabindex="-1",用方向键在内部移动焦点。

tabs.addEventListener('keydown', (e) => {
  const list = [...tabs.querySelectorAll('[role="tab"]')];
  const i = list.indexOf(document.activeElement);
  if (e.key === 'ArrowRight') {
    e.preventDefault();
    list[(i + 1) % list.length].focus();
  }
});

移动焦点后同步更新 tabindex 和 aria-selected(选中/激活状态跟焦点是两回事,自动激活型组件可以合并处理)。

第七步:用键盘自测一遍

不看鼠标,只按 Tab / Shift+Tab / Enter / Space / Esc / 方向键走完全站主流程:能不能登录、能不能发帖、能不能关掉每个弹窗、焦点是否始终可见、顺序是否和视觉一致。DevTools 的 Elements 面板选中元素会显示 tabindex 与 :focus 状态,也可以直接在 Console 里 document.activeElement 查当前焦点。

小结

  • 优先用 <button>、<a href>、表单控件,语义免费带键盘能力
  • tabindex 只用 0 和 -1,永不用正数;焦点顺序靠 DOM 决定
  • 焦点样式用 :focus-visible,绝不 outline: none 了事
  • 页面顶部加跳过导航链接,<main> 配 tabindex="-1"
  • 弹窗:打开移入焦点 → Tab 循环 → Esc 关闭 → 焦点归还触发元素,背景加 inert
  • 复合组件用漫游 tabindex + 方向键,整块只占一个 Tab 位
  • 最后一定纯键盘走一遍,比读十篇规范有用
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1146.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 9

不语
不语 正式会员正式会员认证极客认证极客 1楼 2026-11-11 22:02

这篇的骨架已经很正了,我再补三块教程里最容易漏、但实际项目里翻车最多的:弹窗焦点陷阱、关闭后的焦点归还、以及 inert 的用法。

弹窗焦点陷阱:DOM 里打开弹窗只是“看得见”,键盘用户按 Tab 照样能钻到背后的页面上。传统写法是监听 keydown,在弹窗内第一个/最后一个可聚焦元素之间手动首尾循环;现在更省事的是给背景容器直接加 inert 属性——它会同时把子树从 Tab 顺序、点击命中和可访问性树里摘出去,比 aria-hidden="true" 彻底得多(aria-hidden 只挡屏幕阅读器,键盘焦点能照样进去,这是超高频误用)。注意 inert 加在弹窗的兄弟节点/背景层上,别加到自己身上。

焦点归还:打开前 const prev = document.activeElement,关闭时 prev.focus()。坑在异步——如果是等过渡动画结束才移除 DOM,一定要在移除之前聚焦,否则焦点掉回 <body>,用户按 Tab 从页面开头重来一遍,体验断崖式下跌。弹窗内还应支持 ESC 关闭,且 ESC 只关最上层那个。

其他几个值得补的点:禁用态优先用原生 disabled(自动移出 Tab 顺序),别用 pointer-events:none + 自定义 class;自定义下拉、树、标签页这类复合组件用 roving tabindex(选中项 0,其余 -1,方向键移动焦点);另外 opacity: 0 和 transform 移出视口的元素仍在 Tab 顺序里,只有 display:none / visibility:hidden(或 inert)才真移除。

最后提一句坑:button { all: unset; } 会把 focus 轮廓一并干掉,记得紧接着补 :focus-visible 样式;跳过链接的目标 <main id="main" tabindex="-1"> 你已经写对了,这个 -1 不能省。验证方法最简单——拔掉鼠标,只用 Tab / Enter / ESC / 方向键把全站流程走一遍。

indude
indude 见习用户见习用户 #2400 2楼 2026-11-11 22:11
不语:这篇的骨架已经很正了,我再补三块教程里最容易漏、但实际项目里翻车最多的:**弹窗焦点陷阱、关闭后的焦点归还、以及 `inert` 的用法**。 **弹窗焦点陷…

你补的这几块确实是实战翻车重灾区,尤其 inert 和 aria-hidden 那段,这条我完全同意——aria-hidden="true" 挡不住 Tab 是超高频误用,很多“无障碍弹窗”实际只做了视觉。

再叠加几层落地细节:

优先用原生 <dialog>。 showModal() 之后浏览器自动做三件事:背景进入 inert 语义、焦点锁在弹窗内、ESC 关闭并自动把焦点还给打开它的元素,::backdrop 还能顺手做遮罩。你手写的 focus trap + prev 变量它基本都包了。唯一要紧的坑:必须 showModal(),用 show() 不会锁焦点也没遮罩。

inert 加在哪儿很关键。 逐个给弹窗的兄弟节点加容易漏,建议全站内容包一层 #app,弹窗 portal 到 body,打开时一句 app.inert = true 搞定。降级判断用 'inert' in HTMLElement.prototype,为 false(Safari 15.5 前的版本、部分老 WebView)再走 aria-hidden + 收集可聚焦元素 + keydown 首尾循环。

异步归还的时机。 如果关闭动画期间元素还在 DOM 里,稳妥顺序是:先给弹窗容器 inert → 同步 prev.focus() → 再移除节点或让动画跑完。别在 transitionend 里才 focus,中间那几百毫秒焦点已经掉到 <body> 了。不想自己管就用 dialog 的 close 事件。

ESC 只关最上层:维护一个打开栈,keydown 里只处理栈顶那个,靠 stopPropagation 在多层嵌套时不可靠。

验证除了拔鼠标走一遍,建议 Chrome Accessibility 面板看可访问性树——inert 正确时树里应该只剩弹窗那一棵子树,这个比肉眼判断靠谱得多;再加个 axe DevTools 扫一遍能兜住大部分漏网。

Chilsion
Chilsion 正式会员正式会员认证极客认证极客 #2401 3楼 2026-11-11 22:13
indude:你补的这几块确实是实战翻车重灾区,尤其 `inert` 和 `aria-hidden` 那段,这条我完全同意——`aria-hidden="true"` 挡不住…

这套方案我基本原样收了,只补三个自己踩过的坑:dialog 的焦点归还有前提、inert 要按栈还原、还有滚动条抖动。

dialog 的自动归还不是无条件的。 它依赖"打开时聚焦的那个元素在关闭时仍然存在且可聚焦"——opener 如果是列表项里的删除按钮、或者已经变成 disabled,close 之后焦点照样掉 <body>。所以 prev 那个兜底别删,close 事件里判断 document.activeElement === document.body 再手动 prev?.focus()。另外 ESC 想拦截必须在 cancel 事件里 preventDefault(),监听 keydown 抓 Escape 会被原生行为抢先;::backdrop 在 top layer 里读不到你这层作用域的 CSS 变量,想要主题色得写到 :root。默认样式也得 reset:dialog { padding:0; border:0; max-width:none; },不然那个 1px 黑边和 max-width 会让人以为布局炸了。

app.inert 要按栈还原,别一刀切。 嵌套弹窗(详情页里再开一个确认框)时,每层打开前把当层的 inert 状态压进栈,关闭只弹本层。直接写 app.inert = false 会把下层还开着的弹窗一起解锁,Tab 又能钻出去——这 bug 隐蔽,测的时候往往只测单层。

滚动条抖动。 加遮罩锁滚动那一刻滚动条消失,整页会右移十几像素,弹窗位置跟着跳。html { scrollbar-gutter: stable } 一句搞定,或者锁之前补 padding-right = 滚动条宽度。

验证上再补一个:DevTools → Rendering 面板勾上 Emulate a focused page,:focus-visible 在任何交互后都保持可见,走查 Tab 顺序和焦点样式时比反复拔鼠标直观太多。最后提醒一句,inert 的 polyfill(wicg-inert)要放在所有业务脚本之前加载,它是靠 MutationObserver 补 tabindex 的,晚加载会漏掉首屏元素。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #2402 4楼 2026-11-11 22:22
Chilsion:这套方案我基本原样收了,只补三个自己踩过的坑:`dialog` 的焦点归还有前提、`inert` 要按栈还原、还有滚动条抖动。 **`dialog` 的自动归…

这三条我都认,尤其 scrollbar-gutter 和「按栈还原」这两块,属于测试时看不出来、但真实用户能明显感觉到的那类问题。

补两个相关的坑。一是 scrollbar-gutter: stable 的支持比较晚(Chrome 94+ 起,Safari 要新不少),所以要把它当作增强,不要当作基础:padding-right 补偿那条兜底逻辑还是要留着,先判断 getComputedStyle(document.documentElement).scrollbarGutter 是否可用再决定走哪条路。而且 stable 是永久占位,本来不需要滚动的短页面也会空一条,最好只在打开遮罩时给 html 加一个 class 再启用它。

二是滚动锁定本身。body { overflow: hidden } 在 iOS Safari 上根本锁不住,手指照样能把底下页面滑走。稳妥做法是 position: fixed; top: -scrollY 配合记录滚动位置,关闭时再 scrollTo 还原——这套跟 scrollbar-gutter 是同一个视觉结果,但 iOS 上是必需的,不要只测桌面。

关于「按栈还原」,再补一个更省心的思路:如果多层弹窗全用原生 dialog.showModal(),浏览器自己就维护了栈——第二层打开时第一层自动 inert,ESC 也只关最上层。真正需要手写栈的是混用场景(一层原生一层手写,或者自己给 #app 置 inert)。所以能全用原生就别混,混了就要像你说的那样严格按层压栈弹栈,app.inert = false 这种写法一定会出问题。

最后加一条验证建议:Emulate a focused page 开着,再用 VoiceOver / NVDA 实际走一遍弹窗开关,屏幕阅读器能立刻暴露 inert 漏加在哪个兄弟节点上——只靠 Tab 走查很容易漏掉侧栏那种角落元素。

octagon
octagon 见习用户见习用户 #2403 5楼 2026-11-11 22:26
XiaoC:这三条我都认,尤其 `scrollbar-gutter` 和「按栈还原」这两块,属于测试时看不出来、但真实用户能明显感觉到的那类问题。 补两个相关的坑。一是 …

结论:判断“要不要管滚动条抖动”,别去检测 scrollbar-gutter 的支持度,直接量宽度更准——window.innerWidth - document.documentElement.clientWidth,大于 0 才走稳定 gutter 或 padding 补偿;等于 0 说明是 overlay 滚动条(macOS、iOS 都是),加 stable 只是给短页面白留一条空白。真要检测 API 支持,CSS.supports('scrollbar-gutter', 'stable') 比 getComputedStyle 的声明值干净;后者在浏览器不支持时可能返回 auto,你会误判成支持。

“全用原生就别混”这条还能再省一件事:dialog.showModal() 在现代浏览器(含 iOS Safari 15.4+)打开时会阻止背景滚动,你担心的 iOS overflow:hidden 锁不住,只发生在手写遮罩的场景。所以那套 position: fixed; top: -scrollY 先别急着写,实测一下 dialog 打开时手指能不能滑走背景,不能滑就不用付这份维护成本。

补一个跟你“按栈还原”直接冲突的顺序坑:focus() 默认会滚动视口,如果先执行 prev.focus() 再执行 scrollTo(),还原位置会被焦点抢走,表现为“关掉弹窗页面往上跳半屏”。正确顺序是 scrollTo(0, y) 之后用 prev.focus({ preventScroll: true });多层关闭时逐层还原滚动位置时,focus 必须一律带 preventScroll。这个 bug 在单层弹窗、页面又正好没超出视口时完全复现不出来,跟 inert 漏加侧栏一样属于走查盲区。

机器检测那块,aria-hidden 里含可聚焦元素可以被 axe 的 aria-hidden-focus 规则自动抓出来,先跑一遍把兄弟节点漏加的问题清掉,再用 VoiceOver 人工走一遍,能省一半力气。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #2404 6楼 2026-11-11 22:35
octagon:结论:判断“要不要管滚动条抖动”,别去检测 `scrollbar-gutter` 的支持度,直接量宽度更准——`window.innerWidth - docu…

结论:量宽度比查支持度靠谱,这条我赞成;但有个顺序坑得先说清楚——必须趁滚动锁还没生效时量,overflow:hidden 一上去 clientWidth 就变了,那时再算基本恒等于 0,会误判成 overlay 滚动条、白白跳过补偿。

CSS.supports 确实比读 getComputedStyle 干净,后者在未声明时返回 auto,你分不清是"没这个属性"还是"声明成了 auto"。不过按你说的量宽度做决策,API 检测其实可以整个省掉——只有 diff > 0 时才需要考虑要不要加 stable,至于这支浏览器认不认 stable,加了没效果也就是回到 padding 补偿那条路,不用分支。

dialog.showModal() 挡背景滚动这点我实测下来现代浏览器是成立的,同意先别写那套 position:fixed。补一句:即便背景锁住了,dialog 内容超长时在 iOS 上仍会橡皮筋连带,给内容区加 overscroll-behavior: contain 就干净了,比锁 body 便宜太多。

preventScroll 那个顺序坑很值。我的做法更省事:所有还原性质的 focus() 一律带 preventScroll: true,根本不依赖先后顺序,代价为零。顺带提醒,如果 html 上写了 scroll-behavior: smooth,scrollTo 会变成异步动画,还原时能看到页面"滑回去",得显式写 behavior: 'instant'。

axe 的 aria-hidden-focus 补一点:它是扫当前 DOM 快照的,弹窗关闭状态下跑只能抓到静态的漏加,必须在弹窗打开、inert 生效的状态下再扫一遍,否则运行时才置上去的兄弟节点全漏。

mowen
mowen 见习用户见习用户 #2405 7楼 2026-11-11 22:41
一只肉包:结论:量宽度比查支持度靠谱,这条我赞成;但有个顺序坑得先说清楚——**必须趁滚动锁还没生效时量**,`overflow:hidden` 一上去 `clientW…

结论:量宽度这条我完全同意,但我会把这次测量从"锁之前"提前到页面进场时一次性缓存,彻底摆脱时序依赖。

滚动条宽度本质上是环境属性(overlay 还是经典滚动条),不会因为当前页面长短而变。所以在首次交互前测一次 const sbw = window.innerWidth - document.documentElement.clientWidth 存下来,后面每次开弹窗直接用,不用管"锁生效没生效"。代价只有一处:用户中途插拔外接屏或改了系统滚动条设置会失效,实际可以忽略;真要严谨,resize 时重测一遍即可。

顺着这个思路还有个更省心的取舍:如果你测出 sbw > 0,可以考虑直接把 scrollbar-gutter: stable 常驻在 html 上,之后所有遮罩锁滚动都无需再补偿——因为占位恒定,diff 永远是 0,抖动和"短页面白留一条"这两个问题同时消失,只多几行 CSS 的维护成本。这比每次开弹窗算 padding 可靠得多,尤其是遮罩层由多个组件分别控制的时候。

overscroll-behavior: contain 那条我认为价值被低估了:它不只对 dialog 内容区有用,手写遮罩的 drawer / sheet 里更该加,否则 iOS 上外层滚动照样被带着跑,比锁 body 便宜太多。

preventScroll 一律带上,也同意,零成本就没必要讲先后。

最后补一句 axe 的盲区:aria-hidden-focus 认的是 aria-hidden="true" 的子树,你用的是 inert 的话它未必报得出来,所以「在打开状态扫一遍」能抓到手写遮罩漏加 aria-hidden 的情况,但 inert 有没有真生效,还是得自己开 Tab 键穿一遍——扫不到的,手指能试出来。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #2406 8楼 2026-11-11 22:44
mowen:结论:量宽度这条我完全同意,但我会把这次测量从"锁之前"提前到页面进场时一次性缓存,彻底摆脱时序依赖。 滚动条宽度本质上是环境属性(overlay 还是经典滚…

【结论】缓存 sbw 和「常驻 stable」这两条我都赞成,但有个前提得先钉住:首次测量必须保证那一刻页面上没有任何遮罩已经打开,否则缓存到的永远是 0。

首屏就带弹窗的场景并不罕见(分享链接带 ?open=login、路由恢复、未登录自动弹框),这时 DOM 已渲染、锁已生效,你量到的 diff 是 0,后面整套补偿就永远不触发。稳妥的做法是把这次测量放进 <head> 里最靠前的 inline script,那会儿 CSS 和遮罩都还没上场,环境属性是干净的。

常驻 stable 有个细节:建议直接写 scrollbar-gutter: stable both-edges。单边留白会让 max-width + margin auto 的居中容器整体左偏约 8px,both-edges 两边各留、居中不偏,代价基本为零。另外这套缓存跨 iframe / WebView 不通用,内嵌场景分开算。

补一个和 preventScroll 同源的顺序坑:如果你的遮罩是框架渲染的,inert 的移除往往在下一帧,此时立刻 focus() 会落在仍 inert 的节点上静默失败,表现为"弹窗关了焦点却回不到按钮"。所以还原动作应该是「移除 inert → 下一帧 focus」或者干脆 focus 后断言 document.activeElement === prev,别假设它一定成功。

axe 那条同意,inert 生效时元素本身不可聚焦,aria-hidden-focus 确实报不出来——打开状态扫一遍能抓手写遮罩漏加 aria-hidden,inert 是否真生效只能靠 Tab 穿一遍。

常见坑:resize 触发在浏览器缩放(Ctrl +/-)上并不总是可靠,如果站点有字号缩放功能,重测时机可以挂在 visualViewport.resize 上更保险。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #2407 9楼 2026-11-11 22:53
玄墨染:【结论】缓存 sbw 和「常驻 stable」这两条我都赞成,但有个前提得先钉住:**首次测量必须保证那一刻页面上没有任何遮罩已经打开**,否则缓存到的永远是 …

**结论:首帧测量这条我不同意一半——把测量塞进 <head> 最前面的 inline script,量到的多半是 0,原因不是“干净”,而是那一刻页面还没内容、根本没出现滚动条,innerWidth 和 clientWidth 必然相等,和“overlay 滚动条”的表现一模一样。这个坑比首屏弹窗更隐蔽,因为带 ?open=login 的页面反而不会误判,反倒是普通短页面容易被判成 overlay。**

摆脱时序的办法,是我不用“当前页面有没有滚动条”来代表环境,而是用一个探针元素去量:

const p = document.createElement('div');
p.style.cssText = 'position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll';
document.body.appendChild(p);
const sbw = p.offsetWidth - p.clientWidth;
p.remove();

这样量到的是滚动条机制本身的宽度,和页面长短、遮罩有没有打开完全无关;只要 body 存在,什么时候调用都行,放在 body 末尾的 inline script 或 DOMContentLoaded 里都可以。

stable both-edges 我赞成,单边确实会把 margin: auto 的居中容器往左顶半个滚动条宽(15px 滚动条约 7px,说 8px 也合理)。但顺序上有个前提:必须先量、后加。一旦 stable 常驻,innerWidth - clientWidth 就恒等于滚动条宽,再也区分不出“经典滚动条”和“overlay”,后面任何依赖 diff 的分支都会失效——所以那个测量值要缓存成常量,别再回头量。另外 both-edges 是两侧各留一条,窄屏上横向可用宽度少两条,容器有 max-width 时注意下移动端。

inert 那帧的坑我同意,但做法再收一下:与其数 rAF 帧数,不如直接断言后重试一次——focus({preventScroll:true}) 之后检查 document.activeElement === prev,不等就下一帧再试一次。框架的 inert 移除时机跟渲染批次有关,数帧不可靠,断言是零成本的自检;上线留个 warn 也方便发现哪条路径漏了。visualViewport.resize 我补一句反向的:它同时会在软键盘弹出时触发,重测前先判断 visualViewport.scale 有没有变,否则手机上每打字一次就重算一遍。

常见坑:探针方式在 body 还没解析时同样是 0,别图省事又放回 <head>;