首页 / 文章 / 可被爬虫访问的React手风琴:使用CSS网格实现折叠,而非卸载组件。

可被爬虫访问的React手风琴:使用CSS网格实现折叠,而非卸载组件。

构建嵌套的React手风琴组件,将内容保留在DOM中以便索引,通过网格行实现高度动画效果,每层仅允许同时展开一个,并在关闭时重置状态。

1798 词

大多数手风琴教程仅在面板展开时才显示它。对于模态框来说这没问题,但在作品集、文档中心或常见问题解答这类内容丰富的页面上,这意味着当爬虫渲染页面时,所有已关闭部分的文本都不会出现在文档中。本指南重新设计了一套嵌套的可折叠部分,确保没有内容会离开DOM,高度变化能够平滑过渡且无需使用特殊数值,“仅一个展开”功能在所有嵌套层级都能正常工作,重新打开某个部分会从初始状态开始,同时点击目标也会定位到标题文本的结束位置。

场景设定:十一个部分,三层嵌套

想象一个拥有十一个可折叠顶级板块的个人网站:关于、可用性、经验、作品集、教育背景、语言能力、技能、实验室、下载内容、位置信息以及联系方式。其中还有若干子板块。仅“联系方式”这一板块就分为三层,公共资料、出版物和招聘信息各自又细分为不同的子组。

当所有板块完全展开时,页面会变成一大片难以浏览的文字;而折叠后则便于快速查看。因此,让这些板块可折叠显然是最佳选择,问题在于如何实现。

几乎所有教程都会教授的模式是条件渲染:

{isOpen && (
  <div className="content">
    {children}
  </div>
)}

这种方法确实有效,再加上包装元素甚至还能实现动画效果。但它实际上违背了网站便于被查找的初衷。

条件加载真正的作用

{isOpen && ...}不会隐藏任何内容。当isOpen为假时,React根本不会创建该子树,因此DOM中也没有需要隐藏或显示的内容。

对于模态框和下拉菜单来说,这正是正确的行为;已关闭的对话框本就不应存在于文档中。但对于内容页面而言,情况则恰恰相反。在作品集中,折叠后的板块才是核心——多年的工作经验、项目描述、技术清单、职责与成果。招聘人员可能搜索的每一个关键词都存在于那些初始为关闭状态的板块中。

Google确实会执行JavaScript,所以现在的情况已不像过去那么严重了。但页面仍会以初始状态呈现。爬虫不会点击那些箭头图标。凡是加载时就被卸载的元素,从索引的角度来看就不存在于页面上。

通过样式折叠而非渲染

解决方法是把“折叠”视为样式问题而非渲染决策。内容只会加载一次并保持加载状态,只有其可见高度会发生变化。

传统的控制高度动画的方法是使用max-height,这迫使你猜测一个大于内容最高部分的数值,并接受不均匀的动画时序,因为过渡效果是在整个猜测范围内进行的,而非实际高度范围。CSS Grid则提供了一种更简洁的方法,此处通过Tailwind类展示:

<div
  className={`grid transition-all duration-300 ${
    isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
  }`}
>
  <div className="overflow-hidden">{children}</div>
</div>

为何grid-rows技巧有效

大小为 1fr 的网格轨道会扩展以适应其内容的自然高度,而大小为 0fr 的轨道则会收缩至零。浏览器能够在两者之间进行插值,因此过渡效果十分平滑且无需设定上限。内部的 overflow-hidden 包装层至关重要:如果没有它,内容将会从高度为零的行中溢出,而无法被裁剪。

无论处于展开状态还是关闭状态,其标记格式都完全相同。只有计算出的高度和透明度会有所不同,因此文档中的所有内容都会保留并可用于索引。

需要注意一点:对 grid-template-rows 进行动画处理是浏览器的一项相对较新的功能。旧版本的浏览器只能直接在两种状态之间切换,因此如果必须支持这些旧版本浏览器,就需要准备备用方案或接受无法实现动画的效果。请根据自身的用户流量查看当前的支持情况。

避免将折叠内容纳入标签顺序

将内容保留在DOM中会带来一些与无障碍功能相关的副作用,需要妥善处理。在视觉上已折叠的面板中的链接和按钮仍可接收键盘焦点,屏幕阅读器也可能会读取到它们。在面板关闭时为其添加inert属性(或至少添加aria-hidden并使内部控件无法获得焦点),这样既能让爬虫读取到文档中的文本,又能使其无法被交互。应为标题按钮添加aria-expanded属性,以便辅助技术了解其状态。浏览器还提供了hidden="until-found"属性,该属性可使内容仍可通过“页面查找”功能被搜索到;这一选项值得考虑,但其样式和动画处理方式与网格布局方法有所不同。

“一次仅打开一个”的范围控制

在实现折叠功能后,下一个需求就是经典的手风琴式行为:打开一个部分就会关闭其余部分。对于扁平列表而言,只需在顶部设置一个状态变量来记录当前打开的部分,再在每个项目中进行相等性检查即可。

嵌套结构会立即打破这一规则。一旦打开某个子部分,整个父部分就会自动关闭,因为子部分同样属于可折叠元素,而规则无法区分二者。如果展开如公司招聘板块这样的子组,包含它的部分会立即在光标下方关闭。

这一限制需要明确的适用范围:排他性仅适用于同一父节点下的兄弟元素之间,而非整个页面。每个可折叠元素都属于其父节点所在的组,同时也会为自己的子元素创建一个新的组。该组实际上是一个小型结构,用于存储打开状态及对应的设置函数:

