富文本编辑器 Quill.js 系列三:架构与扩展

阅读量:

概要

Quill.js 是一个借助 contenteditable + MutationObserver 实现的 API 驱动的富文本编辑器。

有以下特性:

  1. 为开发者构建,细粒度访问、更新文本内容,一致的的 JSON 格式输入输出
  2. 跨平台。所有现代浏览器,在手机端、平板、桌面
  3. 伸缩性强。大小项目都可简单使用,可通过自定义扩展丰富富文本功能。

架构

Quill.js 架构清晰,核心功能简介:

  • Delta:富文本数据层,负责描述数据
  • Parchment:富文本控制器层,负责操控 dom,将数据变化映射到 UI 表示
  • Formats :Quill支持多种格式,包括UI控件和API调用
  • Module:模块化形式,负责解耦功能项,管控模块加载

模块机制

Quill 的模块注册全局唯一,通过键值对保存模块名称与定义。

我们可以调试,查看已注册的模块:

  1. 进入 https://quilljs.com/
  1. 审查元素找到编辑器容器类名为 ql-container 的元素并选中
  2. 控制台执行 $0.__quill.constructor.imports ,ps:$0为当前选中的元素

可以看到注册的内容格式有一些规则:

  1. 核心内置模块:delta parchment 这些直接用包名替代了
  2. blots/前缀模块:控制器元素,同时也会注册在 parchment 全局环境下,可通过 Parchment.query(balabala)查到到
  1. attributors/前缀模块:简化对 DOM 属性更新的工具,同时会注册到 Parchment,统一接管
  2. formats/前缀模块:功效和 blots + attributors 一致,为了与菜单栏操作有所对应
  3. moduels/前缀模块:功能集合,包括内置的剪切板、历史记录、快捷键等核心模块,这里也同样适合作为扩展自定义模块的切入点

模块通过调用 Quill.register()注册,会保存在 Quill.imports变量上,比较特殊的 blots/ formats/前缀会同时注册到 Parchment(用于管理 blot)上,blot 之于富文本就相当于 DOM 之于 web。

API 驱动

我们一起通过案例了解 Quill.js 的定位: “API 驱动的富文本编辑器”。

加粗 功能为例,将 [0, 5) 范围内的字符进行加粗,让我们大概了解下 quill 的执行流程。

一种方式是命令式调用:

quill.formatText(0, 5, 'bold', true);

执行流程:

  1. 定位选区的范围
  2. 确定编辑器 formatText,开始、结束位置,格式化方式
  3. 遍历选区子节点,这里我们先假设最简单场景:无格式化文本
  4. 创建 bold blot,并将文本 Leaf 节点插入到 bold 节点内
  5. 替代 bold blot 到选区的元素位置
  6. 计算 Delta 最新数据,触发编辑器更新事件

这种触发更新的方式是先更新 dom,再更新 delta 数据。

另一种调用方式:updateContents

quill.updateContents(new Delta().retain(5, { bold: true }));

执行流程:

  1. 遍历 delta 操作项
  2. 发现 attributes 有内容,执行 formatAt 方法
  3. 节点更新操作类似上述 3,4,5 流程,不重复赘述
  4. 遍历 delta 删除项(示例这一步不会发生)
  5. 触发编辑器更新事件

扩展实战

模块扩展

模块可以作为功能扩展的集合,Quill 中的模块、Blot 扩展都通过面向对象的编程方式实现。模块的 hook 包括:

  • static register:会在 Quill.register执行时调用
  • constructor:在实例化时调用,例如:
new Quill({
  modules: {
    demo: {
      // demo module options
    }
  }
})
class DemoModule<T = any> {
  static register() {
    // 这里可以注册 blot
    Quill.register(ImageBlot);
  }

  quill: Quill;
  options: T;

  constructor(quill, options: T = {} as any) {
    this.quill = quill;
    this.options = options;

    // 可以在这里扩展内置模块
    // 例如:增加剪切板匹配、增加快捷键
    this.addClipboardMatcher();
  }

  /**
   * 挂载剪切板控制
   */
  private addClipboardMatcher() {
    this.quill.clipboard.addMatcher('IMG', (node: HTMLImageElement, delta: Delta) => {
      // 这里可以对剪切板中的图片进行处理
      // 例如替换使用图片上传组件
    });
  }
}

Blot 扩展

要自定义编辑器中的板块,那就需要用到 Blot:文档展示的基本单元。《富文本编辑器 Quill.js 系列二:Parchment - 文档模型》中,简述了 Blot 的生命周期,这里就派上用场了。

假设我们想要使用 React 渲染 Blot,可以这么做:

const Parchment = Quill.import('parchment');
// 内联嵌入式 Blot,ps: 块级可以使用 Quill.import('blots/block/embed')
const { Embed } = Parchment;

class RcBlot<Props = {}> extends Embed {
  static blotName = 'demo-blot';
  static tagName = 'demo-blot';
  // blot hook: 创建时调用
  static create(value: Record<string, string>) {
    const node: HTMLElement = super.create(value);
    // 禁用编辑,交由 React 接管
    node.contentEditable = 'false';
    // 使用 dataset 保存组件 props
    Object.assign(node.dataset, value);
    return node;
  }

  // blot hook: 获取 value
  static value(domNode: HTMLElement) {
    return { ...domNode.dataset };
  }

  // blot hook: delta format
  format(name: string, value: string) {
    this.domNode.dataset[name] = value;
    this.render();
  }

