响应性

lexical
Created 2/3/2023
Updated 11/6/2023

响应性

参考

Lexical 提供了灵活的响应系统,包括 listener 侦听器、node transform 节点转换、command dispatch 指令分发

说明

它们都是通过 register 为前缀的方法进行注册的,而且这些方法都会返回值都是一个函数,可用于从编辑器上取消注册

侦听器

侦听器 Listener 通过监听编辑器的特定操作,以执行相应的响应。

Lexical 提供了多种类型的侦听器:

  • registerRootListener 根节点侦听器:当编辑器的根节点所绑定的 DOM 元素发生变化/替换时,就会触发该侦听器的回调函数执行
    该方法的回调函数的入参有两个,第一个参数是(变更后)新的根节点(在页面上所对应的 HTMLElement),第二个参数是旧的根节点(在页面上所对应的 HTMLElement)
    该侦听器的常见使用场景是将相关设置从旧的 root element 移除,并添加到新的 root element
    js
    // 使用 removeRootListener() 方法在编辑器上注册一个侦听器
    // 当编辑器的根节点所对应的 HTMLElement 变更时,就会触发侦听器的回调函数
    // 其返回值是一个函数,调用它可取消注册
    const removeRootListener = editor.registerRootListener(
      (rootElement, prevRootElement) => {
        // ...
        if(rootElement) {
          // 将当前 root element 设置为可编辑的 contentEditable = 'true'
          rootElement.contentEditable = 'true'
          // 并使用 TailwindCSS 将边框颜色设置为橙色
          rootElement.classList.add('border-orange-500')
        }
    
        if(prevRootElement) {
          // 之前的 root element 则变得不可编辑
          prevRootElement.contentEditable = 'false'
          // 并将边框颜色恢复为默认颜色
          prevRootElement.classList.remove('border-orange-500')
        }
      }
    );
    
    // Do not forget to unregister the listener when no longer needed!
    removeRootListener();
    
    提示

    可以通过 editor.setRootElement(domElement) 设置/切换编辑器的根节点所对应的 HTMLElement

  • registerEditableListener 可编辑状态侦听器:当编辑器的可编辑状态发生改变时,就会触发该侦听器的回调函数执行
    该方法的回调函数的入参是一个布尔值,表示(变更后)编辑器的可编辑状态
    js
    const removeEditableListener = editor.registerEditableListener(
      (editable) => {
        // The editor's mode is passed in!
        console.log(editable);
      },
    );
    
    // Do not forget to unregister the listener when no longer needed!
    removeEditableListener();
    
    注意

    虽然可以直接操作页面上的 DOM 元素来设置/切换编辑器的可编辑性,例如 rootElement.contentEditable='false',但是通过该方式是无法触发侦听器作出响应的。

    必须通过编辑器实例来设置/切换其可编辑性 editor.setEditable(boolean) 才可以触发侦听器作出响应

  • registerUpdateListener 编辑器更新侦听器:当编辑器 commit 提交更新时(页面的 DOM 发生变化时),就会触发该侦听器的回调函数执行
    该方法的回调函数的入参是一个对象,包含一些属性:
    update-listener-callback-parameter
    update-listener-callback-parameter
    • editorState 更新后(最新)的 Editor State 编辑器状态
    • preEditorState 更新前的 Editor State 编辑器状态
    • tags 一个标签集合,是在该次更新时传递给编辑器的(用以标记这次更新 ❓)
    js
    // 使用 registerUpdateListener() 方法在编辑器上注册一个侦听器
    // 当编辑器 commit 一次更新时,就会触发侦听器的回调函数
    // 其返回值是一个函数,调用它可取消注册
    const removeUpdateListener = editor.registerUpdateListener((params) => {
      // ...
    });
    
    // 如果不再需要侦听器时,记得调用相应的方法 removeUpdateListener() 取消注册
    removeUpdateListener();
    
    瀑布式更新

    需要警惕不要引发 waterfall update 瀑布式更新。

    js
    editor.registerUpdateListener(() => {
      // ❌ 不要这么做
      // 👎 请不要在 registerUpdateListener 侦听器的回调函数中触发更新
      editor.update(() => {
        // ...
      });
    });
    

    应该避免registerUpdateListener 更新侦听器的回调函数中又触发另一次更新,因为这会就像骨牌效应一样,新的更新再触发 registerUpdateListener 回调函数调用,这会造成性能上的消耗。而如果条件设置不恰当,还可能又会触发另一次更新,这就会造成往复循环而无法停止。

    👍 如果希望编辑器的某种变化依赖于一种更新,可以采用 node transform 节点转换。

    通过监听某种类型的节点,当它发生变化时,对节点进行相应的转换处理,而最终这些操作都会整合到一次更新中,从而避免瀑布式连锁反应更新。

  • registerTextContentListener 编辑器文本内容变更侦听器:当编辑器 commit 提交更新且造成文本内容变化时,就会触发该侦听器的回调函数执行。
    换而言之,如果前后版本的编辑器状态的文本内容没有变化,就不会触发该侦听器的回调函数
    该方法的回调函数的入参是一个字符串,是更新后的文本内容
    js
    // 使用 registerTextContentListener() 方法在编辑器上注册一个侦听器
    // 当编辑器 commit 一次更新,且该更新会造成编辑器的文本内容发生变化时,就会触发侦听器的回调函数
    // 其返回值是一个函数,调用它可取消注册
    const removeTextContentListener = editor.registerTextContentListener(
      (textContent) => {
        // The latest text content of the editor!
        console.log(textContent);
      },
    );
    
    // Do not forget to unregister the listener when no longer needed!
    removeTextContentListener();
    
  • registerMutationListener 节点变化侦听器:当特定类型的节点发生变化时,就会触发该侦听器的回调函数执行。
    有 3 种类型的节点变化(对应于其整个生命周期的变化):
    • created 创建节点
    • destroyed 销毁/删除节点
    • updated 更新节点

    该方法的接受两个参数。
    第一个参数是需要监听的节点类型(节点类)
    第二个参数是回调函数,其入参是一个映射。里面所包含的元素就是发生了变化的节点,其键名是该节点的标识符(对于非根节点,以数字表示;根节点以 root 表示),其值是该节点的变化类型(created | destroyed | updated 三个值之一)
    js
    // 使用 registerMutationListener() 方法在编辑器上注册一个侦听器
    // 当编辑器 commit 一次更新,且该更新会造成编辑器的文本内容发生变化时,就会触发侦听器的回调函数
    // 其返回值是一个函数,调用它可取消注册
    const removeMutationListener = editor.registerMutationListener(
      MyCustomNode, // 需要监听的节点类型
      (mutatedNodes) => {
        console.log(mutatedNodes);
        // mutatedNodes 是一个映射
        // 其中每个元素都是属于该类型且发生了变化的节点
        for (let [nodeKey, mutation] of mutatedNodes) {
          console.log(nodeKey, mutation)
        }
      },
    );
    
    // Do not forget to unregister the listener when no longer needed!
    removeMutationListener();
    
  • registerDecoratorListener 装饰节点侦听器:当编辑器中任何一个装饰节点发生变化时,就会触发该侦听器的回调函数执行
    该方法的回调函数的入参是一个对象,它包含了各装饰节点。属性名称是各节点的标识符(对于非根节点,以数字表示;根节点以 root 表示);属性值就是装饰节点
    js
    const removeDecoratorListener = editor.registerDecoratorListener(
      (decorators) => {
        // The editor's decorators object is passed in!
        console.log(decorators);
      },
    );
    
    // Do not forget to unregister the listener when no longer needed!
    removeDecoratorListener();
    

