跟着 AI 学(二)- Quill 接入速通

一份极简的 Quill 富文本编辑器快速上手指南,30 分钟完成基础接入。

一、Quill 是什么?

Quill 是一个现代化的富文本编辑器,专为 Web 应用设计。

核心特性

  • 所见即所得的编辑体验
  • 丰富的文本格式(粗体、斜体、列表等)
  • 可扩展的工具栏
  • 轻量高效(核心库仅 43KB)

适用场景

  • 文章编辑器
  • 评论系统
  • 表单内容输入
  • 后台管理系统

二、核心概念速览

2.1 数据存储格式

Quill 原生输出 HTML 格式:

typescript 复制代码
const html = quill.root.innerHTML
// 输出: "<p>这是<strong>加粗</strong>的内容</p>"

为什么选择 HTML?

  • ✅ 直接展示,无需转换
  • ✅ 支持丰富样式
  • ✅ 与编辑器无缝集成

存储建议 : 数据库使用 TEXT 类型字段


2.2 图片处理机制

Quill 默认行为: 将图片转为 Base64 直接嵌入 HTML

html 复制代码
<img src="data:image/png;base64,iVBORw0KGgo...">

优点 : 无需后端,立即可用 缺点: 文件体积大 33%,不适合生产环境

生产建议: 自定义上传到服务器,只保存 URL


2.3 安全防护

使用 dangerouslySetInnerHTML 渲染 HTML 存在 XSS 风险。

解决方案: 使用 DOMPurify 过滤

typescript 复制代码
import DOMPurify from 'dompurify'

<div dangerouslySetInnerHTML={{
  __html: DOMPurify.sanitize(userInput)
}} />

三、快速上手(30 分钟)

步骤 1: 安装依赖(2 分钟)

bash 复制代码
# 安装核心库
yarn add quill dompurify

# 安装类型定义
yarn add -D @types/dompurify

# 在入口文件引入样式
# import 'quill/dist/quill.snow.css'

步骤 2: 创建基础组件(10 分钟)

创建文件 src/components/RichEditor/index.tsx:

tsx 复制代码
import React, { useRef, useEffect } from 'react'
import { Modal } from 'antd'
import Quill from 'quill'

interface RichEditorProps {
  open: boolean
  title?: string
  value?: string
  onOk: (content: string) => void
  onCancel: () => void
}

const RichEditor: React.FC<RichEditorProps> = ({
  open,
  title = '富文本编辑器',
  value = '',
  onOk,
  onCancel
}) => {
  const editorRef = useRef<HTMLDivElement>(null)
  const quillRef = useRef<Quill | null>(null)

  useEffect(() => {
    if (open && editorRef.current && !quillRef.current) {
      // 延迟初始化,确保 DOM 已渲染
      const timer = setTimeout(() => {
        if (editorRef.current) {
          // 创建 Quill 实例
          const quill = new Quill(editorRef.current, {
            theme: 'snow',
            placeholder: '请输入内容...',
            modules: {
              toolbar: [
                ['bold', 'italic', 'underline', 'strike'],
                ['blockquote', 'code-block'],
                [{ header: 1 }, { header: 2 }],
                [{ list: 'ordered' }, { list: 'bullet' }],
                [{ color: [] }, { background: [] }],
                ['link', 'image'],
                ['clean']
              ]
            }
          })

          quillRef.current = quill

          // 设置初始内容
          if (value) {
            quill.root.innerHTML = value
          }
        }
      }, 200)

      return () => clearTimeout(timer)
    }

    // 弹窗关闭时销毁实例
    if (!open && quillRef.current) {
      quillRef.current = null
      if (editorRef.current) {
        editorRef.current.innerHTML = ''
      }
    }
  }, [open, value])

  const handleOk = () => {
    if (quillRef.current) {
      const html = quillRef.current.root.innerHTML
      onOk(html)
    }
  }

  return (
    <Modal
      title={title}
      open={open}
      onOk={handleOk}
      onCancel={onCancel}
      width={800}
      destroyOnClose
    >
      <div
        ref={editorRef}
        style={{
          minHeight: '400px',
          border: '1px solid #d9d9d9',
          borderRadius: '4px'
        }}
      />
    </Modal>
  )
}

export default RichEditor

