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. 注意事项

  1. 编辑器实例必须使用 shallowRef 创建,避免不必要的性能开销
  2. 组件销毁时必须调用 editor.destroy() 方法释放资源
  3. 使用 v-model 双向绑定时,需要正确处理 props 和 emits
  4. 自定义图片上传时,需要处理好错误情况和加载状态
  5. 编辑器内容可能包含 HTML 标签,展示时需要使用 v-html 指令
    以上就是 wangeditor-next 在 Vue3 中的完整集成使用文档,包括二次封装、自定义图片选择和自定义图片上传等功能。
最后修改:2025 年 05 月 21 日
如果觉得我的文章对你有用,请随意赞赏