节点转换器

节点转换 node transform 是指当某个节点(发生变化后)符合特定的规则时,会触发相应的转换/操作

说明

当节点发生变化后,但是此时还没有将更改应用到页面的相应的 DOM 元素上,只是将节点标记上待刷新(在程序内部将其标记为「脏」 marking nodes dirty)

然后所有节点转换规则 transforms 都会(根据规则的注册次序有先后次序 ❓)依次检查这个 dirty node 是否符合转换规则,如果符合就触发相应的转变操作。直到所有的 transforms 都清除对该节点的 dirty 标记

即转换操作是在编辑器将更新 commit 提交到页面之前发生的(下图的绿色框),所以可以将多个 node transforms 节点转变整合起来,最后只触发一次页面的 DOM 重绘,这样就可以提升性能

编辑器的生命周期
编辑器的生命周期

图摘自 Lexical 官方文档

另外有一个细节需要留意,除了被直接更改的节点会被标记为 dirty,其他相关联的节点也有可能被标记为 dirty。

例如在一个 ElementNode 中插入一个新的子节点时,那么除了这个新增的节点被标记为 dirty 以外,作为(直接)父节点的 ElementNode 也会被标记为 dirty。

此外如果新增节点影响到 siblings 邻近节点(一般是让邻近节点的 __prev 属性或 __next 属性发生改变),那么邻近节点也会被标记上 dirty

