富文本编辑器架构设计

119 阅读14分钟

基于slate二次封装的富文本编辑器,采用插件的形式利于拓展。架构上分为核心层,插件层,渲染层,基础能力层

一 slate的基本介绍和使用

首先我们需要对slate有一个简单的的了解

下面来看一个demo,了解一下slate的基本使用。

/**
 * 主编辑器组件
 */
const initialValue = [
    {
        type:"paragraph",
        children :[
            {text: "你在干嘛"}
        ]
    }
]
 
const Editor = ({ initialValue }: CoreEditorProps) => {
  const editor = withReact(createSlateEditor());
  
  const renderLeaf = (props) => {
        // 根据props返回不同的结构,例如
        // if(props.leaf.bold){
        //    return <strong {...props.attributes}>{props.children}</strong>
        //}
        //
        //
      return <span {...props.attributes}>{children}</span>
  }
  
  // 处理键盘事件
  const handleKeyDown = () => {}
  // 渲染element节点,与renderLeaf 类似
  const renderElement = () => {}
  return (
    <Slate editor={editor} initialValue={initialValue} onChange={(children) => {
      console.log(children)
    }}>
      <Editable
        className={styles.editorContent}
        renderLeaf={renderLeaf}
        renderElement={renderElement}
        placeholder='在文舟上书写你的思绪,随文字流动,记录每一次灵感。'
        onKeyDown={handleKeyDown}
      />
    </Slate>
  );
};

上面的例子就是最基本的使用方法,已经可以进行正常的文字编辑,我们之所以要二次封装,是为了满足其他的业务开发。需要一些自定义拓展。但同时编辑器又是一个较为复杂的模块,因此我们需要一个好的架构支持我们的业务,同时考虑以后的扩展性。

二 架构图&流程

2.1 流程图

image.png

我们将编辑器核心组件做如上的抽象,在我们进行代码实现的时候脑中的思路就会清晰很多。这里解释一下结构规范这一层所做的事情,在编辑器中有一些结构规范是需要遵守的,这样能够避免我们的结构发生预期外的错误和降低数据结构的复杂性,其中有一些是编辑器最佳实践的规范,有一些是自定义规范。例如:

  • 编辑器中至少需要一个 {type:"paragraph",children:[{text:""}]} 的段落放在最后
  • {type:"table",children:[]}中children必须有内容,否则就删除table节点
  • 对于mark节点(leaf节点)的属性,如果为false,则删除该属性,{text:"文本",blod:false}情况不允许存在,只允许存在{text:"文本",blod:true}与{text:"文本"}
  • 对于两个相同的文本节点且属性相同情况,合并为一个节点{text:"这是"}和{text:"文本"}合并为{text:"这是文本"}

上面简单举例了一些较为常见的,根据不同的业务,会有不同的schema设计,也就会有不同的结构校验与修复。

2.2 流程图

接下来我们将以文本加粗为例,按上述的分层进行代码实现。

我们先来整理一下加粗文本的流程

image.png

三 代码实现

首先达成共识,加粗的数据结构定为{text:"文本",blod:true}

3.1 编辑器主入口

首先需要我们需要将上面的const editor = withReact(createSlateEditor()); 进行一些更改,需要给editor拓展功能。

// core/createEditor.ts
import { createEditor as createSlateEditor } from "slate";
import { withReact } from "slate-react";
import type { SlatePlugin } from "./types";

export const createEditor = (plugins: SlatePlugin[]) => {
  let editor = withReact(createSlateEditor());

  // 保存原始 normalizeNode
  const { normalizeNode } = editor;

  // 重新覆盖 editor.normalizeNode
  editor.normalizeNode = (entry) => {
    // 遍历每个插件的 normalizeNode
    plugins.forEach((plugin) => {
      plugin.normalizeNode?.(entry, editor);
    });

    // 最后调用默认的 normalizeNode 保证 Slate 树正常
    normalizeNode(entry);
  };

  // withEditor 是一个“编辑器能力增强函数”
  // Slate 的 editor 本质是一个「可变对象 + 方法集合」
  // 它是一个 可以被装饰(decorate)的对象
  // 可拦截 可替换 可组合 顺序明确
  // 还可以拓展权重相关内容
  plugins.forEach((plugin) => {
    if (plugin.withEditor) {
      editor = plugin.withEditor(editor);
    }
  });

  return editor;
};

这里的editor.normalizeNode(就是我们上面提到的数据校验和修复,每次change后slate都会自动执行)与plugin.withEditor我后面会提到,现在可以不用太在意,只需要知道我们使用的是插件增强后的editor。

