Skip to content

富文本编辑器

Vue 富文本编辑器工具。

TipTap

基本信息

特点

  • ✅ 基于 ProseMirror
  • ✅ 完全无头(Headless),UI 完全可定制
  • ✅ 模块化设计,按需引入扩展
  • ✅ TypeScript 支持
  • ✅ 协同编辑支持
  • ✅ Markdown 快捷键
  • ✅ 现代化 API

安装

bash
npm install @tiptap/vue-3 @tiptap/starter-kit

基础用法

vue
<script setup lang="ts">
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'

const editor = useEditor({
  content: '<p>Hello World! 🌍️</p>',
  extensions: [
    StarterKit,
  ],
})
</script>

<template>
  <EditorContent :editor="editor" />
</template>

<style>
/* 基础样式 */
.ProseMirror {
  padding: 1rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  min-height: 200px;
}

.ProseMirror:focus {
  outline: none;
  border-color: #0abab5;
}
</style>

带工具栏的完整示例

vue
<script setup lang="ts">
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { ref } from 'vue'

const editor = useEditor({
  content: '<p>开始编辑...</p>',
  extensions: [StarterKit],
})
</script>

<template>
  <div class="editor" v-if="editor">
    <!-- 工具栏 -->
    <div class="toolbar">
      <button 
        @click="editor.chain().focus().toggleBold().run()"
        :class="{ 'is-active': editor.isActive('bold') }"
      >
        粗体
      </button>
      <button 
        @click="editor.chain().focus().toggleItalic().run()"
        :class="{ 'is-active': editor.isActive('italic') }"
      >
        斜体
      </button>
      <button 
        @click="editor.chain().focus().toggleStrike().run()"
        :class="{ 'is-active': editor.isActive('strike') }"
      >
        删除线
      </button>
      <button 
        @click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
      >
        H1
      </button>
      <button 
        @click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
      >
        H2
      </button>
      <button 
        @click="editor.chain().focus().toggleBulletList().run()"
        :class="{ 'is-active': editor.isActive('bulletList') }"
      >
        无序列表
      </button>
      <button 
        @click="editor.chain().focus().toggleOrderedList().run()"
        :class="{ 'is-active': editor.isActive('orderedList') }"
      >
        有序列表
      </button>
      <button 
        @click="editor.chain().focus().toggleCodeBlock().run()"
        :class="{ 'is-active': editor.isActive('codeBlock') }"
      >
        代码块
      </button>
      <button @click="editor.chain().focus().undo().run()">
        撤销
      </button>
      <button @click="editor.chain().focus().redo().run()">
        重做
      </button>
    </div>

    <!-- 编辑器内容 -->
    <EditorContent :editor="editor" />
  </div>
</template>

<style scoped>
.editor {
  border: 1px solid #ddd;
  border-radius: 8px;
  overflow: hidden;
}

.toolbar {
  display: flex;
  gap: 4px;
  padding: 8px;
  background: #f5f5f5;
  border-bottom: 1px solid #ddd;
  flex-wrap: wrap;
}

