wangEditor 5 集成 Vue3 实战:从入门到「中文输入法首字崩溃」的深度修复
一篇关于 wangEditor 富文本编辑器的集成实践与源码级踩坑记录。 覆盖:编辑器选型、安装使用、Vue3 封装,以及一个在空编辑器里「首字输入中文必崩」的诡异 Bug 的完整排查与修复过程。
一、为什么选 wangEditor

在 Vue 生态里做富文本,可选方案不少:Quill、TinyMCE、CKEditor、wangEditor……对于中文开发者来说, wangEditor 几乎是上手成本最低的一个 :
- 国产开源、中文文档完善 :官网、教程、社区提问全中文,遇到问题能快速找到同类案例;
- 基于 Slate.js 二次封装 :底层是成熟的可控编辑内核,插件体系清晰(菜单、渲染、解析均可扩展);
- 官方提供 Vue3 / React 封装 :
@wangeditor/editor-for-vue,开箱即用; - 体积与体验均衡 :默认工具栏开箱即得,支持图片/视频上传、表格、代码块、全屏等常用能力;
- MIT 协议 :商用无忧。
需要特别说明的是: 原版 wangEditor 自 2023 年起已停止维护, @wangeditor/editor@5.1.23 是最终版本 。社区有延续分支 wangEditor-next ,但如果你像我们一样,因为历史原因锁定了 5.1.23 且 不降级、不换编辑器 ,那么它的已知问题就需要自己在应用层解决——这正是本文后半部分的重点。
二、快速上手:安装与基础用法
2.1 安装
npm i @wangeditor/editor @wangeditor/editor-for-vue
# 或 pnpm / yarn 同理两个包都需要安装: @wangeditor/editor 是核心引擎, @wangeditor/editor-for-vue 是 Vue 组件封装层。
2.2 原生用法(不依赖框架)
wangEditor 本身不依赖框架,直接用 DOM 就能跑:
<div id="editor"></div>
<div id="toolbar"></div>import { createEditor, createToolbar } from '@wangeditor/editor'
import '@wangeditor/editor/dist/css/style.css'
const editor = createEditor({
selector: '#editor', // 编辑器容器
html: '<p>你好,wangEditor</p>', // 初始内容
config: {
placeholder: '请输入内容…'
},
mode: 'default' // default 全部功能 / simple 精简版
})
const toolbar = createToolbar({
editor,
selector: '#toolbar',
config: {},
mode: 'default'
})
// 读取内容
editor.getHtml()
editor.getText()
// 销毁(重要!)
editor.destroy()2.3 核心概念速览
| 概念 | 说明 |
|---|---|
createEditor | 创建编辑器实例,返回 IDomEditor |
createToolbar | 创建工具栏,需传入编辑器实例 |
mode | default (完整工具栏)/ simple (精简) |
config | 编辑器配置: placeholder 、 MENU_CONF (菜单配置,如上传)、 maxLength 、 readOnly 等 |
getHtml() / getText() | 读取 HTML / 纯文本 |
destroy() | 销毁实例并解绑事件, 弹窗/抽屉场景必须调用,防止内存泄漏 |
三、在 Vue3 中集成 wangEditor
3.1 官方组件的最小用法
<template>
<div style="border: 1px solid #ccc">
<Toolbar
style="border-bottom: 1px solid #ccc"
:editor="editorRef"
:default-config="toolbarConfig"
mode="default"
/>
<Editor
v-model="valueHtml"
style="height: 300px; overflow-y: hidden"
:default-config="editorConfig"
mode="default"
@on-created="handleCreated"
/>
</div>
</template>
<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, ref, shallowRef } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
// 编辑器实例必须用 shallowRef,避免深层响应式追踪导致性能问题
const editorRef = shallowRef()
const valueHtml = ref('<p>hello</p>')
const toolbarConfig = {}
const editorConfig = {
placeholder: '请输入内容…',
// 图片/视频上传等在这里配置 MENU_CONF
// MENU_CONF: { uploadImage: { customUpload: ... } }
}
const handleCreated = editor => {
editorRef.value = editor
}
// 组件销毁时手动销毁编辑器
onBeforeUnmount(() => {
editorRef.value?.destroy()
})
</script>3.2 弹窗 / 抽屉场景的封装
在后台管理系统中,富文本几乎总是出现在「新增/编辑弹窗」里。此时有两个容易踩的坑:
- 每次打开弹窗都要有全新的编辑器实例 ,关闭后必须销毁,否则会出现编辑器复用、事件泄漏;
- 如果弹窗用了
destroy-on-close(销毁关闭后的内容),编辑器随组件卸载自动重建,但 销毁时机与输入法合成(IME)的交互容易出问题 。
因此我们封装了一个 自包含组件 ReWangEditor ,把创建、销毁、v-model 清洗全部收敛在组件内部:
<!-- ReWangEditor/index.vue(核心骨架,节选) -->
<script setup lang="ts">
import { onBeforeUnmount, ref, shallowRef } from 'vue'
import '@wangeditor/editor/dist/css/style.css'
import { Editor as WangEditor, Toolbar as WangToolbar } from '@wangeditor/editor-for-vue'
const editorRef = shallowRef()
const html = ref('<p><br></p>') // 初始内容兜底
const handleCreated = editor => {
editorRef.value = editor
}
onBeforeUnmount(() => {
editorRef.value?.destroy()
})
</script>
<template>
<div class="re-wang-editor">
<WangToolbar :editor="editorRef" :default-config="{}" mode="default" />
<WangEditor
v-model="html"
:default-config="{ placeholder: '请输入内容…' }"
mode="default"
@on-created="handleCreated"
/>
</div>
</template>业务页面直接 v-model 即可:
<ReWangEditor v-model="form.content" placeholder="请输入通知内容…" />四、踩坑实录:中文输入法「首字必崩」
4.1 错误现象
在 空编辑器 中, 第一次输入中文 (输入法合成)时,控制台瞬间抛出一串错误:
Uncaught Error: Cannot resolve a DOM point from Slate point: {"path":[0,0],"offset":0}
at toDOMPoint (index.esm.js:39)
at toDOMRange (index.esm.js:39)
at compositionend (index.esm.js:39)
at setTimeout handler*compositionend (index.esm.js:39)
...
Uncaught (in promise) TypeError: can't access property "removeChild", e is null
at removeChild (index.esm.js:7)
... changeViewState ... onChange ... insertText ... compositionend
Uncaught (in promise) DOMException: Node.removeChild: The node to be removed is not a child of this node
... setSelection ... onDOMSelectionChange ...而 先输入英文/数字则一切正常 ,之后再切中文也正常。也就是说: 空内容 + 首字符走中文输入法 = 必现崩溃,且崩溃后编辑器进入损坏状态(无法继续输入/删除)。
4.2 初步排查:是封装的问题吗?
起初怀疑是 Vue 封装层(v-model 回写、弹窗销毁时机)导致的。但做了三件事后基本排除了封装责任:
- 官方 demo 同样复现 :在 wangEditor 官网 demo 上,火狐/部分 Chrome 环境直接输入中文即可复现同一错误(GitHub issue #6122 、 #5994 );
- 英文先输入则安全 :说明问题与「空内容」强相关,与组件封装弱相关;
- 上游已停更 :5.1.23 是最终版,issue 无人修复,必须自己解决。
结论:这是 wangEditor 5.1.23 自身的一个已知缺陷 ,且只在「空段落 + 输入法合成」场景触发。
4.3 深入源码:崩溃到底发生在哪
为了搞清楚根因,直接反编译 @wangeditor/editor@5.1.23 的 bundle,找到 compositionend 事件处理器的实现(去混淆后):
compositionend(event, state, editor) {
// ...
const selection = editor.selection
if (selection != null) {
// ① Chrome/Firefox:先执行「清理裸露文本节点」
(IS_CHROME || IS_FIREFOX) && DomEditor.cleanExposedTexNodeInSelectionBlock(editor)
// ② 把合成结果写入模型
const data = event.data
if (data) {
Editor.insertText(editor, data)
}
// ③ 关键:延迟一帧,恢复/校验 DOM 选区
if (!IS_SAFARI) {
setTimeout(() => {
const sel = editor.selection
if (sel != null) {
const startContainer = savedStartContainer
if (startContainer != null
&& DomEditor.toDOMRange(editor, sel).startContainer !== startContainer) {
startContainer.textContent = savedText || ''
}
}
})
}
}
}再看 toDOMPoint (崩溃点):
toDOMPoint(editor, point) {
const parent = ... // point.path 的父节点(如 <p>)
const domEl = DomEditor.toDOMNode(editor, parent)
// 在 <p> 内查找所有 slate 叶子 span:data-slate-string / data-slate-zero-width
const leaves = domEl.querySelectorAll('[data-slate-string], [data-slate-zero-width]')
// 遍历叶子累加长度,定位 point.offset……
if (!matched) {
throw new Error(`Cannot resolve a DOM point from Slate point: ${JSON.stringify(point)}`)
}
}把空段落渲染出的 DOM 结构打出来,一切真相大白:
<!-- 空段落:空文本节点被渲染成「零宽占位 span」 -->
<p data-slate-node="element">
<span data-slate-node="text">
<span data-slate-leaf="true">
<span data-slate-zero-width="n"><br></span>
</span>
</span>
</p>完整崩溃链路 :
- 空段落里的空文本被渲染为
data-slate-zero-width占位 span(内含零宽字符\uFEFF和<br>); - 中文输入法合成时,浏览器会 破坏这个零宽占位 span (删除/替换
\uFEFF节点并写入合成文字); compositionend处理器第一步cleanExposedTexNodeInSelectionBlock会把「不在 slate 标记 span 内的裸文本节点」 删除 ——此时段落里已经没有任何 leaf span 了;- 随后
insertText更新了 模型 ,但 DOM 的重新渲染是异步的; - 步骤③的
setTimeout里调用toDOMRange(editor, selection),在<p>里querySelectorAll一个 leaf span 都找不到 →toDOMPoint抛错; - 错误让后续的 DOM 提交(snabbdom patch)级联
removeChild崩溃,编辑器进入损坏状态。
而 英文/数字输入不经过 compositionend (走 beforeinput → insertText ),所以安全; 先输入英文再输中文 之所以安全,是因为此时叶子已经是真实的 data-slate-string span,合成文字会写进这个 span 内部(受 data-slate-string 保护),不会触发上述销毁。
4.4 解决方案:让空段落「永不真空」
既然崩溃的前提是「空段落渲染成零宽占位 span」,那就让 空段落永远渲染成真实的文本节点 ——用一个零宽空格 \u200B (ZWSP,正常字符、肉眼不可见)作为空段落的常驻文本:
\u200B是真实文本 → 叶子渲染为data-slate-stringspan(与「先输入英文」后的状态完全一致);- 输入法合成时文字进入该 span 内部, 被
data-slate-string保护 ,不会被cleanExposedTexNodeInSelectionBlock删除; toDOMRange永远能解析到 leaf span,不再抛错;- 对外展示/存储时把
\u200B剥掉,父组件感知不到它的存在。
4.4.1 关键坑:Slate 的 setNodes 改不了文本
第一版实现里,我们用 Transforms.setNodes(editor, { text: '\u200B' }, { at: path }) 去改写空文本,结果 静默无效 。翻开源 Slate 0.72 的源码:
for (var k in props) {
if (k === 'children' || k === 'text') {
continue; // ← setNodes 明确跳过 text 键!
}
...
}setNodes 只允许修改元素的属性, 文本内容必须走 insertText / removeText 操作 。改为:
Transforms.insertText(editor, '\u200B', { at: { path, offset: 0 } })4.4.2 完整修复:三处配合
只有一处覆盖不全面,因为 wangEditor 创建期的 normalizeContent 只规范顶层元素、触达不到文本节点 。所以「初始空状态」必须显式种入,运行时场景交给 normalizeNode 插件:
① 初始空内容直接种入 \u200B (创建期无法只靠插件)
const html = ref(
props.modelValue?.trim() ? stripZeroWidth(props.modelValue) : `<p>\u200B</p>`
)② 创建完成后兜底补种(防止解析器剥离了零宽空格)
const handleCreated = editor => {
editorRef.value = editor
if (editor.isEmpty()) {
Transforms.insertText(editor, '\u200B', { at: { path: [0, 0], offset: 0 } })
}
}③ 注册插件:用 normalizeNode 拦截「空文本」,覆盖全删后 / 回车新起空行等运行时场景
import { Boot, SlateEditor, SlateText, SlateTransforms } from '@wangeditor/editor'
Boot.registerPlugin(editor => {
const originNormalizeNode = editor.normalizeNode
editor.normalizeNode = entry => {
const [node, path] = entry
// 空文本 → 零宽空格(真实文本节点 → data-slate-string span)
if (SlateText.isText(node) && node.text === '') {
const [parent] = SlateEditor.parent(editor, path)
if (!editor.isVoid(parent)) {
SlateTransforms.insertText(editor, '\u200B', { at: { path, offset: 0 } })
return
}
}
originNormalizeNode(entry)
}
return editor
})4.4.3 边界处理:对外保持「空内容」语义
常驻 \u200B 后, editor.isEmpty() 恒为 false ,原生 placeholder 不再显示;同时 getHtml() 会带出零宽字符。因此在封装层做了两件事:
剥离零宽字符,保证 v-model 输出干净:
const stripZeroWidth = (s: string) => s.replace(/\u200B|\uFEFF/g, '')
watch(html, v => {
const clean = stripZeroWidth(v)
// 无可见文本且无图片/视频等资源 → 视为空
emit('update:modelValue', isEffectivelyEmpty(clean) ? '' : clean)
})同步回写时按剥离后的值比较,避免 setHtml 回写循环:
watch(
() => props.modelValue,
v => {
if (v == null) return
const normalized = stripZeroWidth(v)
if (!normalized.trim()) return // 空值:保留编辑器内部零宽占位
if (normalized !== stripZeroWidth(html.value)) html.value = normalized
}
)placeholder 自绘 (原生命名占位失效后的替代方案):
<div v-if="showPlaceholder" class="re-wang-editor__placeholder">
{{ placeholder }}
</div>showPlaceholder = 内容剥离后为空 且 不在输入法合成中;样式与原生占位保持一致( left: 10px; top: 17px; color: 灰; pointer-events: none )。
4.5 验证
在真实浏览器中对组件做了逐项验证:
| 场景 | 结果 |
|---|---|
| 空编辑器初始化 | 叶子为 <span data-slate-string="true">\u200B</span> ✅ |
| 英文输入 | 模型正常、输出干净 ✅ |
| 模拟中文合成输入 | 无任何报错, data-slate-string span 保持 ✅ |
| 回车新起空行 | 新空段落同样是 string span(运行时 normalize 生效)✅ |
| 全选删除后再输入 | 自动重新种入 \u200B ,不再回到崩溃态 ✅ |
| v-model 输出 | form.content 中无任何零宽字符 ✅ |
vue-tsc 类型检查 | 通过 ✅ |
最后,由于浏览器自动化无法 100% 模拟真实输入法合成,最终的「中文首字输入」请在真实浏览器里亲手验证一遍。
五、总结
- wangEditor 5 上手快、中文生态好 ,但原版已停止维护(最终版 5.1.23),锁版本使用时要对已知缺陷有心理准备;
- Vue3 集成建议把「创建 / 销毁 / v-model 清洗」收敛成一个自包含组件,弹窗场景务必
destroy-on-close+ 手动destroy(); - 「空编辑器首字输入中文崩溃」是 wangEditor 5.1.23 的真实缺陷:空文本节点渲染的
data-slate-zero-width占位 span 在输入法合成中被浏览器破坏,导致compositionend后的toDOMRange无法解析 DOM 点; - 修复思路是 让空段落永不渲染零宽占位 ——用零宽空格
\u200B常驻文本节点,配合normalizeNode插件覆盖所有运行时空段落场景;其中「SlatesetNodes跳过text键」是极易踩的暗坑; - 修复后对外保持空语义(剥离零宽字符、自绘 placeholder),业务代码零感知。
附:本文所有结论均基于
@wangeditor/editor@5.1.23+@wangeditor/editor-for-vue@5.1.12+ Vue3(Composition API)+ Element Plus 弹窗场景实测验证。