// core/index.tsx
import { Slate, Editable } from 'slate-react'
import { useMemo } from 'react'
import { createEditor } from './createEditor'
import { corePlugins } from './plugins'
import { createRenderLeaf } from './render/renderLeaf'
import { createRenderElement } from './render/renderElement'
import type { CoreEditorProps } from './types'
import styles from "./styles.module.less";
/**
 * 主编辑器组件
 */
const Editor = ({ initialValue }: CoreEditorProps) => {
  const editor = useMemo(() => createEditor(corePlugins), [])
  console.log(corePlugins)

  return (
    <Slate editor={editor} initialValue={initialValue} onChange={(editor) => {
      console.log(editor)
    }}>
      <Editable
        className={styles.editorContent}
        renderLeaf={createRenderLeaf(corePlugins)}
        renderElement={createRenderElement(corePlugins)}
        placeholder='在文舟上书写你的思绪,随文字流动,记录每一次灵感。'
        onKeyDown={(event) => {
          for (const plugin of corePlugins) {
            plugin.onKeyDown?.(event, editor)
            if (event.defaultPrevented) break
          }
        }}
      />
    </Slate>
  );
};

export default Editor;  
// core/render/renderLeaf.tsx
import type { ReactElement } from 'react'
import type { SlatePlugin } from '../types'
import type { RenderLeafProps } from 'slate-react'

export const createRenderLeaf =
    (plugins: SlatePlugin[]) =>
        (props: RenderLeafProps): ReactElement => {
            let el = props.children;
            for (const plugin of plugins) {
                if (plugin.renderLeaf) {
                    el = plugin.renderLeaf({ ...props, children: el }) || el;
                }
            }
            return <span {...props.attributes}>{el}</span>
        }

从上面代码我们可以简单地看到,对于renderLeaf,renderElement,onKeyDown,editor 我们都使用了引入,也就是说我们都进行了一定的封装。

我们的终极目的是,所有的东西都通过插件的形式进行插入,上面的封装只是将插件的内容进行分发。

明确一下我们需要做的事情

  1. 首先我们的加粗需要有**加粗文本**按下空格以及ctrl+b进行加粗两种
  2. 回车时加粗区域取消

3.2 插件统一导出

插件的统一导出,我们主要关注BoldPlugin,hotkeyPlugin,markdownPlugin

// core/plugins/index.ts
import { BoldPlugin } from "./marks/bold";
import { ItalicPlugin } from "./marks/italic";
import { CodePlugin } from "./marks/code";
import { HotkeyPlugin } from "./hotkey/hotkey";
import { createNormalizePlugin } from "./normailze";
import { removeFalseMark } from "./normailze/removeFalseMark";
import { BOLD_KEY, ITALIC_KEY, CODE_KEY } from "./marks";
import { ensureAtLeastOneParagraph } from "./normailze/ensureAtLeastOneParagraph";
import { createMarkdownPlugin } from "./markdown";
import { markdownBoldRule } from "./markdown/markdownBoldRule";
import { markdownItalicRule } from "./markdown/markdownItalicRule";
import { markdownInlineCodeRule } from "./markdown/markdownCodeRule";

// markdownPlugin
const markdownPlugin = createMarkdownPlugin({
  rules: [markdownBoldRule, markdownItalicRule, markdownInlineCodeRule],
});

// 结构化数据
const normalizePlugin = createNormalizePlugin({
  rules: [ensureAtLeastOneParagraph, removeFalseMark(BOLD_KEY), removeFalseMark(ITALIC_KEY), removeFalseMark(CODE_KEY)],
});

// 基础插件
export const markPlugins = [BoldPlugin, ItalicPlugin, CodePlugin];

// 快捷键
const hotkeyPlugin = HotkeyPlugin(markPlugins);
// 这里的顺序非常重要
export const corePlugins = [normalizePlugin, markdownPlugin, hotkeyPlugin, ...markPlugins];

hotkeyPlugin插件主要用于收集处理快捷键的行为,BoldPlugin则是注册了快捷键,给出渲染规则,markdownPlugin则是专门处理语法糖的插件(也就是说加粗行为本身跟markdown语法是解耦的)。

3.3 boldPlugin

// core/plugins/marks/bold.ts
import { Editor } from "slate";
import type { SlatePlugin } from "../../types";
import { cancelMarkWhenInsertBreak, toggleMark } from "../../utils";

export const BOLD_KEY = "bold";
const BOLD_HOTKEY = 'mod+b'

