引言
在Web开发中,富文本编辑器是一个常见的需求,特别是在内容管理系统(CMS)或博客平台中。本文将详细解析一个基于Vue 3和@wangeditor/editor-for-vue
的富文本编辑器组件的实现,重点介绍其关键功能及其背后的设计思路。通过阅读本文,你将学会如何创建一个支持图片上传、双向数据绑定、异步图片加载的富文本编辑器组件。
组件概述
本文所述的Vue组件旨在提供一个可高度自定义的富文本编辑器。这个组件包含以下主要关键点:
- 富文本内容编辑
- 工具栏配置
- 自定义图片上传与插入
- 通过
v-model
实现双向数据绑定 - 异步加载并渲染图片
组件的核心功能实现
1. 管理编辑器实例:为什么选择 shallowRef
?
背景
在Vue 3中,ref
和shallowRef
是用来创建响应式数据的工具。通常情况下,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应用场景。如果你正在开发一个需要富文本编辑器的项目,可以参考本文的实现思路,结合实际需求进行扩展和优化。