那么所有被标记上 dirty 的节点都会被 transform 检查一遍

使用 editor.registerNodeTransform() 方法为特定的节点类型注册一个节点转换器(设置一个转换规则)。

该方法有两个参数。第一个参数是节点类型(节点类);第二个参数是一个函数,其入参是(该类型节点中)发生改变的节点

在转换规则中,必须要设置合理的 preconditions 预设/判断条件,以避免进入无限循环的节点转换。

注意

应该在转换器中设置合理的条件判断是十分必要的,即要让节点转换有选择性退出的机制,避免让程序进入「死循环」

例如在节点发生转换后,如果又能够再次触发相同的转换发生,就会不断地往复循环,无法停止。由于 node transform 节点转换是发生在更新 update 应用到页面前的,则循环触发的节点转换就会阻止页面的更新,最直观的表示是编辑无法响应用户的输入操作

js
// 为文本节点设置两个节点转换器
// Transform 1
editor.registerNodeTransform(TextNode, textNode => {
  // 该转换规则是在文本内容等于 'modified' 时
  // 将它的内容改为 're-modified'
  // This transform runs twice but does nothing the first time because it doesn't meet the preconditions
  if (textNode.getTextContent() === 'modified') {
    textNode.setTextContent('re-modified');
  }
})
// Transform 2
editor.registerNodeTransform(TextNode, textNode => {
  // 该转换规则是在文本内容等于 'original' 时
  // 将它改为 'modified'
  // This transform runs only once
  if (textNode.getTextContent() === 'original') {
    textNode.setTextContent('modified');
  }
})

// 设置一个更新侦听器
editor.registerUpdateListener(({editorState}) => {
    // 当更新应用到页面后,该回调函数就会触发
    console.log('updated');
});
注意

以上示例中,在节点转换中进行了文本内容的更改,可能会导致光标定位的问题(内容转换后,光标不在单词的末尾),可以通过其他命令调整光标的位置进行优化

以上示例中,Transform 1 节点转换器注册时间点早于 Transform 2,所以它会先执行。

  1. 如果用户在编辑器里依次键入单词 original 的字母,当敲下最后一个字母 l 时,(在该字母显示到页面之前)
  2. 由于文本节点更改了(在应用到页面之前),则触发节点转换,先是 Transform 1 进行判断,但是由于不符合条件,所以不做任何处理
  3. 然后 Transform 2 再进行判断,由于符合条件,所以会触发转换操作,将文本节点的内容改为 modified
  4. 因为文本节点更改了,所以再次触发下一轮的 transforms 检查。同样地,先从 Transform 1 开始对该文本节点进行判断,由于符合条件,所以将文本节点的内容改为 re-modified
  5. 因为文本节点更改了,所以再次触发下一轮的 transforms 检查。再一次先从 Transform 1 开始对该文本节点进行判断,由于不符合条件,所以不做任何处理
  6. 然后 Transform 2 再进行判断,由于不符合条件,也不做任何处理。最后所有的 transforms 都没有将该文本节点标记为 dirty,所以更新可以应用到页面的 DOM 元素
  7. 此时更新侦听器会触发,在控制台打印出 updated

具体的实例可以参考官方制作的一些插件:

提示

对于需要将符合指定模式的文本内容转换为特定节点的场景,Lexical 为此提供了 registerLexicalTextEntity() 方法(由 lexical-text 模块包所提供,其实也是基于 node transform 节点转换的方法进行封装)

更具体而言,是输入的文本内容符合指定的模式时,就会创建一个特定的目标节点,将该部分文本内容包裹起来

需要注意的一点是,节点转换的过程中,文本内容并没有改变,可以理解为将匹配的内容提取出来(如果匹配的内容处于文本节点的中间时,那么该文本节点会自动进行「分裂」 split 为多个节点),并创建一个特定的目标节点将其包裹