export const BoldPlugin: SlatePlugin = {
  key: BOLD_KEY,

  // 视图层
  renderLeaf: ({ leaf, attributes, children }) => {
    if (leaf.bold) {
      children = <strong>{children}</strong>;
    }
    return children;
  },

  // onKeyDown(事件层)
  hotkeys: [
    {
      hotkey: BOLD_HOTKEY,
      handler: (editor) => toggleMark(editor, BOLD_KEY),
    },
  ],

  // withEditor(行为层)
  withEditor: (editor) => {
    cancelMarkWhenInsertBreak(editor, BOLD_KEY)
    return editor
  },
};

插件会用到工具函数

import { Editor } from "slate";

// 1. 工具函数
export const toggleMark = (editor: Editor, markKey: string) => {
  const marks = Editor.marks(editor);
  const isActive = marks ? (marks as any)[markKey] : false;
  if (isActive) {
    Editor.removeMark(editor, markKey);
  } else {
    Editor.addMark(editor, markKey, true);
  }
};


import { Editor } from "slate";
// 回车取消mark
export const cancelMarkWhenInsertBreak = (editor: Editor, markKey: string) => {
  const { insertBreak } = editor;
  editor.insertBreak = () => {
    const marks = Editor.marks(editor);
    if (marks && (marks as any)[markKey]) {
      Editor.removeMark(editor, markKey);
    }
    insertBreak();
  };
};

上面就是blod的插件,我来解释其中几个点:

hotkeys用于注册快捷键以及handler函数,但是调用其实是HotkeyPlugin插件在调用,也就是每个插件注册自己的快捷键,但是统一收口,只在HotkeyPlugin作为输出

// core/plugins/hotkey/HotkeyPlugin.ts
import type { SlatePlugin } from "../../types";
import { isHotkey } from "is-hotkey";
import type { HotkeyConfig } from "../../types";

export const HotkeyPlugin = (plugins: SlatePlugin[]): SlatePlugin => {
  // 收集所有插件的 hotkeys
  const hotkeyMap: HotkeyConfig[] = [];

  plugins.forEach((plugin) => {
    if (plugin.hotkeys) {
      hotkeyMap.push(...plugin.hotkeys);
    }
  });

  return {
    key: "hotkey",

    onKeyDown: (event, editor) => {
      for (const { hotkey, handler } of hotkeyMap) {
        if (isHotkey(hotkey, event)) {
          event.preventDefault();
          handler(editor);
          break;
        }
      }
    },
  };
};

关于所谓的行为层

其实这里的功能就是一个,回车的时候取消加粗区域。这里的withEditor是怎么实现的,就又需要介绍一下slate的设计。slate所有的行为和数据都在实例对象editor中,其中数据在editor.children中。正因为他是一个对象,所以我们能够随意地拓展,原方法或者添加新方法,最终再返回editor这个对象。这里就是一个很好的例子。

实际上我们是改变了编辑器的回车方法,给他添加了我们想要的取消mark的操作。

这里在拓展说一说,withEditor可以做什么,比如我这不是一个加粗的plugin,而是一个card的plugin,那么我就可以这样做:

// withEditor(行为层)
  withEditor: (editor) => {
    editor.addCard = Transforms.setNodes(
        editor,
        { type: card },
        { at: { anchor: startPoint, focus: endPoint }}
      );
    return editor
  },

其中Transforms.setNodes是slate的api,addCard 是我们添加的方法,这么做的好处是

  1. 对于业务方,我们不应该让他们使用最底层api,原子化程度应该在封装核心组件时就定好。业务方拿到editor之后,就只需要直接调用addCard方法就好,不用关心底层实现。
  2. 基于上一点,我们将多个操作封装成一个原子操作后,我们进行比如说撤回,前进的操作时,他是根据我们封装的行为去进行的,打个比方,我们添加一个表格,直接就是一个操作,而不是添加一个table类型的节点,再添加tr类型的节点,再添加td。这样我们进行撤回时能够整个撤回,不会出现预期外的错误。

现在我们就已经支持了快捷键加粗,回车取消加粗mark的功能了。

看完上面的代码,大家会发现,无论是渲染,快捷键,编辑器行为增强,都可以放到一个插件里面,这样的拓展性可以说是非常好,斜体,行内代码的功能,只需要再加两个跟blodPlugin一样的插件就能实现。另外一方面,插件内的所有东西不是杂乱无章的,插件内所有的东西,他最终都需要统一收口,例如renderLeaf(收口渲染),hotkeyPlugin(收口快捷键),createEditor(收口增强编辑器行为,规范检查数据结构)

3.4 markdown支持

