基于 Lexical 实现变量输入编辑器
我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。 本文作者:霁明 在 AIWorks 的工作流和 Agent 编排系统中,有一个核心需求:支持在节点配置面板的配置项中引用上游节点的输出变量。例如,一个 LLM 节点需要引用“开始节点”的用户输入或自定义变量,或者引用上一个“HTTP 请求节点”的返回结果。 最直接的方案是使用传统的 Input 或 Textarea 组件,配合变量占位符语法如 我们期望的用户体验是: 实现后的效果如下: Lexical 是 Meta(Facebook)于 2022 年开源的一个可扩展的可扩展富文本编辑器框架,它专注于提供高可靠性、出色的可访问性和高性能,让开发者能构建出从简单文本到复杂富文本协作编辑器的应用。它核心是一个轻量、无依赖的编辑器,通过模块化的插件机制支持自定义功能,支持与 React 等前端框架进行绑定,旨在简化富文本编辑器的开发和维护。 在深入实现之前,我们需要理解 Lexical 的几个核心概念。 Lexical 采用不可变状态设计。编辑器的所有内容都存储在 关键点: Lexical 的内容由树状节点结构组成: 核心节点类型: DecoratorNode 是实现自定义可视化元素的关键,后文会详细讲解。 Lexical 使用命令模式处理用户输入和操作: Lexical 内置了许多命令,例如:KEY\_DOWN\_COMMAND、UNDO\_COMMAND、INSERT\_TAB\_COMMAND 等,具体可查看LexicalCommands.ts 命令优先级从高到低: 节点转换是 Lexical 的强大特性,允许监听特定类型节点的变化并自动处理: 这是实现“输入特定文本自动转换为自定义节点”的核心机制。 Lexical 采用组合式插件设计: 插件通过 这是整个方案的核心。我们通过继承 关键设计点: 当用户输入 工作流程: 注意这里我们并不直接插入 变量格式通过正则表达式定义: 会渲染一个可视化变量标签,包含节点图标、节点名称和变量名,效果如下: 在某些场景(如 HTTP 节点的 URL 输入、条件节点的表达式输入),我们需要限制编辑器为单行模式: 这个插件通过两种机制实现单行限制: 编辑器内容需要与后端数据同步,我们采用纯文本格式存储。 编辑器状态初始化: 编辑器状态同步: 由于 本文介绍了基于 Lexical 实现工作流变量输入编辑器的完整方案: 这套方案适用于: 欢迎关注【袋鼠云数栈 UED 团队】\~1. 引言
1.1 背景与动机
{{nodeId.variableName}}。但这种方案存在明显的用户体验问题:/ 字符时,自动弹出变量选择菜单{{#nodeId.variableName#}} 格式,便于后端解析1.2 最终效果

/,即刻弹出变量选择悬浮菜单2. 技术选型:为什么选择 Lexical?
2.1 Lexical 简介
2.2 主流富文本框架对比
维度 Lexical Slate Tiptap ProseMirror Editor.js Quill 维护方 Meta 社区 Tiptap 团队 社区 CodeX 团队 社区 是否开源 是 (MIT) 是 (MIT) 是 (MIT) 是 (MIT) 是 (Apache 2.0) 是 (BSD) React 支持 原生 原生 支持 需适配层 支持 支持 学习曲线 中等 中等偏高 中等偏低 陡峭 低 低 社区生态 增长迅速 稳定 繁荣 稳定 稳定 稳定 TS 支持 完善 完善 完善 支持 支持 支持 核心优势 高可靠性、高性能、Meta 背书,适合现代 web 应用 灵活性极高、符合 React 直觉 兼顾易用与强大、UI 无头 协同编辑天花板、极其严谨 块级结构、天然适合 CMS 简单易用、稳定 主要劣势 文档仍可优化 升级可能断层 协作/高级功能需付费订阅 开发门槛极高 跨行选择等体验有限 定制复杂功能较难 适用场景 现代高性能 React 应用 需要极度定制 UI 的 React 项目 快速交付的产品 复杂协同办公 (Google Docs 类) 新闻发布、类 Notion 编辑器 评论区、简单博客、CMS 2.3 选择 Lexical 的理由
2.4 AIWorks 使用的依赖
{
"lexical": "^0.35.0",
"@lexical/react": "^0.35.0",
"@lexical/text": "^0.35.0",
"@lexical/utils": "^0.35.0"
}lexical:核心库,提供编辑器状态管理、节点系统、命令系统@lexical/react:React 绑定,提供 Composer、插件等组件@lexical/text:文本处理工具,包含文本实体(Text Entity)相关功能@lexical/utils:工具函数,如 mergeRegister 用于批量注册/注销3. Lexical 核心概念速览
3.1 编辑器状态
EditorState 中,任何修改都会产生新的状态对象。// 读取状态(只读操作)
editor.getEditorState().read(() => {
const root = $getRoot();
const text = root.getTextContent();
});
// 更新状态(写操作)
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText('Hello');
}
});read() 内只能读取,不能修改update() 内可以读取和修改$ 开头的函数(如 $getRoot、$getSelection)只能在这两个回调中调用3.2 节点体系
RootNode
└── ParagraphNode (ElementNode)
├── TextNode ("普通文本")
├── VariableLabelNode (DecoratorNode)
└── TextNode ("更多文本")类型 说明 示例 RootNode根节点,每个编辑器有且仅有一个 - ElementNode容器节点,可包含子节点 ParagraphNode, ListNode TextNode文本叶子节点 普通文本内容 DecoratorNode装饰器节点,可渲染自定义 React 组件 变量标签、提及、表情 3.3 命令系统
// 创建自定义命令
const HELLO_WORLD_COMMAND: LexicalCommand<string> = createCommand();
// 注册自定义命令行为
editor.registerCommand(
HELLO_WORLD_COMMAND,
(payload: string) => {
console.log(payload);
return false;
},
COMMAND_PRIORITY_LOW,
);
// 触发对应命令
editor.dispatchCommand(HELLO_WORLD_COMMAND, 'Hello World!');COMMAND_PRIORITY_CRITICAL (4)COMMAND_PRIORITY_HIGH (3)COMMAND_PRIORITY_NORMAL (2)COMMAND_PRIORITY_LOW (1)COMMAND_PRIORITY_EDITOR (0)3.4 节点转换
editor.registerNodeTransform(TextNode, (textNode) => {
// 每当 TextNode 发生变化时触发
const text = textNode.getTextContent();
// 检测特定模式并转换
if (isVariablePattern(text)) {
const variableNode = $createVariableLabelNode(...);
textNode.replace(variableNode);
}
});3.5 插件架构
<LexicalComposer initialConfig={config}>
{/* 核心编辑插件 */}
<RichTextPlugin contentEditable={...} placeholder={...} />
{/* 功能插件 */}
<HistoryPlugin /> {/* 撤销/重做 */}
<OnChangePlugin /> {/* 内容变化监听 */}
<VariableLabelPlugin /> {/* 自定义:变量渲染 */}
<VariableLabelPickerPlugin />{/* 自定义:变量选择 */}
</LexicalComposer>useLexicalComposerContext() 获取编辑器实例:const MyPlugin = () => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
// 使用 editor 注册命令、转换等
}, [editor]);
return null; // 无 UI 的纯逻辑插件
};4. 整体架构设计
4.1 架构图

