Vue 3 富文本编辑器组件详解

引言

在Web开发中,富文本编辑器是一个常见的需求,特别是在内容管理系统(CMS)或博客平台中。本文将详细解析一个基于Vue 3和@wangeditor/editor-for-vue的富文本编辑器组件的实现,重点介绍其关键功能及其背后的设计思路。通过阅读本文,你将学会如何创建一个支持图片上传、双向数据绑定、异步图片加载的富文本编辑器组件。

组件概述

本文所述的Vue组件旨在提供一个可高度自定义的富文本编辑器。这个组件包含以下主要关键点:

  • 富文本内容编辑
  • 工具栏配置
  • 自定义图片上传与插入
  • 通过v-model实现双向数据绑定
  • 异步加载并渲染图片

组件的核心功能实现

1. 管理编辑器实例:为什么选择 shallowRef

背景

在Vue 3中,refshallowRef是用来创建响应式数据的工具。通常情况下,ref会让它内部的所有对象都响应式地更新,但有时候我们并不需要对所有内部对象进行追踪。这时,shallowRef就是一个更好的选择。

解释

ref vs shallowRef

  • ref:会递归地让内部所有对象都变成响应式,这对于简单数据类型很好用,但如果是复杂对象(例如编辑器实例),它可能会带来性能问题。
  • shallowRef:只会让最外层的引用是响应式的,内部对象不会变成响应式,这样可以避免不必要的性能开销。

在组件中的应用

在我们的富文本编辑器组件中,我们使用shallowRef来管理编辑器实例,这样我们只需要追踪编辑器实例的创建和销毁,而不用关心编辑器内部的复杂数据结构。

代码示例:

const editorRef = shallowRef(); // 只让 editorRef 是响应式的,而不是它内部的所有对象

const handleCreated = (editor) => {
  editorRef.value = editor; // 当编辑器实例创建时,将其保存到 editorRef
};

2. 数据同步:理解 v-model 和 update 事件

背景

在Vue 3中,v-model 是用来实现双向数据绑定的工具,它允许父组件和子组件之间轻松地同步数据。我们可以通过自定义事件来更好地控制这个数据同步过程。

解释

v-model的默认行为:

默认情况下,v-model 绑定的数据是modelValue,它通过 update:modelValue 事件来同步数据。

自定义事件:

我们可以通过 defineEmits 来声明和触发自定义事件,例如 update:modelText,用于同步其他类型的数据(如纯文本内容)。

在组件中的应用

在这个组件中,我们通过 v-model 实现了HTML内容的同步,同时通过 update:modelText 事件来同步编辑器中的纯文本内容。

代码示例:

const emit = defineEmits(['update:modelValue', 'update:modelText']); // 声明将会触发的事件

const htmlContent = ref('');

watch(() => props.modelValue, (val) => {

  if (val === htmlContent.value) return;
   
  htmlContent.value = val;
    
  nextTick(() => {
    emit('update:modelText', editorRef.value.getText().trim()); // 当内容变化时,同步文本内容
  });
});
​
watch(htmlContent, (val) => {
  emit('update:modelValue', val); // 同步HTML内容
  emit('update:modelText', editorRef.value.getText().trim());  // 同步纯文本内容
});

3. 处理模态框:如何自定义编辑器中的模态框样式?

背景

在富文本编辑器中,模态框(如图片上传、链接插入等)是常见的交互元素。为了让这些模态框符合我们的UI风格,我们通常需要自定义它们的样式和位置。

解释

  • 模态框的样式控制:
    • 我们可以监听编辑器的 modalOrPanelShow 事件,在模态框显示时,动态设置它的样式和位置,使其在屏幕上居中显示。

在组件中的应用

在编辑器实例创建后,我们通过监听 modalOrPanelShow 事件,自定义设置模态框的样式和位置。

代码示例:

const handleCreated = (editor) => {

  editorRef.value = editor;
​
  // 当模态框显示时,调整其样式
  editorRef.value.on('modalOrPanelShow', (modalOrPanel) => {
      
    if (modalOrPanel.type !== 'modal') return;
​
    const { $elem } = modalOrPanel;
    const width = $elem.width();
    const height = $elem.height();
​
    $elem.css({
      left: '50%',
      top: '50%',
      marginLeft: `-${width / 2}px`,
      marginTop: `-${height / 2}px`,
      zIndex: 1000,
      position: 'fixed',
      height: 'fit-content',
    });
  });
};

4. 图片上传与插入:如何自定义图片上传逻辑?

背景

图片上传是富文本编辑器中的重要功能。通常,我们需要自定义上传逻辑,以便将图片上传到服务器,并将服务器返回的图片信息插入到编辑器中。

解释

自定义图片上传:

  • MENU_CONF 是一个配置对象,用于自定义富文本编辑器中的菜单行为。这里的配置项 ['uploadImage'] 针对的是图片上传功能,其中 customUpload 是编辑器提供的方法,用于自定义图片上传的流程,当用户在编辑器中选择上传图片时,这个方法会被调用。
  • 这里的 file 参数表示用户选择上传的图片文件。