上面我们已经实现了加粗的渲染,快捷键,以及换行的取消加粗处理,接下来我们继续,我们需要添加两个**包裹的语法加粗支持。

这里我们能够简单的想到触发条件**加粗文本**之后还需要加一个空格才会进行加粗的操作,所以这里还是键盘事件。对于markDown我们还是用插件的形式来进行封装

import type { SlatePlugin } from "../../types";
import type { MarkdownRule } from "./types";

interface CreateMarkdownPluginOptions {
  rules: MarkdownRule[];
}

export const createMarkdownPlugin = (options: CreateMarkdownPluginOptions): SlatePlugin => {
  const { rules } = options;

  return {
    key: "markdown",

    onKeyDown(event, editor) {
      for (const rule of rules) {
        const triggers = Array.isArray(rule.trigger) ? rule.trigger : [rule.trigger];

        if (!triggers.includes(event.key)) continue;

        const match = rule.match(editor);
        if (!match) continue;

        event.preventDefault();
        rule.apply(editor, match);
        return;
      }
    },
  };
};

插件的统一出口,其实markdown语法就是一些规则检测,所以我们将每个规则单独写,但统一为一个插件。

对于markdownBoldRule其实只是需要做两件事,第一是检测到满足触发条件,第二是满足后做替换逻辑,下面代码可能看起来会很多,但是不用担心,因为注释比较详细,而且我们也不关注具体的slate api做了什么,我们只需要关心我们需要做什么,理解架构和设计是我这篇文章的初心。

/**
 * 实现 Markdown 加粗语法 **文本** 的处理规则
 * 当用户输入 **加粗文字** 并按空格后,将其转换为富文本格式的加粗效果
 */
import { Editor, Range, Text, Transforms, Point } from "slate";
import type { MarkdownRule } from "./types";
import { isBlockElement } from "../../utils";
import { BOLD_KEY } from "../marks/bold";

