从零开始构建 React 文档系统 - 完整实现指南

从零开始构建 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. 用户可以点击目录快速跳转

为什么选择这个架构?

灵活性 :可以支持任意深度的嵌套

可维护性 :改变数据结构不需要改变代码逻辑

性能 :即使有大量文档也能快速加载菜单

可扩展:容易添加搜索、过滤等功能


深入思考题

  1. Q: 为什么 parent_uuid 为空表示根节点?

    A: 因为根节点没有父节点,所以 parent_uuid 用空字符串表示"没有父节点"的状态。

  2. Q: 递归会不会导致无限循环?

    A: 不会。因为数据结构是有向无环图(DAG),最终一定会到达叶子节点。

  3. Q: 能不能用迭代(while/for)代替递归?

    A: 可以,但代码会更复杂。递归是处理树形结构的最自然的方式。

  4. Q: 如果要支持删除、新增、编辑节点怎么办?

    A: 需要同时更新 structure.json.md 文件,然后刷新页面重新加载。

  5. 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_uuidchild_uuid 维护父子关系
  • 用递归把扁平数组转为树
  • slug 关联到实际的 markdown 文件
  • type 区分可交互和不可交互的节点

希望这篇文章能帮助你彻底理解递归和树形结构!如有问题,欢迎讨论。

相关推荐
比特森林探险记2 小时前
Hooks、状态管理
前端·javascript·react.js
landonVM2 小时前
Linux 上搭建 Web 服务器
linux·服务器·前端
css趣多多2 小时前
路由全局守卫
前端
AI视觉网奇2 小时前
huggingface-cli 安装笔记2026
前端·笔记
比特森林探险记2 小时前
组件通信 与 ⏳ 生命周期
前端·javascript·vue.js
2301_792580002 小时前
xuepso
java·服务器·前端
海绵宝龙3 小时前
Vue中nextTick
前端·javascript·vue.js
天生欧皇张狗蛋3 小时前
前端部署path问题
前端
H_z_q24013 小时前
Web前端制作一个评论发布案例
前端·javascript·css