博主介绍
👨💻 了解博主:波仔椿
📖 人生箴言:组件拆得够小,页面才装得下变化。
🧰 我的专栏:Vue3-实战派
文章内容
最近在做一个内容创作平台,产品同学提了个需求:编辑器里得能一键「润色」、能「改写」、能「扩写缩写」,还得能根据标题直接「撰写」一整段。说白了就是把 AI 塞进编辑器里。我翻了一圈方案,Markdown 编辑器用 md-editor-v3,富文本编辑器用 wangeditor v5,AI 流式输出用原生 fetch + ReadableStream 搞定。今天把整套实现拆给你看,从选型到落地,一条龙。

一、先搞清楚:Markdown 编辑器和富文本编辑器到底差在哪
动手之前先理清概念,不然选型容易跑偏。
Markdown 编辑器:用户写的是 Markdown 源码(比如 ## 标题、**加粗**),编辑器负责实时渲染预览。输出物是 .md 文本,轻量、可 diff、适合技术文档和博客。
富文本编辑器:用户直接在「所见即所得」的页面上操作(像 Word 一样选中文字点加粗),输出物是 HTML。适合后台管理、CMS、新闻发布这类场景。
| 维度 | Markdown 编辑器(md-editor-v3) | 富文本编辑器(wangeditor v5) |
|---|---|---|
| 输入方式 | 写 Markdown 源码 | 所见即所得,直接操作 |
| 输出格式 | Markdown 文本(.md) | HTML |
| 实时预览 | 左右分栏实时渲染 | 编辑即预览,无需分栏 |
| 版本对比 | 纯文本可 diff,适合 Git | HTML diff 噪音大 |
| 适合场景 | 技术文档、博客、知识库 | CMS、后台管理、新闻发布 |
| 学习成本 | 需懂 Markdown 语法 | 零门槛,会用 Word 就会 |
| AI 接入 | 替换整段 Markdown 即可 | 需操作选区、插入 HTML |
说白了:技术内容用 Markdown 编辑器,业务内容用富文本编辑器,两者各管一摊,别硬凑。
二、用 md-editor-v3 搭一个 Markdown 编辑器
1. 安装与引入
md-editor-v3 是一款专为 Vue3 打造的 Markdown 编辑器,用 JSX + TypeScript 开发,内置 Prettier 美化、Mermaid 图表、KaTeX 公式、图片上传等能力,还支持暗黑主题。当前稳定版 v6.x,要求 Node 16+(建议 18+)。
# 需要 Node 16+,建议 18+
npm i md-editor-v3
入口处引入一次全局样式:
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import 'md-editor-v3/lib/style.css'
createApp(App).mount('#app')
2. 基础用法:一个能跑的 Markdown 编辑器
先画张组件关系图,一眼看明白整个编辑器的数据流:
下面这个组件拿来就能跑,v-model 双向绑定内容,editorRef 拿编辑器实例:
<!-- src/components/MarkdownEditor.vue -->
<script setup>
import { ref, shallowRef } from 'vue'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
// 编辑器内容,v-model 双向绑定
const text = ref('# Hello md-editor-v3\n\n写点啥吧……')
// 编辑器实例,用 ref 拿到暴露的方法
const editorRef = ref()
// 工具栏:按需显示,省得界面太挤
const toolbars = [
'bold', 'underline', 'italic', 'strikeThrough',
'-', 'title', 'sub', 'sup', 'quote', 'unorderedList', 'orderedList', 'task',
'-', 'code', 'codeRow', 'link', 'image', 'table', 'mermaid', 'katex',
'=', 'preview', 'previewOnly', 'fullscreen'
]
</script>
<template>
<MdEditor
ref="editorRef"
v-model="text"
:toolbars="toolbars"
editorId="md-editor-1"
style="height: 500px"
/>
</template>
几个关键点说明:
v-model绑定的是 Markdown 源码字符串,改text.value就能改编辑器内容toolbars数组里的'-'是分组分隔符,'='后面的按钮会靠右显示editorId给编辑器一个唯一标识,多个编辑器同页必须不同editorRef用来调暴露的方法(下一节接 AI 会用)
3. editorRef 暴露的几个关键方法
md-editor-v3 从 v2.5.0 起在实例上暴露了一批方法,接 AI 时会用到这几个:
// 获取当前选中的文本(v4.11.0+ 支持)
const selected = editorRef.value?.getSelectedText()
// 往编辑器里插内容,回调参数是当前选中文本
editorRef.value?.insert((selectedText) => {
return {
targetValue: `**${selectedText}**`, // 插入的内容
select: true, // 插入后是否选中,默认 true
deviationStart: 0, // 选中区域的起始偏移
deviationEnd: 0 // 选中区域的结束偏移
}
})
// 重新渲染预览
editorRef.value?.rerender()
// 手动触发保存(ctrl+s 也会触发)
editorRef.value?.triggerSave()
你发现没有:
insert的回调会把你当前选中的文本传进来,这就给「选中一段文字→AI 润色→插回去」留好了口子,设计得很巧妙。
三、用 wangeditor v5 搭一个富文本编辑器
1. 安装与引入
wangeditor v5 基于 slate.js 开发,Vue3 要装 @wangeditor/editor-for-vue@next(注意带 @next,不带的是 Vue2 版)。
# @next 是 Vue3 版,不带是 Vue2 版,别装错
npm i @wangeditor/editor @wangeditor/editor-for-vue@next
2. 基础用法:一个能跑的富文本编辑器
wangeditor 的结构是「工具栏 + 编辑区」两部分,比 md-editor-v3 多一个 Toolbar 组件:
下面这个组件拿来就能跑,注意 editorRef 必须用 shallowRef:
<!-- src/components/RichEditor.vue -->
<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, ref, shallowRef } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
// 编辑器实例,必须用 shallowRef,用 ref 会报错
const editorRef = shallowRef()
// 编辑器内容 HTML
const valueHtml = ref('<p>hello wangeditor</p>')
const toolbarConfig = {}
const editorConfig = { placeholder: '请输入内容...' }
// 拿到 editor 实例
const handleCreated = (editor) => {
editorRef.value = editor
}
// 组件销毁时必须销毁编辑器,否则内存泄漏
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
</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"
/>
</div>
</template>
这里有几个坑点必须提前说:
| 坑点 | 原因 | 解法 |
|---|---|---|
editorRef 用 ref 报错 | editor 实例是复杂对象,Vue 深度代理会冲突 | 必须用 shallowRef |
| 组件销毁后内存泄漏 | 编辑器绑了全局事件,不手动销毁不会释放 | onBeforeUnmount 里调 editor.destroy() |
onXxx 生命周期不生效 | wangeditor 的生命周期必须用 Vue 事件传,不能写进 editorConfig | 用 @onChange、@onFocus 等模板事件 |
| 异步设置内容不显示 | 编辑器渲染前赋值会被覆盖 | 在 @onCreated 之后赋值 |
有意思的地方就在这儿:wangeditor 把
onXxx生命周期强制走 Vue 事件,是故意不让塞进editorConfig——这样事件和配置解耦,组件卸毁时 Vue 能自动帮你解绑,设计上比一股脑塞配置要干净。
3. editorRef 暴露的关键方法(接 AI 会用)
wangeditor 的实例方法很多,接 AI 文本处理主要用这几个:
const editor = editorRef.value
// 获取选中的纯文本
const selectedText = editor.getSelectionText()
// 删除当前选中的内容(润色/改写后要先删旧内容再插新的)
editor.deleteFragment()
// 插入 HTML,注意方法名带 dangerously,非 editor.getHtml() 格式不能保证语义正确
editor.dangerouslyInsertHtml('<p>AI 润色后的内容</p>')
// 在选区插入纯文本
editor.insertText('一段文字')
// 恢复上一次的选区(编辑器失焦后选区会丢,得先存再恢复)
editor.restoreSelection()
// 聚焦编辑器
editor.focus()
接 AI 的核心流程就是:getSelectionText() 拿选中文字 → 调 AI → deleteFragment() 删旧的 → dangerouslyInsertHtml() 插新的。下面把这套路子封装成通用能力。
四、AI 文本能力的实现思路
1. AI 能力分类与 Prompt 设计
产品要的五种 AI 能力,对应五种不同的 Prompt 模板:
| 能力 | 作用 | Prompt 模板 |
|---|---|---|
| 润色 | 优化措辞、修正语病,保留原意 | 请润色以下文本,保持原意,使表达更流畅:${text} |
| 改写 | 换一种表达方式重写 | 请改写以下文本,换一种表达方式:${text} |
| 扩写 | 在原文基础上展开补充 | 请扩写以下文本,补充细节使内容更丰富:${text} |
| 缩写 | 精简压缩,保留核心信息 | 请缩写以下文本,保留核心信息,控制在原长一半:${text} |
| 撰写 | 根据标题/要点直接生成 | 请根据以下标题撰写一段内容,约300字:${text} |
2. 流式输出:为什么不用一次性返回
AI 生成几百字往往要 5-15 秒,如果走「等全部生成完再返回」的非流式模式,用户对着空白干等,体验很差。流式输出让 AI 像打字机一样逐字吐出来,用户立刻就能看到内容在长,体感延迟从「等 10 秒」变成「立刻有动静」。
先看流式和非流式的数据流差异:
3. 封装 useAIStream 组合式函数
核心思路:用 fetch 发请求,从 response.body 拿到 ReadableStream,用 getReader() 逐块读,TextDecoder 把二进制解码成字符串,按 SSE 格式(data: {json})解析出增量文本。
这里有个大坑:TCP 分包不保证和 SSE 的逻辑行对齐,一个 JSON 可能被拆成两半。解法是维护一个 buffer,解析失败的行先存起来,等下一块拼接后再解析。
// src/composables/useAIStream.js
import { ref, onUnmounted } from 'vue'
/**
* AI 文本流式请求组合式函数
* @param {string} endpoint 后端 AI 接口地址
*/
export function useAIStream(endpoint) {
// 流式输出的文本,逐字增长
const result = ref('')
// 是否正在生成
const loading = ref(false)
// 错误信息
const error = ref('')
let controller = null
/**
* 发起一次 AI 文本处理请求
* @param {string} text 待处理的文本
* @param {string} action 处理类型:polish|rewrite|expand|shorten|write
*/
const generate = async (text, action) => {
if (!text.trim()) {
error.value = '文本不能为空'
return
}
// 每次请求前重置状态
result.value = ''
error.value = ''
loading.value = true
// 用 AbortController 支持中途取消
controller = new AbortController()
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, action }),
signal: controller.signal
})
if (!response.ok) {
throw new Error(`请求失败:${response.status}`)
}
// 核心:从 body 拿到可读流
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = '' // 缓存不完整的 JSON 行
while (true) {
const { value, done } = await reader.read()
if (done) break
// stream: true 防止多字节字符(中文)被截断
const chunk = buffer + decoder.decode(value, { stream: true })
buffer = ''
// SSE 格式:每条消息以 data: 开头,用换行分隔
const lines = chunk.split('\n').filter(line => line.startsWith('data: '))
for (const line of lines) {
// 去掉 "data: " 前缀
const raw = line.slice(6).trim()
// [DONE] 是流结束的哨兵值
if (raw === '[DONE]') {
loading.value = false
return
}
try {
const json = JSON.parse(raw)
// OpenAI 兼容格式:增量在 choices[0].delta.content
const delta = json.choices?.[0]?.delta?.content || ''
if (delta) {
result.value += delta
}
} catch (e) {
// JSON 不完整(被 TCP 拆断),存进 buffer 等下一轮
buffer = `data: ${raw}`
}
}
}
} catch (err) {
// 用户主动取消会抛 AbortError,不算错误
if (err.name !== 'AbortError') {
error.value = err.message
}
} finally {
loading.value = false
}
}
// 取消当前请求
const cancel = () => {
if (controller) {
controller.abort()
controller = null
loading.value = false
}
}
// 组件卸载时取消未完成的请求
onUnmounted(cancel)
return { result, loading, error, generate, cancel }
}
这段代码有几个关键设计:
buffer缓存:解析失败的data:行不丢,留着等下一块拼上再解析,解决 JSON 被拆断的问题{ stream: true }:TextDecoder.decode加这个参数,防止中文字符(UTF-8 三字节)被从中间截断AbortController:用户不想等了能立刻取消,组件卸载也会自动取消,不会留垃圾请求[DONE]哨兵:OpenAI 兼容的 SSE 协议用这个标记流结束
4. 流式输出的时序
把整个流式过程画成时序图,谁调谁一目了然:
一句话总结:流式的本质不是「更快」,而是「让用户立刻看到有动静」,用即时反馈对抗等待焦虑。
五、把编辑器和 AI 接到一起:完整实战
1. 整体架构
两种编辑器接 AI 的套路略有不同,画张图先理清:
核心差异在最后一步:Markdown 编辑器用 insert 替换选区,富文本编辑器用 deleteFragment 删旧的再 dangerouslyInsertHtml 插新的。
2. Markdown 编辑器 + AI 完整组件
把第二节的基础组件升级一下,加一个 AI 工具栏,支持润色、改写、扩写、缩写:
<!-- src/components/MdEditorWithAI.vue -->
<script setup>
import { ref } from 'vue'
import { MdEditor, NormalToolbar } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { useAIStream } from '../composables/useAIStream'
const text = ref('# 标题\n\n这是一段需要润色的文字,写得不太通顺,你帮我改改。')
const editorRef = ref()
// AI 流式请求,endpoint 换成你的后端地址
const { result: aiResult, loading: aiLoading, generate, cancel } = useAIStream('/api/ai/text')
// AI 动作配置
const actions = [
{ key: 'polish', label: '润色' },
{ key: 'rewrite', label: '改写' },
{ key: 'expand', label: '扩写' },
{ key: 'shorten', label: '缩写' }
]
// 触发 AI 处理
const handleAI = async (action) => {
// 先拿编辑器里选中的文字,没选中就用整篇
const selected = editorRef.value?.getSelectedText() || text.value
if (!selected.trim()) return
await generate(selected, action)
// 流式结束后,把结果插回编辑器(替换选区)
editorRef.value?.insert(() => ({
targetValue: aiResult.value,
select: true
}))
}
</script>
<template>
<div>
<!-- AI 工具栏:通过 defToolbars 插槽注入自定义按钮 -->
<MdEditor
ref="editorRef"
v-model="text"
editorId="md-ai-1"
:defToolbars="actions"
style="height: 500px"
>
<template #defToolbars>
<NormalToolbar
v-for="action in actions"
:key="action.key"
:title="`AI${action.label}`"
>
<button
class="ai-btn"
:disabled="aiLoading"
@click="handleAI(action.key)"
>
{{ aiLoading ? '生成中…' : action.label }}
</button>
</NormalToolbar>
</template>
</MdEditor>
<!-- 流式结果实时预览 -->
<div v-if="aiLoading || aiResult" class="ai-preview">
<div class="ai-preview-header">
<span>AI 生成结果</span>
<button v-if="aiLoading" class="ai-cancel" @click="cancel">停止</button>
</div>
<div class="ai-preview-body">{{ aiResult }}</div>
</div>
</div>
</template>
<style scoped>
.ai-btn {
padding: 4px 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #f0f9ff;
cursor: pointer;
font-size: 12px;
}
.ai-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.ai-preview {
margin-top: 12px;
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 12px;
background: #fafafa;
}
.ai-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
color: #606266;
}
.ai-cancel {
border: none;
background: #f56c6c;
color: #fff;
padding: 2px 10px;
border-radius: 4px;
cursor: pointer;
}
.ai-preview-body {
white-space: pre-wrap;
line-height: 1.6;
color: #303133;
}
</style>
这里用 NormalToolbar 把自定义的 AI 按钮塞进编辑器工具栏,点击后通过 getSelectedText() 拿选中文字,调 AI 流式生成,结束后用 insert 插回。
3. 富文本编辑器 + AI 完整组件
富文本这边套路类似,但最后一步用 deleteFragment + dangerouslyInsertHtml,而且要先 restoreSelection 恢复选区(因为点按钮时编辑器失焦了):
<!-- src/components/RichEditorWithAI.vue -->
<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, ref, shallowRef } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useAIStream } from '../composables/useAIStream'
const editorRef = shallowRef()
const valueHtml = ref('<p>这是一段需要润色的文字,写得不太通顺,你帮我改改。</p>')
const handleCreated = (editor) => {
editorRef.value = editor
}
onBeforeUnmount(() => {
editorRef.value?.destroy()
})
const { result: aiResult, loading: aiLoading, generate, cancel } = useAIStream('/api/ai/text')
const actions = [
{ key: 'polish', label: '润色' },
{ key: 'rewrite', label: '改写' },
{ key: 'expand', label: '扩写' },
{ key: 'shorten', label: '缩写' }
]
const handleAI = async (action) => {
const editor = editorRef.value
if (!editor) return
// 拿选中的纯文本,没选中就用整篇纯文本
let selected = editor.getSelectionText()
if (!selected.trim()) {
selected = editor.getText()
}
if (!selected.trim()) return
await generate(selected, action)
// 流式结束后,把结果插回编辑器
editor.restoreSelection() // 先恢复选区(点按钮时失焦了)
editor.deleteFragment() // 删除原来选中的内容
editor.dangerouslyInsertHtml(`<p>${aiResult.value}</p>`) // 插入 AI 生成的内容
}
</script>
<template>
<div>
<!-- AI 操作栏,放在编辑器上方 -->
<div class="ai-toolbar">
<button
v-for="action in actions"
:key="action.key"
class="ai-btn"
:disabled="aiLoading"
@click="handleAI(action.key)"
>
{{ aiLoading ? '生成中…' : action.label }}
</button>
<button v-if="aiLoading" class="ai-cancel" @click="cancel">停止生成</button>
</div>
<div style="border: 1px solid #ccc">
<Toolbar
style="border-bottom: 1px solid #ccc"
:editor="editorRef"
mode="default"
/>
<Editor
style="height: 500px; overflow-y: hidden"
v-model="valueHtml"
mode="default"
@onCreated="handleCreated"
/>
</div>
<div v-if="aiLoading || aiResult" class="ai-preview">
<div class="ai-preview-header">
<span>AI 生成结果</span>
</div>
<div class="ai-preview-body">{{ aiResult }}</div>
</div>
</div>
</template>
<style scoped>
.ai-toolbar {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.ai-btn {
padding: 6px 16px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #f0f9ff;
cursor: pointer;
font-size: 14px;
}
.ai-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.ai-cancel {
border: none;
background: #f56c6c;
color: #fff;
padding: 6px 16px;
border-radius: 4px;
cursor: pointer;
}
.ai-preview {
margin-top: 12px;
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 12px;
background: #fafafa;
}
.ai-preview-header {
margin-bottom: 8px;
font-size: 14px;
color: #606266;
}
.ai-preview-body {
white-space: pre-wrap;
line-height: 1.6;
color: #303133;
}
</style>
关键点:富文本编辑器点按钮会失焦,选区丢了,必须先
restoreSelection()恢复选区,再deleteFragment()删旧内容,最后dangerouslyInsertHtml()插新内容,三步缺一不可。
六、踩坑与优化
实际跑下来有几个坑得提一下:
1. 高频追加导致渲染卡顿
result.value += delta 每来一个 token 就触发一次响应式更新,AI 输出快时一秒能触发几十次,DOM 更新跟不上会卡。用 requestAnimationFrame 限流,合并到每帧只渲染一次:
// src/composables/useAIStream.js(节选优化版)
let pendingDelta = ''
let scheduled = false
const flushDelta = () => {
result.value += pendingDelta
pendingDelta = ''
scheduled = false
}
const appendDelta = (delta) => {
pendingDelta += delta
if (!scheduled) {
scheduled = true
requestAnimationFrame(flushDelta)
}
}
// 流式循环里把 result.value += delta 换成 appendDelta(delta)
2. 编辑器选区丢失
富文本编辑器点工具栏按钮会失焦,选区丢了。wangeditor 有 restoreSelection() 能恢复上一次选区,但前提是你没在中间做过其他选区操作。md-editor-v3 的 insert 是自己处理选区的,不用额外操心。
3. 取消请求的清理
用 AbortController 取消后,fetch 会抛 AbortError,得在 catch 里判断 err.name !== 'AbortError' 再标记错误,不然用户主动取消也会显示报错。
4. 后端接口格式
前端这套流式解析是按 OpenAI 兼容的 SSE 格式写的(data: {json}\n\n,[DONE] 结束)。如果你的后端用的是别的协议(比如纯文本流、WebSocket),解析逻辑要相应调整,但 ReadableStream + TextDecoder + buffer 这套骨架是不变的。
精彩推荐
本篇博客文章唯一版权归属©波仔椿
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/weixin_48337566/article/details/163664979




