React 富文本二次开发实战:自定义 Blot

678 阅读2分钟

富文本编辑需求往往复杂性较高,业务场景中常常需要结构化信息的嵌入,如用户提及、文件引用、图片模块等。这篇文章将从实战出发,深入介绍如何基于 ReactQuill 自定义 Blot,打造一个可插拔、可交互、可扩展的富文本组件体系。

功能1:从零实现 @提及模块

一、效果说明

在协作文档、评论区、社交输入框中,用户提及是一项非常常见的功能。当我们输入 @ 并选择一个用户时,富文本编辑器应当能以高亮样式显示用户名,并在后台存储结构化信息(如用户 ID),便于后续业务逻辑处理。

我们需要实现:

  • 输入 @ + 关键词 ➜ 弹窗联想用户
  • 插入后显示:蓝底高亮 + 不可编辑 + 可提取结构化数据
  • 支持 Delta 格式存储 / 还原

插入结果示意:

<span class="mention-blot" data-id="u1" contenteditable="false">@lin</span>

二、功能目标拆解

需求点技术实现
自定义 @ lin 的展示自定义 Inline Blot
插入时传入结构化数据insertEmbed('mention', { ... })
展示样式固定,不可编辑contenteditable="false"
提交时提取所有提及用户getContents() + 解析 Delta
JSON 数据还原为内容setContents()

三、具体实现

1. 定义 Mention Blot 类

说明:

  • blotName 定义 Quill 中使用的 blot 类型名称,后续 insertEmbed('mention', ...) 就是依赖这个名称。
  • create 方法定义了如何将数据转为 DOM 节点,记得加上 contenteditable=false,防止用户手动修改。
  • value 用于反解析 DOM 节点为结构化数据,在 getContents() 中就会保留。
import Quill from 'quill'

const Inline = Quill.import('blots/inline')

class MentionBlot extends Inline {
  static blotName = 'mention'
  static className = 'mention-blot'
  static tagName = 'span'

  static create(data: { id: string; name: string }) {
    const node = super.create()
    node.setAttribute('data-id', data.id)
    node.setAttribute('contenteditable', 'false')
    node.innerText = `@${data.name}`
    return node
  }

  static value(node: HTMLElement) {
    return {
      id: node.getAttribute('data-id') || '',
      name: node.innerText.replace(/^@/, '')
    }
  }
}

Quill.register(MentionBlot)

2. React 中插入 mention

说明:

  1. 通过 quillRef.current.getEditor() 获取 Quill 实例,插入指定的 embed 内容。
  2. 插入后需要手动设置新光标位置,否则继续输入可能在组件前面。
import React, { useRef } from 'react'
import ReactQuill from 'react-quill'
import 'react-quill/dist/quill.snow.css'

export const QuillEditor = () => {
  const quillRef = useRef<any>()

  const insertMention = () => {
    const editor = quillRef.current?.getEditor()
    const range = editor?.getSelection()
    if (range) {
      editor.insertEmbed(range.index, 'mention', {
        id: 'u1',
        name: 'lin'
      })
      editor.setSelection(range.index + 1)
    }
  }

  return (
    <>
      <button onClick={insertMention}>插入 @lin</button>
      <ReactQuill ref={quillRef} theme="snow" />
    </>
  )
}

3. 提取结构化数据

const delta = editor.getContents()
const mentions = delta.ops
  .filter(op => op.insert && op.insert.mention)
  .map(op => op.insert.mention)

// 输出:
// [{ id: 'u1', name: 'lin' }]

4. 从 JSON 恢复回富文本

const delta = {
  ops: [
    { insert: '你好,' },
    { insert: { mention: { id: 'u1', name: 'lin' } } },
    { insert: ',欢迎!' }
  ]
}

editor.setContents(delta)

功能2:富文本框内放图片信息(图片 icon + 名称),点击弹出 Modal 预览

一、实现效果

有些业务场景中,我们不仅要展示图片,还要显示图片名称、图标,并且支持点击查看大图。例如在任务评论区中:

“请查阅这张设计图 [🖼 图1:主流程页面.png]”。

目标:

  • 在富文本中以“图标 + 名称”的形式插入图片引用
  • 点击引用项时弹出 Modal 预览大图
  • 插入时、保存时都保留结构化数据(id, name, url)

二、实现

1. 自定义 ImageInfoBlot 类

说明:

  • 和 Mention Blot 类似,结构化数据通过 DOM 属性传入,后续可提取。
  • 设置 contenteditable=false 避免破坏图片结构。
  • 点击事件可以用监听容器的方式处理(委托),不直接在 create 中注册。
import Quill from 'quill'
const Inline = Quill.import('blots/inline')

class ImageInfoBlot extends Inline {
  static blotName = 'imageInfo'
  static tagName = 'span'
  static className = 'image-info-blot'

  static create(value: { id: string; name: string; url: string }) {
    const node = super.create()
    node.setAttribute('data-id', value.id)
    node.setAttribute('data-url', value.url)
    node.setAttribute('data-name', value.name)
    node.setAttribute('contenteditable', 'false')

    node.innerHTML = `
      <span style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;">
        <img src="icon-url" width="14" />
        <span>${value.name}</span>
      </span>
    `
    // 点击事件由组件统一监听,这里不写 onclick
    return node
  }

  static value(node: HTMLElement) {
    return {
      id: node.getAttribute('data-id') || '',
      url: node.getAttribute('data-url') || '',
      name: node.getAttribute('data-name') || '',
    }
  }
}

Quill.register(ImageInfoBlot)

2. React 富文本编辑器组件,监听点击显示 Modal

import React, { useRef, useEffect, useState } from 'react'
import ReactQuill from 'react-quill'
import { Modal } from 'antd'

export const QuillEditor = () => {
  const quillRef = useRef<any>()
  const [modalInfo, setModalInfo] = useState<null | { url: string; name: string }>(null)

  useEffect(() => {
    const editor = quillRef.current?.getEditor()
    if (!editor) return

    const container = editor.root

    const handleClick = (e: MouseEvent) => {
      const target = e.target as HTMLElement
      const imageEl = target.closest('.image-info-blot') as HTMLElement
      if (imageEl) {
        const url = imageEl.getAttribute('data-url') || ''
        const name = imageEl.getAttribute('data-name') || ''
        if (url) {
          setModalInfo({ url, name })
        }
      }
    }

    container.addEventListener('click', handleClick)
    return () => container.removeEventListener('click', handleClick)
  }, [])

  return (
    <>
      <ReactQuill ref={quillRef} theme="snow" />
      <Modal
        open={!!modalInfo}
        onCancel={() => setModalInfo(null)}
        footer={null}
        width={600}
        title={modalInfo?.name}
      >
        <img
          src={modalInfo?.url}
          alt={modalInfo?.name}
          style={{ width: '100%' }}
        />
      </Modal>
    </>
  )
}

小结

通过自定义 Blot,可以实现的功能很多,比如文中介绍的两种功能:

  • ✅ @提及用户:插入结构化用户信息,支持提交与回显
  • ✅ 图片信息模块:显示图片名称 + 图标,点击弹出 Modal 预览

当然,这只是冰山一角,借助 Quill 的 Blot 机制,还可以实现:

  • ✅ 任务卡片 / 项目引用块
  • ✅ #话题、$变量 等标记模块
  • ✅ 可拖拽/排序的结构化块
  • ✅ 富文本模板动态渲染字段
  • ✅ 粘贴内容结构清洗、自动识别模块

欢迎大家评论区补充~