1. 我为什么用 React 重构合同审查组件
先说项目背景。我在做一套合同在线审查系统,业务方是一批法务同事,他们每天要打开动辄几十上百页的合同文本,在密密麻麻的条款里找指定段落、填审查意见、标注风险等级。老系统是服务端渲染,每看一条都要刷新页面,体验很差,法务同学抱怨了好几次。后来决定把合同审查端整体迁到 React,其中一个核心模块就是文档结构树——把整份合同的条款层级、附件清单、正文锚点全部拆成树形导航,做到点一下就能跳到对应条款,同时在正文里滚动时也能反向感知当前在哪一条。
这个组件看着不起眼,真正动手写才发现坑很多:树节点渲染性能、跨组件联动定位、大文档下的状态同步、展开折叠后的滚动偏移,随便一个点没处理好,交付后都会被业务用到崩溃。这篇文章我从头到尾复盘一下这个组件的设计思路和实现细节,重点讲文档结构树的渲染方式和条款定位的完整链路,适合正在做复杂文档类前端应用、或者想在 React 应用里做内容导航与联动定位的读者参考。里面涉及的代码都是我在项目里实际跑过的,可以放心抄作业,但建议先看完思路再动手,很多坑是藏在代码之外的。
2. 整体设计与方案选型
2.1 组件要解决的核心问题
合同审查场景下,文档结构树不是普通的"菜单树"。法务要的不是一个静态目录,而是三层诉求叠加在一起的复合功能:
- 第一层是把合同结构化:合同 XML 里的章、条、款、附件,甚至表格标题、签署页,都要按层级展示出来,让人一眼看清文档的骨架。
- 第二层是快速定位:点击树的任意节点,正文区要立刻滚动到对应条款位置,并高亮显示,方便法务接着往下审。
- 第三层是反向联动:法务直接在正文里滚动阅读时,树节点要跟着高亮,告诉用户"你现在读到哪一条了",同时右侧的审查意见区也可以随条款切换而联动刷新。
三层诉求意味着这个组件不能只做纯展示,它必须同时处理"文档结构数据的解析与建模"、"树形组件的渲染优化"和"正文滚动与树节点之间的双向通信"三件事。这也是为什么我最终没有直接套一个现成的目录组件,而是基于 React 自行封装。
2.2 为什么用扁平化数据驱动树结构
拿到合同 XML 后,我第一版直接按照 XML 嵌套关系渲染 React 组件树:一个 Section 组件里递归渲染它的子 Section。合同短的还好,几十条条款、十几层嵌套的类型也能跑。等真上了一份上百页的招投标合同,问题立刻出来了——包含几百个节点的树,React 每次渲染都要递归生成一大堆虚拟节点,在没有做任何优化的情况下,单纯展开收起都会有明显卡顿。
后面我翻了社区里大量关于 react 渲染性能的讨论,又看了几篇 react 面试题里关于"为什么虚拟 DOM 快"的分析,意识到问题的关键不在虚拟 DOM,而在组件递归嵌套带来的渲染成本。正确的思路是:不要用组件嵌套来表达树,而是把树拍平成一维数组,用 parentId 和 level 字段来表达层级关系,渲染时只渲染当前视图内可见的节点。
拍平之后的节点结构长这样:
interface ClauseNode { id: string; // 条款唯一 ID,跨树和正文区共用 parentId: string | null; title: string; // 条款标题,如 "第一条 定义" rawText: string; // 条款在正文中的文本内容 level: number; // 层级,0 表示章,1 表示条,2 表示款 childrenCount: number; startOffset: number; // 在全文中的起始字符偏移 endOffset: number; isExpanded: boolean; visible: boolean; // 是否在筛选后显示 }树组件只负责接收这个数组,根据每个节点的 visible 和 isExpanded 决定要不要渲染。视图层不再递归,性能问题消掉一大半。后续做虚拟滚动、搜索过滤、定位高亮,全都变成对数组的简单操作。很多 react 图表库和高级表格组件也采用类似思路,你可以理解为"数据驱动视图,而不是视图递归自身"。
2.3 组件通信方案:父传子、子传父与事件总线
树组件、正文组件、审查意见面板是三个相对独立的模块,之间的联动非常频繁。我在设计通信方案时遵循了一个原则:能放在顶层的状态不要下沉,能用事件广播的不要层层传 props。
具体的分工是这样:
- 父传子:合同详情页从接口拿条款树数据后,通过 props 把扁平节点数组传给 StructureTree 组件,把正文 HTML 传给 DocViewer 组件。树展开收起的状态由 StructureTree 内部维护,不往上层冒泡,因为它只影响树自己的展示。
- 子传父:用户点击树节点时,StructureTree 要通知父组件"我选中了第 3 条",父组件更新 currentClauseId 状态,再通过 props 传给 DocViewer 和 ReviewPanel。这个场景必须经过父组件,因为三个子模块都需要依赖这个状态来刷新。
- 跨组件通信:正文区滚动过程中高频触发"当前可视条款"的更新,如果都走父组件状态,整个详情页每次滚动都要重新渲染,极其浪费。这里我用了 useRef 维护一个轻量的事件总线,DocViewer 滚动时只把最新的 currentClauseId 广播给 StructureTree,树节点自己决定要不要更新高亮。
通信方案的选型很多人不重视,我强烈建议一开始就画清楚数据流图。审查组件这种强联动场景,如果通信方案混乱,后面每加一个功能都是一次灾难。
3. 文档结构树的渲染实现
3.1 树节点数据的生成与规范化
合同 XML 转成 ClauseNode 数组,这一步是整个组件的地基。后台给的原始数据通常是两种格式:一种是规范的 XML,一种是拍平的 JSON 列表。不管是哪种,我都建议在组件外层先做一次数据清洗,统一成前文定义的 ClauseNode。清洗过程要做三件事。
第一,字段映射:后台字段名五花八门,有的叫 sectionId,有的叫 clausCode,统一映射成 id。这一步看着机械,但是不做的话后面每个子组件都要面对字段命名不一致的问题,会烦死。
第二,计算 startOffset 和 endOffset。这一步对正文定位至关重要。全量合同文本是一个很长的字符串,我遍历解析得到的条款列表,按顺序累加每个条款的文本长度,得到每个条款在全文中的字符区间,存进节点里:
let offset = 0; const nodes = rawClauses.map((clause) => { const start = offset; offset += clause.rawText.length; return { id: clause.id, parentId: clause.parentId, title: clause.title, level: clause.level, startOffset: start, endOffset: offset, // ... }; });有了字符偏移量,后面做定位就非常直接了。正文本体只是一个超长的 div,定位时算出目标区间开始位置在整个内容里的偏移,再做滚动计算。
第三,计算 childrenCount 并标记叶子节点。树组件要知道每个节点有没有子节点,才能决定展开箭头的显示。这个字段在后端给的数据里通常没有,需要前端根据 parentId 统计。
3.2 展开/收起与增量渲染策略
树数据拍平之后,渲染逻辑就变得非常清爽。我维护一个 expandedIds 集合,初始时默认展开到二级条款,也就是 level 为 0 和 1 的节点全部展开,二级以下的折叠。这个策略是调研了法务同事的使用习惯后定的:默认展示到"条"这个粒度,既能看到合同的全貌,又不会因为全部展开信息量太大而淹没重点。
渲染时,核心过滤逻辑是这样的:
const visibleNodes = useMemo(() => { const result: ClauseNode[] = []; for (const node of allNodes) { if (node.level === 0) { result.push(node); continue; } const parent = nodeMap.get(node.parentId!); if (parent?.isExpanded) { result.push(node); } } return result; }, [allNodes, expandedIds]);这是一个简化的伪代码,但核心逻辑就是这个:父节点没展开,子节点一律不展示。allNodes 是所有扁平节点的数组,nodeMap 是 id 到节点的映射表,这两个数据都会被 useMemo 缓存,只有当原始节点数据或 expandedIds 变化时才会重新计算,避免了在渲染函数里反复做 O(n) 的查找。
在此基础上,每个树节点组件都用 React.memo 包裹:
const TreeNodeItem = memo(function TreeNodeItem({ node, onToggle, onSelect, }: TreeNodeProps) { return ( <div className={`tree-node level-${node.level} ${node.isActive ? "active" : ""}`} onClick={() => onSelect(node)} > <span className="toggle-icon" onClick={(e) => { e.stopPropagation(); onToggle(node.id); }} > {node.childrenCount > 0 ? (node.isExpanded ? "−" : "+") : ""} </span> <span className="node-title" title={node.title}> {node.title} </span> </div> ); });配合 React.memo,当 expandedIds 变化时,只有那些父节点展开状态受影响的节点会重新渲染,其余节点全部跳过。实测下来,500 个节点左右的合同,展开收起操作都能保持在 60fps,不再有之前的卡顿感。
3.3 性能优化:虚拟列表与数据缓存
虽然扁平化解决了一部分性能问题,但合同审查看的场景是几百甚至上千条款,全部渲染出来,DOM 节点依然很多。这里我建议加虚拟滚动。
react-window 是社区里比较成熟的方案,但它默认只支持固定行高,而树的每个节点高度其实可以统一——因为每条树节点只有一行文字。我把每个树节点的高度固定为 32px,这样就满足了 react-window 的使用条件:
import { FixedSizeList as List } from "react-window"; const TreeList = ({ visibleNodes, activeId, onSelect, }: TreeListProps) => { const itemCount = visibleNodes.length; return ( <List height={600} itemCount={itemCount} itemSize={32} width="100%" itemData={{ visibleNodes, activeId, onSelect }} > {NodeRow} </List> ); };这里的 NodeRow 是一个渲染行的组件,通过 index 从 itemData 里取出对应节点。react-window 的 itemData 设计很好,它把每次渲染都可能变化的数据放在一个引用里,减少子组件 props 的变化次数。虚拟列表接上后,无论合同有多少条款,树区域 DOM 数量始终控制在可视区范围内,性能彻底无忧。
还要提一下数据缓存。合同详情页每次重新进入,都要重新发起接口请求拿树数据。我在请求层做了一层简单的缓存:同一个合同 ID 的条款树数据,在页面会话内只请求一次,后续直接走内存缓存。这样即使用户反复切换左侧树和正文区,也不会有加载等待。如果后续要服务端加缓存,可以在接口响应头里加 ETag,前端判断 304 后复用缓存数据。这一层单独拎出来说,是因为很多前端同学容易忽略"重复请求同一份数据"带来的浪费,这在大型文档应用里差别很大。
3.4 React 19 新特性带来的优化空间
这个项目开发周期比较长,中间正好赶上 React 19 发布,顺手把部分渲染逻辑切到了新特性上。有两个点值得分享。
第一个是 useTransition。树的展开收起如果遇到超大文档,状态更新和 DOM 变更依然需要时间,会导致交互响应卡顿。React 19 里的 useTransition 可以把非紧急更新标记为过渡:
const [isPending, startTransition] = useTransition(); const handleToggle = (id: string) => { startTransition(() => { setExpandedIds((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }); };这样点击展开箭头后,输入框的文本输入、按钮点击这类紧急交互不会被阻塞,体验会顺畅很多。需要注意,startTransition 里更新的状态如果被其他组件同步依赖,要处理好 pending 状态,避免出现 UI 不一致。
第二个是 cache 指令。React 19 提供的 cache 可以用来缓存函数的计算结果,虽然我们项目里没有特别重度使用,但如果你在组件渲染过程中有复杂的推导逻辑,比如根据节点 id 数组生成缩进布局,可以包一层 cache,避免重复计算。社区里有不少 react 相关的讨论提到 cache 在高频更新场景下的收益,但也有人认为它加剧了内存占用,我的建议是先分析热点再上,别盲目用。
4. 文档定位与正文联动的完整实现
4.1 定位方案的演进:从 scrollIntoView 到容器内计算
实现"点击树节点 -> 正文滚动到对应条款"这个功能时,我第一版用的是一个看起来很直接的方案——scrollIntoView:
const handleSelectNode = (node: ClauseNode) => { const element = document.getElementById(`clause-${node.id}`); element?.scrollIntoView({ behavior: "smooth", block: "start" }); };听起来没错,每个条款渲染时带上 id,点击时找到元素滚动过去。实际一跑,问题接二连三。
最麻烦的是滚动容器不是 window。我们的页面布局是左侧树、中间正文、右侧审查意见,正文区是一个独立的 overflow: auto 容器。scrollIntoView 默认会滚动所有可滚动的祖先元素,如果页面本身也发生滚动,两个滚动位置互相干扰,视觉上就很诡异。而且它还容易把正文区的元素滚到父容器顶部之外,被固定的页眉遮住。
后来我改成在容器内手动计算滚动位置:
const scrollToClause = (clauseId: string) => { const container = docViewerRef.current; const target = container.querySelector(`[data-clause-id="${clauseId}"]`); if (!target) return; const containerRect = container.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); const offsetTop = targetRect.top - containerRect.top + container.scrollTop; container.scrollTo({ top: offsetTop - HEADER_OFFSET, behavior: "smooth", }); highlightClause(clauseId); };这个方案把滚动计算完全约束在正文容器内部:先算出目标条款相对于容器顶部的距离,再减去头部固定的审查工具栏高度,确保条款标题正好出现在可视区顶部,而不是被遮挡。HEADER_OFFSET 是一个常量,我用的是 64px,如果你的页面头部更高,按实际情况调整。
4.2 正文区反向定位:IntersectionObserver 思路
另一条链路是正文滚动时,树节点跟随高亮。刚开始我是在正文容器上绑 scroll 事件,然后遍历条款节点看哪个区域包含容器第一条可见文本。这种做法的问题是 scroll 事件触发频率太高,每次都要 O(n) 遍历所有节点,大文档下计算量不小。
后面换成了 IntersectionObserver,性能一下子上来了。思路是:正文渲染完成后,为每个条款块注册一个 IntersectionObserver,设置 root 为正文容器,threshold 范围为 [0, 0.5]。当条款区域进入或离开容器可视范围时,回调会告诉我们当前哪些条款是可见的。取其中视觉上最靠上的那个,作为"当前阅读条款"。
const observer = useRef<IntersectionObserver | null>(null); useEffect(() => { const container = docViewerRef.current; const clauses = container.querySelectorAll("[data-clause-id]"); observer.current = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { const id = entry.target.getAttribute("data-clause-id"); currentClauseRef.current = id; emit("clause-change", id); } } }, { root: container, threshold: [0, 0.5], } ); clauses.forEach((el) => observer.current?.observe(el)); return () => observer.current?.disconnect(); }, [docContent]);这里我用的是发布订阅模式,事件的接收方是树组件。树组件监听到 clause-change 事件后,更新自己的 activeId 状态,高亮对应节点。如果遇到节流需求,也可以把 emit 改成通过 requestAnimationFrame 批量派发,避免高频触发父组件状态更新。
4.3 双向定位的高亮与边界处理
定位不止是滚动,还有高亮。点击树节点后,正文章节除了滚动到目标位置,目标条款的背景色高亮一段时间,提示法务"就是这一段"。我在条款块的 DOM 结构上通过>.clause-active { background-color: rgba(255, 200, 87, 0.2); transition: background-color 0.3s ease; }
高亮结束后要移除样式,我用了一个 1.5s 的定时器自动清除。这里有个细节:如果用户连续点击不同节点,上一个定时器还没跑完,新的高亮已经加上,会造成两个条目同时高亮的错乱。处理方案是每次点击节点时先清除上一个定时器,再设置新定时器。
反向高亮(正文滚动导致树节点高亮)也要小心。如果正文在滚动过程中触发了 clause-change 事件,树组件更新高亮是合理的。但如果用户操作的是树组件,触发了正文区滚动,滚动的过程中又触发了 IntersectionObserver 回调,树组件再收到 clause-change 事件,就形成了循环更新。解决办法是在手动定位时给树组件加一个锁:
const isProgrammaticScroll = useRef(false); const handleSelectNode = (node: ClauseNode) => { isProgrammaticScroll.current = true; scrollToClause(node.id); setTimeout(() => { isProgrammaticScroll.current = false; }, 300); }; // 树组件内部监听事件时判断 emit("clause-change", id, { source: isProgrammaticScroll.current ? "tree" : "scroll" });这个锁的原理是:程序触发的滚动结束后,短时间内忽略滚动产生的事件,避免回头又更新树组件的高亮。300ms 是经验值,太长会导致真正的主观滚动被忽略,太短会导致还在滚动动画中事件又触发,你可以根据自己滚动动画的时长调整。
4.4 从定位到审查意见区的联动扩展
文档定位不只要联动正文和树,还要带动右侧的审查意见面板。法务点击某一条款后,右侧要显示这条款下所有的审查意见和风险标记。这个场景的数据流是:StructureTree 选中节点后,把节点 id 上报给父组件,父组件拿着这个 id 去请求接口获取该条款的审查意见列表,再传给 ReviewPanel。
实际开发中这个链路还可以做进一步优化。比如接口返回的审查意见里带有 riskType 和 clauseId,我们可以提前按 clauseId 建一个映射表,避免每次点击都发请求。对已经加载过的 clauseId 做内存缓存,点击时先展示缓存内容,再静默请求服务端确认是否有更新。这样用户的体感是"点哪都有内容",而不是看到 loading。
这类优化在业务项目里往往比组件本身更能决定成败。法务一天要看几十条合同,每条几十个条款,如果每点一次都要转圈等接口,效率大打折扣。
5. 常见问题与排查技巧实录
5.1 树节点渲染后空白,数据在但 DOM 不在
这是我在接入后台数据时遇到的第一个问题。接口正常返回了条款数据,console.log 也能看到数组里几十个对象,但树组件渲染出来是空的。排查思路:先看数据结构,发现返回的是嵌套 JSON,而我直接塞给树组件,树组件按扁平数组渲染,自然取不到子节点。后期同事遇到类似问题,我一般建议第一步先打印 visibleNodes.length 和第一条节点信息,如果数组有值,就是渲染条件的问题;如果数组为空,就是数据解析的问题。这个问题让我养成了一个习惯:所有树组件都要求先用 mock 数据验证渲染逻辑,再接真实接口,两者一旦混淆,定位问题的时间会成倍增加。
5.2 定位偏移:目标条款被头部工具栏遮住
前面提到了 HEADER_OFFSET 的引入,它解决的其实是一个很经典的"锚点被 sticky 元素遮挡"问题。初期没做这个补偿时,每次点条款,正文是滚到位了,但条款标题被固定在顶部的审查操作栏挡住,需要手动再往上滚一点,体验非常差。排查的时候一眼看上去位置不对,但很难立刻想通原因,后来把视觉偏移量和页面 layout 结合起来分析才定位到是 sticky 遮挡。
一个更稳妥的做法是给每条目的锚点添加 scroll-margin-top 样式:
[data-clause-id] { scroll-margin-top: 72px; }这样即使后续计划改用 scrollIntoView,也不用担心被遮挡的问题。两个方案可以同时保留,一个在代码层面控制滚动位置,一个在样式层面给锚点预留空间,双保险。
5.3 展开/收起时虚拟列表闪烁
虚拟列表在数据量变化时会出现闪烁,具体表现是:点开一个父节点,列表的行数瞬间变多,滚动条和可视区内容突然跳动一下。这个问题一度很困扰我,排查下来发现,是因为展开后组件的 itemCount 变化了,而 react-window 的滚动位置还停留在旧列表的场景里。
解决方案有两个。第一是给展开收起操作加上前面说的 useTransition,让 DOM 更新延迟一点点,让视觉上更顺滑。第二是更精细地管理列表的滚动偏移:当用户展开节点时,系统记录当前可见的第一个节点的 id,等列表渲染完成后,从新列表中找回这个节点 id,再把这个节点滚动到原来的位置。
const handleToggle = (id: string) => { const anchorIndex = visibleNodes.findIndex((n) => n.id === firstVisibleIdRef.current); setExpandedIds((prev) => { /* ... */ }); requestAnimationFrame(() => { listRef.current?.scrollToItem(anchorIndex, "smart"); }); };这个方案的思路是"保持用户的上下文位置",避免因为展开操作导致当前位置跳到不知道哪里去。我第一次做完这个优化后明显感觉树组件的专业感上来了——小细节,但在实际使用中的感知特别强烈。
5.4 契约文本跨域加载导致正文区渲染为空
还有一个跟渲染相关的坑,发生在从后端拿 HTML 字符串渲染正文时。合同全文经常从一个不通的 CDN 服务器加载,偶尔会因为跨域限制加载失败。最初我没处理加载失败场景,正文区一直白屏,树组件不管点什么都定位不到目标。
后来我在正文加载逻辑里做了异常捕获和统一兜底:加载失败时显示一个固定的错误提示文案,同时保留 useState 里的空状态,让树组件至少能正常渲染,不能被正文加载失败拖垮。这里顺便提醒,做类似文档型应用,外部资源加载失败一定要做兜底,不然组件之间的强联动会让你报表问题表到头秃。
5.5 热词联动排查:从 impeller 渲染到 React DOM 渲染的联想
这个问题纯属我自己项目开发中的一次偶发调查,但收获不小。一次用户反馈说"正文区域渲染异常,出现灰色块",恰好在网上看到关于 impeller 渲染引擎原理的讨论,联想到渲染管线的问题,虽然他们一个是 Flutter 的渲染引擎,一个浏览器 DOM 渲染,方法论完全不同,但排查思路是一致的:先分清是数据层问题还是渲染层问题。
我当时的排查方法是打开 React DevTools,选中正文容器看虚拟 DOM 的结构是否正确,再对比浏览器实际 DOM 的数量。如果是数据层问题,虚拟 DOM 里内容就缺斤短两;如果数据没问题但浏览器渲染不对,再怀疑样式或布局。这次排查最终定位到原因是正文 HTML 里含有一段未闭合的 table 标签,浏览器解析时自动补齐,渲染结果和预期不符。把有问题的 HTML 段抓出来做了预处理再用 dangerouslySetInnerHTML 插入,问题解决。
这类渲染异常有个共性特点:报错信息不明显,甚至不报错,只是肉眼看着不对,而且不是每次都能复现。遇到这种问题,一定要记住"数据先行"的排查思路:先把数据层和渲染层分开验证,不要一开始就盯着某个技术细节猜。
6. 从组件到产品:一个完整审查场景的落地心得
写完上面这些实现细节,回过头来说点产品层面的体会,这部分可能比代码本身对你有用。
合同审查组件的价值不在于"React 用什么 API",而在于它真正改变了法务的工作方式。以前法务在文档里定位一条内容是靠 Ctrl+F 搜关键词,搜到之后还要自己数在第几条;有了树组件,点一下目录就跳过去了,树的节点自带条款编号和标题,不会看错位置。而反向定位则让法务在通读合同时不用刻意记"这条在第几条",树组件的跟随高亮自然会把当前审到的位置标出来。
从技术视角看,这类组件有一个底层逻辑值得反复咀嚼:前端应用的复杂度通常不在单个组件有多难写,而在多个组件之间的联动状态怎么设计、怎么传递、怎么收敛。树组件、正文组件、意见面板,它们各自都不复杂,难的是让它们像一个整体一样流畅协作。我在这个项目里反复打磨的就是这条链路:状态收敛在哪一层、事件怎么广播、高频更新的局部化、加载失败兜底、性能优化的粒度。把这些想明白了,React 的技术细节反而是水到渠成的事。
如果你准备在自己的项目里实现类似的文档结构树,建议从最小可行版本做起:先把扁平化数据结构搭好,再实现树渲染和双向定位,最后再考虑虚拟列表和性能优化。不要一上来就堆 react-window 加 zustand 加一堆进阶库,因为每一层抽象都会增加排查问题的难度。把基础链路跑通,你才能真实感受到哪些地方是瓶颈、哪些优化是必要投资。
最后分享一个小技巧:调试这种联动组件,最有效的工具不是 console.log,而是 React DevTools 的 Profiler。打开 Profiler 录一段"点击树节点 + 滑动正文"的操作,看哪个组件的渲染耗时异常,问题点往往就在那里。我项目里好几个优化点都是靠 Profiler 找出来的,你试过之后应该会有同样的感觉。