如果对所包裹的文本内容进行更改,使其不再符合指定模式,就会删除特定的目标节点,并将所包裹的文本内容「释放」出来(如果前后都是文本节点,可能出现自动多个文本节点合并为一个情况)

例如当用户在字符串 tag 前面输入井号是文本内容为 #tag,Lexical 就会创建一个标签节点 TagNode,并将该文本内容作为 TagNode 的子节点;而如果用户在井号后添加感叹号,将文本内容更改为 #!tag 由于不符合指定规律(用一个正则表达式来进行匹配判断),则会自动销毁标签节点 TagNode,但是所包裹的文本内容并没有删除,只是「释放」出来转变回普通的文本节点

⚠️ 这 Lexical 兼容 Markdown 特殊字符的方式并不相同,例如输入大于号 # 并按空格键后,会生成一个标题节点 HeadingNode,但是这个特殊符号会替换掉(即文本内容会变更)

ts
// 该类型记录了文本匹配的具体位置
// 以便截取出这一部分的文本内容
export type EntityMatch = { start: number, end: number };

// 该方法返回一个数组,将其解构后可以用在 `mergeRegister()` 方法中,为文本节点注册一个节点转换器
registerLexicalTextEntity<T extends TextNode>(
  editor: LexicalEditor, // 编辑器实例
  // 设置文本配对规则的函数
  // 它的入参是当前所遍历的文本节点的内容(字符串)
  // 如果可以匹配到,就返回一个 EntityMatch 对象(记录了匹配命中的区域,起始点和终止点)
  // 如果没有匹配到,就返回 null
  getMatch: (text: string) => null | EntityMatch,
  // 节点类型,需要将匹配的部分转换成哪一种目标节点类型
  targetNode: Klass<T>,
  // 创建目标节点的函数
  // 它的入参是命中规则的文本节点,里面包含的内容就是命中的那一部分
  // 返回是所需要转换成的节点类型的实例
  createNode: (textNode: TextNode) => T,
): Array<() => void>;

Lexical 官方在 @lexical-react 模块包中对 registerLexicalTextEntity 方法进行封装,并提供了一个 hook useLexicalTextEntity,其实就是省略了第一个参数 editor

具体用法可以参考一个 AutoLink 的例子

或查看这个例子,它可以在用户输入 'blue''red''green' 单词时,将这些字符串转换为一个 ColoredNode 颜色节点,该节点的作用就是为其内容的文字颜色设置为蓝、红或绿色

文本节点转换的相关代码如下

tsx
const createColoredNode = useCallback(
  (textNode: TextNode): ColoredNode => {
    return $createColoredNode(
      textNode.getTextContent(),
      textNode.getTextContent(),
    );
  },
  [],
);

const getColoredMatch = useCallback((text: string) => {
  const words = text.split(/\s+/);
  for (const word of words)
    if (COLORS.includes(word))
      return {
        start: text.indexOf(word),
        end: text.indexOf(word) + word.length,
      };

  return null;
}, []);

useLexicalTextEntity<ColoredNode>(
  getColoredMatch, // 函数,其中包含匹配模式,并返回一个对象 { start: number, end: number } 以描述文本内容所匹配的部分
  ColoredNode, // 需要转换为哪个类型的节点
  createColoredNode, // 创建该节点的方法
);

指令分发

指令分发 command dispatch 就像 DOM 分发事件一样,当指令被分发时,预先注册到编辑器上的相应的回调函数就会被调用

提示

最为常见的使用场景是监听键盘的按键组合,并在按下时分发相应的指令 dispatch command,这样就可以为编辑器设置各种丰富的快捷键。

