wangEditor 5 集成 Vue3 实战:从入门到「中文输入法首字崩溃」的深度修复

2026-09-17 104 浏览 0 评论 0 字

一篇关于 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创建工具栏,需传入编辑器实例
modedefault (完整工具栏)/ 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 弹窗 / 抽屉场景的封装

在后台管理系统中,富文本几乎总是出现在「新增/编辑弹窗」里。此时有两个容易踩的坑:

  1. 每次打开弹窗都要有全新的编辑器实例 ,关闭后必须销毁,否则会出现编辑器复用、事件泄漏;
  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 回写、弹窗销毁时机)导致的。但做了三件事后基本排除了封装责任:

  1. 官方 demo 同样复现 :在 wangEditor 官网 demo 上,火狐/部分 Chrome 环境直接输入中文即可复现同一错误(GitHub issue #6122 、 #5994 );
  2. 英文先输入则安全 :说明问题与「空内容」强相关,与组件封装弱相关;
  3. 上游已停更 :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">&#xFEFF;<br></span>
    </span>
  </span>
</p>

完整崩溃链路 :

  1. 空段落里的空文本被渲染为 data-slate-zero-width 占位 span(内含零宽字符 \uFEFF 和 <br> );
  2. 中文输入法合成时,浏览器会 破坏这个零宽占位 span (删除/替换 \uFEFF 节点并写入合成文字);
  3. compositionend 处理器第一步 cleanExposedTexNodeInSelectionBlock 会把「不在 slate 标记 span 内的裸文本节点」 删除 ——此时段落里已经没有任何 leaf span 了;
  4. 随后 insertText 更新了 模型 ,但 DOM 的重新渲染是异步的;
  5. 步骤③的 setTimeout 里调用 toDOMRange(editor, selection) ,在 <p> 里 querySelectorAll 一个 leaf span 都找不到 → toDOMPoint 抛错;
  6. 错误让后续的 DOM 提交(snabbdom patch)级联 removeChild 崩溃,编辑器进入损坏状态。

而 英文/数字输入不经过 compositionend (走 beforeinput → insertText ),所以安全; 先输入英文再输中文 之所以安全,是因为此时叶子已经是真实的 data-slate-string span,合成文字会写进这个 span 内部(受 data-slate-string 保护),不会触发上述销毁。

4.4 解决方案:让空段落「永不真空」

既然崩溃的前提是「空段落渲染成零宽占位 span」,那就让 空段落永远渲染成真实的文本节点 ——用一个零宽空格 \u200B (ZWSP,正常字符、肉眼不可见)作为空段落的常驻文本:

  • \u200B 是真实文本 → 叶子渲染为 data-slate-string span(与「先输入英文」后的状态完全一致);
  • 输入法合成时文字进入该 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% 模拟真实输入法合成,最终的「中文首字输入」请在真实浏览器里亲手验证一遍。


五、总结

  1. wangEditor 5 上手快、中文生态好 ,但原版已停止维护(最终版 5.1.23),锁版本使用时要对已知缺陷有心理准备;
  2. Vue3 集成建议把「创建 / 销毁 / v-model 清洗」收敛成一个自包含组件,弹窗场景务必 destroy-on-close + 手动 destroy() ;
  3. 「空编辑器首字输入中文崩溃」是 wangEditor 5.1.23 的真实缺陷:空文本节点渲染的 data-slate-zero-width 占位 span 在输入法合成中被浏览器破坏,导致 compositionend 后的 toDOMRange 无法解析 DOM 点;
  4. 修复思路是 让空段落永不渲染零宽占位 ——用零宽空格 \u200B 常驻文本节点,配合 normalizeNode 插件覆盖所有运行时空段落场景;其中「Slate setNodes 跳过 text 键」是极易踩的暗坑;
  5. 修复后对外保持空语义(剥离零宽字符、自绘 placeholder),业务代码零感知。

附:本文所有结论均基于 @wangeditor/editor@5.1.23 + @wangeditor/editor-for-vue@5.1.12 + Vue3(Composition API)+ Element Plus 弹窗场景实测验证。


发布评论

发布评论前请先 登录。
0 评论
点赞
收藏

评论列表 0

暂无评论