引言
在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应用场景。如果你正在开发一个需要富文本编辑器的项目,可以参考本文的实现思路,结合实际需求进行扩展和优化。