Vue 3 富文本编辑器 Composable 架构设计:从 contenteditable 到生产可用

4 阅读10分钟

Vue 3 富文本编辑器 Composable 架构设计:从 contenteditable 到生产可用

需求很简单:一个输入框,能打字,能粘贴截图。评估了一圈第三方库,最后决定用 contenteditable 自己搓。


先说场景

业务上需要一个富文本输入框。要求不高:

  • 能输入文字、换行
  • 能粘贴截图(微信截图、系统截图直接 Ctrl+V)
  • 图片上传到 OSS,编辑器里正常显示
  • 输出 HTML,v-model 双向绑定

就这些。没有协同编辑,没有 Markdown,没有复杂排版。

按常理直接上 TipTap 或者 Quill 就完事了。但评估了一下:

方案问题
TipTap (ProseMirror)核心 + 插件打包下来不小,我只需要粘贴图片这一个功能
Slate.js偏 React 生态,Vue 3 用起来别扭
Quill开箱即用但定制性差,它的剪贴板处理不好改
Lexical (Meta)架构很新,但 Vue 支持还不完善

说白了就是:杀鸡用牛刀。我只需要一个能粘贴图片的 contenteditable,不想引入一整个编辑器框架。

于是决定自研。零依赖,就一个 Vue 3 组件。


架构总览

最终拆成四个文件,每个只管一件事:

src/components/RichEditor/
├── types.ts              # 类型定义
├── sanitize.ts           # HTML 白名单过滤(XSS 防护)
├── useRichEditor.ts      # 核心 Composable(粘贴、上传、光标、同步)
└── RichEditor.vue        # 主组件(模板 + 样式 + 胶水逻辑)

数据流是这样的:

用户操作(输入/粘贴)
  ↓
DOMcontenteditable div)
  ↓
useRichEditor(处理 + sanitize)
  ↓
emit('update:modelValue')
  ↓
父组件 v-model

设计原则很简单:Composable 是大脑,Vue 组件是胶水,sanitize 是独立的安保

RichEditor.vue 只负责绑定事件、渲染模板、处理 v-model 的 watch 回显。所有逻辑都在 useRichEditor 里,组件文件很干净。


types.ts — 先定类型,再写逻辑

这个文件是第一个写的。不是因为有强迫症,而是因为类型定义本质上就是接口契约

先把 Props、Emits、Expose 的形状定好,后面每个模块照着契约实现就行。不用写到一半发现参数传错了回头改。

一个值得说的设计:UploadResult 拆成了 pathurl 两个字段。

interface UploadResult {
  /** 服务端存储路径,提交给后端时使用 */
  path: string    // '3010/ads_algorithm/xxx.jpg'
  /** 完整可访问 URL,编辑器内预览显示时使用 */
  url: string     // 'https://oss.example.com/3010/ads_algorithm/xxx.jpg?Expires=...'
}

为什么拆两个?因为 OSS 签名 URL 会过期。src 里放完整 URL 是给浏览器显示用的,data-oss-path 里放相对路径是给后端用的。后端回显的时候拿 data-oss-path 重新签名就行,前端完全不用管过期逻辑。

如果只存一个 URL,过期了就白屏了。

另外,SanitizeConfig 也独立成了类型:

interface SanitizeConfig {
  allowedTags: string[]
  allowedAttributes: Record<string, string[]>
  allowedStyles: string[]
}

安全策略跟业务逻辑解耦。改白名单不用动 composable 代码。


sanitize.ts — 独立的安全层

这个模块跟编辑器逻辑完全无关,纯输入输出函数。放出来是因为 XSS 防护不应该是粘贴逻辑的一部分,它应该是一个独立的过滤器

为什么不用正则

正则处理 HTML 有个根本问题:HTML 不是正则语言。

// 这种正则能防住简单的 <script>,但防不住:
content.replace(/<script.*?>.*?<\/script>/gi, '')

// 编码绕过
<img src=x onerror="&#97;lert(1)">

// 嵌套绕过
<<script>script>alert(1)</script>

// 注释绕过
<script>alert(1)<!-- </script>