为编辑器增加指令的流程如下:

  1. 创建/定义一个指令
    使用方法 createCommand(type?: string) 创建一个自定义指令
    它的(可选)参数是一个字符串,作为指令的标识符
    ts
    const HELLO_WORLD_COMMAND: LexicalCommand<string> = createCommand();
    

    Lexical 已经提供了一些内置指令,可直接导入使用,具体可以查看官方文档LexicalCommands.ts 源文件
    提示

    推荐采用全大写字母作为指令的名称及其变量名

    在创建自定义的指令时,有一个与 TypeScript 相关的(可选)设置,即指明在注册指令处理函数时,该函数可接受的参数 payload 的数据类型

    ts
    // 创建一个自定义指令
    // 并指定了指令处理函数的参数的数据类型为 SomeType
    const MY_COMMAND = createCommand<SomeType>();
    
    editor.registerCommand(MY_COMMAND, payload => {
      // Type of `payload` is inferred here.
      // But lets say we want to extract a function to delegate to
      handleMyCommand(editor, payload);
      return true;
    });
    
    // 如果处理函数抽离出来
    // 那么它的入参 `payload` 的数据类型可以通过 helper function 辅助函数
    // CommandPayloadType<typeof MY_COMMAND>) 基于创建指令时的设置进行推断
    function handleMyCommand(editor: LexicalEditor, payload: CommandPayloadType<typeof MY_COMMAND>) {
      // `payload` is of type `SomeType`, extracted from the command.
    }
    
  2. 注册指令的处理/响应/回调函数
    使用方法 editor.registerCommand() 为特定的指令注册回调/响应/处理函数
    ts
    const removeListener = editor.registerCommand(
      COMMAND,
      (payload) => boolean, // Return true to stop propagation.
      priority,
    );
    // ...
    removeListener(); // Cleans up the listener.
    

    该方法有三个参数:
    • 第一个参数 COMMAND 是需要注册的指令
    • 第二个参数是响应函数,它可以接受一个参数,作为指令的额外信息,最后需要返回一个布尔值(以表示指令是否已经处理完毕)
    • 第三个参数 priority0 | 1 | 2 | 3 | 4 这 5 个数值的任意一个,以表示该处理函数的执行优先级
      Lexical 为这一系列的数字提供了 alias 别名可供选用,让代码更具语义
      ts
      export const COMMAND_PRIORITY_EDITOR = 0; // 默认,最低的优先级
      export const COMMAND_PRIORITY_LOW = 1; // 较低的优先级
      export const COMMAND_PRIORITY_NORMAL = 2; // 中等优先级
      export const COMMAND_PRIORITY_HIGH = 3; // 较高的优先级
      export const COMMAND_PRIORITY_CRITICAL = 4; // 最高的优先级
      

    该方法的返回值是一个函数,调用它可以取消注册
    说明

    对于同一个指令,可以注册多个响应函数,因为指令就像 DOM 事件,也会进行 propagation 传播。

    就像对于同一个类型的 DOM 事件,可以在 DOM 结构树的不同(嵌套层级)元素上注册侦听器,当事件在 DOM 结构树上进行传播时,会根据 DOM 树的结构依次触发响应的侦听器的回调函数。

    而指令的回调函数的执行先后顺序是由它们的在注册时,所设置的优先级 priority 决定的。

    可以在 DOM 事件的处理函数中调用 event.stopPropagation() 方法,来阻止事件继续传播。

    同样地可以将指令的处理函数的返回值设置为 true 表示指令已经被处理了,这样该指令优先级较低的处理函数就不会被调用

    例如以下是在 RichTextPlugin 插件里注册 KEY_TAB_COMMAND 指令(按下 Tab 键会分发该指令)

    ts
    // 为命令 KEY_TAB_COMMAND 注册一个处理函数
    // 当用户按下 Tab 键时会调用
    editor.registerCommand(
      KEY_TAB_COMMAND,
      (payload) => {
        // 传入的参数的数据类型是按键事件 KeyboardEvent
        const event: KeyboardEvent = payload;
        event.preventDefault(); // 阻止默认的按键行为(如光标向后移动或焦点的跳转)
        // 并分发其他的指令
        // 根据用户是否同时按下了 `Shift` 键,而分发不同的指令
        // 该处理函数所返回的布尔值
        // 由「二级」分发的指令的返回值所决定
        return editor.dispatchCommand(
          event.shiftKey ? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND,
        );
      },
      COMMAND_PRIORITY_EDITOR, // 该变量表示数值 0,即采用最低的优先级
    );
    

    而在表格相关的源码中则为 Tab 按键注册了不同的处理函数,其行为是切换到下一个单元格,而且优先级为 4,这样就可以实现在不同的场景中相同的指令触发不同的行为逻辑

  3. 在特定的场景中分发指令
    使用方法 editor.dispatchCommand() 分发指令
    js
    editor.dispatchCommand(command, payload);
    

    第一个参数 command 是需要分发的指令
    第二个参数 payload 是需要传递给指令处理函数的数据
    然后编辑器会指定相应的指令的处理函数,如果该指令的最后一个处理函数成功执行则返回 true,否则返回 false(所以一般返回的是 true) ❓
    说明

    一般在按键事件里分发指令,这样就可以将指令与按键事件相关联,实现按下特定的快捷键,让编辑器执行特定的操作

    如果是通过按键触发指令分发,则一般所传递的数据是原生的事件

    js
    editor.dispatchCommand(KEY_ARROW_LEFT_COMMAND, event);
    

