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 # 主组件(模板 + 样式 + 胶水逻辑)
数据流是这样的:
用户操作(输入/粘贴)
↓
DOM(contenteditable div)
↓
useRichEditor(处理 + sanitize)
↓
emit('update:modelValue')
↓
父组件 v-model
设计原则很简单:Composable 是大脑,Vue 组件是胶水,sanitize 是独立的安保。
RichEditor.vue 只负责绑定事件、渲染模板、处理 v-model 的 watch 回显。所有逻辑都在 useRichEditor 里,组件文件很干净。
types.ts — 先定类型,再写逻辑
这个文件是第一个写的。不是因为有强迫症,而是因为类型定义本质上就是接口契约。
先把 Props、Emits、Expose 的形状定好,后面每个模块照着契约实现就行。不用写到一半发现参数传错了回头改。
一个值得说的设计:UploadResult 拆成了 path 和 url 两个字段。
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="alert(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:fixed、z-index:99999、opacity:0 这些样式照样能搞事情——覆盖你的 UI、隐藏恶意内容、搞乱布局。白名单只放安全的属性:text-align、margin-*、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)
}
重点在 checkIsEmpty。contenteditable 空的时候 innerHTML 不一定是空字符串——可能是 <br>、<div><br></div>、 各种变体。
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 去 和空白后是不是空。这样 <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 input → syncContent → emit('update:modelValue') → 父组件更新 prop → watch 触发 → 写回 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 classis-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 里。每个子模块解决一个具体问题:内容同步、光标管理、粘贴处理、图片上传、输入法兼容。互不耦合,各自可测。