所以用浏览器原生的 DOMParser 解析,然后按白名单重建。浏览器帮你 parse,不会被骗。

核心逻辑

export function sanitizeHtml(html: string, config = DEFAULT_CONFIG): string {
  const doc = new DOMParser().parseFromString(html, 'text/html')
  const fragment = document.createDocumentFragment()
  sanitizeNodes(doc.body.childNodes, fragment, config)
  const div = document.createElement('div')
  div.appendChild(fragment)
  return div.innerHTML
}

思路很直白:解析成 DOM 树 → 遍历节点 → 白名单内的保留,不在白名单的剔除标签但保留文本内容。

function sanitizeNodes(nodes, parent, config) {
  nodes.forEach(node => {
    if (node.nodeType === Node.TEXT_NODE) {
      parent.appendChild(node.cloneNode())
      return
    }
    if (node.nodeType !== Node.ELEMENT_NODE) return

    const tagName = node.tagName.toLowerCase()
    if (!config.allowedTags.includes(tagName)) {
      // 标签不在白名单:丢弃标签,保留文字
      sanitizeNodes(node.childNodes, parent, config)
      return
    }
    // 白名单内的标签:过滤属性后重建
    const clean = document.createElement(tagName)
    // ... 过滤属性、检查 URL 安全性、过滤 style
    parent.appendChild(clean)
    sanitizeNodes(node.childNodes, clean, config)
  })
}

几个关键细节:

URL 安全检查isSafeUrl 只允许 http:https:data:image/blob: 和相对路径。javascript:vbscript:data:text/html 一律拒绝。

style 过滤sanitizeStyle 单独抽了一个函数。CSS 属性虽然不能执行 JS(expression() 那些老 IE 时代的坑已经过去了),但 position:fixedz-index:99999opacity:0 这些样式照样能搞事情——覆盖你的 UI、隐藏恶意内容、搞乱布局。白名单只放安全的属性:text-alignmargin-*padding-*max-width 这些。

stripImages 选项 — 混合粘贴的时候,从网页复制的内容可能带外链 <img>。这些图片链接不一定安全,也不可控。所以混合粘贴路径下传 stripImages: true,把 HTML 里的 img 全部剔除,图片统一走 clipboardData.items 上传流程。


useRichEditor.ts — 核心 Composable

这是整个组件的大脑。内部按职责拆了五个子模块。

内容同步 — syncContent

function syncContent() {
  const el = editorRef.value
  if (!el) return

  if (checkIsEmpty(el)) {
    isEmpty.value = true
    emit('update:modelValue', '')
    return
  }
  isEmpty.value = false
  emit('update:modelValue', el.innerHTML)
}

重点在 checkIsEmptycontenteditable 空的时候 innerHTML 不一定是空字符串——可能是 <br><div><br></div>&nbsp; 各种变体。

function checkIsEmpty(el: HTMLElement): boolean {
  if (el.querySelector('img')) return false  // 有图片就是有内容
  const text = (el.textContent || '').replace(/\u00a0/g, '').trim()
  return text.length === 0
}

两步判断:先看有没有 <img>(图片算内容),再看 textContent&nbsp; 和空白后是不是空。这样 <br><div><br></div>、纯空格都会被正确判定为空。

光标管理 — getSafeRange / insertHtmlAtCursor

这是 contenteditable 最容易出 bug 的地方。

用户粘贴的时候编辑器可能没有焦点——比如点了旁边的按钮,回来直接 Ctrl+V。这时候 window.getSelection() 拿不到 Range,直接 insertHTML 会报错。

所以 getSafeRange 做了三级兜底:

function getSafeRange(): Range | null {
  let selection = window.getSelection()
  // 第一级:有选区,直接用
  if (selection?.rangeCount) return selection.getRangeAt(0)

  // 第二级:没有选区,先 focus 再试
  editorRef.value?.focus()
  selection = window.getSelection()
  if (selection?.rangeCount) return selection.getRangeAt(0)

  // 第三级:还是不行,手动创建一个 Range 放到末尾
  const range = document.createRange()
  range.selectNodeContents(editorRef.value!)
  range.collapse(false)  // 折叠到末尾
  selection = window.getSelection()
  if (!selection) return null
  selection.removeAllRanges()
  selection.addRange(range)
  return range
}