关键点说明:

  • 使用 useRef 保持 Quill 实例
  • 延迟 200ms 初始化,确保 Modal 渲染完成
  • 弹窗关闭时销毁实例,避免内存泄漏

步骤 3: 集成到表单(15 分钟)

在页面中使用组件:

tsx 复制代码
import { useState } from 'react'
import { Form, Button } from 'antd'
import DOMPurify from 'dompurify'
import RichEditor from '@/components/RichEditor'

const DemoPage = () => {
  const [form] = Form.useForm()
  const [editorOpen, setEditorOpen] = useState(false)

  const handleEditorOk = (html: string) => {
    form.setFieldsValue({ content: html })
    setEditorOpen(false)
  }

  const onFinish = (values: any) => {
    console.log('提交的数据:', values)
    // 这里调用接口保存数据
  }

  return (
    <Form form={form} onFinish={onFinish}>
      <Form.Item label="内容" name="content">
        <div>
          {/* 显示区域 */}
          <div
            style={{
              border: '1px solid #d9d9d9',
              borderRadius: '4px',
              padding: '12px',
              minHeight: '100px',
              backgroundColor: '#fafafa',
              marginBottom: '8px',
              cursor: 'pointer'
            }}
            onClick={() => setEditorOpen(true)}
            dangerouslySetInnerHTML={{
              __html: DOMPurify.sanitize(
                form.getFieldValue('content') ||
                '<p style="color:#999">点击编辑内容</p>'
              )
            }}
          />

          {/* 编辑按钮 */}
          <Button onClick={() => setEditorOpen(true)}>
            编辑
          </Button>
        </div>
      </Form.Item>

      <Form.Item>
        <Button type="primary" htmlType="submit">
          提交
        </Button>
      </Form.Item>

      {/* 富文本编辑器 */}
      <RichEditor
        open={editorOpen}
        title="内容编辑"
        value={form.getFieldValue('content')}
        onOk={handleEditorOk}
        onCancel={() => setEditorOpen(false)}
      />
    </Form>
  )
}

export default DemoPage

效果:

  1. 点击显示区域或"编辑"按钮,打开编辑器
  2. 编辑完成点击"确定",内容自动更新
  3. 点击"提交",获取 HTML 内容

安全要点:

  • ✅ 显示时使用 DOMPurify.sanitize() 过滤
  • ✅ 添加 ql-editor class 保持样式一致

步骤 4: 自定义图片上传(可选)

如果需要上传图片到服务器而非使用 Base64:

tsx 复制代码
// 在 RichEditor 组件中添加上传逻辑

import { message } from 'antd'

const RichEditor: React.FC<RichEditorProps> = (props) => {
  // ... 其他代码

  // 上传函数(需根据实际接口调整)
  const uploadImage = async (file: File): Promise<string> => {
    const formData = new FormData()
    formData.append('file', file)

    // 调用上传接口
    const response = await fetch('/api/upload', {
      method: 'POST',
      body: formData
    })

    const result = await response.json()
    return result.data.url  // 返回图片 URL
  }

  // 选择本地图片
  const selectLocalImage = () => {
    const input = document.createElement('input')
    input.setAttribute('type', 'file')
    input.setAttribute('accept', 'image/*')
    input.click()

    input.onchange = async () => {
      const file = input.files?.[0]
      if (!file) return

      const quill = quillRef.current
      if (!quill) return

      // 文件大小验证
      if (file.size > 5 * 1024 * 1024) {
        message.error('图片大小不能超过 5MB')
        return
      }

      try {
        message.loading({ content: '上传中...', key: 'upload', duration: 0 })

        // 上传到服务器
        const imageUrl = await uploadImage(file)

        // 获取光标位置
        const range = quill.getSelection(true)

        // 插入图片
        quill.insertEmbed(range.index, 'image', imageUrl)

        // 移动光标到图片后面
        quill.setSelection(range.index + 1, 0)

        message.success({ content: '上传成功', key: 'upload' })
      } catch (error) {
        message.error({ content: '上传失败', key: 'upload' })
      }
    }
  }

  // 修改 Quill 初始化配置
  const quill = new Quill(editorRef.current, {
    theme: 'snow',
    modules: {
      toolbar: {
        container: [
          ['bold', 'italic', 'underline'],
          ['image']  // 图片按钮
        ],
        handlers: {
          // 自定义图片处理
          image: selectLocalImage
        }
      }
    }
  })

  // ... 其他代码
}