type CollapseGroup = {
  openKey: string | null;
  setOpenKey: (key: string | null) => void;
};

该形状通过 React context 共享,null 表示“不在组内”:

const CollapseGroupContext = createContext<CollapseGroup | null>(null);

每个节点都会读取其父节点的 context 以判断自身是否处于开启状态,然后将其子节点包裹在新的 provider 中。在三层嵌套结构中,状态存在于三个独立的 context 中,而非像 contact/profiles/boards 这样使用复合键的单一扁平结构。这样一来,每一层都保持简单,且无需额外的状态管理即可实现无限深度的嵌套。

父节点关闭时重置嵌套状态

一个更为微妙的可用性问题会在使用该页面一段时间后出现。先打开一个部分,再打开其下的子部分,接着打开孙部分。关闭最顶层的部分后去查看其他内容。之后再次打开该顶层部分时,它又会恢复到你之前留下的那三层嵌套状态。

保留状态看似体贴,但实际上会让人迷失方向。重新打开某项内容会被视为从头开始,而界面却与这种预期相悖。用户会忘记自己之前处于什么状态,但用户界面却不会。

解决办法是让关闭节点时能明确清除其下的所有内容。提供者负责管理其所在组的开放密钥:

const CollapseGroupProvider = ({ isOpen, children }) => {
  const [openKey, setOpenKey] = useState<string | null>(null);

而每当提供者自身的节点被关闭时,相关效果就会清除该密钥:

  useEffect(() => {
    if (!isOpen) setOpenKey(null);
  }, [isOpen]);  // ...
};

为何级联机制能自动处理

当一级节点被关闭时,其提供者会将二级节点的选择状态重置为 null。这样一来所有二级节点都会被关闭,进而触发它们对应提供者的效果,这些效果又会清除三级节点,依此类推。这种重置无需显式的树形遍历即可传播到任意深度。

其代价是每个层级都需要在独立的渲染阶段处理,因为特效是在渲染之后才运行的。对于少数层级而言,这种差异几乎无法察觉。如果在非常复杂的层级结构中发现了明显的闪烁现象,另一种方法是在父级关闭时通过更改其key值来重新挂载子级提供者,这样就能一步清除嵌套状态。无论采用哪种方式,由于内容仍然保持挂载状态,只有已打开的key会被重置;DOM内容本身永远不会被销毁。

缩小过大的点击区域

一些较小的问题可能需要很长时间才能诊断出来。每个标题按钮都占据了整行:

className="flex w-full items-center justify-start gap-3 py-1 ..."

因此整行都会对点击做出反应,就连几乎占据整个屏幕的标题后的空白区域也不例外。尝试选中文本或在边缘点击时,某些部分会意外地折叠或展开。

将样式从 w-full 更改为 w-fit 后,按钮的尺寸会根据其内容来确定:表情符号、标题以及箭头图标。

className="flex w-fit items-center justify-start gap-3 py-1 ..."

刻意选择 w-fit 而非仅仅删除 w-full 是出于特定考虑。使用 display: flex 且宽度自动调整的 <button>,其尺寸取决于各浏览器对表单控件的默认处理方式;明确指定 w-fit 可以避免依赖这种在所有浏览器中都一致的行为。

在小屏幕上也不会出现功能退化。采用 fit-content 宽度时,按钮会限制在可用空间内,不会超出容器范围,因此手机上较长的标题依然会像之前一样自动换行。

测试状态级联效果

由状态变化驱动的嵌套逻辑在评审时看似合理,但实际使用中却会出问题。在发布之前,值得用 jsdom 和 React 模拟该组件,并验证关键场景:

  • 先打开 L1,再打开 L2,最后打开 L3,则三个层级都会保持开启状态。
  • 关闭 L1 后,三个层级都会关闭。
  • 重新打开 L1 时,仅第1层级开启,第2层和第3层保持关闭。
  • 打开 L1 的某个子层级时,整个 L1 分支都会保持关闭。
  • 关闭并重新打开整个区域时,其下方的所有内容都会保持关闭。

第三种场景正是重置功能存在的意义所在,也是仅凭代码外观就轻易信任时最容易出错的情况。此外,还应该添加断言来确保渲染后的标记中仍包含已折叠的内容,因为整个设计的功能都依赖于这一特性。

总结

截图中并未显示这些选项。访问者不会注意到折叠后的文本仍然存在于DOM中,也不会发现重新展开某个区块后会恢复初始状态,或是标题区域在文本结束处不再响应点击。如果实现得当,用户只会感受到没有任何烦人的体验,而搜索引擎则能查看完整页面。

  • 对于关闭后不应存在的元素,如模态框和菜单,应使用“卸载”方式处理;而对于始终属于页面内容的元素,则用CSS实现折叠功能。
  • 通过grid-template-rows将高度在0fr和1fr之间动态变化,并使用overflow-hidden属性的子元素,而非随意设定max-height。
  • 让折叠后的面板处于“惰性”状态,这样隐藏的内容虽可被索引,但无法获得焦点。
  • 将手风琴状态限制在同级兄弟组中,每层仅保留一个上下文,并在父级关闭时清除子级状态。
  • 根据内容大小调整交互式标题的尺寸,通过测试状态转换而非肉眼观察来判断效果。
  • 如需了解关于在保持内容可索引的同时降低屏幕外内容渲染成本的相关技术,请参阅利用content-visibility避免屏幕外内容渲染。