export const markdownBoldRule: MarkdownRule = {
  key: BOLD_KEY, // 规则标识符,用于区分不同的 Markdown 规则

  trigger: " ", // 触发该规则的关键字符,这里是空格键

  /**
   * 匹配函数,检测当前光标位置是否存在可转换的 Markdown 加粗语法
   * @param editor Slate 编辑器实例
   * @returns 如果找到匹配的加粗语法,返回包含文本和范围的对象;否则返回 null
   */
  match(editor) {
    const { selection } = editor;
    // 检查是否有选区且选区是否为折叠状态(即光标而非选择区域)
    if (!selection || !Range.isCollapsed(selection)) return null;

    const cursor = selection.anchor; // 获取光标位置
    // 查找当前光标所在的块级元素
    const block = Editor.above(editor, {
      match: isBlockElement(editor),
    });
    if (!block) return null; // 如果没有找到块级元素,则返回

    // 获取当前块的起始位置
    const blockStart = Editor.start(editor, block[1]);
    // 获取从块开始到当前光标位置的文本内容
    const text = Editor.string(editor, {
      anchor: blockStart,
      focus: cursor,
    });

    // 使用正则表达式匹配以 **文本** 结尾的模式
    const match = text.match(/**([^*]+)**$/);
    if (!match) return null;

    // 计算匹配到的加粗语法的起始位置
    const [textNode, textPath] = Editor.node(editor, cursor);
    if (!Text.isText(textNode)) return null;
    const startOffset = cursor.offset - match[0].length;
    if (startOffset < 0) return null;
    const start: Point = {
      path: textPath,
      offset: startOffset,
    };
    // 返回匹配到的文本内容和范围信息
    return {
      text: match[1],
      range: { anchor: start, focus: cursor },
    };
  },

  /**
   * 应用函数,将匹配到的 Markdown 加粗语法转换为实际的加粗样式
   * @param editor Slate 编辑器实例
   * @param match 包含匹配文本和范围的对象
   */
  apply(editor, match) {
    const { range, text } = match;

    Editor.withoutNormalizing(editor, () => {
      const { anchor, focus } = range;
      // 1 删除开头和结尾的 ** 符号(保留中间文本及原有 mark)
      const start = anchor.offset;
      const end = focus.offset;

      // 先删除结尾 **
      Transforms.delete(editor, {
        at: {
          anchor: { path: range.anchor.path, offset: end - 2 },
          focus: { path: range.anchor.path, offset: end },
        },
      });

      // 再删除开头 **
      Transforms.delete(editor, {
        at: {
          anchor: { path: range.anchor.path, offset: start },
          focus: { path: range.anchor.path, offset: start + 2 },
        },
      });

      // 2 计算加粗文本的新范围
      const startPoint = anchor;
      const endPoint: Point = { path: anchor.path, offset: start + text.length };
      // 3 添加 bold mark,保留原有 mark
      Transforms.setNodes(
        editor,
        { bold: true },
        { at: { anchor: startPoint, focus: endPoint }, match: Text.isText, split: true }
      );

      // 4 将光标移动到加粗文本末尾
      Transforms.select(editor, endPoint);

      // 5 移除当前 mark,避免后续输入继续加粗
      Editor.removeMark(editor, BOLD_KEY);
    });
  },
};

其实上面就两个函数match和apply。要做的事情就是

当trigger,也就是空格被按下时,触发match,match的作用就是用来查找符合条件的**加粗**文本。如果找到满足条件的match(也就是在编辑器里面的位置,第几个节点的第几个字到第几个节点的第几个字,start和end),最后如果有match,使用apply,就是把{text:"加粗**"}变为{text:"加粗",bold:true}然后重新计算光标位置,重置好。

直到这里,我们的功能层面都已经没问题了。

3.5 normailze

但还记得上面的标准化,也就是检查与修复数据结构。我们好像还没做。那我们现在就把他加上

聪明的你已经想到了,我们还是会用插件的形式去处理,如果你有印象的话,你应该还记得我们上面说过统一收口。

现在上代码,遍历插件,把他们的normalizeNode 都执行一遍,很简单的逻辑。

import type { SlatePlugin } from "../../types";
import type { NormalizeRule } from "./types";

interface CreateNormalizePluginOptions {
  rules: NormalizeRule[];
}

export const createNormalizePlugin = (options: CreateNormalizePluginOptions): SlatePlugin => {
  const { rules } = options;

  return {
    key: "normalize",

    withEditor(editor) {
      const { normalizeNode } = editor;

      editor.normalizeNode = (entry) => {
        // 1️⃣ 执行 normalize rules
        for (const rule of rules) {
          const handled = rule(entry, editor);
          if (handled) return;
        }

        // 2️⃣ 交回 Slate 默认 normalize
        normalizeNode(entry);
      };

      return editor;
    },
  };
};

统一收口

import { Text, Transforms } from "slate";
import type { NormalizeRule } from "./types";

export const removeFalseMark = (key: string): NormalizeRule => {
  return (entry, editor) => {
    const [node, path] = entry;

    if (!Text.isText(node)) return;

    if ((node as any)[key] === false) {
      Transforms.unsetNodes(editor, key, { at: path });
      return true; // ⭐ 已处理,终止 normalize
    }
  };
};

这里做了统一封装,因为像加粗这种mark(或者你可以理解为行内元素),都可以用统一的逻辑处理,斜体,行内代码都是一样。

对于mark节点(leaf节点)的属性,如果为false,则删除该属性,{text:"文本",blod:false}情况不允许存在,只允许存在{text:"文本",blod:true}与{text:"文本"}

行文至此,最后说一些题外话

四 总结

Intent Layer(Plugin / Hotkey / Markdown)
Application Layer(Command)
Engine Layer(Slate / Operations)

插件不操作 Slate
Command 不关心触发
Slate 不理解业务
 // AI老师太好用了,比我自己总结的好太多了,清晰明了
 

四 题外话&参考文档

下图中core就是编辑器组件的目录结构

基本经过上面简单的例子的封装,这个编辑器组件已经是一个架构清晰,拓展容易的富文本编辑器了,对于简单的编辑功能已经能够没有压力的实现。但编辑器对于前端领域来说本身是一个复杂的东西,还可以拓展很多的点。对于面试准备来说是一个很好的项目。下面简单拓展一下几个点,后续会陆续实现:

  • 命令(Command),用于与底层api与业务分离,一个命令代表一个完整的编辑意图(Command 的价值在于定义“一次操作的语义边界”),复用,语义化,更好的undo/redo(没有Command也能完成undo/redo),协同编辑。

    • 关于这里再补充一张图,用于更好的理解Command与Slate Operations的区别,不是简单的调用关系,而是层级上的关系
    •     User Intent
            ↓
          Command
            ↓
          Slate Operations
            ↓
          History / Sync / CRDT
      
  • 数据的序列化与反序列化,例如大模型习惯输出markdown文档(ai写作),怎么将其放到编辑器中。添加导出word功能,怎么将数据转化为html供api转化为word。

  • 协同编辑,自定义光标,websocket,结构一致,意图一致,体验自然

如果能做到最后一步协同编辑,这个编辑器项目也算是大成了,希望那一天早些到来。

参考文档:

slate官网:docs.slatejs.org/

plate官网:platejs.org/