产品经理说:这个页面要加一个目录
故事要从某个周一的下午说起。
我正准备去泡杯咖啡,结果被产品喊住:
产品:“嘿,我们这个页面块太多了,能不能右边放一个目录?点一下就能跳过去的那种。像文章大纲那样的。”
我:愣住了三秒,点点头,说“没问题。”
于是,我决定搞一个能自动识别结构、自动跳转、自动生成的目录组件。说干就干!
组件实现目标
我们要实现一个支持如下写法的目录组件:
<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 id | cloneElement 注入 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 写好就行,剩下的它自己会长出来 😎」