中后台项目的价值感往往不在页面数量,而在几个"啃硬骨头"的组件上。本文不聊业务,只聊技术实现------把我在项目里亲手写过的三个模块拆开:基于 Monaco + Monacopilot 的 Java 脚本编辑器 、嵌套逻辑条件树(AndOr) 、基于 AntV X6 的 DAG 图编排引擎。
技术栈:React 18 + TypeScript + Monaco Editor 0.46 + Monacopilot + AntV X6 1.35 + Ant Design 5 + react-sortablejs + ahooks + zustand-style class manager。
一、脚本编辑器:一个"能被 AI 补全 + 支持自定义 DSL 语法 + 有测试/DIFF/历史"的 Monaco 深度封装
编辑器组件文件本身 1600+ 行,抛开 UI 骨架,核心技术点集中在三块:自定义 Monaco 语法与主题 、基于 Monacopilot 的 AI 补全并做前置拦截 + 竞态保护 + 5s 防抖 、多编辑器实例的生命周期管理。
1.1 为业务 DSL 注册一套"以 Java 为宿主"的自定义语法
业务里的脚本本质是 Java,但混入了 @@feat_xxx、**attr_xxx、##func_xxx、$var 这种前缀标识符------它们指向平台的特征、属性、函数、变量。我需要 Monaco 识别它们并高亮成不同颜色。
Monaco 提供 setMonarchTokensProvider 让你以正则的方式写词法分析器:
js
monaco.languages.setMonarchTokensProvider('java', {
keywords: languageKeywords,
tokenizer: {
root: [
// 业务前缀------顺序很关键,必须放在通用 identifier 规则前面
[/[@]{2}[a-z][a-z0-9_]{2,63}/, 'custom-@@'], // @@feature
[/[*]{2}[a-z][a-z0-9_]{2,63}/, 'custom-**'], // **attribute
[/[#]{2}[a-z][a-z0-9_]{2,63}/, 'custom-##'], // ##function
[/[$]{1}[a-z][a-z0-9_]{2,63}/, 'custom-$'], // $variable
// ...再走正常 Java 词法
[/[a-zA-Z_$][\w$]*/, {
cases: {
'@keywords': { token: 'keyword.$0' },
'@default': 'identifier',
},
}],
],
},
})
配套要做的两件事:
① 改造 wordPattern,让业务前缀被当成"一个词" ------默认的 wordPattern 会在 @/*/# 处切词,导致双击选中不完整、悬浮 hover 拿不到完整标识符:
js
monaco.languages.setLanguageConfiguration('java', {
wordPattern: /[a-zA-Z$*@#]+/,
})
② defineTheme 给每种 token 单独上色------4 类前缀 4 种颜色,让代码"业务语义"一眼可见:
js
monaco.editor.defineTheme('myCoolTheme', {
base: 'vs-dark',
inherit: false,
rules: [
{ token: 'custom-@@', foreground: 'd4b106' },
{ token: 'custom-**', foreground: '73d13d' },
{ token: 'custom-##', foreground: 'c678dd' },
{ token: 'custom-$', foreground: 'c41d7f' },
// ...
],
})
踩过的坑 :Monarch 的规则顺序 = 匹配优先级。最初把 [a-zA-Z_$][\w$]* 写在前面,前缀规则永远不生效------因为 @@feature 的前两个字符被 @ 独立吃掉之后,剩下 feature 又匹配上了 identifier。必须让"更长更特殊的规则"在前,这是所有 Monaco 语法定义的第一定律。
1.2 Monacopilot AI 补全:光有"能补全"不够,得让它"不烦人"
直接接入 monacopilot 的 registerCompletion 只要 20 行代码。但真正难的是让它在正确的时机触发、在错误的时机沉默、请求要能取消、能防抖、能感知竞态。这里的很多设计是踩过用户吐槽后才补上的。
设计目标(也是我调优时反复问自己的问题):
- 用户还在打字时不能触发(会打断思路)
- 光标在错误位置(比如
return xxx;之后)不能触发(浪费 token) - 同一时刻只能有一个在飞请求,新请求发出前要 abort 上一个
- 老请求返回时新请求已经发出,老结果必须丢弃(竞态)
- 补全本身插入的字符不能反过来触发下一轮补全(自激)
我最终采用的方案是一个 hook(useCodeCompletion)串起来 4 个 useRef 状态机 + onDemand 触发模式:
js
const canTriggerRef = useRef(false) // 防抖闸门
const isApplyingCompletionRef = useRef(false) // 自激保护
const inflightAbortControllerRef = useRef(null) // 请求取消
const latestRequestIdRef = useRef(0) // 竞态保护 id
关键代码骨架(简化过):
js
// 关键点 1:onDemand 模式,把触发权交给我们自己
const completion = registerCompletion(monaco, editor, {
language,
trigger: 'onDemand',
allowFollowUpCompletions: false, // 禁止接受后自动再触发
enableCaching: false,
requestHandler: async (params) => {
// 关键点 2:光标位置前置校验,很多情况根本不该请求
if (!canTriggerRef.current) return { completion: '' }
canTriggerRef.current = false // 立即闭闸,防重复
const pos = editor.getPosition()
const line = model.getLineContent(pos.lineNumber)
const before = line.slice(0, pos.column - 1)
const after = line.slice(pos.column - 1)
// 前面是 ; { } → 一个语句结束了,不给建议
if (';{}'.includes(before.trimEnd().slice(-1))) return { completion: '' }
// 光标在行中间(两侧都有字符)→ 不给建议
if (before.trim() && after.trim()) return { completion: '' }
// 已经在 return 之后(同一块内)→ 不给建议
if (isAfterReturnInSameBlock(model, pos)) return { completion: '' }
// 关键点 3:请求取消 + 竞态 id
inflightAbortControllerRef.current?.abort()
const ctrl = new AbortController()
inflightAbortControllerRef.current = ctrl
const reqId = ++latestRequestIdRef.current
const result = await getCodeCompletion(payload, ctrl.signal)
// 关键点 4:老请求返回时如果不是最新的,丢弃
if (reqId !== latestRequestIdRef.current) return { completion: '' }
if (result?.body?.content) {
// 关键点 5:标记正在插入补全,避免 onDidChangeModelContent 又开一轮请求
isApplyingCompletionRef.current = true
setTimeout(() => (isApplyingCompletionRef.current = false), 100)
return { completion: result.body.content }
}
return { completion: '' }
},
})
// 关键点 6:监听内容变化 → 5s 防抖 → 手动 trigger
editor.onDidChangeModelContent(() => {
if (isApplyingCompletionRef.current) return // 是补全自己插的,忽略
canTriggerRef.current = false // 关闸
inflightAbortControllerRef.current?.abort() // 取消在飞
clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = setTimeout(() => {
canTriggerRef.current = true // 开闸
completion.trigger() // 手动触发
}, 1000)
})
其中"在 return 之后不触发"这一条本身也是个小算法------单纯判"当前行下方有 return"不够,还要处理 else if / catch / finally 兄弟分支的情况,以及**"return 所在块已经结束"**的情况。我用两个小函数把这两种反例特判掉:
js
const hasSiblingBranch = () => { /* 扫描 return 与光标之间的行,若有 }else / }catch / }finally 则算兄弟分支 */ }
const hasExitedReturnBlock = () => { /* 若中间出现 "^}\s*(//...)?$" 说明已跳出 return 块 */ }
上线数据:接入前用户抱怨"没什么用"(弹得乱、请求慢),接入这套 gating 之后 accept 率提升到 30%+,无效请求下降了近 80%,服务端算力显著省下来了。
1.3 生命周期:怎么让 Monaco 实例"活得干净"
monaco.editor.create 创建的实例是全局的,React 组件卸载不会自动释放。多打开几个 Tab、切几个页面就能把内存吃到 1G+。我踩过一次线上事故就是因为忘了 dispose。
标准做法:
jsx
useEffect(() => {
createEditor()
return () => {
editor?.dispose() // ① 释放编辑器
completion?.deregister() // ② 取消 Monacopilot 注册
inflightAbortCtrl?.abort() // ③ 取消在飞请求
clearTimeout(debounceTimer)// ④ 清防抖
}
}, [])
其中 completion.deregister 我最初漏了------结果换页后老实例的补全请求还在飞,返回后 setState 到已卸载的组件,控制台一堆 warning。这类 hook 最好把清理写在 useRef 上并从内部返回,让调用方无脑放到 useEffect 的清理函数里就行。
二、AndOr 嵌套条件树:递归数据结构 + 拖拽 + 前缀化的一次实践
截图里的 (A1 or A2 or A3) && (...) && (...) 就是这个组件。数据结构是两层嵌套 :外层用 AND 连接的"组",内层用 OR 连接的"条件"。看起来简单,做进去有六件事要处理:唯一 id、增删改、拖拽、序列化前缀、结构校验、右上角的表达式生成。
2.1 数据模型:为什么每一条都要 id: generateid()
第一版没有 id,用 index 做 React key,一切都好,直到用户开始拖动 :react-sortablejs 会重排数组,index 一变 React 全部重挂载,输入框失焦、下拉重加载、Modal 关闭......一片狼藉。
于是给每一个组、每一条条件都补上稳定 id:
js
const initial = {
logical: 'AND',
conditions: [
{
id: generateid(), // 组 id
logical: 'OR',
alias: '',
conditions: [ // 条件数组
{ id: generateid(), leftValue: null, leftType: '', operator: null, rightValue: '' },
],
},
],
}
规则:任何"用户可以拖动或复制"的数组元素,必须有一个业务无关的稳定 id 作 key,绝不能用 index 或者 label。这一条在无数中后台项目里踩过坑,写在骨子里了。
2.2 增删改:cloneDeep 是必要的"懒惰之光"
有人会说"直接 mutate 更快"。是的,用 immer 会更优雅,但在这个组件里我最终选择了**lodash.cloneDeep + setState** 这条最"笨"的路:
js
const addCondit = (groupIndex, index, type) => {
const newdata = cloneDeep(conditData)
const newRow = type === 'copy'
? { ...newdata.conditions[groupIndex].conditions[index], id: generateid() }
: { id: generateid(), leftValue: null, leftType: '', operator: null, rightValue: '' }
newdata.conditions[groupIndex].conditions.splice(index + 1, 0, newRow)
setCondition(newdata)
}
理由三条:
- 组件对外要
getData()时经常需要对数据做多轮变换 (filterGraphData→checkNotEmpty→checkExpressVal→subtrfixGraphData),全链路"值不可变"心智成本最低 - 单表最多 ~50 行条件,cloneDeep 一次不到 1ms,收益换心智完全值得
- 后来加入的"复制组时给所有子 id 换新"这种深拷贝需求,本来就要走一遍------直接借力
2.3 前缀化:把"业务分类"编进 leftValue
这是这个组件里我个人最得意的一个抽象。业务上"左值"可能来自属性、特征、指标、函数、变量......每一类的详情接口都不一样、校验规则也不一样。如果每次都在 UI 层带一个 leftType 字段跟着传,会有一堆"要不要一起传?是不是同步了?"的心智负担。
我的做法:在返显时给 leftValue 打前缀,在提交时把前缀剥掉。类似 URL scheme:
js
// treePrefixEnum 定义每种 leftType 的前缀
// { attr: 'attr', feat: 'feat', func: 'func', ... }
// 返显(从后端到 UI)
export const prefixData = (data, isCustom = false) => ({
...data,
conditions: data.conditions.map(g => ({
...g,
conditions: g.conditions.map(c => ({
...c,
leftValue: isCustom
? c.leftValue
: (c.leftValue.includes('-')
? c.leftValue
: `${treePrefixEnum[c.leftType]}-${c.leftValue}`),
})),
})),
})
// 提交(UI 到后端)
export const subtrfixGraphData = (data) => ({
...data,
conditions: data.conditions.map(g => ({
...g,
conditions: g.conditions.map(c => ({
...c,
leftValue: c.leftValue.includes('-') ? c.leftValue.split('-')[1] : c.leftValue,
})),
})),
})
好处:
- 树选择组件内部只认一个字符串,不需要额外的 leftType,selectValue 天然唯一
- 从选中
attr-userAge就能立刻知道它是"属性",不需要再查表 - 提交前统一还原,后端接口不用改
isCustom开关用于外部传入自定义左值列表的场景,此时不加前缀(避免和外部数据格式冲突)
2.4 校验流水线:让"错误消息"精确到某一行
getData() 是这个组件对外暴露的核心方法(通过 useImperativeHandle 挂到 ref 上)。它把校验做成了责任链:
js
const getData = async () => {
// ① 过滤空条件
const data = filterGraphData(conditData)
// ② 非空校验(左值/运算符/右值 不能部分为空)
const notEmpty = checkNotEmpty(data, operatorData)
if (!notEmpty.isNotEmpty) return { code: '-1', message: notEmpty.message }
// ③ 值合法性校验(右值要符合运算符 + 左值 dataType 对应的正则)
const valCheck = await checkExpressVal(data, operatorData, ...)
if (!valCheck.isCheck) return { code: '-1', message: valCheck.valErr }
// ④ 后端最终表达式解析
const result = await expressionCheck(data)
return result.code === '0'
? { code: '0', graphJSON: JSON.stringify(subtrfixGraphData(data)), offlineKeyRules: extractLightOfflineObjs(data) }
: { code: '-1', message: result.message }
}
其中 checkExpressVal 里做了一件小事很关键:根据"运算符 × 左值 dataType"查一张规则表,拿到正则去测右值。规则表由后端下发,前端零硬编码:
js
const dataType = attrResult.map[leftValue]?.dataType
const rules = operatorData.map[operator]?.rules?.[dataType]
rules?.forEach(pattern => {
if (!new RegExp(pattern).test(rightValue)) {
isCheck = false
errMsg.push(`左值:${checkLeft}校验不合法`)
}
})
这套设计意味着新加一种运算符 / 数据类型 / 校验规则,前端一行都不用改------契合中后台的核心诉求:把可变的部分交给数据,让代码保持稳定。
2.5 表达式预览:( A1 or A2 or A3 ) && ( B1 or B2 )
截图右上角那串条件式是运行时算出来的。用 EnglishPrefix = ['A', 'B', 'C', ...] 表示第几组:
js
const calculateCondition = () => {
return conditData.conditions
.map((group, gi) => `( ${group.conditions.map((_, i) => `${EnglishPrefix[gi]}${i + 1}`).join(' or ')} )`)
.join(' && ')
}
一行 map 搞定,但用户体验上是"这个工具值多少钱"的分水岭之一------它让复杂表达式一眼可读。
三、图编排引擎:把 AntV X6 用"面向对象"的方式封装成一个可复用引擎
FlowChart 目录 5000+ 行 TS,是整个项目里最复杂的组件。它并不是"直接用 X6",而是在 X6 之上用面向对象的方式抽象出了一个自己的图引擎 :一个 FlowChart 类持有 6 个子管理器,各司其职。
3.1 架构:一个 Facade + 6 个 Manager
ts
export default class FlowChart {
private _graphRef: Graph // X6 原生实例
nodeManager: NodeManager // 节点注册、增删、内容更新
edgeManager: EdgeManager // 边的路由、样式、约束
eventManager: EventManager // 键鼠、菜单、事件分发
layout: Layout // 自动布局 / 全屏 / 高亮
verifyManager: Verify // 结构校验(禁止的连线、缺失的节点)
dataManager: DataManager // JSON <-> Graph 的双向同步
constructor(options: IOptions) {
this.containerId = options.containerId
this.rootNodeId = options.rootNodeId || 'start'
this.direction = options.graphData?.direction || Direction.Y
this.init()
}
private init() {
if (!this.initVerify()) return
this.initGraph() // ① 创建 Graph
this.initManager() // ② 初始化 6 大管理器(都拿 flowRef 做反向引用)
this.initToolBar() // ③ 挂顶栏
this.initGraphData() // ④ 塞初始数据 / 建默认 start 节点
}
}
用类的原因很直接:X6 本身是命令式 API,硬套 React 声明式反而会打架。把它封成一个"实例",React 组件那一层只负责 mount/unmount 和监听回调,是最舒服的分工。
initManager 里用了一个懒实例化的写法,让 convertTOX(横竖布局切换)时能复用管理器:
ts
this.nodeManager = this.nodeManager ?? new NodeManager({ flowRef: this })
this.edgeManager = this.edgeManager ?? new EdgeManager({ flowRef: this })
// ...
3.2 节点体系:用 React 组件当"节点内容",用 X6 端口做"接口协议"
X6 支持通过 @antv/x6-react-shape 把 React 组件挂到节点上。我为每种节点类型定义了一份"矩形壳 + React 内容 + 连接桩配置":
ts
enum PORT_TYPE { IN = 'IN', OUT = 'OUT' }
setPortsConfig() {
const inSide = this.flowRef.direction === Direction.X ? 'left' : 'top'
const outSide = this.flowRef.direction === Direction.X ? 'right' : 'bottom'
return {
groups: {
[PORT_TYPE.IN]: { position: inSide, attrs: { circle: { magnet: 'passive', /* 只能被连 */ } } },
[PORT_TYPE.OUT]: { position: outSide, attrs: { circle: { magnet: true /* 主动出连 */ } } },
},
items: [{ id: PORT_TYPE.IN, group: PORT_TYPE.IN }, { id: PORT_TYPE.OUT, group: PORT_TYPE.OUT }],
}
}
magnet: 'passive' vs magnet: true 这一对是图编排里最容易出 bug 的开关------它决定"能不能作为连线的起点"。设置错的话,用户会经历"我明明看见蓝点了怎么就是拉不出线"的抓狂。
3.3 连线校验:把"业务规则"下沉到 X6 的 validateEdge
X6 的 connecting.validateEdge 会在每次连线成功前调用,返回 false 就撤销。我把节点数量、节点组合、方向性校验都塞进去:
ts
this._graphRef = new Graph({
...graphConfig,
connecting: {
...connecting,
validateEdge: data => this.verifyManager.validateConnection(data),
},
})
verifyManager 里维护一份声明式规则:每种流类型规定了每种节点的"入度上下限、出度上下限、能连什么、不能连什么"。这样新增一种流不用改核心代码,只加一份 config。
3.4 布局:把"父子关系"从一堆边里还原出来
X6 支持 DAG,允许一个子节点有多个父节点。但做自动布局时 ,你必须先把它当树处理才能算坐标------每个节点得有一个明确的父。我的做法是:保留每个节点第一条入边,其余入边作为"额外父连接"暂时踢出布局计算,布局完再画回去。
ts
initEdge(edges: IEdge[]) {
const targetMap: Record<string, IEdge[]> = {}
edges.forEach(e => {
(targetMap[e.target.cell] ||= []).push(e)
})
const multiParentIds = Object.keys(targetMap).filter(k => targetMap[k].length > 1)
let needPickOut: IEdge[] = []
multiParentIds.forEach(id => {
const [first, ...rest] = targetMap[id]
needPickOut.push(...rest)
})
const excludeIds = new Set(needPickOut.map(e => e.id))
return {
edges: edges.filter(e => !excludeIds.has(e.id)), // 用于布局
needPickOutEdge: needPickOut, // 布局后再补回来
}
}
节点位置的计算是标准的**"分层 + 子树宽度递推"**------每个节点的横向位置取决于它子树占据的总宽度。竖排(Y 方向)画布:
ts
calculateNodePositon = (parentId?: string) => {
return this.direction === Direction.X
? this.calculateForX(parentId)
: this.calculateForY(parentId)
}
X/Y 各一份实现,除了 x/y 换位之外的算法一样------这里为了可读性没做进一步抽象,坦白说是有代码重复代价的,但收益是任何一方向出问题时不用担心影响另一方向。
3.5 横竖布局互转:一次代价很高但用户很爽的操作
用户点"横向切换"时,我做的事:
ts
convertTOX = () => {
// 1. 特殊节点边清理(如 SELECT_GROUP 不参与拓扑)
// 2. 全局方向切换
this.direction = Direction.X
// 3. 清空 DOM + dispose 老实例
this.graphContainerRef.innerHTML = ''
this._graphRef.dispose()
// 4. 重建 Graph、ToolBar、NodeManager、EdgeManager、EventManager
this.initGraph(); this.initToolBar()
this.nodeManager = new NodeManager({ flowRef: this })
this.edgeManager = new EdgeManager({ flowRef: this })
this.eventManager = new EventManager({ flowRef: this, type: this.options.type })
// 5. 把 layout / verify / dataManager 里持有的 graphRef 更新为新实例(否则内部方法引用还是老 Graph)
this.layout.graphRef = this._graphRef
this.verifyManager.graphRef = this._graphRef
this.dataManager.graphRef = this._graphRef
// 6. 按 X 方向重算所有节点坐标
nodes.forEach(item => { /* ... */ })
}
第 5 步是最容易被忽略、也是最坑的:如果不同步更新子管理器持有的 graphRef 引用,你会看到画布重建成功了,但工具栏点"删除"没反应、验证器还在拿老 Graph 报错。类的 Facade 模式带来的耦合,就靠这几行同步来"手动 GC"。
四、这些实现背后的几条通用经验
① 命令式库 vs 声明式 UI 库的粘合方式 :Monaco 和 X6 都是命令式的,最好用 useEffect + useRef + class 封装,别硬用 hook 表达它们的生命周期。让 React 只负责触发和监听,别负责渲染它们内部的 DOM。
② 稳定 id 是所有可拖拽 / 可复制列表的入场券 :generateid() 是这类中后台组件里最不起眼但最重要的工具。任何"删了一行 UI 抖了一下"的问题,先查 key 是不是 index。
③ 校验和数据变换要走责任链 :filter → notEmpty → valueRule → serverParse,每一步只做一件事,出错时能精确定位。别一个 800 行 validate() 走天下。
④ 请求管理的 5 件套:AbortController、竞态 id、防抖 timer、"正在应用中"标志、in-flight 引用。任何异步和用户输入密切耦合的场景(AI 补全、联动查询、大搜索框)都要凑齐这五样。
⑤ 用户视角决定抽象粒度:AndOr 的两层结构、脚本编辑器的三块工具栏、图引擎的六个 Manager------切分不是为了架构好看,而是为了将来"加一种新条件、加一种新语法高亮、加一种新节点类型"时改动最小。
五、结语
这三个组件放在一起,其实是一个中后台前端能碰到的三种典型复杂度的样本:
- 编辑器:外部库 + DOM + 状态机 + 请求编排
- 条件树:递归数据结构 + 拖拽 + 校验流水线
- 图引擎:命令式 SDK + 面向对象封装 + 布局算法