  // blot hook: 组件挂载
  attach() {
    super.attach();
    this.render();
  }

  // blot hook: 组件卸载
  detach() {
    super.detach();
    ReactDOM.unmountComponentAtNode(this.domNode!);
  }

  // 外部操作更新
  setState(value: Partial<Props>) {
    Object.assign(this.domNode.dataset, value);
    this.render();
  }

  private getReactComponent(): ComponentType<any> {
    // 这里可以置顶 React 组件
    return Fragment;
  }

  // 获取当前 blot 所在编辑器 Quill 实例
  private getQuill() {
    return Quill.find(this.scroll.domNode.parentElement);
  }

  // render React component
  private render(appendProps?: Partial<Props>) {
    const ReactComponent = this.getReactComponent();
    const quill = this.getQuill();

    ReactDOM.render(
      <ReactComponent {...RcBlot.value(this.domNode)} {...appendProps} />,
      this.domNode!,
    );
  }
}

注册 blot,通过 Quill.register(RcBlot)进行注册,blotName 会用作 Parchment 注册中心的唯一标识,tagName 会用作创建 DOM。

通过 formt hook 和扩展的 setState,可以做到 quill 与 React 组件双向的同步。前者用于接收 updateContents引发的变更进而同步 react 组件;后者用于组件内部引发的状态变更,将数据同步到 quill 中。

借助 React,我们可以极大地简化 并 复用现有 UI 组件。

图片插入实战

假设我们需要新增一个图片模块,支持上传、编辑尺寸、删除、粘贴适配等功能,功能还算比较全。

借助于上述的 Blot React 扩展功能,我们可以快速开发图片组件、上传组件。这里把 2 者区分开来,主要是因为便于 delta 数据的区分,总不能把上传状态中的信息,保存到数据库吧。

以下是 ImageBlot ImageUploadBlot 实现:

// RcBlot 实现见上文
class ImageBlot extends RcBlot {
  static blotName = 'qh-image';
  static tagName = 'qh-image';

  render() {
    super.render({
      onResize: (width, height) => {
        this.setState({
          width,
          height,
        });
      },
    });
  }

  getReactComponent() {
    // Image 是 React 组件,接收 src,width,height,onResize 等属性
    // 实现忽略,具体脑补吧,比较简单
    return Image;
  }
}

class ImageUploadBlot extends RcBlot {
  static blotName = 'qh-image-upload';
  static tagName = 'qh-image-upload';

  getReactComponent() {
    // ImageUpload 接收 status,previewUrl,width,height,percent,error 等属性
    // 实现忽略,具体脑补吧,比较简单
    return ImageUpload;
  }
}

实现 Blot 后,需要使用 Module 进行包裹,方便使用方接入。

class ImageModule {
  static register() {
    // blot 注册
    Quill.register(ImageUploadBlot);
    Quill.register(ImageBlot);
  }

  quill: Quill;
  options: {
    // 上传交由使用方实现
    request: any;
  };

  constructor(quill: Quill, options: IImageModuleOptions) {
    super(quill, options);

    if (!options.request) {
      throw new Error('options request is required');
    }

    // 限于篇幅,粘贴、拖放略...
    quill.root.addEventListener('drop', this.handleDrop, false);
    quill.root.addEventListener('paste', this.handlePaste, false);

    this.addClipboardMatcher();
  }

  private addClipboardMatcher() {
    // 替换剪切板中的图片为自定义 ImageUploadBlot,并且可自行控制上传到自有云服务
    this.quill.clipboard.addMatcher('IMG', (node: HTMLImageElement, delta: Delta) => {
      return new Delta(
        delta.map((op) => {
          if (typeof op.insert === 'object' && typeof op.insert?.image === 'string') {
            return {
              insert: {
                [ImageUploadBlot.blotName]: {
                  status: 'uploading',
                  percent: 0,
                  previewUrl: op.insert.image,
                  width: 200,
                  height: 100,
                },
              },
            };
          }
          return op;
        })
      );
    });
  }
}

实现图片插入上传流程:

  1. quill.getSelection()获取选区
  2. 使用 URL.createObjectURL 加载图片获取图片元信息
  3. 使用 quill.insertEmbed 插入 ImageUploadBlot
  4. 调用 options.request上传图片
  5. 监听上传回调使用 quill.updateContents更新 upload blot 状态
  6. 上传成功删除 upload blot,插入 ImageBlot,流程结束

总结

通过上文我们了解到 Quill.js 的基本概念与功能,清晰合理的架构,以及偏内部实现相关的 模块注册机制、常用的 2 个 API 执行流程。

通过模块扩展、Blot 扩展的示例,以及图片插入实战,结合代码演示,丰富了 Quill.js 应用部分的展示。

Quill.js 的优点有很多:架构清晰、分层合理,源码实现上也比较简洁,Delta 的数据操作且能适应多人协作。

笔者认为缺点也是有的:

  1. 官方文档一般,使用示例,如何扩展还是比较欠缺的;
  2. 上手困难,如果只是用最基础的 Demo 那这条忽略,假如涉及到富文本的扩展,大概率还是要去阅读源码的;
  3. 模块的注册部分实现是全局的,假设想要实现同名 blot 的不同实现且运行在同个上下文内,就无法做到,模块的注册使用 provider + inject 的方式会更好。举个此场景的例子:在同一页面展示 pc web & 移动端 web 编辑器的使用案例 并 提供 demo 展示。

完。


comments powered by Disqus