4.2 组件职责划分
组件/模块 职责 PromptEditor业务组件,连接 workflow store,处理多行提示词场景 VariableEditor业务组件,处理单行变量输入场景 Editor核心组件,封装 Lexical 编辑器和所有插件 VariableLabelNode自定义节点,渲染为 React 组件,用于反显变量标签 VariableLabelPlugin自定义插件,监听文本变化,将变量语法转换为变量标签 VariableLabelPickerPlugin自定义插件,处理 / 触发和变量选择SingleLinePlugin自定义插件,限制单行输入 4.3 数据流及渲染过程
5. 核心实现详解
5.1 自定义 VariableLabelNode
DecoratorNode 来创建一个可以渲染 React 组件的自定义节点:export class VariableLabelNode extends DecoratorNode<JSX.Element> {
__variableKey: string; // 变量的完整标识,如 {{#nodeId.name#}}
__variableLabel: string; // 显示用的标签
__isSystemVariable: boolean; // 是否为系统变量
static getType(): string {
return "variableLabel";
}
// 返回 React 组件作为节点的渲染内容
decorate(): JSX.Element {
return (
<VariableLabel
variableLabel={this.__variableLabel}
isSystemVariable={this.__isSystemVariable}
/>
);
}
// ... 其他方法
}VariableLabel 组件,实现可视化展示5.2 触发器:
/ 唤起变量选择菜单/ 时,我们需要弹出一个变量选择菜单。这里使用 Lexical 官方提供的 LexicalTypeaheadMenuPlugin:const VariableLabelPickerPlugin = ({ variableGroups }) => {
const [editor] = useLexicalComposerContext();
// 自定义触发匹配:检测用户输入 /
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch("/", {
minLength: 0,
});
// 用户选择变量后的处理逻辑
const onSelectOption = useCallback((selectedOption, nodeToRemove, closeMenu) => {
editor.update(() => {
// 删除触发字符 /
if (nodeToRemove) nodeToRemove.remove();
// 插入变量文本,格式为 {{#nodeId.variableName#}}
selection.insertNodes([
$createTextNode(`{{#${selectedOption.nodeId}.${selectedOption.name}#}}`),
]);
closeMenu();
});
}, [editor]);
// ...
};/ → checkForTriggerMatch 返回匹配结果VariableMenu 组件,显示可用变量列表onSelectOption 插入格式化的变量文本VariableLabelPlugin 监测到文本变化,自动转换为节点VariableLabelNode,而是插入格式化的文本字符串。这是为了解耦选择逻辑和渲染逻辑——文本到节点的转换由下一个插件统一处理。5.3 文本实体识别与自动转换
VariableLabelPlugin 负责监听文本变化,当发现符合变量格式的文本时,自动将其转换为 VariableLabelNode:const VariableLabelPlugin = () => {
const [editor] = useLexicalComposerContext();
// 创建变量节点的工厂函数
const createVariableLabelPlugin = useCallback((textNode: TextNode) => {
const text = textNode.getTextContent();
const info = parseVariableTokenInfo(text);
return $createVariableLabelNode(
text,
info?.variableName ?? "",
info?.isSystemVariable ?? false,
);
}, []);
useEffect(() => {
// 注册文本实体转换器
registerLexicalTextEntity(
editor,
getVariableMatchInText, // 正则匹配函数
VariableLabelNode,
createVariableLabelPlugin,
);
}, [editor]);
// ...
};// 用户变量格式:{{#uuid.variableName#}}
export const USER_VARIABLE_REGEX = new RegExp(
"(\\{\\{)(#)([a-fA-F0-9-]{36}\\.[a-zA-Z0-9_]+)(#)(\\}\\})",
);
// 系统变量格式:{{#system.xxx#}}
export const SYSTEM_VARIABLE_REGEX = new RegExp(
"(\\{\\{)(#)(system\\.[a-zA-Z0-9_]+)(#)(\\}\\})",
);registerLexicalTextEntity 是核心的转换逻辑,它注册了两个 Transform:export function registerLexicalTextEntity(editor, getMatch, targetNode, createNode) {
// 1. TextNode → VariableLabelNode 的转换
const textNodeTransform = (node: TextNode) => {
const text = node.getTextContent();
const match = getMatch(text);
if (match === null) return;
// 分割文本节点,将匹配部分替换为目标节点
const [nodeToReplace, remainingNode] = node.splitText(match.start, match.end);
const replacementNode = createNode(nodeToReplace);
nodeToReplace.replace(replacementNode);
// 递归处理剩余文本(可能包含多个变量)
if (remainingNode) textNodeTransform(remainingNode);
};
// 2. 反向转换:当节点内容不再匹配时还原为文本
const reverseNodeTransform = (node) => {
const match = getMatch(node.getTextContent());
if (match === null) {
replaceWithSimpleText(node); // 还原为普通文本
}
};
return [
editor.registerNodeTransform(TextNode, textNodeTransform),
editor.registerNodeTransform(targetNode, reverseNodeTransform),
];
}5.4 变量标签的可视化渲染
VariableLabel 组件负责将变量以友好的方式呈现给用户:const VariableLabel = ({ variableLabel, isSystemVariable }) => {
const { Icon, nodeLabel, displayLabel } = useVariableLabelInfo(
variableLabel,
isSystemVariable,
);
return (
<div className="inline-flex items-center rounded-sm bg-bg-primary-4 px-[2px]">
<Icon className="flex-shrink-0" />
<span className="text-text-2-icon">{nodeLabel}</span>
<span className="text-text-4-description">/</span>
<span className="text-primary-default">{displayLabel}</span>
</div>
);
};
5.5 编辑器单行模式
const SingleLinePlugin = ({ onEnter }) => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
mergeRegister(
// 1. 限制 RootNode 只保留一个段落
editor.registerNodeTransform(RootNode, (rootNode) => {
if (rootNode.getChildrenSize() <= 1) return;
const children = rootNode.getChildren();
const firstChild = children[0];
// 将后续段落的内容合并到第一个段落
for (let i = 1; i < children.length; i++) {
const paragraph = children[i];
paragraph.getChildren().forEach(child => firstChild.append(child));
paragraph.remove();
}
}),
// 2. 拦截 Enter 键
editor.registerCommand(KEY_ENTER_COMMAND, (event) => {
event?.preventDefault();
onEnter?.(); // 可以触发外部回调,如提交表单
return true;
}, COMMAND_PRIORITY_HIGH),
);
}, [editor, onEnter]);
return null;
};5.6 编辑器状态初始化与同步
export const textToEditorState = (text = "") => {
const lines = text.split("\n");
const paragraph = lines.map((p) => ({
children: [{ text: p, type: "text", ... }],
type: "paragraph",
//...
}));
return JSON.stringify({
root: { children: paragraph, type: "root", ... },
});
};const handleEditorChange = (editorState: EditorState) => {
const text = editorState.read(() => {
return $getRoot()
.getChildren()
.map((p) => p.getTextContent())
.join("\n");
});
onChange(text);
};VariableLabelNode.getTextContent() 返回原始变量格式({{#nodeId.name#}}),导出的文本可以直接存储,再次加载时会自动转换回节点形式。6. 总结
/ 触发展示变量选择菜单最后
袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star