构建 FormData 对象:

  • FormData 是用于构建上传文件请求体的标准接口,它能够以键值对的形式存储文件和数据,用于文件上传。
  • formData.append('file', file) 将图片文件添加到 FormData 对象中,键名为 file

异步上传文件:

  • 使用 await upload(formData) 将构建好的 FormData 对象发送给服务器进行文件上传。
  • upload 是自定义的一个API方法,用于将文件上传到服务器。上传完成后,服务器会返回一个响应数据(res),其中包含了上传成功后的图片信息,如 id 和 sign

插入图片:

  • 上传成功后,通过 insertFn 方法将图片插入到编辑器中。
  • insertFn 是编辑器提供的一个函数,用于在编辑器中插入图片或其他内容。通过调用 insertFn,你可以控制图片的插入行为。

insertFn 方法的参数含义

insertFn 是编辑器传递给 customUpload 方法的一个回调函数,用于在上传成功后将图片插入到编辑器中。它通常接受三个参数:

insertFn(url, altText, href)

url (第一个参数):

  • 图片的实际 URL 地址。通常情况下,这是图片在服务器上存储的地址,用于在编辑器中渲染图片。
  • 在代码中,这个参数被传入一个空字符串 '',意思是不直接使用 URL 来展示图片,而是通过后面的 id 和 sign 来渲染。

altText (第二个参数):

  • 图片的替代文本(alt text),当图片无法加载时显示的文字。
  • 这里也是传入空字符串 '',表示不设置替代文本。

href (第三个参数):

  • 图片的链接地址。通常用于点击图片时跳转的目标 URL。
  • 在代码中,传入的是一个包含图片 id 和 sign 的字符串。这是用于在渲染时通过 id 和 sign 来向服务器端请求图片资源。

在组件中的应用

在这个组件中,我们通过自定义 uploadImage 配置,来实现图片的上传和插入逻辑。

代码示例:

const editorConfig = {
  MENU_CONF: {
    ['uploadImage']: {
      async customUpload(file, insertFn) {
        const formData = new FormData();
        formData.append('file', file);
        const res = await upload(formData); // 上传图片到服务器
        insertFn('', '', `${res.data.id}&${res.data.sign}`); // 插入图片,将 id 和 sign 传入
      },
    },
  },
};

5. 延迟加载图片:如何确保图片正确显示?

背景

在富文本编辑器中,图片可能不会直接以URL的形式插入,而是以某种标识符(如id 和 sign)的形式存在。为了正确显示这些图片,我们需要在渲染时动态加载它们的实际URL。

解释

参数检查:

  • handleImage 接受一个 dom 参数,并首先检查这个参数是否为 Element 类型。
  • 如果不是 Element,则输出警告信息 dom is not Element,提示传入的参数不正确。

查找图片元素:

  • 如果 dom 是有效的 Element,函数会调用 dom.querySelectorAll('img'),遍历所有图片(<img>)元素。
  • 这些图片元素通常是从编辑器中插入的,它们可能还没有被正确渲染出来。

处理 data-href 属性:

  • 对于每个图片元素,函数检查它的 data-href 属性。这个属性包含图片的标识信息,比如 id 和 sign,它们通过 & 符号连接。
  • data-href 属性在图片插入时被设置,用于标识图片的存储位置或其他元数据。

提取 id 和 sign

  • 如果 data-href 属性存在,并且包含 & 符号,函数会将其拆分成 id 和 sign 两部分。
  • id 和 sign 是服务器识别和验证图片的关键信息。

获取图片URL:

  • 函数调用 getImage({ id, sign }),这是一个异步操作,会向服务器请求,通过 id 和 sign 获取图片的实际URL。
  • getImage 函数返回一个Promise,成功时会解析出图片的URL。

设置图片 src

  • 当URL获取成功后,函数将该URL设置为图片元素的 src 属性,使图片可以正确显示。

在组件中的应用

handleImage 函数遍历编辑器中的所有图片元素,根据 data-href 属性中的标识符,从服务器获取图片的实际URL,并将其应用到图片元素上。

代码示例:

const handleImage = (dom) => {
  if (dom instanceof Element) {
    dom.querySelectorAll('img').forEach((element) => {
      const href = element.getAttribute('data-href');
      if (href && href.includes('&')) {
        const [id, sign] = href.split('&');
        if (id && sign) {
          getImage({ id, sign }).then((url) => {
            element.src = url; // 设置图片的 src 为实际URL
          });
        }
      }
    });
  } else {
    console.warn('dom is not Element');
  }
};

完整组件代码

最后,让我们把所有的关键点结合起来,看看这个富文本编辑器组件的完整实现:

<template>
  <div>
    <Toolbar
      :editor="editorRef"
      :editorId="editorId"
      :defaultConfig="toolBarConfig"
    />
    <Editor
      v-model="htmlContent"
      :defaultConfig="editorConfig"
      :editorId="editorId"
      :style="editorStyle"
      @on-change="handleChange"
      @on-created="handleCreated"
    />
  </div>