.toolbar button {
  padding: 6px 12px;
  border: 1px solid #ddd;
  background: white;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.toolbar button:hover {
  background: #e9e9e9;
}

.toolbar button.is-active {
  background: #0abab5;
  color: white;
  border-color: #0abab5;
}

.ProseMirror {
  padding: 16px;
  min-height: 300px;
  outline: none;
}

.ProseMirror h1 {
  font-size: 2em;
  margin: 0.5em 0;
}

.ProseMirror h2 {
  font-size: 1.5em;
  margin: 0.5em 0;
}

.ProseMirror code {
  background: #f4f4f4;
  padding: 2px 4px;
  border-radius: 3px;
  font-family: monospace;
}

.ProseMirror pre {
  background: #1e1e1e;
  color: #d4d4d4;
  padding: 12px;
  border-radius: 4px;
  overflow-x: auto;
}
</style>

常用扩展

bash
# 图片支持
npm install @tiptap/extension-image

# 链接支持
npm install @tiptap/extension-link

# 表格支持
npm install @tiptap/extension-table @tiptap/extension-table-row @tiptap/extension-table-cell @tiptap/extension-table-header

# 高亮支持
npm install @tiptap/extension-highlight

# 文本对齐
npm install @tiptap/extension-text-align

# 占位符
npm install @tiptap/extension-placeholder

使用扩展示例

vue
<script setup lang="ts">
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import Highlight from '@tiptap/extension-highlight'
import Placeholder from '@tiptap/extension-placeholder'

const editor = useEditor({
  extensions: [
    StarterKit,
    Image,
    Link.configure({
      openOnClick: false,
    }),
    Highlight.configure({
      multicolor: true,
    }),
    Placeholder.configure({
      placeholder: '开始输入内容...',
    }),
  ],
  content: '',
})

// 插入图片
const addImage = () => {
  const url = window.prompt('图片 URL')
  if (url) {
    editor.value.chain().focus().setImage({ src: url }).run()
  }
}

// 添加链接
const setLink = () => {
  const url = window.prompt('链接 URL')
  if (url) {
    editor.value.chain().focus().setLink({ href: url }).run()
  }
}
</script>

获取和设置内容

vue
<script setup lang="ts">
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { ref } from 'vue'

const editor = useEditor({
  extensions: [StarterKit],
  content: '<p>初始内容</p>',
})

// 获取 HTML
const getHTML = () => {
  console.log(editor.value.getHTML())
}

// 获取 JSON
const getJSON = () => {
  console.log(editor.value.getJSON())
}

// 获取纯文本
const getText = () => {
  console.log(editor.value.getText())
}

// 设置内容
const setContent = () => {
  editor.value.commands.setContent('<p>新内容</p>')
}

// 清空内容
const clearContent = () => {
  editor.value.commands.clearContent()
}
</script>

优势

  • 🎨 完全可定制: UI 完全由你控制
  • 📦 模块化: 只引入需要的功能
  • 🚀 性能优异: 基于 ProseMirror
  • 🤝 协同编辑: 内置协同编辑支持
  • 📱 移动端友好: 响应式设计

适用场景

  • 需要高度定制 UI 的项目
  • 需要协同编辑功能
  • 现代化的 Web 应用
  • 需要精确控制编辑器行为

wangEditor

基本信息

特点

  • ✅ 开箱即用,配置简单
  • ✅ 中文文档完善
  • ✅ 功能丰富(表格、代码高亮、上传图片等)
  • ✅ 轻量级(gzip 后约 40kb)
  • ✅ TypeScript 支持
  • ✅ 支持 Vue 2/3
  • ✅ 国内团队维护

安装

bash
# Vue 3
npm install @wangeditor/editor @wangeditor/editor-for-vue@next

# Vue 2
npm install @wangeditor/editor @wangeditor/editor-for-vue

基础用法(Vue 3)

vue
<script setup lang="ts">
import { onBeforeUnmount, ref, shallowRef } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import '@wangeditor/editor/dist/css/style.css'

// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef()

// 内容 HTML
const valueHtml = ref('<p>hello</p>')

const toolbarConfig = {}
const editorConfig = { 
  placeholder: '请输入内容...',
}

// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
  const editor = editorRef.value
  if (editor == null) return
  editor.destroy()
})

const handleCreated = (editor) => {
  editorRef.value = editor // 记录 editor 实例,重要!
}

const handleChange = (editor) => {
  console.log('content changed', editor.getHtml())
}
</script>

<template>
  <div style="border: 1px solid #ccc">
    <Toolbar
      style="border-bottom: 1px solid #ccc"
      :editor="editorRef"
      :defaultConfig="toolbarConfig"
      mode="default"
    />
    <Editor
      style="height: 500px; overflow-y: hidden;"
      v-model="valueHtml"
      :defaultConfig="editorConfig"
      mode="default"
      @onCreated="handleCreated"
      @onChange="handleChange"
    />
  </div>
</template>

工具栏配置

vue
<script setup lang="ts">
const toolbarConfig = {
  // 显示哪些菜单
  toolbarKeys: [
    'headerSelect',
    'bold',
    'italic',
    'underline',
    'through',
    '|',
    'color',
    'bgColor',
    '|',
    'fontSize',
    'fontFamily',
    'lineHeight',
    '|',
    'bulletedList',
    'numberedList',
    'todo',
    '|',
    'justifyLeft',
    'justifyCenter',
    'justifyRight',
    'justifyJustify',
    '|',
    'indent',
    'delIndent',
    '|',
    'insertLink',
    'insertImage',
    'insertVideo',
    'insertTable',
    'codeBlock',
    'divider',
    '|',
    'undo',
    'redo',
    '|',
    'fullScreen',
  ],
  
  // 排除哪些菜单
  excludeKeys: [
    'group-video', // 排除视频
  ],
}
</script>

