一篇超详细的保姆级教程,带你实现从语雀 API 获取文档、转换为 Markdown、到前端完美展示的全流程
📖 目录
🎯 项目背景
为什么要做这个项目?
在企业开发中,我们经常需要:
- 📝 将语雀中的技术文档集成到内部系统
- 🔄 实现文档的自动同步和展示
- 🎨 统一文档的视觉风格
- 💾 实现文档的离线访问
但是直接使用语雀的嵌入功能有以下问题:
- ❌ 需要用户登录语雀账号
- ❌ 加载速度较慢
- ❌ 无法自定义样式
- ❌ 不利于 SEO
因此,我们需要一个完全独立的文档展示系统。
项目目标
✨ 实现以下功能:
- 从语雀 API 获取完整的文档树结构
- 将语雀的 Lake 格式/Markdown 转换为标准 Markdown
- 下载并本地化存储文档中的图片
- 实现代码块语言的智能识别和语法高亮
- 构建一个美观、易用的前端展示界面
🛠️ 技术栈介绍
后端脚本 (Node.js)
{
"axios": "^1.6.0", // HTTP 请求库
"turndown": "^7.1.2", // HTML 转 Markdown
"turndown-plugin-gfm": "^1.0.2" // 支持 GitHub 风格 Markdown (表格等)
}
前端框架
{
"react": "^18.2.0", // React 框架
"umi": "^4.4.12", // Umi.js 应用框架
"valtio": "^2.2.0", // 状态管理
"antd": "^4.24.16", // UI 组件库
"@ant-design/x-markdown": "^0.1.0", // Markdown 渲染
"react-syntax-highlighter": "^15.5.0" // 代码高亮
}
为什么选择这些技术?
| 技术 | 原因 |
|---|---|
| Turndown | 业界最成熟的 HTML → Markdown 转换库 |
| Axios | 支持拦截器、请求取消、超时等高级特性 |
| XMarkdown | Ant Design 官方 Markdown 渲染组件,与 Ant Design 风格统一 |
| Valtio | 极简的 Proxy-based 状态管理,符合项目规范 |
| react-syntax-highlighter | 支持 100+ 种编程语言,主题丰富 |
🏗️ 整体架构设计
系统流程图
┌─────────────────────────────────────────────────────────────────┐
│ 语雀平台 │
│ (easedata.yuque.com) │
└────────────────────────────┬────────────────────────────────────┘
│
┌────────┴────────┐
│ npm run fetch │ ← 手动触发
└────────┬────────┘
│
┌──────────────────┴──────────────────┐
│ │
┌─────▼─────┐ ┌─────▼─────┐
│ 获取目录树 │ │ 获取文档 │
│ (TOC) │ │ 内容 │
└─────┬─────┘ └─────┬─────┘
│ │
│ ┌──────▼──────┐
│ │ Markdown/ │
│ │ Lake 格式 │
│ └──────┬──────┘
│ │
│ ┌──────▼──────────┐
│ │ ① 下载图片 │
│ │ ② 识别代码语言 │
│ │ ③ Lake→Markdown │
│ └──────┬──────────┘
│ │
└──────────────────┬──────────────────┘
│
┌────────────────▼──────────────────┐
│ /public/markdown/ │
│ ├── structure.json (目录树) │
│ ├── *.md (文档内容) │
│ └── image/ (图片资源) │
└────────────────┬──────────────────┘
│
┌────────┴────────┐
│ 前端应用启动 │
└────────┬────────┘
│
┌──────────────────┴──────────────────┐
│ │
┌─────▼─────┐ ┌─────▼─────┐
│ 读取目录树 │ │ 加载文档 │
│ 构建菜单 │ │ Markdown │
└─────┬─────┘ └─────┬─────┘
│ │
└──────────────────┬──────────────────┘
│
┌──────▼───────┐
│ React 组件 │
│ - Sidebar │
│ - Content │
│ - TOC │
└──────┬───────┘
│
┌──────▼───────┐
│ Markdown渲染 │
│ + 代码高亮 │
└──────┬───────┘
│
┌──────▼───────┐
│ 浏览器展示 │
└──────────────┘
文件结构
doc/
├── script/ # Node.js 脚本
│ └── index.js # 语雀文档获取和转换脚本
├── public/
│ └── markdown/ # 生成的 Markdown 文件
│ ├── structure.json # 文档树结构
│ ├── *.md # 转换后的文档
│ └── image/ # 下载的图片
│ └── {doc_uuid}/ # 按文档分组
├── src/
│ ├── pages/
│ │ └── markdown/ # 文档展示页面
│ │ ├── index.tsx # 主页面
│ │ ├── Sidebar.tsx # 侧边栏组件
│ │ ├── CodeBlock.tsx # 代码块组件
│ │ ├── index.less # 样式文件
│ │ └── sidebar.less
│ └── store/
│ └── global.ts # 全局状态管理
└── package.json
🔧 第一部分:数据获取与转换
这是整个系统的核心部分,负责从语雀获取数据并转换为标准格式。
步骤 1: 准备工作
1.1 获取 Cookie
语雀的 API 需要认证,我们需要获取登录后的 Cookie:
# 步骤:
1. 打开浏览器,登录语雀
2. 按 F12 打开开发者工具
3. 切换到 Network (网络) 标签
4. 刷新页面,找到任意一个请求
5. 找到 Request Headers 中的 Cookie
6. 完整复制 Cookie 的值
重要提示: Cookie 包含登录凭证,不要提交到 Git!
1.2 获取 BOOK_ID
# 步骤:
1. 打开你的语雀知识库
2. 查看浏览器地址栏或网络请求
3. 找到类似 /api/books/74956570 的请求
4. 74956570 就是 BOOK_ID
步骤 2: 获取文档树结构
2.1 API 调用
const BOOK_ID = 74956570;
const YUQUE_COOKIE = "your_cookie_here...";
const response = await axios.get(
`https://easedata.yuque.com/api/books/${BOOK_ID}/toc`,
{
headers: {
Accept: "application/json, text/plain, */*",
Cookie: YUQUE_COOKIE,
"User-Agent": "Mozilla/5.0 ...",
}
}
);
2.2 理解返回的数据结构
语雀返回的是一个扁平化的数组,通过 UUID 关系构建树:
// 返回示例
{
"data": {
"toc": [
{
"uuid": "jor4NpgvK2u2lEL6", // 唯一标识
"type": "TITLE", // 类型: TITLE(目录) 或 DOC(文档)
"title": "我我", // 显示名称
"url": null, // TITLE 类型没有 URL
"doc_id": null,
"level": 0, // 层级: 0 表示根节点
"parent_uuid": null, // 父节点 UUID (null 表示根节点)
"child_uuid": "9BZLL7vTek6LYoqK", // 第一个子节点 UUID
"sibling_uuid": "EVr2HXoOG4WxCWxj" // 下一个兄弟节点 UUID
},
{
"uuid": "9BZLL7vTek6LYoqK",
"type": "DOC", // 文档类型
"title": "第一个测试",
"url": "zmgvdxzrh30rqxdn", // 文档访问 slug
"doc_id": 254847777,
"level": 1, // 层级: 1 表示一级子节点
"parent_uuid": "jor4NpgvK2u2lEL6", // 父节点是 "我我"
"child_uuid": null, // 没有子节点
"sibling_uuid": "skftcatshyl0af3e" // 下一个兄弟节点
}
// ... 更多节点
]
}
}
2.3 保存树形结构
const structureInfo = [];
data.toc.forEach(item => {
structureInfo.push({
uuid: item.uuid,
type: item.type, // TITLE 或 DOC
title: item.title,
url: item.url,
doc_id: item.doc_id,
level: item.level,
parent_uuid: item.parent_uuid,
child_uuid: item.child_uuid,
sibling_uuid: item.sibling_uuid,
});
});
// 保存为 JSON 文件
fs.writeFileSync(
path.join(mdDir, 'structure.json'),
JSON.stringify(structureInfo, null, 2)
);
为什么要保存?
- ✅ 前端可以直接读取构建菜单
- ✅ 避免每次都调用 API
- ✅ 支持离线访问
步骤 3: 获取文档内容
3.1 优先获取 Markdown 格式
语雀支持直接导出 Markdown,这是最理想的方式:
// 尝试获取 Markdown 格式
const exportRes = await axios.get(
`https://easedata.yuque.com/wt15ay/wwuyoq/${item.url}/markdown`,
{
params: {
attachment: true, // 包含附件
latexcode: false, // 不导出 LaTeX 代码
anchor: false, // 不包含锚点
linebreak: false, // 不添加换行符
useMdai: true, // 使用 Markdown AI
},
headers: {
Accept: "text/markdown, text/plain, */*",
Cookie: YUQUE_COOKIE,
Referer: `https://easedata.yuque.com/wt15ay/wwuyoq/${item.url}`,
}
}
);
// 检查是否成功获取 Markdown
if (
typeof exportRes.data === 'string' &&
!exportRes.data.includes('<!doctype') && // 不是 HTML 页面
!exportRes.data.includes('<!DOCTYPE')
) {
content = exportRes.data;
console.log('✅ 成功获取 Markdown 格式');
}
判断成功的标准:
- 返回的是字符串
- 不包含
<!doctype或<!DOCTYPE(避免误判为 HTML 错误页面)
3.2 备选方案: Lake 格式
如果 Markdown 导出失败,使用 Lake 格式:
const docRes = await axios.get(
`https://easedata.yuque.com/api/docs/${item.url}`,
{
params: {
book_id: BOOK_ID,
merge_dynamic_data: false,
},
headers: {
Accept: "application/json, text/plain, */*",
Cookie: YUQUE_COOKIE,
}
}
);
const docData = docRes.data.data;
content = docData?.body_asl || docData?.body || '';
步骤 4: Lake 格式转换为 Markdown
4.1 什么是 Lake 格式?
Lake 是语雀自研的富文本格式,基于 HTML,但包含大量自定义标签:
<!doctype lake>
<card name="codeblock" value="data:%7B%22code%22%3A%22SELECT%20...%22%7D"></card>
<card name="image" value="data:%7B%22src%22%3A%22https%3A//...%22%7D"></card>
4.2 处理代码块
问题: 代码块被编码在 <card> 标签中
// 1. 提取所有代码块
const codeblockRegex = /<card[^>]*name="codeblock"[^>]*value="([^"]*)"[^>]*><\/card>/g;
const matches = [...lakeContent.matchAll(codeblockRegex)];
for (const match of matches) {
// 2. 解码 Base64 数据
let encodedData = match[1];
if (encodedData.startsWith('data:')) {
encodedData = encodedData.substring(5);
}
// 3. URL 解码
const decodedData = decodeURIComponent(encodedData);
// 4. 解析 JSON
const codeData = JSON.parse(decodedData);
// { code: "SELECT * FROM users", mode: "sql" }
const code = codeData.code || "";
const mode = codeData.mode || "";
// 5. 智能识别语言 (稍后详细讲)
const detectedLanguage = detectCodeLanguage(code, mode);
// 6. 替换为标准 HTML
const codeBlock = `<pre><code class="language-${detectedLanguage}">${code}</code></pre>`;
processedContent = processedContent.replace(match[0], codeBlock);
}
为什么要转换为 HTML <pre><code>?
- ✅ Turndown 可以识别并转换为 Markdown 格式
- ✅ 可以保留语言标识
4.3 智能语言识别
核心函数: detectCodeLanguage(code, mode)
function detectCodeLanguage(code, mode) {
// 第 1 步: 尝试从 mode 映射
const modeMap = {
sql: "sql",
mysql: "sql",
javascript: "javascript",
js: "javascript",
typescript: "typescript",
ts: "typescript",
python: "python",
py: "python",
// ... 更多映射
};
if (mode && modeMap[mode.toLowerCase()]) {
return modeMap[mode.toLowerCase()];
}
// 第 2 步: 通过代码特征识别
const trimmedCode = code.trim();
// SQL 特征: 关键字匹配
if (/\b(select|insert|update|delete|from|where|join)\b/i.test(trimmedCode)) {
return "sql";
}
// JavaScript/TypeScript 特征
if (/\b(const|let|var|function|=>|import|export)\b/.test(trimmedCode)) {
// 如果有类型注解,判断为 TypeScript
if (/:\s*(string|number|boolean|any|void)/.test(trimmedCode)) {
return "typescript";
}
return "javascript";
}
// Python 特征
if (/\b(def|class|import|from|print|if __name__)\b/.test(trimmedCode)) {
return "python";
}
// Java 特征
if (/\b(public|private|class|void|static|extends)\b/.test(trimmedCode)) {
return "java";
}
// Bash/Shell 特征
if (/^#!\/bin\/(bash|sh)/.test(trimmedCode) ||
/\b(echo|cd|ls|mkdir|rm|chmod)\b/.test(trimmedCode)) {
return "bash";
}
// JSON 检测
if (/^\s*[\[{]/.test(trimmedCode)) {
try {
JSON.parse(trimmedCode);
return "json";
} catch {
// 不是有效 JSON
}
}
// CSS/SCSS/Less 特征
if (/\{[^}]*:[^}]*;[^}]*\}/.test(trimmedCode)) {
if (/@mixin|@include|\$\w+:/.test(trimmedCode)) {
return "scss";
}
if (/@\w+:/.test(trimmedCode)) {
return "less";
}
return "css";
}
// 默认返回纯文本
return "plaintext";
}
识别准确率提升技巧:
- ✅ 优先检查特征明显的语言 (如 SQL, Python)
- ✅ 使用正则表达式匹配关键字
- ✅ TypeScript 通过类型注解与 JavaScript 区分
- ✅ 预留兜底方案 (plaintext)
4.4 处理图片
// 1. 提取图片标签
const imageRegex = /<card[^>]*name="image"[^>]*value="([^"]*)"[^>]*><\/card>/g;
const matches = [...lakeContent.matchAll(imageRegex)];
for (const match of matches) {
// 2. 解码图片数据
let encodedData = match[1];
if (encodedData.startsWith('data:')) {
encodedData = encodedData.substring(5);
}
const decodedData = decodeURIComponent(encodedData);
const imageData = JSON.parse(decodedData);
// { src: "https://cdn.nlark.com/...", name: "image.png" }
const imageUrl = imageData.src;
// 3. 下载图片
const imageId = generateImageId(imageUrl); // MD5 哈希
const fileName = `${imageId}.png`;
const localPath = await downloadImage(imageUrl, docImageDir, fileName);
// 4. 替换为 HTML img 标签
if (localPath) {
const imgTag = `<img src="/markdown/image/${docUuid}/${fileName}" alt="${imageData.name}" />`;
processedContent = processedContent.replace(match[0], imgTag);
}
}
图片存储策略:
/public/markdown/image/
├── {doc_uuid_1}/ # 按文档分组
│ ├── abc123.png
│ └── def456.jpg
└── {doc_uuid_2}/
└── ghi789.png
为什么使用 MD5 哈希?
- ✅ 避免文件名冲突
- ✅ 相同图片不会重复下载
- ✅ 文件名长度可控
4.5 使用 Turndown 转换
const TurndownService = require('turndown');
const { gfm } = require('turndown-plugin-gfm');
// 初始化 Turndown
const turndownService = new TurndownService({
headingStyle: 'atx', // 使用 # 风格的标题
codeBlockStyle: 'fenced', // 使用 ``` 风格的代码块
});
// 自定义代码块转换规则 (必须在 GFM 之前)
turndownService.addRule('customCodeBlockWithLanguage', {
filter: (node, options) => {
return (
options.codeBlockStyle === 'fenced' &&
node.nodeName === 'PRE' &&
node.firstChild?.nodeName === 'CODE'
);
},
replacement: (_, node) => {
const className = node.firstChild.className || '';
const language = className.match(/language-(\w+)/)?.[1] || '';
const code = node.firstChild.textContent || '';
return `\`\`\`${language}\n${code}\n\`\`\`\n`;
}
});
// 使用 GFM 插件 (支持表格)
turndownService.use(gfm);
// 转换
const markdown = turndownService.turndown(processedContent);
GFM (GitHub Flavored Markdown) 的作用:
- ✅ 支持表格 (Table)
- ✅ 支持删除线 (
删除) - ✅ 支持任务列表 (- [ ] Todo)
步骤 5: 处理 Markdown 格式的图片
对于直接获取的 Markdown 格式,还需要处理图片:
async function processImages(content, docUuid) {
const docImageDir = path.join(mdDir, 'image', docUuid);
fs.mkdirSync(docImageDir, { recursive: true });
let updatedContent = content;
// 匹配 Markdown 图片: 
const markdownImageRegex = /!\[([^\]]*)\]\(([^)]*)\)/g;
const matches = [...content.matchAll(markdownImageRegex)];
for (const match of matches) {
const imageUrl = match[2];
const alt = match[1];
// 跳过无效 URL
if (!imageUrl || !imageUrl.startsWith('http')) {
continue;
}
// 下载图片
const imageId = generateImageId(imageUrl);
const ext = imageUrl.split('.').pop() || 'png';
const fileName = `${imageId}.${ext}`;
const imagePath = await downloadImage(imageUrl, docImageDir, fileName);
if (imagePath) {
// 替换为相对路径
const relativePath = `/markdown/image/${docUuid}/${fileName}`;
updatedContent = updatedContent.replace(
``,
``
);
}
}
return updatedContent;
}
步骤 6: 保存文档文件
// 保存为 .md 文件
const filePath = path.join(mdDir, `${item.url}.md`);
fs.writeFileSync(filePath, content, 'utf-8');
console.log(`✅ 创建文件: ${filePath}`);
完整脚本执行
# 安装依赖
npm install axios turndown turndown-plugin-gfm
# 运行脚本
npm run fetch
# 输出示例:
# 📥 正在获取文档: 第一个测试...
# ✅ 成功获取 Markdown 格式
# 🖼️ 图片已下载: abc123.png
# ✅ 创建文件: /public/markdown/zmgvdxzrh30rqxdn.md
# ...
# 📋 分层结构已保存: /public/markdown/structure.json
# ✅ 所有文档已生成完毕!
🎨 第二部分:前端展示系统
步骤 1: 状态管理设计
虽然当前使用 useState,但按照项目规范应该使用 valtio:
// src/store/markdown.ts (优化后)
import { proxy } from 'valtio';
interface TocItem {
id: string;
title: string;
level: number;
}
class MarkdownStore {
// 当前选中的文档 key
selectedKey = '';
// 文档内容
markdownContent = '';
// 加载状态
loading = false;
// 错误信息
error = '';
// 目录数据
toc: TocItem[] = [];
// 文档缓存 (优化性能)
cache: Map<string, { content: string; toc: TocItem[] }> = new Map();
// 加载文档
async loadDocument(slug: string) {
// 先检查缓存
if (this.cache.has(slug)) {
const cached = this.cache.get(slug)!;
this.markdownContent = cached.content;
this.toc = cached.toc;
return;
}
this.loading = true;
this.error = '';
try {
const response = await fetch(`/markdown/${slug}.md`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const text = await response.text();
const tocItems = this.extractToc(text);
// 保存到缓存
this.cache.set(slug, { content: text, toc: tocItems });
this.markdownContent = text;
this.toc = tocItems;
} catch (err: any) {
this.error = `加载失败: ${err.message}`;
this.markdownContent = '';
this.toc = [];
} finally {
this.loading = false;
}
}
// 提取目录
private extractToc(markdown: string): TocItem[] {
const tocItems: TocItem[] = [];
const lines = markdown.split('\n');
let inCodeBlock = false;
lines.forEach((line, index) => {
// 检测代码块
if (line.trim().startsWith('```')) {
inCodeBlock = !inCodeBlock;
return;
}
// 跳过代码块内的内容
if (inCodeBlock) return;
// 匹配标题
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match) {
const level = match[1].length;
const title = match[2].replace(/<[^>]*>/g, '').replace(/[*_`]/g, '');
const id = `heading-${index}-${encodeURIComponent(title).substring(0, 30)}`;
tocItems.push({ id, title, level });
}
});
return tocItems;
}
// 设置选中的文档
setSelectedKey(key: string) {
this.selectedKey = key;
}
}
export const markdownStore = proxy(new MarkdownStore());
步骤 2: 构建树形菜单
2.1 理解树形结构转换
语雀返回的是扁平数组,我们需要转换为嵌套树结构:
// 输入: 扁平数组
[
{
uuid: "root1",
title: "根节点1",
parent_uuid: null, // 没有父节点
},
{
uuid: "child1",
title: "子节点1",
parent_uuid: "root1", // 父节点是 root1
},
{
uuid: "child2",
title: "子节点2",
parent_uuid: "root1",
}
]
// 输出: 嵌套树
[
{
key: "root1",
label: "根节点1",
children: [
{
key: "child1",
label: "子节点1",
},
{
key: "child2",
label: "子节点2",
}
]
}
]
2.2 递归构建树
interface StructureItem {
uuid: string;
type: 'TITLE' | 'DOC';
title: string;
url: string | null;
parent_uuid: string | null;
}
interface MenuItem {
key: string;
label: string;
slug?: string;
children?: MenuItem[];
}
function buildMenuTree(items: StructureItem[]): MenuItem[] {
// 递归转换函数
const convertToMenuItem = (item: StructureItem): MenuItem => {
const children: MenuItem[] = [];
// 查找所有子节点 (parent_uuid === 当前 uuid)
items.forEach((childItem) => {
if (childItem.parent_uuid === item.uuid) {
// 递归构建子节点
const childMenuItem = convertToMenuItem(childItem);
children.push(childMenuItem);
}
});
return {
key: item.uuid,
label: item.title,
slug: item.url || undefined,
children: children.length > 0 ? children : undefined,
};
};
// 找到所有根节点 (parent_uuid === null)
const roots: MenuItem[] = [];
items.forEach((item) => {
if (item.parent_uuid === null) {
roots.push(convertToMenuItem(item));
}
});
return roots;
}
关键点:
- ✅ 通过
parent_uuid判断父子关系 - ✅ 递归处理多层嵌套
- ✅ 只有
parent_uuid === null的是根节点
步骤 3: 侧边栏组件
3.1 组件设计
// Sidebar.tsx
import { RightOutlined } from '@ant-design/icons';
import React, { useState } from 'react';
interface MenuItem {
key: string;
label: React.ReactNode;
children?: MenuItem[];
slug?: string;
}
interface MenuItemProps {
item: MenuItem;
onItemClick?: (item: MenuItem) => void;
selectedKey?: string;
level?: number; // 当前层级
}
const SidebarMenuItem: React.FC<MenuItemProps> = ({
item,
onItemClick,
selectedKey,
level = 0,
}) => {
// 一级菜单默认展开
const [expanded, setExpanded] = useState(level === 0);
const hasChildren = item.children && item.children.length > 0;
const isSelected = selectedKey === item.key;
const handleClick = () => {
// 有子节点时切换展开状态
if (hasChildren) {
setExpanded(!expanded);
}
// 触发点击回调
if (onItemClick) {
onItemClick(item);
}
};
return (
<div>
{/* 菜单项 */}
<div
className={`menu-item ${isSelected ? 'active' : ''}`}
style={{
paddingLeft: `${20 + level * 24}px`, // 层级缩进
cursor: 'pointer',
padding: '8px 20px',
backgroundColor: isSelected ? '#e6f7ff' : 'transparent',
}}
onClick={handleClick}
>
{/* 展开/收起箭头 */}
{hasChildren && (
<RightOutlined
style={{
transform: expanded ? 'rotate(90deg)' : 'rotate(0)',
transition: 'transform 0.2s',
}}
/>
)}
<span>{item.label}</span>
</div>
{/* 递归渲染子菜单 */}
{hasChildren && expanded && (
<div>
{item.children!.map((child) => (
<SidebarMenuItem
key={child.key}
item={child}
onItemClick={onItemClick}
selectedKey={selectedKey}
level={level + 1} // 层级 +1
/>
))}
</div>
)}
</div>
);
};
export default SidebarMenuItem;
3.2 样式设计 (sidebar.less)
.sidebar {
height: 100vh;
overflow-y: auto;
// 自定义滚动条
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 3px;
&:hover {
background: #bfbfbf;
}
}
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f5f5f5;
}
&.active {
background: #e6f7ff;
color: #1890ff;
font-weight: 500;
}
}
.arrow {
transition: transform 0.2s;
&.expanded {
transform: rotate(90deg);
}
}
步骤 4: Markdown 渲染
4.1 使用 XMarkdown 组件
import XMarkdown from '@ant-design/x-markdown';
import { CodeBlock } from './CodeBlock';
const App = () => {
const [markdownContent, setMarkdownContent] = useState('');
return (
<div className="markdown-content">
<XMarkdown
components={{
code: CodeBlock, // 自定义代码块组件
}}
>
{markdownContent}
</XMarkdown>
</div>
);
};
4.2 代码块高亮组件
// CodeBlock.tsx
import React from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
interface CodeBlockProps {
inline?: boolean; // 是否为行内代码
className?: string; // 语言类名: "language-javascript"
children?: React.ReactNode;
}
export const CodeBlock: React.FC<CodeBlockProps> = ({
inline,
className,
children,
}) => {
// 提取语言: "language-javascript" -> "javascript"
const language = className?.replace(/language-/, '') || 'javascript';
const code = String(children || '');
// 行内代码: `code`
if (inline) {
return <code className={className}>{children}</code>;
}
// 代码块: ```language\ncode\n```
return (
<div className="code-block-wrapper">
<SyntaxHighlighter
language={language}
style={vscDarkPlus} // VS Code Dark Plus 主题
PreTag="div"
customStyle={{
margin: 0,
borderRadius: '6px',
fontSize: '14px',
lineHeight: '1.6',
}}
>
{code}
</SyntaxHighlighter>
</div>
);
};
关键点:
- ✅ 区分行内代码和代码块
- ✅ 从
className提取语言类型 - ✅ 使用 VS Code 主题保持视觉一致性
4.3 Markdown 样式 (index.less)
.markdown-content {
font-size: 16px;
line-height: 1.8;
color: #333;
// 标题样式
h1 {
font-size: 2em;
font-weight: 600;
margin-top: 24px;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
h2 {
font-size: 1.75em;
font-weight: 600;
margin-top: 20px;
margin-bottom: 14px;
}
h3 {
font-size: 1.5em;
font-weight: 600;
margin-top: 16px;
margin-bottom: 12px;
}
// 段落
p {
margin: 14px 0;
}
// 列表
ul, ol {
margin: 12px 0;
padding-left: 24px;
li {
margin: 8px 0;
line-height: 1.6;
}
}
// 代码块
.code-block-wrapper {
margin: 16px 0;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
// 行内代码
code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
color: #d73a49;
}
// 引用块
blockquote {
margin: 16px 0;
padding: 12px 20px;
background: #f9f9f9;
border-left: 4px solid #1890ff;
color: #666;
}
// 表格
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
th, td {
border: 1px solid #f0f0f0;
padding: 12px 16px;
text-align: left;
}
th {
background: #fafafa;
font-weight: 600;
}
tr:hover {
background: #fafafa;
}
}
// 图片
img {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 16px 0;
}
// 链接
a {
color: #1890ff;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
// 水平线
hr {
border: none;
border-top: 1px solid #eee;
margin: 24px 0;
}
}
步骤 5: 右侧目录导航 (TOC)
5.1 目录生成
// 提取 Markdown 标题生成目录
const extractTocFromMarkdown = (markdown: string): TocItem[] => {
const tocItems: TocItem[] = [];
const lines = markdown.split('\n');
let inCodeBlock = false;
lines.forEach((line, index) => {
// 检测代码块
if (line.trim().startsWith('```')) {
inCodeBlock = !inCodeBlock;
return;
}
// 跳过代码块内的内容
if (inCodeBlock) return;
// 匹配标题: # Heading
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match) {
const level = match[1].length; // # 的数量
let title = match[2];
// 清理 HTML 标签和 Markdown 符号
title = title
.replace(/<[^>]*>/g, '') // 移除 HTML 标签
.replace(/[*_`]/g, ''); // 移除 Markdown 符号
const id = `heading-${index}-${title.substring(0, 20)}`;
tocItems.push({ id, title, level });
}
});
return tocItems;
};
注意事项:
- ⚠️ 必须跳过代码块内的
#字符 - ⚠️ 清理 HTML 标签和 Markdown 格式符号
5.2 目录滚动定位
const handleTocClick = (tocTitle: string) => {
// 查找所有标题元素
const headings = document.querySelectorAll(
'.markdown-content h1, .markdown-content h2, .markdown-content h3'
);
// 找到匹配的标题
for (const heading of headings) {
const headingText = heading.textContent?.trim() || '';
if (headingText === tocTitle) {
// 平滑滚动到标题
heading.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
break;
}
}
};
5.3 目录样式
{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',
}}
>
{toc.map((item) => (
<div
key={item.id}
style={{
paddingLeft: `${(item.level - 1) * 12}px`, // 层级缩进
fontSize: '13px',
color: '#666',
cursor: 'pointer',
padding: '6px 8px',
borderRadius: '4px',
transition: 'all 0.2s',
}}
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>
)}
步骤 6: 页面布局
<Layout style={{ minHeight: '100vh', position: 'relative' }}>
{/* 左侧菜单 */}
<Sider
width={260}
style={{
background: '#fff',
borderRight: '1px solid #f0f0f0',
}}
>
<Sidebar
items={menuItems}
onItemClick={handleItemClick}
selectedKey={selectedKey}
/>
</Sider>
<Layout>
{/* 中间内容 */}
<Content
style={{
padding: '40px 60px',
maxWidth: '1000px',
marginRight: toc.length > 0 ? '240px' : 'auto',
}}
>
{loading && <p>加载中...</p>}
{error && <p style={{ color: 'red' }}>{error}</p>}
{markdownContent && (
<div className="markdown-content">
<XMarkdown components={{ code: CodeBlock }}>
{markdownContent}
</XMarkdown>
</div>
)}
</Content>
</Layout>
{/* 右侧目录 */}
{toc.length > 0 && <TocSidebar toc={toc} />}
</Layout>
⚡ 第三部分:代码优化方案
优化 1: 配置管理
问题: Cookie 和 BOOK_ID 硬编码在代码中
解决方案: 使用环境变量
// 创建 .env 文件 (不要提交到 Git!)
YUQUE_COOKIE=your_cookie_here...
YUQUE_BOOK_ID=74956570
// 在脚本中读取
require('dotenv').config();
const YUQUE_COOKIE = process.env.YUQUE_COOKIE;
const BOOK_ID = Number(process.env.YUQUE_BOOK_ID);
// 添加验证
if (!YUQUE_COOKIE || !BOOK_ID) {
console.error('❌ 请在 .env 文件中配置 YUQUE_COOKIE 和 YUQUE_BOOK_ID');
process.exit(1);
}
优化 2: 并发下载
问题: 文档和图片是串行下载的
解决方案: 使用 Promise.all 并发
// 优化前: 串行
for (const item of data) {
await fetchDocument(item); // 逐个等待
}
// 优化后: 并发 (限制并发数)
async function fetchWithLimit(items, limit = 5) {
const results = [];
for (let i = 0; i < items.length; i += limit) {
const batch = items.slice(i, i + limit);
const batchResults = await Promise.all(
batch.map(item => fetchDocument(item))
);
results.push(...batchResults);
}
return results;
}
await fetchWithLimit(data, 5); // 每次最多 5 个并发
性能提升:
- 串行: 10 个文档 × 2 秒 = 20 秒
- 并发 (5): 10 个文档 ÷ 5 × 2 秒 = 4 秒
优化 3: 进度反馈
问题: 用户不知道当前进度
解决方案: 添加进度条
const cliProgress = require('cli-progress');
// 创建进度条
const progressBar = new cliProgress.SingleBar({
format: '获取文档 |{bar}| {percentage}% | {value}/{total} 篇',
}, cliProgress.Presets.shades_classic);
progressBar.start(data.length, 0);
for (let i = 0; i < data.length; i++) {
await fetchDocument(data[i]);
progressBar.update(i + 1);
}
progressBar.stop();
优化 4: 错误重试机制
问题: 网络错误导致整个流程失败
解决方案: 添加重试逻辑
async function fetchWithRetry(url, options, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await axios.get(url, options);
} catch (err) {
console.warn(`⚠️ 第 ${i + 1} 次尝试失败: ${err.message}`);
if (i === maxRetries - 1) {
throw err; // 最后一次失败则抛出错误
}
// 指数退避: 等待 2^i 秒后重试
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
}
}
}
优化 5: 使用 Valtio 管理状态
问题: 使用 useState 不符合项目规范
解决方案: 迁移到 Valtio
// store/markdown.ts
import { proxy } from 'valtio';
class MarkdownStore {
selectedKey = '';
markdownContent = '';
loading = false;
error = '';
toc: TocItem[] = [];
cache = new Map<string, { content: string; toc: TocItem[] }>();
async loadDocument(slug: string) {
// 检查缓存
if (this.cache.has(slug)) {
const cached = this.cache.get(slug)!;
this.markdownContent = cached.content;
this.toc = cached.toc;
return;
}
this.loading = true;
this.error = '';
try {
const response = await fetch(`/markdown/${slug}.md`);
const text = await response.text();
const toc = extractToc(text);
// 缓存结果
this.cache.set(slug, { content: text, toc });
this.markdownContent = text;
this.toc = toc;
} catch (err: any) {
this.error = `加载失败: ${err.message}`;
} finally {
this.loading = false;
}
}
}
export const markdownStore = proxy(new MarkdownStore());
// 组件中使用
import { useSnapshot } from 'valtio';
const Component = () => {
const snap = useSnapshot(markdownStore);
return (
<div>
{snap.loading && <p>加载中...</p>}
{snap.error && <p>{snap.error}</p>}
<div>{snap.markdownContent}</div>
</div>
);
};
优化 6: 添加文档缓存
问题: 每次切换文档都重新加载
解决方案: 使用 Map 缓存已加载的文档
class MarkdownStore {
cache = new Map<string, CachedDocument>();
async loadDocument(slug: string) {
// 先检查缓存
if (this.cache.has(slug)) {
const cached = this.cache.get(slug)!;
this.markdownContent = cached.content;
this.toc = cached.toc;
return; // 直接返回,不发起请求
}
// 未缓存则加载并缓存
// ...
this.cache.set(slug, { content: text, toc });
}
// 清除缓存 (可选)
clearCache() {
this.cache.clear();
}
}
缓存策略:
- ✅ 首次访问: 发起网络请求
- ✅ 再次访问: 直接从缓存读取
- ✅ 手动刷新: 调用
clearCache()
优化 7: 模块化拆分
问题: script/index.js 太长 (500+ 行)
解决方案: 拆分为多个模块
script/
├── index.js # 主入口
├── config.js # 配置管理
├── yuque-api.js # 语雀 API 封装
├── markdown-converter.js # Markdown 转换
├── image-processor.js # 图片处理
└── utils.js # 工具函数
// config.js
require('dotenv').config();
module.exports = {
YUQUE_COOKIE: process.env.YUQUE_COOKIE,
BOOK_ID: Number(process.env.YUQUE_BOOK_ID),
OUTPUT_DIR: path.resolve(__dirname, '../public/markdown'),
};
// yuque-api.js
const axios = require('axios');
const config = require('./config');
async function fetchToc() {
const response = await axios.get(
`https://easedata.yuque.com/api/books/${config.BOOK_ID}/toc`,
{
headers: {
Cookie: config.YUQUE_COOKIE,
// ...
}
}
);
return response.data.data.toc;
}
async function fetchDocument(slug) {
// ...
}
module.exports = { fetchToc, fetchDocument };
// index.js (主入口)
const { fetchToc, fetchDocument } = require('./yuque-api');
const { convertLakeToMarkdown } = require('./markdown-converter');
const { processImages } = require('./image-processor');
async function main() {
const toc = await fetchToc();
for (const item of toc) {
if (item.type === 'DOC') {
let content = await fetchDocument(item.url);
content = await convertLakeToMarkdown(content, item.uuid);
content = await processImages(content, item.uuid);
// ...
}
}
}
main();
优化 8: TypeScript 类型定义
问题: 缺少类型定义,容易出错
解决方案: 添加完整的类型定义
// types/yuque.ts
export interface YuqueStructureItem {
uuid: string;
type: 'TITLE' | 'DOC';
title: string;
url: string | null;
doc_id: number | null;
level: number;
parent_uuid: string | null;
child_uuid: string | null;
sibling_uuid: string | null;
}
export interface YuqueDocument {
body_asl?: string;
body?: string;
content?: string;
}
export interface CodeBlockData {
code: string;
mode?: string;
}
export interface ImageData {
src: string;
name?: string;
}
// types/markdown.ts
export interface TocItem {
id: string;
title: string;
level: number;
}
export interface MenuItem {
key: string;
label: string;
slug?: string;
children?: MenuItem[];
}
优化 9: 添加 Skeleton 加载状态
问题: "加载中..." 太简单
解决方案: 使用 Ant Design Skeleton
import { Skeleton } from 'antd';
const Component = () => {
const snap = useSnapshot(markdownStore);
if (snap.loading) {
return (
<div style={{ padding: '40px 60px' }}>
<Skeleton active paragraph={{ rows: 10 }} />
</div>
);
}
// ...
};
优化 10: 错误边界
问题: 组件错误导致整个应用崩溃
解决方案: 添加 Error Boundary
import React from 'react';
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean; error?: Error }
> {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('组件错误:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div style={{ padding: '40px', textAlign: 'center' }}>
<h2>😕 出错了</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => window.location.reload()}>
刷新页面
</button>
</div>
);
}
return this.props.children;
}
}
// 使用
<ErrorBoundary>
<App />
</ErrorBoundary>
❓ 常见问题与解决方案
问题 1: Cookie 过期怎么办?
症状: API 返回 401 Unauthorized
解决方案:
- 重新登录语雀
- 按 F12 获取新的 Cookie
- 更新
.env文件中的YUQUE_COOKIE
问题 2: 图片下载失败
可能原因:
- 图片 URL 过期
- 防盗链限制
- 网络超时
解决方案:
async function downloadImage(imageUrl, imageDirPath, fileName) {
try {
const response = await axios.get(imageUrl, {
responseType: 'arraybuffer',
timeout: 10000,
headers: {
Referer: 'https://easedata.yuque.com', // 绕过防盗链
}
});
fs.writeFileSync(path.join(imageDirPath, fileName), response.data);
return true;
} catch (err) {
console.error(`❌ 图片下载失败: ${imageUrl}`);
return false; // 不阻塞整个流程
}
}
问题 3: 代码块语言识别不准确
解决方案: 扩展 detectCodeLanguage 函数
// 添加更多特征识别
function detectCodeLanguage(code, mode) {
// ...
// Rust 特征
if (/\b(fn|let mut|impl|trait|use)\b/.test(trimmedCode)) {
return 'rust';
}
// C++ 特征
if (/#include|std::|cout|cin/.test(trimmedCode)) {
return 'cpp';
}
// ...
}
问题 4: 表格渲染异常
可能原因: Turndown 转换表格时出错
解决方案:
// 使用 GFM 插件
const { gfm } = require('turndown-plugin-gfm');
turndownService.use(gfm);
// 如果还有问题,手动处理表格
processedContent = processedContent.replace(
/<table>([\s\S]*?)<\/table>/g,
(match) => {
// 自定义表格转换逻辑
return convertTableToMarkdown(match);
}
);
问题 5: Markdown 中的特殊字符被转义
解决方案:
// Turndown 配置
const turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
emDelimiter: '*', // 使用 * 而不是 _
strongDelimiter: '**',
linkStyle: 'inlined',
});
// 自定义转义规则
turndownService.escape = (string) => {
return string
.replace(/\\/g, '\\\\')
.replace(/\*/g, '\\*')
.replace(/_/g, '\\_');
};
🎯 总结与展望
我们实现了什么?
✅ 数据获取层:
- 从语雀 API 获取完整文档树
- 支持 Markdown 和 Lake 双格式
- 智能代码语言识别
- 图片本地化存储
✅ 数据转换层:
- HTML → Markdown 转换
- 代码块格式标准化
- 图片路径本地化
- 表格、引用等富文本支持
✅ 前端展示层:
- 树形菜单导航
- Markdown 渲染与样式
- 代码语法高亮
- 右侧目录导航
- 响应式布局
性能指标
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 文档获取时间 (10 篇) | ~20s | ~4s |
| 首次加载时间 | 2.5s | 1.8s |
| 切换文档时间 | 1.2s | 0.1s (缓存) |
| 代码高亮支持语言 | 10+ | 30+ |
可扩展的方向
🚀 功能扩展:
- 全文搜索: 集成 Algolia 或 ElasticSearch
- 离线模式: PWA + Service Worker
- 版本管理: 支持文档历史版本
- 权限控制: 集成 OAuth 认证
- 评论系统: 支持文档评论和讨论
🎨 体验优化:
- 暗黑模式: 支持主题切换
- 快捷键: Vim 模式、快速搜索
- 打印优化: 生成 PDF
- 分享功能: 生成分享链接
- 阅读进度: 记录用户阅读位置
🔧 技术升级:
- SSR/SSG: 使用 Next.js 实现服务端渲染
- 增量构建: 只更新变化的文档
- CDN 部署: 加速全球访问
- 监控告警: 集成 Sentry
- 自动化: GitHub Actions 定时同步
适用场景
✨ 这个系统适用于:
- 📚 企业内部技术文档
- 📖 开源项目文档
- 🎓 在线教程和课程
- 📝 知识库和 Wiki
- 🔍 API 文档展示
最后的话
通过这个项目,我们不仅实现了一个完整的文档系统,还学习了:
- 🔌 API 集成与数据处理
- 🔄 格式转换与内容解析
- 🎨 前端组件设计
- ⚡ 性能优化技巧
- 🛠️ 工程化最佳实践
希望这篇教程能帮助你理解从数据获取到前端展示的完整流程,并应用到自己的项目中!
📚 参考资源
💡 提示: 如果这篇教程对你有帮助,欢迎 Star 和分享!