insertHtmlAtCursor 在插入后会把光标移到新内容末尾。不然光标会停在插入内容前面,用户接着打字顺序就反了。

粘贴处理 — handlePaste

三条分支:图片粘贴、纯文本/HTML 粘贴、混合粘贴。

function handlePaste(e: ClipboardEvent) {
  if (props.disabled) return

  const items = Array.from(e.clipboardData?.items || [])
  const imageItems = items.filter(item => item.type.startsWith('image/'))

  if (imageItems.length > 0) {
    e.preventDefault()
    // 混合粘贴:先插入文本(图片统一走 items 上传,不走 HTML 里的外链 img)
    const htmlData = e.clipboardData?.getData('text/html')
    const textData = e.clipboardData?.getData('text/plain')
    if (htmlData || textData) {
      // stripImages: true — 剔除 HTML 里的外链 <img>,防止不可控图片混入
      const safeHtml = htmlData
        ? sanitizeHtml(htmlData, { ...DEFAULT_CONFIG, stripImages: true })
        : escapeText(textData!)
      insertHtmlAtCursor(safeHtml)
      syncContent()
    }
    // 再处理图片(每张独立上传)
    imageItems.forEach(item => {
      const file = item.getAsFile()
      if (file) processImageFile(file)
    })
  } else {
    // 纯文本或 HTML
    e.preventDefault()
    const htmlData = e.clipboardData?.getData('text/html')
    const textData = e.clipboardData?.getData('text/plain')
    const safeHtml = htmlData ? sanitizeHtml(htmlData) : escapeText(textData || '')
    insertHtmlAtCursor(safeHtml)
    syncContent()
  }
}

混合粘贴为什么要先插文本再处理图片?因为图片上传是异步的,文本插入是同步的。先把文本插进去并 syncContent,这样即使图片全部校验失败(格式不对、超大小),文本也不会丢。

还有一个细节:getAsFile() 必须在 paste 事件的同步阶段调用。拖到异步里,引用就丢了。

图片上传 — uploadAndInsert

每张图有独立的 uploadId,独立的 loading 占位,独立的 Promise 链。

