1. 项目背景与核心需求
在合同审查系统的开发过程中,我们面临一个关键挑战:如何将后端返回的Markdown格式合同文档转换为可视化的导航目录,并实现点击目录快速定位到文档对应位置的功能。这个需求看似简单,但实际开发中会遇到几个技术难点:
- 数据结构转换:Markdown是线性文本格式,而导航目录需要树形结构
- 层级关系处理:合同文档可能包含多级标题(#、##、###等),需要准确建立父子关系
- 精确定位:点击目录项时,需要平滑滚动到文档对应位置并高亮显示
经过技术选型,我们决定采用React+Ant Design的技术栈实现这个功能。Ant Design的Tree组件提供了良好的树形结构展示能力,而React的状态管理可以很好地处理数据流转和UI更新。
提示:在实际项目中,合同文档通常由法务人员使用Markdown编写,包含大量条款和子条款,良好的导航功能可以显著提升审查效率。
2. 技术架构设计
2.1 整体架构
系统采用分层架构设计,各层职责明确:
code复制数据层 → 解析层 → 展示层 → 交互层
- 数据层:接收后端返回的Markdown原始数据
- 解析层:将Markdown转换为树形结构
- 展示层:使用Ant Design Tree组件渲染目录树
- 交互层:处理用户点击事件,实现文档定位
2.2 关键技术选型
-
Markdown解析:
- 不依赖第三方库,直接使用正则表达式处理
- 优点:轻量、可控,避免引入不必要的依赖
- 适用场景:只需要解析标题结构的简单需求
-
树形结构构建:
- 使用栈(Stack)数据结构维护层级关系
- 时间复杂度O(n),适合大多数文档规模
-
UI组件:
- Ant Design Tree组件
- 内置展开/收起、选中状态管理等功能
- 支持自定义节点渲染
3. 核心实现细节
3.1 Markdown到树形结构的转换
3.1.1 数据预处理
首先需要处理后端返回的数据,统一转换为字符串格式:
javascript复制useEffect(() => {
if (!resultData || Object.keys(resultData).length === 0) return;
let markdownText = '';
if (typeof resultData === 'string') {
markdownText = resultData;
} else if (typeof resultData === 'object') {
// 分页文档按页码顺序拼接
Object.keys(resultData).sort().forEach(pageKey => {
markdownText += resultData[pageKey] + '\n';
});
}
// 继续解析...
}, [resultData]);
注意:实际项目中,后端可能返回分页的合同数据,需要按页码顺序正确拼接。
3.1.2 栈结构解析算法
核心解析算法使用栈来维护标题的层级关系:
javascript复制const lines = markdownText.trim().split('\n');
const structure = [];
const stack = []; // 用于跟踪各级父节点
lines.forEach((line, index) => {
const match = line.match(/^(#+)\s(.+)/);
if (match) {
const level = match[1].length; // 标题级别
const title = match[2]; // 标题文本
const key = `node-${index}`; // 唯一标识
const node = {
title,
key,
level,
children: []
};
if (level === 1) {
// 一级标题作为根节点
structure.push(node);
stack[0] = node;
stack.splice(1); // 清除更深层级的引用
} else {
// 多级标题处理
const parentLevel = level - 2;
const parent = stack[parentLevel];
if (parent) {
parent.children.push(node);
}
stack[level - 1] = node;
stack.splice(level); // 清除更深层级的引用
}
}
});
栈的工作原理示例:
code复制文档内容:
# 标题1
## 标题1.1
### 标题1.1.1
## 标题1.2
栈的变化:
[标题1] → [标题1, 标题1.1] → [标题1, 标题1.1, 标题1.1.1] → [标题1, 标题1.2]
3.1.3 数据后处理
解析完成后进行数据清理和优化:
javascript复制// 移除空的children数组
const cleanEmptyChildren = (nodes) => {
return nodes.map(node => {
if (node.children?.length === 0) {
const { children, ...nodeWithoutChildren } = node;
return nodeWithoutChildren;
}
if (node.children?.length > 0) {
return {
...node,
children: cleanEmptyChildren(node.children)
};
}
return node;
});
};
// 获取所有节点的key用于默认展开
const getAllKeys = (nodes) => {
return nodes.flatMap(node => {
const keys = [node.key];
if (node.children) {
keys.push(...getAllKeys(node.children));
}
return keys;
});
};
const cleanedStructure = cleanEmptyChildren(structure);
const allKeys = getAllKeys(cleanedStructure);
setTreeData(cleanedStructure);
setExpandedKeys(allKeys); // 默认展开所有节点
3.2 树形组件渲染
3.2.1 自定义节点渲染
为每个节点添加文档图标并处理子节点:
javascript复制const renderTreeNodes = (data) => {
return data.map((node) => {
const iconNode = <FileTextOutlined />;
if (node.children && node.children.length > 0) {
return {
...node,
icon: iconNode,
children: renderTreeNodes(node.children),
};
}
return {
...node,
icon: iconNode,
};
});
};
3.2.2 Tree组件配置
jsx复制<Tree
showIcon={true}
expandedKeys={expandedKeys}
onExpand={onExpand}
onSelect={onSelect}
selectedKeys={[selectedTreeKey]}
treeData={renderTreeNodes(treeData)}
switcherIcon={<DownOutlined style={{ fontSize: 12 }} />}
className={styles.customTree}
/>
关键属性说明:
expandedKeys:控制展开的节点selectedKeys:控制选中的节点onSelect:点击节点的回调函数treeData:树形数据源
3.3 文档定位功能实现
3.3.1 节点点击处理
javascript复制const onSelect = (selectedKeys, info) => {
if (selectedKeys.length > 0) {
const key = selectedKeys[0];
setSelectedTreeKey(key); // 更新选中状态
// 获取点击的节点标题
const title = info.node.title;
// 延迟执行确保DOM更新
setTimeout(() => {
highlightHeading(title);
}, 100);
}
};
3.3.2 精确定位算法
javascript复制const highlightHeading = (title) => {
// 1. 移除旧的高亮
document.querySelectorAll('.heading-highlight').forEach(el => {
el.classList.remove('heading-highlight');
});
// 2. 查找所有标题元素
const allHeadings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
// 3. 匹配并高亮目标标题
allHeadings.forEach((heading) => {
const headingText = heading.textContent?.trim();
if (headingText === title || headingText?.includes(title)) {
// 添加高亮样式
heading.classList.add('heading-highlight');
// 获取滚动容器
const scrollContainer = document.querySelector('.centerContent');
if (scrollContainer && heading) {
// 计算相对位置
const containerRect = scrollContainer.getBoundingClientRect();
const headingRect = heading.getBoundingClientRect();
const relativeTop = headingRect.top - containerRect.top + scrollContainer.scrollTop;
// 平滑滚动到目标位置(预留20px边距)
scrollContainer.scrollTo({
top: relativeTop - 20,
behavior: 'smooth'
});
} else {
// 降级方案
heading.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
});
};
3.3.3 样式实现
css复制/* 高亮样式 */
.heading-highlight {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding-left: 12px;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(255, 193, 7, 0.3);
}
/* 树组件自定义样式 */
.customTree {
background: transparent;
.ant-tree-node-content-wrapper {
&:hover {
background-color: rgba(24, 144, 255, 0.05);
}
&.ant-tree-node-selected {
background-color: rgba(24, 144, 255, 0.1);
}
}
}
4. 进阶优化方案
4.1 处理非常规标题结构
原始方案假设文档从一级标题(#)开始,但实际文档可能:
- 只有二级标题(##)
- 标题级别不连续(如##直接跳到####)
改进后的解析算法:
javascript复制// 第一步:扫描所有标题,找到最小级别
let minLevel = Infinity;
const headers = [];
lines.forEach((line, index) => {
const match = line.match(/^(#+)\s(.+)/);
if (match) {
const level = match[1].length;
if (level < minLevel) {
minLevel = level;
}
headers.push({ index, level, title: match[2] });
}
});
// 第二步:根据最小级别重新组织树结构
headers.forEach(({ index, level, title }) => {
const key = `node-${index}`;
const node = { title, key, level, children: [] };
// 计算相对级别
const relativeLevel = level - minLevel;
if (relativeLevel === 0) {
structure.push(node);
stack[0] = node;
stack.splice(1);
} else {
const parentLevel = relativeLevel - 1;
const parent = stack[parentLevel];
if (parent) {
parent.children.push(node);
} else {
// 找不到父节点时作为根节点的子节点
if (stack[0]) {
stack[0].children.push(node);
}
}
stack[relativeLevel] = node;
stack.splice(relativeLevel + 1);
}
});
4.2 性能优化技巧
- 使用useMemo缓存树形数据:
javascript复制const renderedTreeData = useMemo(() =>
renderTreeNodes(treeData),
[treeData]
);
- 防抖处理滚动事件:
javascript复制const handleScroll = debounce(() => {
// 滚动位置计算逻辑
}, 100);
- 虚拟滚动优化(超长文档):
jsx复制<Tree
...
height={500} // 固定高度启用虚拟滚动
virtual
/>
4.3 扩展功能实现
4.3.1 目录搜索功能
javascript复制const [searchValue, setSearchValue] = useState('');
const highlightMatch = (title) => {
const index = title.toLowerCase().indexOf(searchValue.toLowerCase());
return index > -1 ? (
<span>
{title.substr(0, index)}
<span className="highlight-text">
{title.substr(index, searchValue.length)}
</span>
{title.substr(index + searchValue.length)}
</span>
) : (
<span>{title}</span>
);
};
// 在renderTreeNodes中应用
node.title = highlightMatch(node.title);
4.3.2 同步滚动高亮
javascript复制useEffect(() => {
const scrollContainer = document.querySelector('.centerContent');
const handleScroll = () => {
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
let closestHeading = null;
let minDistance = Infinity;
headings.forEach((heading) => {
const rect = heading.getBoundingClientRect();
const distance = Math.abs(rect.top);
if (distance < minDistance && rect.top >= 0) {
minDistance = distance;
closestHeading = heading;
}
});
if (closestHeading) {
const title = closestHeading.textContent?.trim();
// 更新树节点选中状态...
}
};
scrollContainer?.addEventListener('scroll', handleScroll);
return () => scrollContainer?.removeEventListener('scroll', handleScroll);
}, [treeData]);
5. 项目经验与踩坑记录
5.1 实际开发中的挑战
-
标题匹配问题:
- 问题:文档中可能存在重复标题
- 解决:结合标题级别和上下文进行更精确的匹配
-
性能瓶颈:
- 问题:超长文档(1000+行)解析速度慢
- 优化:改用Web Worker进行后台解析
-
动态内容加载:
- 问题:文档分页加载时目录更新不及时
- 解决:使用React key强制组件重新渲染
5.2 推荐的最佳实践
- 错误边界处理:
javascript复制try {
// 解析逻辑
} catch (error) {
console.error('解析失败:', error);
setTreeData([]); // 降级为空树
}
- 类型安全(TypeScript):
typescript复制interface TreeNode {
title: string;
key: string;
level: number;
children?: TreeNode[];
}
- 可访问性优化:
jsx复制<Tree
aria-label="文档目录"
...
/>
5.3 调试技巧
- 可视化栈状态:
javascript复制console.log('当前栈:', JSON.stringify(stack.map(n => n.title)));
- 标题匹配调试:
javascript复制console.log('匹配标题:', {
target: title,
current: headingText,
matched: headingText === title || headingText?.includes(title)
});
- 滚动位置调试:
javascript复制console.log('滚动位置:', {
containerTop: containerRect.top,
headingTop: headingRect.top,
scrollTop: scrollContainer.scrollTop,
finalPosition: relativeTop - 20
});
6. 技术方案对比
6.1 方案选型考量
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 正则解析+自定义树 | 轻量、可控、性能好 | 功能有限,只解析标题 | 简单文档结构 |
| Markdown解析库 | 功能全面,支持复杂语法 | 体积大,学习成本高 | 需要完整Markdown支持 |
| 服务端生成树结构 | 前端简单,一致性高 | 增加后端负担,实时性差 | 静态文档 |
6.2 性能对比测试
测试文档:500KB合同文档(约1000行)
| 操作 | 正则解析 | 第三方库解析 |
|---|---|---|
| 初始加载 | 120ms | 350ms |
| 树渲染 | 80ms | 120ms |
| 点击响应 | 20ms | 30ms |
| 内存占用 | 15MB | 22MB |
7. 项目演进方向
-
动态加载优化:
- 实现目录树的懒加载
- 分块解析超长文档
-
多文档支持:
- 标签页式多文档浏览
- 文档间交叉引用
-
协作功能:
- 实时共享文档位置
- 协同批注系统
-
智能功能:
- 自动生成条款摘要
- 风险点自动标记
这个合同审查组件经过多次迭代已经成为一个稳定可靠的基础设施,后续我们将继续优化用户体验并扩展更多实用功能。在实际使用中,建议根据具体业务需求调整高亮样式、滚动行为等细节参数。