在当今前端开发领域,Vue3 和 TypeScript 的组合已经成为构建企业级应用的首选方案。wangEditor 作为国内广受欢迎的富文本编辑器,其 v5 版本带来了更现代化的架构和更强大的功能。本文将深入探讨如何在 Vue3 和 TypeScript 环境中优雅地集成 wangEditor v5,特别针对动态表单等复杂场景提供完整解决方案。
首先确保你已经创建了一个 Vue3 + TypeScript 项目。如果尚未创建,可以使用以下命令:
bash复制npm init vue@latest my-wangeditor-project -- --typescript
安装 wangEditor v5:
bash复制npm install @wangeditor/editor @wangeditor/editor-for-vue
创建一个 RichTextEditor.vue 组件作为基础封装:
typescript复制<script setup lang="ts">
import { ref, onBeforeUnmount } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import type { IDomEditor, IEditorConfig } from '@wangeditor/editor'
const editorRef = ref<IDomEditor | null>(null)
const mode = 'default' // 或 'simple'
const editorConfig: Partial<IEditorConfig> = {
placeholder: '请输入内容...',
MENU_CONF: {}
}
const handleCreated = (editor: IDomEditor) => {
editorRef.value = editor
}
onBeforeUnmount(() => {
if (editorRef.value == null) return
editorRef.value.destroy()
})
</script>
<template>
<div class="editor-wrapper">
<Toolbar :editor="editorRef" :mode="mode" />
<Editor
v-model="value"
:defaultConfig="editorConfig"
:mode="mode"
@onCreated="handleCreated"
/>
</div>
</template>
为了充分发挥 TypeScript 的优势,我们需要为编辑器实例和配置项定义完整的类型:
typescript复制import type {
IDomEditor,
IEditorConfig,
IToolbarConfig,
SlateDescendant,
SlateElement,
SlateText
} from '@wangeditor/editor'
interface CustomElement extends SlateElement {
type: string
children: SlateDescendant[]
}
interface CustomText extends SlateText {
text: string
bold?: boolean
italic?: boolean
}
type CustomDescendant = CustomElement | CustomText
declare module '@wangeditor/editor' {
interface SlateElement {
type: string
children: SlateDescendant[]
}
}
wangEditor v5 提供了强大的插件系统,我们可以基于 TypeScript 开发自定义插件:
typescript复制import { Boot } from '@wangeditor/editor'
function withCustomPlugin<T extends IDomEditor>(editor: T) {
const { insertText } = editor
const newEditor = editor
newEditor.insertText = (text: string) => {
if (text === '@') {
// 处理自定义逻辑
return
}
insertText(text)
}
return newEditor
}
Boot.registerPlugin(withCustomPlugin)
在动态表单场景中,我们需要管理多个编辑器实例。下面是一个使用 Vue3 Composition API 的实现:
typescript复制import { ref, computed, watchEffect } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import type { IDomEditor } from '@wangeditor/editor'
interface FormItem {
id: string
content: string
editorInstance: IDomEditor | null
}
const formItems = ref<FormItem[]>([
{ id: 'item1', content: '', editorInstance: null },
{ id: 'item2', content: '', editorInstance: null }
])
const handleCreated = (editor: IDomEditor, itemId: string) => {
const item = formItems.value.find(i => i.id === itemId)
if (item) {
item.editorInstance = editor
}
}
const destroyEditors = () => {
formItems.value.forEach(item => {
if (item.editorInstance) {
item.editorInstance.destroy()
item.editorInstance = null
}
})
}
对于大量动态编辑器实例,我们需要考虑性能优化:
typescript复制const visibleEditors = ref<Set<string>>(new Set())
const lazyLoadEditor = (itemId: string) => {
visibleEditors.value.add(itemId)
}
const unloadEditor = (itemId: string) => {
const item = formItems.value.find(i => i.id === itemId)
if (item?.editorInstance) {
item.editorInstance.destroy()
item.editorInstance = null
}
visibleEditors.value.delete(itemId)
}
watchEffect(() => {
formItems.value.forEach(item => {
if (visibleEditors.value.has(item.id) && !item.editorInstance) {
// 触发编辑器创建
} else if (!visibleEditors.value.has(item.id) && item.editorInstance) {
unloadEditor(item.id)
}
})
})
wangEditor v5 提供了灵活的文件上传配置:
typescript复制const editorConfig: Partial<IEditorConfig> = {
MENU_CONF: {
uploadImage: {
async customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
// 实现自定义上传逻辑
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
})
const result = await response.json()
insertFn(result.url, result.alt)
} catch (error) {
console.error('上传失败:', error)
}
}
}
}
}
我们可以通过 CSS 变量和自定义样式来适配项目设计系统:
css复制.editor-wrapper {
--w-e-toolbar-color: #333;
--w-e-toolbar-bg-color: #f5f5f5;
--w-e-textarea-bg-color: #fff;
--w-e-textarea-color: #333;
--w-e-textarea-border-color: #ddd;
--w-e-toolbar-active-color: #1e88e5;
--w-e-toolbar-active-bg-color: #e3f2fd;
border: 1px solid var(--w-e-textarea-border-color);
border-radius: 4px;
overflow: hidden;
}
.editor-wrapper .w-e-bar {
border-bottom: 1px solid var(--w-e-textarea-border-color);
}
.editor-wrapper .w-e-text-container {
min-height: 300px;
}
与流行的表单库如 VeeValidate 或 FormKit 集成:
typescript复制import { useField } from 'vee-validate'
const { value: editorValue, handleChange } = useField('content')
const handleEditorChange = (editor: IDomEditor) => {
const html = editor.getHtml()
handleChange(html)
}
// 在编辑器组件中
<Editor
:defaultHtml="editorValue"
@onChange="handleEditorChange"
/>
为富文本编辑器编写有效的单元测试:
typescript复制import { mount } from '@vue/test-utils'
import RichTextEditor from './RichTextEditor.vue'
describe('RichTextEditor', () => {
it('should initialize editor', async () => {
const wrapper = mount(RichTextEditor)
await wrapper.vm.$nextTick()
expect(wrapper.find('.w-e-text-container').exists()).toBe(true)
})
it('should emit change event', async () => {
const wrapper = mount(RichTextEditor)
await wrapper.vm.$nextTick()
// 模拟编辑器内容变化
const editor = wrapper.vm.editorRef
editor.insertText('Test content')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('change')).toBeTruthy()
})
})
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 编辑器不显示 | DOM未准备好 | 使用nextTick确保挂载 |
| 类型错误 | 类型定义不全 | 扩展wangEditor类型声明 |
| 内存泄漏 | 实例未销毁 | 确保在onBeforeUnmount中销毁 |
| 样式错乱 | 样式冲突 | 使用scoped样式或CSS变量 |
基于 wangEditor 实现简单的协同编辑功能:
typescript复制import { Boot } from '@wangeditor/editor'
function withCollaboration<T extends IDomEditor>(editor: T) {
const { apply } = editor
const newEditor = editor
newEditor.apply = (operation: any) => {
// 本地应用变更
apply(operation)
// 广播给其他协作者
broadcastOperation(operation)
return newEditor
}
return newEditor
}
Boot.registerPlugin(withCollaboration)
实现自定义节点类型:
typescript复制import { h, VNode } from 'snabbdom'
import { DOMElement } from '@wangeditor/editor'
function renderCustomElement(elemNode: CustomElement, children: VNode[] | null): DOMElement {
const { type } = elemNode
return h(
'div',
{
attrs: {
'data-type': type,
'contenteditable': 'false'
},
style: {
border: '1px dashed #ccc',
padding: '10px',
margin: '10px 0'
}
},
children
)
}
// 注册自定义元素渲染器
Boot.registerRenderElement({
type: 'custom-element',
renderElem: renderCustomElement
})