编辑器配置

vue
<script setup lang="ts">
const editorConfig = {
  placeholder: '请输入内容...',
  
  // 自动 focus
  autoFocus: true,
  
  // 只读模式
  readOnly: false,
  
  // 最大长度限制
  maxLength: 10000,
  
  // 配置上传图片
  MENU_CONF: {
    uploadImage: {
      // 服务端地址
      server: '/api/upload',
      
      // 单个文件的最大体积限制,默认为 2M
      maxFileSize: 2 * 1024 * 1024,
      
      // 最多可上传几个文件,默认为 100
      maxNumberOfFiles: 10,
      
      // 选择文件时的类型限制,默认为 ['image/*']
      allowedFileTypes: ['image/*'],
      
      // 自定义上传参数
      meta: {
        token: 'xxx',
      },
      
      // 自定义增加 http header
      headers: {
        Authorization: 'Bearer xxx',
      },
      
      // 跨域是否传递 cookie
      withCredentials: true,
      
      // 超时时间,默认为 10 秒
      timeout: 10 * 1000,
      
      // 自定义插入图片
      customInsert(res, insertFn) {
        // res 即服务端的返回结果
        const url = res.data.url
        const alt = res.data.alt
        const href = res.data.href
        
        // 从 res 中找到 url alt href ,然后插入图片
        insertFn(url, alt, href)
      },
    },
  },
}
</script>

自定义上传图片

vue
<script setup lang="ts">
const editorConfig = {
  MENU_CONF: {
    uploadImage: {
      // 自定义上传
      async customUpload(file, insertFn) {
        // file 即选中的文件
        // 自己实现上传,并得到图片 url alt href
        const formData = new FormData()
        formData.append('file', file)
        
        const res = await fetch('/api/upload', {
          method: 'POST',
          body: formData,
        })
        const data = await res.json()
        
        // 最后插入图片
        insertFn(data.url, data.alt, data.href)
      },
    },
  },
}
</script>

获取和设置内容

vue
<script setup lang="ts">
import { shallowRef } from 'vue'

const editorRef = shallowRef()

// 获取 HTML
const getHtml = () => {
  const html = editorRef.value.getHtml()
  console.log(html)
}

// 获取纯文本
const getText = () => {
  const text = editorRef.value.getText()
  console.log(text)
}

// 设置 HTML
const setHtml = () => {
  editorRef.value.setHtml('<p>新内容</p>')
}

// 清空内容
const clear = () => {
  editorRef.value.clear()
}

// 禁用/启用
const disable = () => {
  editorRef.value.disable()
}

const enable = () => {
  editorRef.value.enable()
}
</script>

优势

  • 📦 开箱即用: 默认配置即可使用
  • 🇨🇳 中文友好: 中文文档完善
  • 🎯 功能全面: 常用功能都内置
  • 🪶 轻量级: 体积小,性能好
  • 🛠️ 易于配置: 配置简单直观

适用场景

  • 快速开发,不需要过度定制
  • 中文项目
  • 需要完整功能的编辑器
  • 对体积有一定要求

对比选择

特性TipTapwangEditor
UI 定制✅✅✅ 完全自定义⚠️ 有限定制
上手难度⚠️ 较高✅ 简单
开箱即用❌ 需要自己构建 UI✅ 开箱即用
功能丰富度✅ 通过扩展实现✅ 内置丰富功能
体积⚠️ 按需引入✅ 约 40kb (gzip)
TypeScript✅ 完整支持✅ 支持
协同编辑✅ 内置支持❌ 不支持
中文文档⚠️ 英文为主✅ 完善
移动端✅ 友好✅ 支持
学习曲线陡峭平缓
社区活跃度✅✅ 非常活跃✅ 活跃

推荐选择

选择 TipTap 如果你:

  • 需要高度定制 UI
  • 需要协同编辑功能
  • 追求现代化的开发体验
  • 有时间学习和定制

选择 wangEditor 如果你:

  • 需要快速上手
  • 不需要过度定制
  • 中文项目
  • 需要开箱即用的完整功能

其他选择

Quill

Slate

CKEditor 5