注意事项:

  • 上传接口需要根据实际项目调整
  • 返回的 URL 应该是可公开访问的地址
  • 建议添加文件类型和大小验证

四、XSS 安全防护

为什么需要防护?

用户可能输入恶意脚本:

html 复制代码
<p>正常内容 <script>alert('XSS攻击!')</script></p>

如果直接使用 dangerouslySetInnerHTML 渲染,浏览器会执行脚本。


解决方案:DOMPurify

配置过滤规则:

tsx 复制代码
import DOMPurify from 'dompurify'

const sanitizedHtml = DOMPurify.sanitize(userInput, {
  // 允许的标签
  ALLOWED_TAGS: [
    'p', 'br', 'div', 'span',
    'strong', 'em', 'u', 's',
    'h1', 'h2', 'h3',
    'ul', 'ol', 'li',
    'blockquote', 'code', 'pre',
    'a', 'img'
  ],
  // 允许的属性
  ALLOWED_ATTR: [
    'href', 'src', 'alt', 'target',
    'class', 'style'
  ]
})

过滤效果:

html 复制代码
<!-- 输入 -->
<p>文本 <script>alert('XSS')</script> 继续</p>
<img src=x onerror="alert('XSS')">

<!-- 过滤后 -->
<p>文本  继续</p>
<img src="x">
  • <script> 标签被移除
  • onerror 等事件属性被移除
  • ✅ 合法标签和属性保留

快速测试

在浏览器控制台执行:

javascript 复制代码
// 找到显示区域
const div = document.querySelector('.ql-editor')

// 注入测试 HTML
div.innerHTML = '<p>测试 <script>alert("XSS")</script> 结束</p>'

// 观察是否弹出 alert
// - 弹出 → 存在漏洞
// - 不弹 → 已被拦截

五、常见问题

Q1: 如何自定义工具栏?

修改 toolbar 配置:

typescript 复制代码
modules: {
  toolbar: [
    [{ header: [1, 2, 3, false] }],
    ['bold', 'italic'],
    [{ list: 'ordered' }, { list: 'bullet' }],
    ['link']
    // 只保留需要的按钮
  ]
}

Q2: 如何限制内容长度?

typescript 复制代码
const handleOk = () => {
  const html = quill.root.innerHTML
  const text = quill.getText()

  if (text.length > 5000) {
    message.error('内容不能超过 5000 字')
    return
  }

  onOk(html)
}

Q3: 如何实现字数统计?

typescript 复制代码
useEffect(() => {
  if (!quillRef.current) return

  const quill = quillRef.current

  quill.on('text-change', () => {
    const text = quill.getText()
    const wordCount = text.trim().length
    console.log('当前字数:', wordCount)
  })
}, [])

附录:参考资源

官方文档:

技术栈:

  • React 18+
  • Ant Design 5+
  • TypeScript 4.5+

至此,你已掌握 Quill 快速接入的核心知识! 🎉

现在开始动手实践吧!

相关推荐
十里-4 小时前
在 Vue2 中为 Element-UI 的 el-dialog 添加拖拽功能
前端·vue.js·ui
shada4 小时前
从Google Chrome商店下载CRX文件
前端·chrome
左耳咚4 小时前
项目开发中从补码到精度丢失的陷阱
前端·javascript·面试
黑云压城After4 小时前
vue2实现图片自定义裁剪功能(uniapp)
java·前端·javascript
芙蓉王真的好14 小时前
NestJS API 提示信息规范:让日志与前端提示保持一致的方法
前端·状态模式
dwedwswd5 小时前
技术速递|从 0 到 1:用 Playwright MCP 搭配 GitHub Copilot 搭建 Web 应用调试环境
前端·github·copilot
2501_938774295 小时前
Leaflet 弹出窗实现:Spring Boot 传递省级旅游口号信息的前端展示逻辑
前端·spring boot·旅游
meichaoWen5 小时前
【CSS】CSS 面试知多少
前端·css
我血条子呢5 小时前
【预览PDF】前端预览pdf
前端·pdf·状态模式