</template>
​
<script setup>
import { shallowRef, ref, watch, unref, computed, onBeforeUnmount, nextTick } from 'vue';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import { isNumber } from '@/utils';
import { ElMessage } from 'element-plus';
import { upload } from '@/api';
import { useFile } from '@/hooks';
​
const { handleImage } = useFile();
​
const props = defineProps({
  editorId: {
    type: String,
    default: 'custom-editor',
  },
  height: {
    type: [String, Number],
    default: '500px',
  },
  editorConfig: {
    type: Object,
    default: () => {},
  },
  toolBarConfig: {
    type: Object,
    default: () => {},
  },
  readonly: {
    type: Boolean,
    default: false,
  },
  modelValue: {
    type: String,
    default: '',
  },
  modelText: {
    type: String,
    default: '',
  },
  maxLength: {
    type: Number,
    default: 600,
    validator: (val) => {
      return val >= 0;
    },
  },
  placeholder: {
    type: String,
    default: '请输入...',
  },
});

const emit = defineEmits(['change', 'update:modelValue', 'update:modelText']);

const editorRef = shallowRef();

const htmlContent = ref('');

watch(
  () => props.modelValue,
  (val) => {
    if (val === unref(htmlContent)) return;
    htmlContent.value = val;
    nextTick(() => {
      const dom = editorRef.value.getEditableContainer();
      if (dom) {
        handleImage(dom);
      }
      emit('update:modelText', editorRef.value.getText().trim());
    });
  },
  {
    immediate: true,
  }
);

watch(
  () => htmlContent.value,
  (val) => {
    nextTick(() => {
      const dom = editorRef.value.getEditableContainer();
      if (dom) {
        handleImage(dom);
      }
    });
    emit('update:modelValue', val);
    emit('update:modelText', editorRef.value.getText().trim());
  }
);

const handleCreated = (editor) => {
  editorRef.value = editor;
  editorRef.value.on('modalOrPanelShow', (modalOrPanel) => {
    if (modalOrPanel.type !== 'modal') return;
    const { $elem } = modalOrPanel; // modal element
    const width = $elem.width();
    const height = $elem.height();
    $elem.css({
      left: '50%',
      top: '50%',
      marginLeft: `-${width / 2}px`,
      marginTop: `-${height / 2}px`,
      zIndex: 1000,
      position: 'fixed',
      height: 'fit-content',
    });
  });
};
​
// 编辑器配置
const editorConfig = computed(() => {
  return Object.assign(
    {
      placeholder: props.placeholder,
      maxLength: props.maxLength,
      readOnly: props.readonly,
      customAlert: (s, t) => {  // 自定义编辑器警告提示(比如:上传图片过大等)
        switch (t) {
          case 'success':
            ElMessage.success(s);
            break;
          case 'info':
            ElMessage.info(s);
            break;
          case 'warning':
            ElMessage.warning(s);
            break;
          case 'error':
            ElMessage.error(s);
            break;
          default:
            ElMessage.info(s);
            break;
        }
      },
      autoFocus: false,
      scroll: true,
      MENU_CONF: {
        ['uploadImage']: {
          async customUpload(file, insertFn) {
            const formData = new FormData();
            formData.append('file', file);
            const res = await upload(formData);
            // 插入图片 将id和sign传入并保存,渲染的时候使用
            insertFn('', '', `${res.data.id}&${res.data.sign}`);
          },
        },
      },
      hoverbarKeys: {
        // 在点击上传完成的图片时,会弹出快捷编辑框,去除‘编辑’和’查看链接‘按钮,只保留如下按钮配置
        image: {
          menuKeys: ['imageWidth30', 'imageWidth50', 'imageWidth100', 'deleteImage'],
        },
      },
    },
    props.editorConfig || {}
  );
});
​
// 工具栏配置
const toolBarConfig = computed(() => {
  return Object.assign(
    {
      excludeKeys: ['emotion', 'group-video'],  // 去除emo表情按钮,视频上传按钮
    },
    props.toolBarConfig
  );
});
​
// 编辑器样式
const editorStyle = computed(() => {
  return {
    height: isNumber(props.height) ? `${props.height}px` : props.height,
  };
});
​
const handleChange = (editor) => {
  emit('change', editor);
};
​
onBeforeUnmount(() => {
  const editor = unref(editorRef.value);
  editor.destroy();
});
</script>
​
<style src="@wangeditor/editor/dist/css/style.css"></style>

结语

通过对该Vue组件的深入解析,我们看到了它如何利用Vue 3的特性和@wangeditor/editor-for-vue库实现一个功能丰富的富文本编辑器。组件采用了shallowRef来管理编辑器实例,使用v-model机制和自定义事件实现了双向数据绑定,并通过异步操作和事件监听实现了图片上传和展示的自定义处理。

这种实现方式不仅满足了基本的文本编辑需求,还提供了高度的可定制性,适用于各种复杂的Web应用场景。如果你正在开发一个需要富文本编辑器的项目,可以参考本文的实现思路,结合实际需求进行扩展和优化。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值