wangeditor-next 在 Vue3 中的集成使用文档
1. 安装依赖
首先需要安装 wangeditor-next 相关依赖:
pnpm add @wangeditor-next/editor @wangeditor-next/editor-for-vue
2. 二次封装编辑器组件
创建一个 MyEditor.vue 组件,实现对 wangeditor-next 的二次封装:
<template>
<div style="border: 1px solid #ccc">
<Toolbar
style="border-bottom: 1px solid #ccc"
:editor="editorRef"
:defaultConfig="toolbarConfig"
:mode="mode"
/>
<Editor
style="min-height: 450px; overflow-y: hidden"
v-model="valueHtml"
:defaultConfig="editorConfig"
:mode="mode"
@onCreated="handleCreated"
@onDestroyed="handleDestroyed"
/>
<ChooseImage ref="chooseImageRef" :preview="false" :limit="9" />
</div>
</template>
<script setup lang="ts">
import "@wangeditor-next/editor/dist/css/style.css"; // 引入 css
import {
onBeforeUnmount,
ref,
shallowRef,
onMounted,
watch,
nextTick,
} from "vue";
import { Editor, Toolbar } from "@wangeditor-next/editor-for-vue";
import {
type IEditorConfig,
type IToolbarConfig,
} from "@wangeditor-next/editor";
import { DomEditor } from "@wangeditor-next/editor";
import ChooseImage from "./ChooseImage.vue";
import { useUserStore } from "@/stores/user";
const userStore = useUserStore();
const props = defineProps<{
modelValue: string | null;
}>();
// 定义 emits 触发更新事件
const emits = defineEmits<{
(e: "update:modelValue", value: string): void;
}>();
// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef();
// 内容 HTML
const valueHtml = ref(props.modelValue || "");
// 标记编辑器是否已经创建完成
const isEditorCreated = ref(false);
// 标记编辑器是否已经销毁
const isEditorDestroyed = ref(false);
// 初始化内容
onMounted(() => {
try {
valueHtml.value = props.modelValue || "";
} catch (error) {
console.error("初始化内容时出错:", error);
}
});
// 监听外部传入值的变化,更新本地 valueHtml
watch(
() => props.modelValue,
(newValue) => {
try {
const safeNewValue = newValue || "";
if (safeNewValue !== valueHtml.value) {
valueHtml.value = safeNewValue;
if (editorRef.value) {
// 如果编辑器实例存在,更新编辑器内容
editorRef.value.setHtml(safeNewValue);
}
}
} catch (error) {
console.error("更新内容时出错:", error);
}
},
{ immediate: true, deep: true }
);
// 监听本地值的变化,触发更新事件
watch(valueHtml, (newValue) => {
try {
if (!isEditorDestroyed.value) {
emits("update:modelValue", newValue);
}
} catch (error) {
console.error("更新外部值时出错:", error);
}
});
// 工具栏配置
const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: ["undo", "group-video", "emotion", "redo", "group-image"],
insertKeys: {
index: 9,
keys: ["uploadImage"],
},
};
// 编辑器配置
const editorConfig = {
placeholder: "请输入内容...",
MENU_CONF: {
uploadImage: {
// 自定义选择图片
customBrowseAndUpload: (insertFn: any) => {
chooseImageRef.value.open((data: any) => {
if (Array.isArray(data)) {
data.forEach((item) => {
insertFn(item); // 插入图片到编辑器中
});
} else {
insertFn(data); // 插入图片到编辑器中
}
});
},
// 必须属性,设置为 null 或空函数,不然TS报错
base64LimitSize: 0, // 限制 base64 图片大小
server: "", // 图片上传服务器地址,这里留空
meta: {}, // 额外的请求参数
metaWithUrl: false, // 是否将参数拼接到 URL 上
fieldName: "img", // 上传文件的字段名
// 上传之前触发
onBeforeUpload(file: any) {
return file;
},
// 上传进度的回调函数
onProgress(progress: number) {
console.log("progress", progress);
},
// 单个文件上传成功之后
onSuccess(file: any, res: any) {
console.log(`${file.name} 上传成功`, res);
},
// 单个文件上传失败
onFailed(file: any, res: any) {
console.log(`${file.name} 上传失败`, res);
},
// 上传错误,或者触发 timeout 超时
onError(file: any, err: any, res: any) {
console.log(`${file.name} 上传出错`, err, res);
},
},
},
};
const chooseImageRef = ref();
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
try {
if (editorRef.value) {
editorRef.value.destroy();
}
} catch (error) {
console.error("销毁编辑器时出错:", error);
}
});
// 编辑器创建完成回调
const handleCreated = (editor: any) => {
try {
editorRef.value = editor;
isEditorCreated.value = true;
isEditorDestroyed.value = false;
// 编辑器创建完成后设置初始内容
editor.setHtml(props.modelValue);
} catch (error) {
console.error("编辑器创建完成设置内容时出错:", error);
}
};
// 编辑器销毁回调
const handleDestroyed = () => {
isEditorCreated.value = false;
isEditorDestroyed.value = true;
};
// 封装设置编辑器内容的方法
const setEditorHtml = async (html: string) => {
if (!isEditorCreated.value || !editorRef.value || isEditorDestroyed.value) {
// 编辑器未创建完成或已销毁,等待下一个 tick 再尝试
await nextTick();
if (isEditorCreated.value && editorRef.value && !isEditorDestroyed.value)
{
setEditorHtml(html);
}
return;
}
try {
// 直接使用传入的 HTML 内容
editorRef.value.setHtml(html);
} catch (error) {
console.error("设置编辑器内容时出错:", error);
}
};
const mode = "default"; // 或 'simple'
</script>
3. 自定义图片选择组件
创建一个 ChooseImage.vue 组件,用于自定义图片选择:
<template>
<el-dialog v-model="dialogVisible" title="选择图片" width="80%">
<el-upload
v-model:file-list="fileList"
:action="uploadUrl"
:headers="headers"
list-type="picture-card"
:on-success="handleSuccess"
:on-preview="handlePictureCardPreview"
:on-remove="handleRemove"
:limit="limit"
:multiple="true"
>
<el-icon><Plus /></el-icon>
</el-upload>
<div class="image-list" v-if="imageList.length > 0">
<div
v-for="(item, index) in imageList"
:key="index"
class="image-item"
@click="handleSelect(item)"
>
<el-image :src="item" fit="cover" />
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirm">确认</el-button>
</span>
</template>
</el-dialog>
<el-dialog v-model="dialogImageVisible">
<img w-full :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Plus } from '@element-plus/icons-vue';
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
const props = defineProps({
preview: {
type: Boolean,
default: true
},
limit: {
type: Number,
default: 5
}
});
// 对话框显示状态
const dialogVisible = ref(false);
// 预览对话框
const dialogImageVisible = ref(false);
const dialogImageUrl = ref('');
// 上传文件列表
const fileList = ref([]);
// 图片列表
const imageList = ref([]);
// 选中的图片
const selectedImage = ref('');
// 回调函数
let callback: Function | null = null;
// 上传地址和请求头
const uploadUrl = '/api/image/upload';
const headers = {
Authorization: `Bearer ${userStore.token}`
};
// 打开对话框
const open = (cb: Function) => {
dialogVisible.value = true;
callback = cb;
// 可以在这里加载已有图片列表
loadImageList();
};
// 加载图片列表(模拟)
const loadImageList = async () => {
try {
// 实际项目中应该从API获取
// const res = await fetch('/api/images');
// imageList.value = await res.json();
// 这里模拟一些图片
imageList.value = [
'https://example.com/image1.jpg',
'https://example.com/image2.jpg',
'https://example.com/image3.jpg',
];
} catch (error) {
console.error('加载图片列表失败:', error);
}
};
// 上传成功回调
const handleSuccess = (response: any, file: any) => {
// 假设服务器返回的数据格式为 { url: 'image_url' }
if (response && response.url) {
imageList.value.push(response.url);
}
};
// 预览图片
const handlePictureCardPreview = (file: any) => {
dialogImageUrl.value = file.url;
dialogImageVisible.value = true;
};
// 移除图片
const handleRemove = (file: any) => {
const index = imageList.value.indexOf(file.url);
if (index !== -1) {
imageList.value.splice(index, 1);
}
};
// 选择图片
const handleSelect = (url: string) => {
selectedImage.value = url;
};
// 确认选择
const confirm = () => {
dialogVisible.value = false;
if (callback) {
if (selectedImage.value) {
// 单选模式
callback(selectedImage.value);
} else {
// 多选模式,返回所有图片
callback(imageList.value);
}
}
};
// 暴露方法给父组件
defineExpose({
open
});
</script>
<style scoped>
.image-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 20px;
}
.image-item {
width: 100px;
height: 100px;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.3s;
}
.image-item:hover {
border-color: #409EFF;
}
.image-item .el-image {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
4. 在页面中使用富文本编辑器
<template>
<div class="product-edit">
<el-form :model="form" label-width="120px">
<!-- 其他表单项 -->
<el-form-item label="商品描述">
<MyEditor v-model="form.description" />
</el-form-item>
<!-- 其他表单项 -->
<el-form-item>
<el-button type="primary" @click="submitForm">保存</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import MyEditor from '@/components/MyEditor.vue';
const form = ref({
title: '',
description: '<p>这是默认的商品描述</p>',
// 其他字段
});
const submitForm = async () => {
// 提交表单逻辑
console.log('富文本内容:', form.value.description);
// 发送API请求保存数据
};
</script>
5. 自定义图片上传
如果需要自定义图片上传逻辑,可以在 editorConfig 中配置:
// 在 MyEditor.vue 中的 editorConfig 配置
const editorConfig = {
// ... 其他配置
MENU_CONF: {
uploadImage: {
// 方式一:使用服务器上传
server: "/api/image/upload",
headers: { Authorization: `Bearer ${userStore.token}` },
fieldName: "img", // 自定义字段名,默认是 img
// 自定义插入
customInsert: (res: any, insertFn: any) => {
// 假设服务器返回 { data: { path: 'image_url' } }
insertFn(res.data.path);
},
// 方式二:完全自定义上传
customUpload: async (file: File, insertFn: any) => {
// 1. 创建 FormData
const formData = new FormData();
formData.append('file', file);
// 2. 发送请求
try {
const response = await fetch('/api/image/upload', {
method: 'POST',
headers: {
Authorization: `Bearer ${userStore.token}`
},
body: formData
});
const result = await response.json();
// 3. 插入图片
if (result.success) {
insertFn(result.data.url);
} else {
console.error('上传失败:', result.message);
}
} catch (error) {
console.error('上传出错:', error);
}
},
// 方式三:使用自定义图片选择器
customBrowseAndUpload: (insertFn: any) => {
chooseImageRef.value.open((data: any) => {
if (Array.isArray(data)) {
data.forEach((item) => {
insertFn(item); // 插入图片到编辑器中
});
} else {
insertFn(data); // 插入图片到编辑器中
}
});
}
}
}
};
6. 自定义工具栏
可以通过 toolbarConfig 配置工具栏按钮:
// 在 MyEditor.vue 中的 toolbarConfig 配置
const toolbarConfig: Partial<IToolbarConfig> = {
// 排除不需要的按钮
excludeKeys: [
"undo",
"group-video",
"emotion",
"redo",
"group-image"
],
// 插入自定义按钮
insertKeys: {
index: 9, // 插入的位置
keys: ["uploadImage", "customButton"] // 自定义按钮
},
// 自定义按钮配置
MENU_CONF: {
customButton: {
title: '自定义按钮',
iconSvg: '<svg>...</svg>', // 按钮图标
tag: 'button',
exec: () => {
// 按钮点击后的操作
console.log('自定义按钮被点击');
}
}
}
};
7. 完整使用示例
<template>
<div class="article-edit">
<h2>{{ isEdit ? '编辑文章' : '新增文章' }}</h2>
<el-form :model="form" label-width="100px">
<el-form-item label="标题">
<el-input v-model="form.title" placeholder="请输入文章标题" />
</el-form-item>
<el-form-item label="内容">
<MyEditor v-model="form.content" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit">保存</el-button>
<el-button @click="handleCancel">取消</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import MyEditor from '@/components/MyEditor.vue';
import axios from 'axios';
const route = useRoute();
const router = useRouter();
const id = route.params.id;
const isEdit = !!id;
const form = ref({
title: '',
content: ''
});
// 如果是编辑模式,加载文章数据
onMounted(async () => {
if (isEdit) {
try {
const res = await axios.get(`/api/articles/${id}`);
form.value = res.data;
} catch (error) {
console.error('获取文章数据失败:', error);
ElMessage.error('获取文章数据失败');
}
}
});
// 提交表单
const handleSubmit = async () => {
try {
if (isEdit) {
await axios.put(`/api/articles/${id}`, form.value);
ElMessage.success('更新成功');
} else {
await axios.post('/api/articles', form.value);
ElMessage.success('创建成功');
}
router.push('/articles');
} catch (error) {
console.error('保存失败:', error);
ElMessage.error('保存失败');
}
};
// 取消
const handleCancel = () => {
router.back();
};
</script>
8. 注意事项
- 编辑器实例必须使用 shallowRef 创建,避免不必要的性能开销
- 组件销毁时必须调用 editor.destroy() 方法释放资源
- 使用 v-model 双向绑定时,需要正确处理 props 和 emits
- 自定义图片上传时,需要处理好错误情况和加载状态
- 编辑器内容可能包含 HTML 标签,展示时需要使用 v-html 指令
以上就是 wangeditor-next 在 Vue3 中的完整集成使用文档,包括二次封装、自定义图片选择和自定义图片上传等功能。