Lexical 默认通过核心模块 LexicalEvents.ts为编辑器应用了一些内置指令

通过 addRootElementEvents() 方法根节点上监听一些(按键)操作,并在事件处理函数中分发这些内置指令

所以这些内置指令默认会在特定的按键操作下分发,作为开发者就可以直接使用这些内置指令,为它们设置处理函数以实现特定的操作

提示

类似地,可以仿照 Lexical 官方的做法,自定义一些指令,然后在根节点 DOM 上监听按键操作,并在按键事件的处理函数中分发该指令

DOM 原生事件

有时候可能需要对页面上在编辑器里的 DOM 元素进行交互,所以 Lexical 提供了一些途径可以为这些 DOM 元素设置事件处理函数。

可以在不同层次设置 DOM 事件监听器:

  • 根节点:可以在根节点所对应的 DOM 元素(contentEditable="true" 的元素)设置事件监听器。以事件委派 Event Delegation 的方式可以监听到编辑器中的元素所分发的事件。
    可以在 editor.registerRootListener() 根节点侦听器里,设置 root element 的原生事件监听器
    js
    // 事件监听器响应函数
    function myListener(event) {
        // You may want to filter on the event target here
        // to only include clicks on certain types of DOM Nodes.
        alert('Nice!');
    }
    
    // 使用 editor.registerRootListener() 注册一个根节点侦听器
    // 当编辑器的根节点所绑定的 DOM 元素发生变化/替换时,就会触发该侦听器的回调函数执行
    const removeRootListener = editor.registerRootListener((rootElement, prevRootElement) => {
        // 为(变更后)新的根节点的原生点击 click 事件设置监听器
        // add the listener to the current root element
        rootElement.addEventListener('click', myListener);
        // 将旧的根节点所设置的针对原生点击 click 事件的监听器移除
        // remove the listener from the old root element - make sure the ref to myListener
        // is stable so the removal works and you avoid a memory leak.
        prevRootElement.removeEventListener('click', myListener);
    });
    
    // teardown the listener - return this from your useEffect callback if you're using React.
    removeRootListener();
    
  • 特定类型的节点:如果直接基于特定类型的节点所对应的 DOM 元素设置原生事件监听器,都会简化逻辑处理和代码量
    可以在 editor.registerMutationListener() 节点变化侦听器里,为(发生更改的)特定类型的节点所对应的 DOM 元素设置原生事件的监听器
    js
    const removeMutationListener = editor.registerMutationListener(nodeType, (mutations) => {
        // 由于节点变化侦听器的回调函数中,每次都只能获取到发生了变化的节点实例
        // 所以使用一个集合来收集该类型节点的实例
        // 以避免重复设置事件监听器
        const registeredElements: WeakSet<HTMLElement> = new WeakSet();
        editor.getEditorState().read(() => {
            for (const [key, mutation] of mutations) {
               // 基于节点的 key 获取对应的 DOM 元素
                const element: null | HTMLElement = editor.getElementByKey(key);
                if (
                // Updated might be a move, so that might mean a new DOM element
                // is created. In this case, we need to add and event listener too.
                (mutation === 'created' || mutation === 'updated') &&
                element !== null &&
                !registeredElements.has(element)
                ) {
                    registeredElements.add(element);
                    element.addEventListener('click', (event: Event) => {
                        alert('Nice!');
                    });
                }
            }
        });
    });
    
    // teardown the listener
    removeMutationListener();
    

Copyright © 2025 Ben

Theme BlogiNote

Icons from Icônes