从零开始构建 React 文档系统 - 完整实现指南
本文详细讲解如何通过
public/mkdir目录下的静态文件,构建一个完整的动态文档系统,重点讲解递归生成菜单树的思维过程。
思维导图
React 文档系统
|
┌───────────┼───────────┐
| | |
数据层 业务逻辑 展示层
| | |
┌──────┴──────┐ | ┌────┴────┐
| | | | |
structure.json *.md 递归生成 Layout Menu
| | 菜单树 Content TOC
| | | | |
| | | └────┬────┘
| | | |
└─────────────┴─────┼───────────┘
|
Markdown 渲染
代码高亮
目录生成
第一部分:认识数据结构
1.1 文件架构
public/mkdir/
├── structure.json ← 文档结构定义(菜单树数据源)
├── zmgvdxzrh30rqxdn.md ← 第一个测试
├── ghlvwkgc4qo75ro4.md ← 第一个测试的第一个文章
├── kgsi2wn7keqirg9y.md ← 无标题文档
├── df89wno2haxgnguw.md ← 第二个测试的第二个文章
├── skftcatshyl0af3e.md ← 产品介绍测试
└── image/
└── image_0.png ← 图片资源
1.2 structure.json 数据格式
json
[
{
"uuid": "jor4NpgvK2u2lEL6",
"type": "DOC",
"title": "第一个测试",
"slug": "zmgvdxzrh30rqxdn",
"id": 254847777,
"level": 0,
"depth": 1,
"parent_uuid": "", ← 无父级,这是根节点
"child_uuid": "EVr2HXoOG4WxCWxj",
"sibling_uuid": "qJfYkzVGN--pCT_x"
},
{
"uuid": "EVr2HXoOG4WxCWxj",
"type": "DOC",
"title": "第一个测试的第一个文章",
"slug": "ghlvwkgc4qo75ro4",
"id": 254847840,
"level": 1,
"depth": 2,
"parent_uuid": "jor4NpgvK2u2lEL6", ← 有父级,这是子节点
"child_uuid": "4piXSpBHNIxpzuqu",
"sibling_uuid": ""
}
]
关键字段解释:
| 字段 | 作用 | 例子 |
|---|---|---|
uuid |
唯一标识符 | jor4NpgvK2u2lEL6 |
type |
节点类型 | DOC(文档)或 TITLE(标题/分组) |
title |
显示名称 | 第一个测试 |
slug |
对应的 markdown 文件名 | zmgvdxzrh30rqxdn |
parent_uuid |
父节点 ID | 空字符串表示是根节点 |
child_uuid |
第一个子节点 ID | 有值表示有子节点,无值表示叶子节点 |
level |
层级深度 | 0=根,1=二级,2=三级... |
第二部分:递归生成菜单树 - 笨蛋式讲解
这是整个系统的核心难点,我们用最直白的方式讲解。
2.1 问题的出现
我们面临的问题是:如何把一个扁平的数组转换成树形结构?
输入数据(扁平数组):
javascript
[
{ uuid: "A", parent_uuid: "", title: "根节点1" },
{ uuid: "B", parent_uuid: "A", title: "根节点1的子节点" },
{ uuid: "C", parent_uuid: "B", title: "根节点1的孙节点" },
{ uuid: "D", parent_uuid: "", title: "根节点2" }
]
期望输出(树形结构):
根节点1
└── 根节点1的子节点
└── 根节点1的孙节点
根节点2
2.2 递归的核心思想
如果你之前没接触过递归,这样理解:
递归 = 重复使用同一个函数处理相似的问题
就像一个俄罗斯套娃:
- 打开大娃娃 → 里面有个中娃娃
- 打开中娃娃 → 里面有个小娃娃
- 打开小娃娃 → 里面是空的(停止)
在我们的菜单树中:
- 处理一个节点 → 找到它的所有子节点
- 递归处理每个子节点 → 又找到它们的子节点
- 当一个节点没有子节点 → 停止递归
2.3 一步步分析代码
第一步:创建一个"转换函数"
typescript
function convertToMenuItem(item: StructureItem): MenuItem {
// 第1步:为这个节点创建一个空的子节点列表
const children: MenuItem[] = [];
// 第2步:遍历所有项,找出谁是这个节点的子节点
items.forEach((childItem) => {
// 如果某个项的 parent_uuid 等于当前项的 uuid,那它就是我的子节点
if (childItem.parent_uuid === item.uuid) {
// 第3步:递归调用!用同样的函数处理子节点
const childMenuItem = convertToMenuItem(childItem);
// 第4步:把转换后的子节点加入到 children 数组
children.push(childMenuItem);
}
});
// 第5步:返回转换后的菜单项
return {
key: item.uuid,
label: item.title,
children: children.length > 0 ? children : undefined
};
}
第二步:找出所有根节点
typescript
function buildMenuTree(items: StructureItem[]): MenuItem[] {
// 第1步:创建一个空数组存放根节点
const roots: MenuItem[] = [];
// 第2步:遍历所有项
items.forEach((item) => {
// 第3步:如果某个项的 parent_uuid 是空字符串,说明它就是根节点
if (item.parent_uuid === "") {
// 第4步:用我们的"转换函数"处理这个根节点
const menuItem = convertToMenuItem(item);
// 第5步:加入根节点数组
roots.push(menuItem);
}
});
// 第6步:返回所有根节点(完整的树形结构)
return roots;
}
2.4 执行过程的动画演示
让我们用我们的真实数据来演示这个过程:
structure.json 中的数据:
┌─────────────────────────────────────────┐
│ uuid: "jor4NpgvK2u2lEL6" │ ← 根节点1
│ type: "DOC" │
│ title: "第一个测试" │
│ parent_uuid: "" ✓ 这是根节点! │
│ child_uuid: "EVr2HXoOG4WxCWxj" │
└─────────────────────────────────────────┘
|
| convertToMenuItem() 被调用
↓
┌─────────────────────────┐
│ 第1步:创建空 children │
│ children = [] │
└─────────────────────────┘
|
↓
┌─────────────────────────────────────┐
│ 第2步:遍历所有项找子节点 │
│ items.forEach((childItem) => { │
│ if (childItem.parent_uuid │
│ === "jor4NpgvK2u2lEL6") { │
│ 找到了!EVr2HXoOG4WxCWxj 是子节点│
│ } │
│ }) │
└─────────────────────────────────────┘
|
↓
┌──────────────────────────────────┐
│ 第3步:递归处理这个子节点 │
│ convertToMenuItem({ │
│ uuid: "EVr2HXoOG4WxCWxj", │
│ parent_uuid: "jor4..." │
│ }) │
│ │
│ 又会遍历所有项找它的子节点... │
│ 找到 "4piXSpBHNIxpzuqu" │
│ │
│ 再递归处理 "4piXSpBHNIxpzuqu" │
│ 这个没有子节点,停止递归 ✓ │
└──────────────────────────────────┘
|
↓
┌──────────────────────────────────┐
│ 最终得到树形结构: │
│ │
│ 第一个测试 │
│ └── 第一个测试的第一个文章 │
│ └── 无标题文档 │
└──────────────────────────────────┘
2.5 为什么要递归?
如果不用递归,你得这样做:
typescript
// 第1层:找所有根节点
const level0 = items.filter(i => i.parent_uuid === "");
// 第2层:为每个根节点找子节点
level0.forEach(root => {
const level1Children = items.filter(i => i.parent_uuid === root.uuid);
// 第3层:为每个子节点找孙节点
level1Children.forEach(child => {
const level2Children = items.filter(i => i.parent_uuid === child.uuid);
// 第4层:为每个孙节点找曾孙节点
level2Children.forEach(grandchild => {
const level3Children = items.filter(i => i.parent_uuid === grandchild.uuid);
// ... 无限嵌套下去!
});
});
});
这样代码会无限嵌套!而递归只需要一个函数重复调用自己。
第三部分:完整代码实现
3.1 类型定义
typescript
// 结构数据的类型
interface StructureItem {
uuid: string;
type: "TITLE" | "DOC"; // 标题还是文档
title: string; // 显示名称
slug: string; // markdown 文件名
id: number | string;
level: number; // 层级
depth: number;
parent_uuid: string; // 父节点 ID
child_uuid: string; // 第一个子节点 ID
sibling_uuid: string;
}
// 菜单项的类型
type MenuItem = Required<MenuProps>["items"][number];
// 目录项的类型
interface TocItem {
id: string;
title: string;
level: number;
}
3.2 递归生成菜单树
typescript
function buildMenuTree(items: StructureItem[]): MenuItem[] {
// ==================== 核心函数:转换单个节点 ====================
const convertToMenuItem = (item: StructureItem): MenuItem => {
// 步骤1:为这个节点准备一个子节点容器
const children: MenuItem[] = [];
// 步骤2:遍历所有项,找出谁是这个节点的子节点
items.forEach((childItem) => {
if (childItem.parent_uuid === item.uuid) {
// 步骤3:递归处理子节点
const childMenuItem = convertToMenuItem(childItem);
if (childMenuItem) {
children.push(childMenuItem);
}
}
});
// 步骤4:构建菜单项对象
if (item.type === "TITLE") {
// 标题类型:无法点击,只是分组
return {
key: item.uuid,
label: item.title,
children: children.length > 0 ? children : undefined,
};
} else {
// 文档类型:可以点击打开文档
return {
key: item.uuid,
label: item.title,
children: children.length > 0 ? children : undefined,
slug: item.slug, // 保存 slug 以便后续加载文件
} as MenuItem & { slug: string };
}
};
// ==================== 寻找所有根节点 ====================
const roots: MenuItem[] = [];
items.forEach((item) => {
// 根节点的特征:parent_uuid 为空
if (item.parent_uuid === "") {
const menuItem = convertToMenuItem(item);
if (menuItem) {
roots.push(menuItem);
}
}
});
return roots;
}
3.3 导入和使用
typescript
import React, { useState } from "react";
import { Layout, Menu } from "antd";
import type { MenuProps } from "antd";
import XMarkdown from "@ant-design/x-markdown";
import structure from "../../../public/mkdir/structure.json";
export default function Word() {
const [selectedKey, setSelectedKey] = useState<string>("");
const [markdownContent, setMarkdownContent] = useState<string>("");
// 执行递归函数,得到完整的菜单树
const menuItems = buildMenuTree(structure as StructureItem[]);
// 当用户点击菜单项时
const handleMenuClick: MenuProps["onClick"] = (info) => {
// 根据 key 找到对应的 slug
const item = (structure as StructureItem[]).find(
(item) => item.uuid === info.key
);
if (item?.slug && item.slug !== "#") {
setSelectedKey(info.key);
// 加载对应的 markdown 文件
loadMarkdown(item.slug);
}
};
const loadMarkdown = async (slug: string) => {
try {
const response = await fetch(`/mkdir/${slug}.md`);
const text = await response.text();
setMarkdownContent(text);
} catch (err) {
console.error("加载失败:", err);
}
};
return (
<Layout>
<Menu
items={menuItems}
onClick={handleMenuClick}
selectedKeys={[selectedKey]}
/>
<div>
<XMarkdown>{markdownContent}</XMarkdown>
</div>
</Layout>
);
}
第四部分:完整的数据流
┌──────────────────────────────────────────────────────────────────┐
│ 用户打开应用 │
└──────────────────────────────────────────────────────────────────┘
|
↓
┌──────────────────────────────────────────────────────────────────┐
│ 1. 导入 structure.json │
│ import structure from "../../../public/mkdir/structure.json"; │
└──────────────────────────────────────────────────────────────────┘
|
↓
┌──────────────────────────────────────────────────────────────────┐
│ 2. 调用 buildMenuTree(structure) │
│ ↓ │
│ 扁平数组 → 树形菜单 │
│ [ { │
│ { uuid: "A", ... }, key: "A", │
│ { uuid: "B", ... }, children: [{ │
│ { uuid: "C", ... } key: "B", │
│ ] children: [...] │
│ }] │
└──────────────────────────────────────────────────────────────────┘
|
↓
┌──────────────────────────────────────────────────────────────────┐
│ 3. 将菜单树传给 Ant Design Menu 组件 │
│ <Menu items={menuItems} /> │
│ │
│ 浏览器显示: │
│ ✓ 第一个测试 │
│ ✓ 第一个测试的第一个文章 │
│ ✓ 无标题文档 │
│ ✓ 第二个测试 │
│ ✓ 第二个测试的第二个文章 │
│ ✓ 产品介绍测试 │
└──────────────────────────────────────────────────────────────────┘
|
↓
┌──────────────────────────────────────────────────────────────────┐
│ 4. 用户点击菜单项(例如"第一个测试") │
│ onClick 事件触发 → handleMenuClick() │
└──────────────────────────────────────────────────────────────────┘
|
↓
┌──────────────────────────────────────────────────────────────────┐
│ 5. 查找对应的 slug │
│ { uuid: "jor4NpgvK2u2lEL6", slug: "zmgvdxzrh30rqxdn", ... } │
└──────────────────────────────────────────────────────────────────┘
|
↓
┌──────────────────────────────────────────────────────────────────┐
│ 6. 加载 markdown 文件 │
│ fetch("/mkdir/zmgvdxzrh30rqxdn.md") │
└──────────────────────────────────────────────────────────────────┘
|
↓
┌──────────────────────────────────────────────────────────────────┐
│ 7. 提取 markdown 中的标题生成目录 │
│ # 一级标题 ← 抓到!加入目录 │
│ ## 二级标题 ← 抓到!加入目录 │
│ ### 三级标题 ← 抓到!加入目录 │
└──────────────────────────────────────────────────────────────────┘
|
↓
┌──────────────────────────────────────────────────────────────────┐
│ 8. 用 XMarkdown 组件渲染 markdown │
│ ↓ │
│ ┌─────────────────┬──────────────┬──────────────┐ │
│ │ 左侧菜单 │ 中间内容 │ 右侧目录 │ │
│ │ │ │ │ │
│ │ ✓ 第一个测试 │ # 一级标题 │ • 一级标题 │ │
│ │ ✓ 子项1 │ │ • 二级标题 │ │
│ │ ✓ 子项2 │ 这是文档内容 │ • 三级标题 │ │
│ │ │ │ │ │
│ │ ✓ 第二个测试 │ ## 二级标题 │ │ │
│ │ │ │ │ │
│ │ ✓ 产品介绍 │ 更多内容... │ │ │
│ └─────────────────┴──────────────┴──────────────┘ │
└──────────────────────────────────────────────────────────────────┘
第五部分:关键实现细节
5.1 从 markdown 提取目录
typescript
const extractTocFromMarkdown = (markdown: string): TocItem[] => {
const tocItems: TocItem[] = [];
const lines = markdown.split("\n");
lines.forEach((line, index) => {
// 匹配 markdown 标题:# 到 ######
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match) {
// match[1] 是 # 的个数(1-6个),决定标题级别
const level = match[1].length;
// match[2] 是标题文本
let title = match[2];
// 清理特殊字符
title = title
.replace(/<[^>]*>/g, "") // 移除 HTML 标签
.replace(/[*_`]/g, ""); // 移除 markdown 格式符号
// 生成唯一 ID
const id = `heading-${index}-${encodeURIComponent(title)
.replace(/%/g, "")
.substring(0, 30)}`;
tocItems.push({ id, title, level });
}
});
return tocItems;
};
工作原理:
输入 markdown:
# 一级标题
## 二级标题
### 三级标题
处理流程:
行1: "# 一级标题"
↓
正则匹配: match[1] = "#" (1个)
match[2] = "一级标题"
↓
level = 1
title = "一级标题"
↓
加入 tocItems
行2: "## 二级标题"
↓
正则匹配: match[1] = "##" (2个)
match[2] = "二级标题"
↓
level = 2
title = "二级标题"
↓
加入 tocItems
输出:
[
{ id: "heading-0-一级标题", title: "一级标题", level: 1 },
{ id: "heading-1-二级标题", title: "二级标题", level: 2 },
{ id: "heading-2-三级标题", title: "三级标题", level: 3 }
]
5.2 点击目录快速跳转
typescript
const handleTocClick = (tocTitle: string) => {
// 第1步:找到所有标题元素(h1-h6)
const headings = document.querySelectorAll(
".markdown-content h1, " +
".markdown-content h2, " +
".markdown-content h3, " +
".markdown-content h4, " +
".markdown-content h5, " +
".markdown-content h6"
);
// 第2步:遍历所有标题,找到与目录项匹配的那个
for (const heading of headings) {
const headingText = heading.textContent?.trim() || "";
if (headingText === tocTitle) {
// 第3步:平滑滚动到这个标题位置
heading.scrollIntoView({
behavior: "smooth", // 平滑滚动
block: "start" // 滚动到顶部
});
break; // 找到了,停止循环
}
}
};
第六部分:扩展和优化
6.1 性能优化
当你有成千上万的文档时,可以优化查询效率:
typescript
function buildMenuTree(items: StructureItem[]): MenuItem[] {
// 优化:使用 Map 缓存,O(1) 查询而不是 O(n)
const childrenMap = new Map<string, StructureItem[]>();
items.forEach((item) => {
if (!childrenMap.has(item.parent_uuid)) {
childrenMap.set(item.parent_uuid, []);
}
childrenMap.get(item.parent_uuid)!.push(item);
});
const convertToMenuItem = (item: StructureItem): MenuItem => {
// 直接从 Map 中查询,而不是遍历所有项
const children = childrenMap.get(item.uuid) || [];
const menuChildren: MenuItem[] = children.map(convertToMenuItem);
return {
key: item.uuid,
label: item.title,
children: menuChildren.length > 0 ? menuChildren : undefined,
};
};
// 找所有根节点
const roots = (childrenMap.get("") || []).map(convertToMenuItem);
return roots;
}
6.2 错误处理
typescript
const loadMarkdown = async (slug: string) => {
setLoading(true);
setError("");
try {
const response = await fetch(`/mkdir/${slug}.md`);
// 检查 HTTP 状态
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const text = await response.text();
setMarkdownContent(text);
// 提取目录
const tocItems = extractTocFromMarkdown(text);
setToc(tocItems);
} catch (err: any) {
// 用户友好的错误信息
console.error("加载 markdown 失败:", err);
setError(`加载文档内容失败: ${err.message}`);
setMarkdownContent("");
setToc([]);
} finally {
setLoading(false);
}
};
6.3 支持搜索功能
typescript
const searchMenuItems = (items: MenuItem[], keyword: string): MenuItem[] => {
return items
.filter((item) => {
// 递归搜索:如果标题包含关键词,或子项中有匹配
const titleMatch = item.label
?.toString()
.toLowerCase()
.includes(keyword.toLowerCase());
const childrenMatch =
item.children && searchMenuItems(item.children, keyword).length > 0;
return titleMatch || childrenMatch;
})
.map((item) => ({
...item,
children: item.children
? searchMenuItems(item.children, keyword)
: undefined,
}));
};
// 使用示例
const filteredItems = searchMenuItems(menuItems, "测试");
第七部分:总结
核心概念回顾
| 概念 | 说明 | 例子 |
|---|---|---|
| 扁平数组 | 没有层级关系的数据 | structure.json |
| 树形结构 | 有父子关系的数据 | Ant Design Menu |
| 递归 | 函数调用自己 | convertToMenuItem |
| parent_uuid | 父节点标识 | 空 = 根节点 |
| 叶子节点 | 没有子节点的节点 | 能加载 md 文件 |
| slug | 文件名 | zmgvdxzrh30rqxdn |
代码执行顺序
1. 应用启动
↓
2. 导入 structure.json
↓
3. 调用 buildMenuTree() 递归转换
↓
4. Ant Design Menu 显示菜单树
↓
5. 用户点击菜单项
↓
6. 触发 onClick 事件
↓
7. 根据 slug 加载 .md 文件
↓
8. 提取目录,显示在右侧
↓
9. 用 XMarkdown 渲染内容
↓
10. 用户可以点击目录快速跳转
为什么选择这个架构?
✅ 灵活性 :可以支持任意深度的嵌套
✅ 可维护性 :改变数据结构不需要改变代码逻辑
✅ 性能 :即使有大量文档也能快速加载菜单
✅ 可扩展:容易添加搜索、过滤等功能
深入思考题
-
Q: 为什么
parent_uuid为空表示根节点?A: 因为根节点没有父节点,所以
parent_uuid用空字符串表示"没有父节点"的状态。 -
Q: 递归会不会导致无限循环?
A: 不会。因为数据结构是有向无环图(DAG),最终一定会到达叶子节点。
-
Q: 能不能用迭代(while/for)代替递归?
A: 可以,但代码会更复杂。递归是处理树形结构的最自然的方式。
-
Q: 如果要支持删除、新增、编辑节点怎么办?
A: 需要同时更新
structure.json和.md文件,然后刷新页面重新加载。 -
Q: 如何实现快速搜索?
A: 在内存中建立全文索引,或使用搜索库如
fuse.js。
完整代码参考
完整的 /src/pages/word/index.tsx:
typescript
import React, { useState } from "react";
import { Layout, Menu } from "antd";
import type { MenuProps } from "antd";
import XMarkdown, { type ComponentProps } from "@ant-design/x-markdown";
import { CodeHighlighter } from "@ant-design/x";
import structure from "../../../public/mkdir/structure.json";
const { Sider, Content } = Layout;
interface StructureItem {
uuid: string;
type: "TITLE" | "DOC";
title: string;
slug: string;
id: number | string;
level: number;
depth: number;
parent_uuid: string;
child_uuid: string;
sibling_uuid: string;
}
interface TocItem {
id: string;
title: string;
level: number;
}
type MenuItem = Required<MenuProps>["items"][number];
const Code: React.FC<ComponentProps> = (props) => {
const { className, children } = props;
const lang = className?.match(/language-(\w+)/)?.[1] || "";
if (typeof children !== "string") return null;
return <CodeHighlighter lang={lang}>{children}</CodeHighlighter>;
};
function buildMenuTree(items: StructureItem[]): MenuItem[] {
const convertToMenuItem = (item: StructureItem): MenuItem => {
const children: MenuItem[] = [];
items.forEach((childItem) => {
if (childItem.parent_uuid === item.uuid) {
const childMenuItem = convertToMenuItem(childItem);
if (childMenuItem) {
children.push(childMenuItem);
}
}
});
if (item.type === "TITLE") {
return {
key: item.uuid,
label: item.title,
children: children.length > 0 ? children : undefined,
};
} else {
return {
key: item.uuid,
label: item.title,
children: children.length > 0 ? children : undefined,
slug: item.slug,
} as MenuItem & { slug: string };
}
};
const roots: MenuItem[] = [];
items.forEach((item) => {
if (item.parent_uuid === "") {
const menuItem = convertToMenuItem(item);
if (menuItem) {
roots.push(menuItem);
}
}
});
return roots;
}
export default function Word() {
const [selectedKey, setSelectedKey] = useState<string>("");
const [markdownContent, setMarkdownContent] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [toc, setToc] = useState<TocItem[]>([]);
const menuItems = buildMenuTree(structure as StructureItem[]);
const findSlugByKey = (key: string): string | undefined => {
const item = (structure as StructureItem[]).find(
(item) => item.uuid === key
);
return item?.slug;
};
const handleMenuClick: MenuProps["onClick"] = (info) => {
const slug = findSlugByKey(info.key);
if (slug && slug !== "#") {
setSelectedKey(info.key);
loadMarkdown(slug);
}
};
const handleMenuSelect: MenuProps["onSelect"] = (info) => {
const slug = findSlugByKey(info.key);
if (slug && slug !== "#") {
setSelectedKey(info.key);
loadMarkdown(slug);
}
};
const extractTocFromMarkdown = (markdown: string): TocItem[] => {
const tocItems: TocItem[] = [];
const lines = markdown.split("\n");
lines.forEach((line, index) => {
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match) {
const level = match[1].length;
let title = match[2];
title = title.replace(/<[^>]*>/g, "").replace(/[*_`]/g, "");
const id = `heading-${index}-${encodeURIComponent(title)
.replace(/%/g, "")
.substring(0, 30)}`;
tocItems.push({ id, title, level });
}
});
return tocItems;
};
const handleTocClick = (tocTitle: string) => {
const headings = document.querySelectorAll(
".markdown-content h1, .markdown-content h2, .markdown-content h3, .markdown-content h4, .markdown-content h5, .markdown-content h6"
);
for (const heading of headings) {
const headingText = heading.textContent?.trim() || "";
if (headingText === tocTitle) {
heading.scrollIntoView({ behavior: "smooth", block: "start" });
break;
}
}
};
const loadMarkdown = async (slug: string) => {
setLoading(true);
setError("");
try {
const response = await fetch(`/mkdir/${slug}.md`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const text = await response.text();
setMarkdownContent(text);
const tocItems = extractTocFromMarkdown(text);
setToc(tocItems);
} catch (err: any) {
console.error("加载 markdown 失败:", err);
setError(`加载文档内容失败: ${err.message}`);
setMarkdownContent("");
setToc([]);
} finally {
setLoading(false);
}
};
return (
<Layout style={{ minHeight: "100vh", position: "relative" }}>
<Sider
width={260}
style={{
background: "#fff",
borderRight: "1px solid #f0f0f0",
}}
>
<Menu
mode="inline"
selectedKeys={[selectedKey]}
onClick={handleMenuClick}
onSelect={handleMenuSelect}
items={menuItems}
style={{ border: "none" }}
/>
</Sider>
<Layout>
<Content
style={{
padding: "40px 60px",
maxWidth: "1000px",
width: "100%",
marginRight: toc.length > 0 ? "240px" : "auto",
}}
>
{loading && <p>加载中...</p>}
{error && <p style={{ color: "red" }}>{error}</p>}
{!loading && !error && markdownContent && (
<div className="markdown-content">
<XMarkdown components={{ code: Code }}>
{markdownContent}
</XMarkdown>
</div>
)}
{!loading && !error && !markdownContent && (
<div style={{ textAlign: "center", padding: "100px 20px" }}>
<h2>欢迎使用文档系统</h2>
<p style={{ color: "#666" }}>请从左侧菜单选择一个文档查看</p>
</div>
)}
</Content>
</Layout>
{toc.length > 0 && (
<div
style={{
position: "fixed",
right: 0,
top: 0,
width: "240px",
height: "100vh",
background: "#fafafa",
borderLeft: "1px solid #e8e8e8",
overflowY: "auto",
padding: "20px",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{toc.map((item) => (
<div
key={item.id}
style={{
fontSize: "13px",
color: "#666",
cursor: "pointer",
padding: "6px 8px",
borderRadius: "4px",
transition: "all 0.2s",
lineHeight: 1.4,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
paddingLeft: `${(item.level - 1) * 12}px`,
}}
onClick={() => handleTocClick(item.title)}
onMouseEnter={(e) => {
e.currentTarget.style.color = "#1890ff";
e.currentTarget.style.background = "#e6f7ff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "#666";
e.currentTarget.style.background = "transparent";
}}
>
{item.title}
</div>
))}
</div>
</div>
)}
</Layout>
);
}
后记
这个文档系统展示了如何优雅地处理树形数据结构。关键点:
- 用
parent_uuid和child_uuid维护父子关系 - 用递归把扁平数组转为树
- 用
slug关联到实际的 markdown 文件 - 用
type区分可交互和不可交互的节点
希望这篇文章能帮助你彻底理解递归和树形结构!如有问题,欢迎讨论。