支持工单的原话是:「你们的帮助中心根本搜不到东西,我明明看到标题里有’发票’两个字,按 Ctrl+F 一搜是零结果。」
我先去页面上一试,确实如此。那个页面的 FAQ 有 37 条,默认全部折叠,只露出问题标题。用户按下 Ctrl+F,输入「发票」,浏览器统计出来的匹配数是 1——只算上了左侧目录里那两个字。37 条答案正文里的所有内容,对浏览器的查找功能来说等于不存在。
这不是浏览器坏了,是我们自己把内容藏得太彻底了。
display:none 的内容,查找功能看不见
浏览器做页内查找的时候,扫的是渲染树里能参与排版的内容。而 display: none 的元素根本不进渲染树——它连盒子都不生成,谈何查找。
我们原来的折叠面板是这么写的:
<div class="faq-item">
<button class="faq-question" aria-expanded="false">发票开错了能重开吗?</button>
<div class="faq-answer" hidden>
可以。在订单详情页点击「申请重开」,填写说明后……
</div>
</div>
hidden 属性在浏览器默认样式表里等价于 display: none。换句话说,每一个折叠起来的答案,都从”页面”这个概念里被彻底删掉了。
更麻烦的是另一半:这份内容对屏幕阅读器来说同样不可见。视障用户用读屏软件按标题浏览,能读到的也只有问题标题,答案一个字都听不到。这两个问题其实是同一个病根。
想过的几条歪路,以及为什么都不行
第一反应当然是「那就别用 hidden 了,改成高度为 0 或者移出屏幕外」。这样做确实能被搜到,但会带来一串新麻烦:内容还在 Tab 序列里,键盘用户会把焦点跳到看不见的按钮上;读屏软件依然会读出来;如果有 37 条全部展开成零高度,DOM 里的隐藏元素数量会变得很难管理。
第二个念头是用 visibility: hidden。它天生就不参与查找,也不参与 Tab 序列,但代价和 display:none 一样——搜不到、读不到。等于没变。
第三个念头是接管 Ctrl+F。用 JS 拦截 keydown,自己实现一套搜索框。试了两天就放弃了:拦截浏览器快捷键本身就不可靠,移动端没有 Ctrl+F,而且还要自己实现高亮、上一个下一个、循环滚动、匹配计数这一整套东西。更别说用户可能用的是浏览器的菜单项而不是快捷键,你根本拦不住。
绕了一圈才想起来,这个需求浏览器其实已经标准化的解决方案了。
hidden=”until-found”:藏起来,但保留可搜性
把属性值从空改成 until-found:
<div class="faq-answer" hidden="until-found">
可以。在订单详情页点击「申请重开」,填写说明后……
</div>
这一个词带来的变化是根本性的。浏览器对它的处理不是 display: none,而是 content-visibility: hidden。这两者的区别可以用一句话概括:
display: none 是把内容从页面上删掉,content-visibility: hidden 是把内容从画面上跳过。
后者不渲染、不占位、视觉上完全看不见,但它的内容依然存在于 DOM 树、无障碍树和查找索引里。所以:
用户按 Ctrl+F 搜「申请重开」,浏览器能命中这块内容。命中之后,它会把 hidden 属性自动摘掉,让内容显示出来,然后滚动到匹配位置。整个过程不需要我们写一行 JavaScript。
第一次看到这个效果的时候我盯着屏幕看了好一会儿。37 条 FAQ,一条 JS 都没加,Ctrl+F 就正常了。
但是嵌套的容器还没打开
真实页面比 demo 复杂。我们的帮助中心是这样分层的:
分类(折叠)
└── 主题(折叠)
└── FAQ 条目(折叠)
└── 答案正文
最深的地方有四层。当用户搜「申请重开」,浏览器能找到那段文字,它会把最内层那个 hidden="until-found" 的属性摘掉。但外面三层还关着,所以用户看到的依然是三个折起来的标题,只是最里面那层”打开”了——而它打开的父容器根本没显示。
这时候就需要 beforematch 事件出场了。
浏览器的执行顺序是:找到匹配 → 摘掉该元素的 hidden 属性 → 派发 beforematch 事件 → 滚动到匹配位置。
关键点在于事件是在属性摘掉之后才派发的。所以你在处理器里不需要操心”把当前这块展开”——它已经展开了。你要做的是顺着 DOM 往上找,把外层所有还关着的祖先全部打开。
而且这个事件是冒泡的。我们直接在页面根节点上挂一个委托处理器就搞定了:
document.addEventListener('beforematch', (e) => {
let node = e.target.parentElement;
while (node && node !== document.body) {
if (node.hasAttribute('hidden')) {
// 外层的折叠容器还原成普通的开合状态
node.removeAttribute('hidden');
node.setAttribute('aria-expanded', 'true');
// 同步一下自己写的收起按钮的图标状态
const toggle = node.previousElementSibling;
if (toggle && toggle.hasAttribute('aria-expanded')) {
toggle.setAttribute('aria-expanded', 'true');
}
}
node = node.parentElement;
}
});
有一个细节必须注意:整个展开动作要在事件处理器里同步完成。浏览器在这批处理器跑完之后立刻就开始滚动,你要是把展开放进 setTimeout 或者等一个 CSS 过渡结束,滚动会算错位置,页面会停在半路上。我第一版就是写成了异步,结果搜出来的内容在屏幕外面,用户还是看不到。
顺带修掉的一个老坑:hidden 被 display 覆盖
改造过程中还发现了一个存在很久但没人注意的 bug。
展开后的答案是 display: flex 布局。而我们原来的收起逻辑是直接加 hidden 属性。这两个东西会打架——因为浏览器默认样式里的 [hidden] { display: none } 优先级非常低,任何一条作者样式里的 .faq-answer { display: flex } 都能盖过它。
所以在某些浏览器上,答案加上了 hidden 但依然显示着。之前一直没人反馈,可能是大家都没注意到应该收起来却没收到。
换成 hidden="until-found" 之后这个问题自然消失了。因为它生效靠的是 content-visibility 而不是 display,跟你的布局属性完全不冲突。这是我事先没想到的附带收益。
如果你手上还有别的地方在用裸的 hidden 属性,建议顺手在全局样式里补一条,或者干脆统一改成 hidden="until-found"——反正搜得到也没什么坏处。
坑:重新折叠时别写回 true
还有一个很容易忘的地方。折叠面板的收起逻辑原来是:
answer.hidden = true;
这一行会把属性值设成一个空字符串,也就是退回到普通的 hidden。用户第一次搜是能找到的,搜完再手动收起来,第二次就搜不到了。而且这个 bug 很难被试出来——测试同学一般只试一遍搜索流程,不会先搜、再收、再搜。
正确写法是明确指定值:
// 收起
answer.setAttribute('hidden', 'until-found');
// 展开
answer.removeAttribute('hidden');
项目里所有对折叠状态赋值的地方都翻了一遍。用 .hidden = true 这种写法的有六处,全改了。
另一个坑:content-visibility: auto 带来的定位问题
顺手做性能优化的时候,我给长列表加了 content-visibility: auto:
.faq-item {
content-visibility: auto;
contain-intrinsic-size: auto 120px;
}
它的作用是让屏幕外的条目直接跳过渲染,DOM 节点一大堆但实际参与排版的只有可视区那几条。页面滚动流畅度提升明显,Chrome 的性能面板里 Layout 时间从 40ms 左右降到了个位数。
代价是 content-visibility: auto 会隐式地给元素加上 contain: layout paint style。这意味着元素内部所有 position: fixed 和依赖视口定位的东西,都会以这个元素为参照系而不是视口。
我们有一个「复制链接」按钮,点击后弹一个固定在按钮右上角的小提示。加了 content-visibility: auto 之后,这个提示跑到条目左上角去了,因为它不再以视口为基准。
最后是把提示组件拎出来,改成挂在 body 上的全局浮层才解决。
还有 contain-intrinsic-size 那一行。如果不写这个,浏览器不知道屏幕外的元素该占多高,滚动条会随着滚动不断跳动。我一开始漏了它,页面滚一下、滚动条缩一下,体验很糟。写上之后预估高度稳定住了,但也要注意数值别跟实际差太多——差太多的话滚动到位置时会有一瞬间的跳动。
顺便说一句,content-visibility: auto 的元素里的内容是可以被 Ctrl+F 找到的,这一点和 hidden="until-found" 一致。两者可以叠加使用。
聊一下 details 元素
有人会问,那直接用 <details> 不就好了?它天生就是折叠组件。
对于简单的单层折叠,<details> 确实是更优解,而且现代浏览器对它有特殊处理——折叠着的 <details> 内容如果被页内查找命中,浏览器会自动帮你把它展开。这是浏览器内置的行为,不用写任何代码。
而且 <details> 现在支持 name 属性了:
<details name="faq">
<summary>发票开错了能重开吗?</summary>
<p>可以。在订单详情页点击「申请重开」……</p>
</details>
<details name="faq">
<summary>退款多久到账?</summary>
<p>原路退回,一般 1-3 个工作日……</p>
</details>
同一组 name 的 details 会自动互斥——打开一个,其他的自动合上。这正好是手风琴效果,以前得写几十行 JS 才能做到,现在一行属性搞定。
那为什么我们最后没用 <details>?因为三层的嵌套折叠里,中间那层需要根据数据动态渲染不同的内容,而 <summary> 里只能放有限的元素(它是 label 类的元素,放块级内容会出问题)。另外我们的默认状态是全部收起,且折叠状态需要持久化到服务端,用 <details> 的 open 属性来回同步反而更绕。
如果你们只是想做一层简单的 FAQ 折叠,我的建议是直接用 <details>,别折腾。多层嵌套或者状态管理复杂的,才需要考虑 hidden="until-found" 这条路。
一个意外收获:无障碍评分涨了
改造完之后跑了一轮 Lighthouse,无障碍这一项的分数从 89 涨到了 100。
报告里点出来的问题正是「内容被 display: none 隐藏,屏幕阅读器无法访问」。改成 hidden="until-found" 之后,这些内容重新进了无障碍树——读屏用户现在可以用标题快速跳转的方式浏览全部 37 条答案,不用逐条去点开。
这条收益我一开始完全没预料到,属于顺手捡的。
不过要提醒一句:hidden="until-found" 的内容在无障碍树里是存在的,所以它会同时出现在读屏用户的浏览顺序里。如果你只是想让内容能被搜到,但绝对不想让人读屏读到(比如某些冗余的装饰性文字),那这个方案就不合适。
兼容性和降级
主流浏览器现在都跟上了,Safari 稍微晚一点,但也已经支持。真正需要兜底的只有极老版本。
降级方案其实简单到有点寒酸——因为这本来就是一个渐进增强的特性:
if (!('onbeforematch' in document.body)) {
// 老浏览器:不支持 until-found,退回成普通 hidden
document.querySelectorAll('[hidden="until-found"]').forEach(el => {
el.setAttribute('hidden', '');
});
}
老浏览器上内容仍然搜不到,跟改造之前一样,但至少不会出现”属性写着 until-found、浏览器不认识、于是既不隐藏也不报错”的尴尬情况。用一个特性检测把这一层隔开就够了。
检测那个属性用 'onbeforematch' in document.body 是最省事的写法。也可以用 CSS 支持性查询 CSS.supports('content-visibility', 'hidden'),但我觉得前者更直接。
上线前该检查的那几件事
把这次改造里所有需要逐条确认的点整理了一下,如果你们也要做,可以照着过一遍:
所有折叠容器的初始化代码里,写的是 hidden="until-found" 而不是裸的 hidden。收起操作里也一样,别用 element.hidden = true。
页面上有且只有一个 beforematch 监听器,挂在根节点上做事件委托。写在每个条目上会随着列表增长不断增删监听器,没有必要。
处理器内部是同步展开的,没有任何延迟或过渡。展开外层容器之后,别忘了同步 aria-expanded 和你自己那个箭头图标的状态。
接入了 content-visibility: auto 的地方,contain-intrinsic-size 得配对写上。
目录树和锚点跳转也要试一遍。页面里如果有 <a href="#some-answer" rel="external nofollow" > 跳到折叠内容里,同样会触发 beforematch,效果应该跟 Ctrl+F 一致。这一条我们漏测了,上线第二天才有用户反馈说从一个链接点进来页面停在了空白处。
最后,用纯键盘走一遍完整流程:Tab 到问题标题、回车展开、Tab 进答案、再收起来。改造过程中有一版把答案的 Tab 序列搞乱了,因为我在展开时用了 removeAttribute 但忘了恢复按钮上的 aria-controls。
写在最后
回头看这件事,最有意思的不是 hidden="until-found" 这个属性本身,而是我们以前对「隐藏」这个概念的理解太粗了。
在选择隐藏方式的时候,其实至少有四个维度需要分开考虑:视觉上是否可见、是否占据布局空间、是否参与键盘焦点序列、是否参与查找和无障碍树。老的 display: none 和 visibility: hidden 把所有维度一刀切了,所以才会出现「我想让它在视觉上不出现,但还要能被搜到」这种需求无处安放的情况。
hidden="until-found" 提供的正是这个缺失的中间态。37 条 FAQ 的改造,代码量加起来不到 30 行,但它修掉的不只是 Ctrl+F 搜不到这一个问题——还有屏幕阅读器的可访问性、折叠状态判断的健壮性,以及一个存在了很久的 display 覆盖 hidden 的老 bug。
支持工单后来关了。附言只有一句:「现在能搜到了,谢谢。」