function uploadAndInsert(file: File) {
  const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`

  // 1. 插入 loading 占位
  const placeholder = createLoadingPlaceholder(uploadId)
  insertNodeAtCursor(placeholder)

  // 2. 上传
  props.uploadFn(file)
    .then(result => {
      // 3. 成功:替换占位为 <img>
      replacePlaceholderWithImage(uploadId, result)
      syncContent()
    })
    .catch(() => {
      // 4. 失败:移除占位
      removePlaceholder(uploadId)
      ElMessage.error('图片上传失败,请重试')
      syncContent()
    })
}

连续粘贴 5 张图片,就有 5 个独立的 loading 块,5 个独立的上传请求。某张失败不影响其他。

上传成功后生成的 <img> 标签同时携带 src(完整 URL)和 data-oss-path(相对路径):

const img = document.createElement('img')
img.src = result.url                    // 浏览器显示用
img.setAttribute('data-oss-path', result.path)  // 后端用
img.style.maxWidth = '100%'

中文输入法 — composing flag

contenteditable 下用中文输入法,每敲一个拼音字母都会触发 input 事件。如果每次都 syncContent,会把未确认的拼音同步到 modelValue,导致输入被打断。

const isComposing = ref(false)

function handleCompositionStart() { isComposing.value = true }
function handleCompositionEnd() {
  isComposing.value = false
  syncContent()  // 输入法确认后才同步
}

function handleInput() {
  if (isComposing.value) return  // composing 期间跳过
  syncContent()
}

就几行代码,但没加的话中文输入基本不可用。


三个关键设计决策

1. watch(modelValue) 的死循环

v-model 的完整链路是:

DOM inputsyncContentemit('update:modelValue') → 父组件更新 propwatch 触发 → 写回 DOM

问题在最后一步。syncContent 读出来的 innerHTML 跟原始 modelValue 可能有微小差异——属性顺序、空白符、<br> 的写法。浏览器 serialize HTML 的方式跟你写进去的不完全一致。如果不加控制,watch 判定值变了,写回 DOM,又触发 syncContent——死循环。更严重的是写回 DOM 会让光标跳到开头。

解决方案:syncContent 内部 emit 之后设一个 isInternalUpdate flag,watch 里检测到就跳过。

// composable 内
let isInternalUpdate = false

function syncContent() {
  // ...
  isInternalUpdate = true
  emit('update:modelValue', value)
  nextTick(() => { isInternalUpdate = false })
}

// Vue 组件 watch
watch(() => props.modelValue, (newVal) => {
  if (isInternalUpdate) return  // 跳过自身触发的更新
  editorRef.value.innerHTML = sanitizeHtml(newVal || '')
})

只有父组件主动改 modelValue(比如重置表单、回显数据)才会写回 DOM。

2. 回显也走 sanitizeHtml

后端返回的 HTML,理论上应该是安全的。但"前端不信任后端"是基本原则——万一数据库被污染了呢?

所以 watch(modelValue) 里写回 DOM 之前,还是过一遍 sanitizeHtml。即使数据库里被注入了恶意 HTML,到用户浏览器里也会被清洗掉。回显链路跟粘贴链路共用同一套白名单,不用维护两套过滤逻辑。

3. 图片加载失败只做视觉兜底

OSS 签名 URL 过期了,图片就会裂开。这时候的处理是:

  • 通过 MutationObserver 监听新插入的 <img>,绑定 onerror 事件
  • onerror 触发时加一个 CSS class is-broken,显示一个虚线框占位
  • 不修改 modelValue<img> 标签原样保留

为什么不动 modelValue?因为 data-oss-path 还在。后端下次返回换签后的 HTML,图片就恢复了。如果前端把标签删了,这个图片就彻底丢了。


RichEditor.vue — 克制的胶水层

组件文件做的事情很少:

<template>
  <div class="rich-editor" :class="{ 'is-disabled': disabled, 'is-empty': isEmpty }">
    <div v-if="$slots.toolbar" class="rich-editor__toolbar">
      <slot name="toolbar" />
    </div>
    <div
      ref="editorRef"
      class="rich-editor__content"
      :contenteditable="!disabled"
      :style="{ minHeight: height }"
      @input="handleInput"
      @paste="handlePaste"
      @compositionstart="handleCompositionStart"
      @compositionend="handleCompositionEnd"
    />
    <div v-if="isEmpty && placeholder" class="rich-editor__placeholder">
      {{ placeholder }}
    </div>
  </div>
</template>

script 部分就是调用 composable + 处理 watch + defineExpose。

逻辑全在 useRichEditor 里,Vue 组件只管"在哪里渲染"和"什么时候触发"。这样 composable 可以单独测试,不需要挂载组件。

setupImageErrorHandler 也放在 composable 里实现,组件只在 onMounted / onBeforeUnmount 调用返回的 setup/teardown 函数。


可扩展性

当前只支持纯文本 + 图片粘贴,但架构上留了几个口子:

扩展点机制怎么加
工具栏toolbar slot直接在 slot 里放按钮组件
格式命令executeCommand 方法用 Selection/Range API 实现,不用 document.execCommand
自定义样式CSS 变量覆盖 --rich-editor-* 变量即可
内容限制maxLength prop 预留syncContent 里加长度检查

从"纯文本+图片"扩展到"工具栏+列表+加粗"不需要重构架构,composable 内部加子模块就行。


最后

不是所有编辑器都需要 ProseMirror。如果你的需求就是文本输入 + 图片粘贴,用 contenteditable 自己搓一个,代码量可控,依赖为零,出了问题也好排查。

当然这个方案有适用范围。需要协同编辑、复杂排版、Markdown 支持的场景,老老实实用 TipTap。别重复造轮子,但也别为了打个鸡蛋买整套厨房设备。

整个组件四个文件,核心逻辑在 useRichEditor 一个 composable 里。每个子模块解决一个具体问题:内容同步、光标管理、粘贴处理、图片上传、输入法兼容。互不耦合,各自可测。