实现一些组件的思考

中后台项目的价值感往往不在页面数量,而在几个"啃硬骨头"的组件上。本文不聊业务,只聊技术实现------把我在项目里亲手写过的三个模块拆开:基于 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 补全:光有"能补全"不够,得让它"不烦人"

直接接入 monacopilotregisterCompletion 只要 20 行代码。但真正难的是让它在正确的时机触发、在错误的时机沉默、请求要能取消、能防抖、能感知竞态。这里的很多设计是踩过用户吐槽后才补上的。

设计目标(也是我调优时反复问自己的问题):

  1. 用户还在打字时不能触发(会打断思路)
  2. 光标在错误位置(比如 return xxx; 之后)不能触发(浪费 token)
  3. 同一时刻只能有一个在飞请求,新请求发出前要 abort 上一个
  4. 老请求返回时新请求已经发出,老结果必须丢弃(竞态)
  5. 补全本身插入的字符不能反过来触发下一轮补全(自激)

我最终采用的方案是一个 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)
}

理由三条:

  1. 组件对外要 getData() 时经常需要对数据做多轮变换filterGraphDatacheckNotEmptycheckExpressValsubtrfixGraphData),全链路"值不可变"心智成本最低
  2. 单表最多 ~50 行条件,cloneDeep 一次不到 1ms,收益换心智完全值得
  3. 后来加入的"复制组时给所有子 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 + 面向对象封装 + 布局算法
相关推荐
AI行业学习2 小时前
Claude Code + cc-switch + Git + Node.js 一站式完整安装配置教程【8.3】
git·python·安全·前端框架·node.js·html·notepad++
LaughingZhu2 小时前
Product Hunt 每日热榜 | 2026-08-02
前端·神经网络·react.js·搜索引擎·前端框架
90后的晨仔12 小时前
从 H5 到 uni-app:一篇写给前端小白的"翻译指南"
前端·vue.js·前端框架
饼干哥哥17 小时前
字节Seedance2.5终于上线,这次在收割谁?
人工智能·设计模式·前端框架
先吃饱再说2 天前
编写一个颜色选择器之后,我理解了 React 工程化的三层架构
react.js·前端框架·前端工程化
先吃饱再说2 天前
别说你懂 useEffect:从底层机制到生命周期管理,这篇全讲透了
react.js·前端框架
先吃饱再说2 天前
从“传事件”到“只传值”:React + TypeScript 组件 Props 设计的两次进化
react.js·前端框架·前端工程化
泛泛7辈2 天前
【数据库索引标准结构】B+树原理详解与B树对比优势
数据库·b树·前端框架
禅思院3 天前
流式 Markdown 渲染完全指南【引子】
前端·架构·前端框架