Vue.js - 富文本编辑器Tiptap使用详解2(图片上传功能实现)
作者:hangge | 2026-02-11 08:38
本文演示如何实现图片上传功能,包括如何在 Tiptap 编辑器中插入图片节点、实现本地文件选择上传、把后端或第三方存储返回的 URL 插入到编辑器。
(2)运行程序,点击“上传图片”按钮选择需要上传的图片。


二、图片上传功能实现
1,添加依赖
(1)首先我们项目总需要安装 Tiptap 基础依赖,具体安装方法见上一篇文章:
(2)此外,我们还需要安装 @tiptap/extension-image 这个扩展依赖:
npm install @tiptap/extension-image
2,功能实现
(1)下面是一个上传图片功能的样例代码,具体流程为:用户选择图片 → 前端将文件上传到后端 /api/upload → 后端返回 { url } → 前端插入图片节点。
这里通过如下方式实现将上传的图片插入到选择位置:
- 上传图片前,会先在目标位置插入一张占位图片。该占位图片的 alt 属性中写入一个唯一 uploadId(例如 'upload_169...')。
- 上传完成后通过遍历 editor.state.doc 找到具有该 uploadId 的 image 节点并拿到它的文档位置 pos。
- 使用 ProseMirror 的 tr.setNodeMarkup(pos, undefined, newAttrs)(通过 editor.view.dispatch)替换该节点的 attrs,只替换目标节点,不影响其他图片。
<template>
<div>
<div class="toolbar" role="toolbar" aria-label="Editor toolbar">
<button
@click="$refs.fileInput.click()"
>上传图片</button>
<span v-if="uploading">上传中:{{ uploadProgress }}%</span>
</div>
<input ref="fileInput" type="file" @change="onFileChange" accept="image/*"
style="display:none" />
<editor-content :editor="editor" class="editor-content" />
</div>
</template>
<script>
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import { EditorContent } from '@tiptap/vue-2'
import axios from 'axios'
export default {
components: { EditorContent },
data() {
return {
editor: null,
uploading: false,
uploadProgress: 0,
}
},
mounted() {
this.editor = new Editor({
extensions: [StarterKit, Image],
content: '<p>请上传图片测试(修正占位替换逻辑)。</p>',
})
},
methods: {
onFileChange(e) {
const file = e.target.files && e.target.files[0]
if (!file) return
this.uploadAndInsert(file)
e.target.value = ''
},
// 生成唯一 uploadId
genUploadId(file) {
return `upload_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
},
// 根据 uploadId 在 doc 中找到 image node 的 pos 和节点本身
findImageNodeByUploadId(uploadId) {
let found = null
// doc.descendants((node, pos) => { ... }) 遍历节点
this.editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'image' && node.attrs && node.attrs.alt === uploadId) {
found = { node, pos }
return false // 停止遍历
}
return true
})
return found // { node, pos } 或 null
},
// 替换特定 pos 的 image 节点 attrs(只替换该节点)
replaceImageAttrsAtPos(pos, newAttrs) {
const { state, view } = this.editor
const node = state.doc.nodeAt(pos)
if (!node || node.type.name !== 'image') return false
// 合并旧 attrs 与新 attrs(保留 uploadId 可选)
const attrs = Object.assign({}, node.attrs, newAttrs)
const tr = state.tr.setNodeMarkup(pos, undefined, attrs)
view.dispatch(tr)
return true
},
async uploadAndInsert(file) {
// 基本检查
if (!file.type.startsWith('image/')) {
alert('请上传图片文件')
return
}
const uploadId = this.genUploadId(file)
const placeholderSrc =
'data:image/svg+xml;base64,' +
btoa(
`<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 50 50">
<g fill="none" stroke="#999" stroke-width="4" stroke-linecap="round">
<circle cx="25" cy="25" r="20" stroke-opacity="0.2"/>
<path d="M45 25a20 20 0 0 0-20-20" transform="translate(0 0)">
<animateTransform attributeName="transform" type="rotate"
from="0 25 25" to="360 25 25" dur="1s" repeatCount="indefinite"/>
</path>
</g>
</svg>`
)
// 插入占位:把 uploadId 放到 attrs 中
this.editor.chain().focus().setImage({
src: placeholderSrc,
alt: uploadId // 用于后续定位替换
}).run()
this.uploading = true
this.uploadProgress = 0
try {
console.log('开始上传', uploadId)
// 构造 FormData 上传(参照你的后端接口)
const form = new FormData()
form.append('file', file)
const res = await axios.post('/api/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
// 若提示该方法不存在,需要关闭项目的mockjs功能
onUploadProgress: (ev) => {
if (ev.total) this.uploadProgress = Math.round((ev.loaded * 100) / ev.total)
},
})
const url = res.data && (res.data.url || (res.data.data && res.data.data.url))
if (!url) throw new Error('后端未返回图片 URL')
// 找到占位 image 的位置
const found = this.findImageNodeByUploadId(uploadId)
if (found && typeof found.pos === 'number') {
// 用真实 url 替换目标节点 attrs(保留或删除 uploadId)
this.replaceImageAttrsAtPos(found.pos, { src: url, alt: file.name, uploadId: null })
} else {
// 若未找到占位(极少数情况),则退而插入真实图片(并可尝试移除最后的占位)
console.warn('未找到占位节点,直接插入真实图片')
this.editor.chain().focus().setImage({ src: url, alt: file.name }).run()
}
} catch (err) {
console.error('上传失败', err)
alert('上传失败:' + (err.message || '请检查网络或后端'))
// 可选:把占位替换为错误图或删除占位
// 例如:查找并删除占位节点
const found = this.findImageNodeByUploadId(uploadId)
if (found && typeof found.pos === 'number') {
// 删除该节点:tr.delete(found.pos, found.pos + node.nodeSize)
const { state, view } = this.editor
const node = found.node
const tr = state.tr.delete(found.pos, found.pos + node.nodeSize)
view.dispatch(tr)
}
} finally {
this.uploading = false
this.uploadProgress = 0
}
},
},
beforeDestroy() {
if (this.editor) this.editor.destroy()
},
}
</script>
<style scoped>
.toolbar { padding:8px; background:#fafafa; display:flex; gap:6px; flex-wrap:wrap; }
.toolbar button { padding:6px 8px; border:1px solid #eaeaea;
background:white; border-radius:4px; cursor:pointer; }
.toolbar button.active { background:#e6f7ff; border-color:#91d5ff; font-weight:600; }
.editor-content { min-height: 200px; padding: 8px; border: 1px solid #eee; border-radius: 6px; }
</style>
(2)运行程序,点击“上传图片”按钮选择需要上传的图片。

(3)选择后会在目标位置显示一个 loading 图标,并且按钮旁边会显示实时进度。

(4)上传成功后,该位置会显示上传的图片。

附:实现直接粘贴图片、拖拽图片进行上传
1,样例代码
下面对之前的代码做个功能改进,我们除了可用点击“上传图片”按钮选择图片进行上传外。还可以直接将剪切板中的图片粘贴到编辑区进行上传,或者直接拖拽图片文件到编辑区进行上传。
<template>
<div>
<div class="toolbar" role="toolbar" aria-label="Editor toolbar">
<button
@click="$refs.fileInput.click()"
>上传图片</button>
<span v-if="uploading">上传中:{{ uploadProgress }}%</span>
</div>
<input ref="fileInput" type="file" @change="onFileChange" accept="image/*"
style="display:none" />
<editor-content :editor="editor" class="editor-content" />
</div>
</template>
<script>
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import { EditorContent } from '@tiptap/vue-2'
import axios from 'axios'
export default {
components: { EditorContent },
data() {
return {
editor: null,
uploading: false,
uploadProgress: 0,
}
},
mounted() {
this.editor = new Editor({
extensions: [StarterKit, Image],
content: '<p>请上传图片测试(修正占位替换逻辑)。</p>',
})
// 可选:支持粘贴图片
this.editor.on('paste', ({ event }) => {
const clipboard = event.clipboardData
if (!clipboard) return
const items = clipboard.items || []
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.type.includes('image')) {
const file = item.getAsFile()
if (file) this.uploadAndInsert(file)
}
}
})
// 可选:支持拖拽图片文件到编辑区
const dom = this.$el.querySelector('.editor-content')
dom.addEventListener('drop', (e) => {
e.preventDefault()
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
const file = e.dataTransfer.files[0]
if (file.type.startsWith('image/')) this.uploadAndInsert(file)
}
})
},
methods: {
onFileChange(e) {
const file = e.target.files && e.target.files[0]
if (!file) return
this.uploadAndInsert(file)
e.target.value = ''
},
// 生成唯一 uploadId
genUploadId(file) {
return `upload_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
},
// 根据 uploadId 在 doc 中找到 image node 的 pos 和节点本身
findImageNodeByUploadId(uploadId) {
let found = null
// doc.descendants((node, pos) => { ... }) 遍历节点
this.editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'image' && node.attrs && node.attrs.alt === uploadId) {
found = { node, pos }
return false // 停止遍历
}
return true
})
return found // { node, pos } 或 null
},
// 替换特定 pos 的 image 节点 attrs(只替换该节点)
replaceImageAttrsAtPos(pos, newAttrs) {
const { state, view } = this.editor
const node = state.doc.nodeAt(pos)
if (!node || node.type.name !== 'image') return false
// 合并旧 attrs 与新 attrs(保留 uploadId 可选)
const attrs = Object.assign({}, node.attrs, newAttrs)
const tr = state.tr.setNodeMarkup(pos, undefined, attrs)
view.dispatch(tr)
return true
},
async uploadAndInsert(file) {
// 基本检查
if (!file.type.startsWith('image/')) {
alert('请上传图片文件')
return
}
const uploadId = this.genUploadId(file)
const placeholderSrc =
'data:image/svg+xml;base64,' +
btoa(
`<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 50 50">
<g fill="none" stroke="#999" stroke-width="4" stroke-linecap="round">
<circle cx="25" cy="25" r="20" stroke-opacity="0.2"/>
<path d="M45 25a20 20 0 0 0-20-20" transform="translate(0 0)">
<animateTransform attributeName="transform" type="rotate"
from="0 25 25" to="360 25 25" dur="1s" repeatCount="indefinite"/>
</path>
</g>
</svg>`
)
// 插入占位:把 uploadId 放到 attrs 中
this.editor.chain().focus().setImage({
src: placeholderSrc,
alt: uploadId // 用于后续定位替换
}).run()
this.uploading = true
this.uploadProgress = 0
try {
console.log('开始上传', uploadId)
// 构造 FormData 上传(参照你的后端接口)
const form = new FormData()
form.append('file', file)
const res = await axios.post('/api/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
// 若提示该方法不存在,需要关闭项目的mockjs功能
onUploadProgress: (ev) => {
if (ev.total) this.uploadProgress = Math.round((ev.loaded * 100) / ev.total)
},
})
const url = res.data && (res.data.url || (res.data.data && res.data.data.url))
if (!url) throw new Error('后端未返回图片 URL')
// 找到占位 image 的位置
const found = this.findImageNodeByUploadId(uploadId)
if (found && typeof found.pos === 'number') {
// 用真实 url 替换目标节点 attrs(保留或删除 uploadId)
this.replaceImageAttrsAtPos(found.pos, { src: url, alt: file.name, uploadId: null })
} else {
// 若未找到占位(极少数情况),则退而插入真实图片(并可尝试移除最后的占位)
console.warn('未找到占位节点,直接插入真实图片')
this.editor.chain().focus().setImage({ src: url, alt: file.name }).run()
}
} catch (err) {
console.error('上传失败', err)
alert('上传失败:' + (err.message || '请检查网络或后端'))
// 可选:把占位替换为错误图或删除占位
// 例如:查找并删除占位节点
const found = this.findImageNodeByUploadId(uploadId)
if (found && typeof found.pos === 'number') {
// 删除该节点:tr.delete(found.pos, found.pos + node.nodeSize)
const { state, view } = this.editor
const node = found.node
const tr = state.tr.delete(found.pos, found.pos + node.nodeSize)
view.dispatch(tr)
}
} finally {
this.uploading = false
this.uploadProgress = 0
}
},
},
beforeDestroy() {
if (this.editor) this.editor.destroy()
},
}
</script>
<style scoped>
.toolbar { padding:8px; background:#fafafa; display:flex; gap:6px; flex-wrap:wrap; }
.toolbar button { padding:6px 8px; border:1px solid #eaeaea;
background:white; border-radius:4px; cursor:pointer; }
.toolbar button.active { background:#e6f7ff; border-color:#91d5ff; font-weight:600; }
.editor-content { min-height: 200px; padding: 8px; border: 1px solid #eee; border-radius: 6px; }
</style>
2,运行测试
(1)我们可以直接将图片文件拖入到编辑框中进行上传。

(2)或者通过 Ctrl + V 快捷键将复制的图片粘贴到编辑框中上传。

全部评论(0)