打造一个“聪明”的目录组件:从产品需求到最终实现

590 阅读3分钟

产品经理说:这个页面要加一个目录

故事要从某个周一的下午说起。

我正准备去泡杯咖啡,结果被产品喊住:

产品:“嘿,我们这个页面块太多了,能不能右边放一个目录?点一下就能跳过去的那种。像文章大纲那样的。”

我:愣住了三秒,点点头,说“没问题。”

于是,我决定搞一个能自动识别结构、自动跳转、自动生成的目录组件。说干就干!

组件实现目标

我们要实现一个支持如下写法的目录组件:

<AutoDirectory>
  <Card anchor="模块一" title="模块一标题" visible>
    <Card anchor="子模块A" title="子模块A" />
  </Card>
  <Card anchor="模块二" title="模块二标题" visible />
</AutoDirectory>

特点:

  • 不需要手动写目录数据
  • 不限制嵌套层级
  • 只要 Card 上写了 anchor,就能出现在目录里

方案一

基于 Ant Design 的 Anchor 实现目录。

实现思路

我们把这个目录组件命名为 ,它的职责是:

目标技术方案
自动分析子组件结构React.Children + isValidElement
提取所有有 anchor 的组件递归提取
构建嵌套目录树结构用一个 stack 构造层级
自动绑定 DOM idcloneElement 注入 id
平滑滚动跳转scrollIntoView
使用 antd Anchor 渲染目录Anchor.Link 树

实现步骤

1. 提取目录信息(递归分析)

遍历所有子元素,找到那些 props.anchor 存在的组件:

function extractAnchors(nodes: ReactNode): AnchorItem[] {
  return React.Children.toArray(nodes)
    .map((node: any) => {
      if (!isValidElement(node)) return null
      const { anchor, visible = true, title, children: sub } = node.props
      if (!visible || !anchor) return null
      const id = `Anchor-${anchor.replace(/\s+/g, '-')}`
      const childrenAnchors = extractAnchors(sub)
      return {
        title: title || anchor,
        id,
        children: childrenAnchors.length > 0 ? childrenAnchors : undefined,
      }
    })
    .filter(Boolean)
}

Tip:用 isValidElement 可以确保我们只分析 React 元素,跳过其他类型。

2. 自动给每个 Card 添加 DOM id

为了 scrollIntoView 能正常跳转,我们还需要给目标组件加上 id:

function injectId(nodes: ReactNode): ReactNode {
  return React.Children.map(nodes, (child: any) => {
    if (!isValidElement(child)) return child
    const { anchor, visible = true, children: sub } = child.props
    if (!visible || !anchor) return child
    const id = `Anchor-${anchor.replace(/\s+/g, '-')}`
    return cloneElement(child, {
      id,
      children: injectId(sub),
    })
  })
}

Tip:用 cloneElement 的方式不会破坏原组件结构,而且支持递归

3. 渲染目录 UI

使用 Ant Design 的 Anchor 组件:

<Anchor
  items={anchorTree.map(item => ({
    key: item.id,
    href: `#${item.id}`,
    title: item.title,
    children: ... // 递归同样处理
  }))}
  onClick={(e, link) => {
    e.preventDefault()
    const el = document.getElementById(link.href.slice(1))
    el?.scrollIntoView({ behavior: 'smooth' })
  }}
/>

Tips: Ant Design 的 Anchor 组件支持递归嵌套目录结构

4. 整体组件封装

我们可以这样愉快地使用了:

export const AutoDirectory: FC<{ children: ReactNode }> = ({ children }) => {
  const anchorTree = useMemo(() => extractAnchors(children), [children])
  const anchorItems = useMemo(() => toAnchorItems(anchorTree), [anchorTree])
  const wrappedChildren = injectId(children)

  return (
    <div style={{ display: 'flex', gap: 32 }}>
      <div style={{ flex: 1 }}>{wrappedChildren}</div>
      <Affix offsetTop={80}>
        <Anchor
          items={anchorItems}
          onClick={handleClick}
        />
      </Affix>
    </div>
  )
}

使用示例

<AutoDirectory>
  <Card anchor="模块一" title="模块一标题" visible>
    <Card anchor="子模块A" title="子模块A" />
  </Card>
  <Card anchor="模块二" title="模块二标题" visible />
</AutoDirectory>

总结

这个目录组件:

  • ✅ 自动识别结构
  • ✅ 可插拔使用
  • ✅ 对业务组件侵入少
  • ✅ 视觉清晰,交互流畅

方案二

基于 Ant Design 的 Tree 实现一个可折叠目录组件

让目录不再一股脑全部展开,提升页面整洁度

实现思路

我们采用 Ant Design 的 Tree 组件,它非常适合展示嵌套树结构,天然支持折叠、层级缩进。

步骤动作
提取 anchor 树复用 extractAnchors() 方法提取结构
转换为 TreeData使用 toTreeData() 构造成 antd Tree 格式
渲染 Tree用 组件渲染 UI
处理点击事件onSelect 绑定 scrollIntoView

代码实现

1. 转换 anchor 树为 Tree 数据结构

import type { DataNode } from 'antd/es/tree'

function toTreeData(items: AnchorItem[]): DataNode[] {
  return items.map((item) => ({
    key: item.id,
    title: item.title,
    children: item.children ? toTreeData(item.children) : undefined,
  }))
}

2. 使用 Tree 渲染目录

我们把方案一的 Anchor 改为 Tree,加上折叠交互和点击跳转逻辑:

import { Tree, Affix } from 'antd'
import type { TreeProps } from 'antd'

export const AutoDirectory: FC<{ children: ReactNode }> = ({ children }) => {
  const anchorTree = useMemo(() => extractAnchors(children), [children])
  const treeData = useMemo(() => toTreeData(anchorTree), [anchorTree])
  const wrappedChildren = injectId(children)

  const handleTreeSelect: TreeProps['onSelect'] = (selectedKeys) => {
    const id = selectedKeys[0]
    const el = document.getElementById(id as string)
    if (el) {
      el.scrollIntoView({ behavior: 'smooth' })
    }
  }

  return (
    <div style={{ display: 'flex', gap: 32 }}>
      <div style={{ flex: 1 }}>{wrappedChildren}</div>
      <Affix offsetTop={80}>
        <Tree
          showLine={{ showLeafIcon: false }}
          treeData={treeData}
          defaultExpandAll
          onSelect={handleTreeSelect}
        />
      </Affix>
    </div>
  )
}

小结

通过使用 antd Tree,给目录组件带来了更多灵活性和交互体验:

  • ✅ 多级嵌套目录

  • ✅ 折叠 / 展开控制

  • ✅ 平滑跳转锚点

花絮

当产品又来找我说:

「这个新页面也加个目录吧?」

我一边点头一边说:

「anchor 写好就行,剩下的它自己会长出来 😎」