2025-12-18-前端画布设计Vol.2 实现富文本编辑
uuid: 1355fb60-e356-11f0-957a-89aa7dac40fe title: 2025-12-18-前端画布设计Vol.2 实现富文本编辑 mathjax: true abbrlink: 10362 published: 2025-12-28 02:58:47 category: 前端 tags: - 前端 - 富文本编辑 - Canvas - wangEditor
画布项目中富文本编辑器的实现浅析
0x00 概述
富文本编辑能力是基于 wangEditor(v5 版本)的 React 封装组件 @wangeditor/editor-for-react 搭建起来的。整套功能落在两个组件的协作上:一个是 RichTextEditor.tsx,它是对编辑器本身的核心封装,负责把工具栏和编辑区渲染出来并完成各项配置;另一个是 BottomTextEditor.tsx,它是一块底部的面板式编辑器,只在用户选中单个文本元素时才浮现出来,把富文本编辑真正接入到画布的操作流程中,一边实时把改动写回元素内容,一边记录下撤销与重做所需的操作历史。
从交互形态上看,它呈现为画布底部的一块面板——单击某个文本元素,面板便会从下方滑出,供用户就地编辑。
0x01 RichTextEditor 组件实现
RichTextEditor 是对 wangEditor 的一层二次封装,目的是提供一个可复用的富文本编辑器:
import "@wangeditor/editor/dist/css/style.css";import { useState, useEffect } from "react";import { Editor, Toolbar } from "@wangeditor/editor-for-react";
const toolbarConfig: Partial<IToolbarConfig> = { toolbarKeys: [ "bold", "italic", "underline", "through", "|", "fontSize", "fontFamily", "color", "bgColor", "|", "justifyLeft", "justifyCenter", "justifyRight", "|", "undo", "redo", ],};
const editorConfig: Partial<IEditorConfig> = { placeholder: "请输入文本...", autoFocus: true,};
useEffect(() => { return () => { if (editor == null) return; editor.destroy(); setEditor(null); };}, [editor]);
return ( <div className="..."> <Toolbar editor={editor} defaultConfig={toolbarConfig} mode="simple" /> <Editor defaultConfig={editorConfig} value={value} onCreated={setEditor} onChange={(editor) => onChange(editor.getHtml())} mode="simple" style={{ height: "200px", overflowY: "auto" }} /> </div>);这段封装里有几处值得留意的设计。工具栏的内容由 toolbarConfig 中的 toolbarKeys 显式列出,只保留了加粗、斜体、下划线、删除线,再加上字号、字体、文字颜色和背景色,以及左中右三种对齐方式,最后附上撤销与重做——刻意做减法,把菜单收敛到画布文本最常用的那几项。编辑器本身的配置 editorConfig 也很克制,只设了占位符和自动聚焦;wangEditor 其实还支持诸如最大长度、自定义菜单一类更丰富的选项,但这里有意保持最小化,够用即可。
生命周期管理放在 useEffect 里完成:当组件卸载或者 editor 实例发生变化时,会调用 editor.destroy() 把旧实例销毁掉,防止内存泄漏。内容的同步则走一进一出两条路——初始的 HTML 由 value 这个 prop 注入,而内容一旦变化,就通过 onChange 回调里的 editor.getHtml() 取出最新的 HTML 再向上通知父组件。
0x02 BottomTextEditor 组件实现
如果说 RichTextEditor 解决的是”编辑器长什么样”,那么 BottomTextEditor 解决的就是”编辑器怎么融进画布”。它把富文本编辑器接入到画布的状态管理中,并且只在选中单个文本元素的场景下被激活:
const { selectedIds, elements, updateElement } = useStore();
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null;const element = selectedId ? elements[selectedId] : null;
const [localHtml, setLocalHtml] = useState("");
useEffect(() => { if (element && element.type === "text") { setLocalHtml(element.text || ""); }}, [element?.id, element?.text]);
const handleChange = (html: string) => { setLocalHtml(html); const initialText = element.text || ""; updateElement(element.id, { text: html });
const updateCommand = new UpdateElementPropertyCommand( { id: element.id, property: "text", oldValue: initialText, newValue: html, }, "修改文本内容", ); undoRedoManager.executeCommand(updateCommand);};
return ( <div className="fixed bottom-8 ... animate-slide-up"> <RichTextEditor value={localHtml} onChange={handleChange} /> </div>);组件首先要判断当前该不该显示。它从 Zustand 的 canvasStore 中取出选中的 ID 列表和元素集合,只有当恰好选中了一个、且类型为文本的元素时,才会把编辑器渲染出来。
这里一个关键设计是维护了一份本地状态 localHtml。组件用 useState 存了一份 HTML 的本地副本,并通过 useEffect 在选中元素变化时把 store 里的 element.text 同步过来。之所以要多此一层副本,是为了应对中文输入法在拼写组合阶段的麻烦:用拼音输入时,wangEditor 的 onChange 会非常频繁地触发,如果每一次都直接去改全局 store,很容易引发输入卡顿、光标乱跳甚至内容错乱。先用本地状态把这些高频的中间变化缓冲下来,就能避免对 store 的频繁冲击。
内容变更的处理集中在 handleChange 里,它做了三件事:先把最新的 HTML 写进本地状态;再实时调用 updateElement 把改动同步到画布 store,驱动 canvas 重新渲染这个文本元素;最后创建一个 UpdateElementPropertyCommand 命令并执行,命令里同时记录了修改前的旧值和修改后的新值,从而把这次编辑纳入完整的撤销/重做历史。
0x03 关键问题解决与设计
回过头看,这套实现真正着力解决的是两个问题。一是输入法兼容性:中文输入过程中,组合事件(composition)会让编辑器反复触发变更,若在 onChange 里直接改全局状态,性能和体验都会受损,本实现借助 setLocalHtml(html) 的本地缓冲缓解了这一点。二是实时渲染与历史管理的统一:一方面实时更新 store 保证画布上的文本能即时反映改动,另一方面又通过命令模式把每次操作记录下来,让 undo/redo 得到完整支持。
0x04 当前实现的风险与不足分析
当前这套实现满足了基础的富文本编辑需求,但坦白说还留着几处明显的短板。
输入法体验的优化并不彻底。虽然本地状态的缓冲缓解了 IME 组合阶段的频繁更新,但代码并没有真正去监听 compositionstart 和 compositionend 事件。在一些极端输入场景下,比如快速切换输入法或者连续输入长句,仍可能出现光标偏移、临时内容丢失的现象。
内容净化与 XSS 防护也不够。目前完全依赖 wangEditor 自带的有限转义,没有引入 DOMPurify 这类专门的净化库。一旦用户插入外部链接、图片或自定义 HTML,就存在存储型或 DOM 型 XSS 的潜在风险,在内容后续被导出或分享的场景下尤其需要警惕。
样式与对齐的精度同样存在隐患。wangEditor 生成的 HTML 往往带着多层 span、div 嵌套,这种结构和画布自定义的文本渲染逻辑未必完全吻合,结果就是编辑器里看到的预览效果,和画布最终渲染出来的样子在行高、字间距、对齐方式上会有细微出入。
性能与内存方面也有可改进的余地。长文本场景下,频繁的实时更新——既要触发 store 更新又要记录命令——可能带来轻微卡顿;加上目前没有对编辑器实例做复用,在不同文本元素之间快速切换时会反复创建和销毁实例,平白增加内存开销。
针对这些问题,后续有几条比较清晰的改进路径。XSS 方面,可以引入 DOMPurify,在内容存储前(提交到 store 或后端那一刻)以及渲染时都统一调用 DOMPurify.sanitize 做严格过滤,并自定义一份白名单,既保留 wangEditor 必需的标签和属性,又把所有事件处理器和危险协议清理干净。输入法方面,可以在 RichTextEditor 里监听 composition 事件,组合阶段先按住不触发 handleChange,等到 compositionend 之后再统一更新 store 和命令记录,把输入延迟和光标问题进一步消除。性能方面,则可以做成单一的全局编辑器实例、根据选中元素动态切换内容来实现复用,并在长文本时加上防抖,减少那些不必要的 store 更新和命令执行。
0x05 总结
这块功能的本质就是把 wangEditor 的 React 封装,与 Zustand 状态管理和命令模式结合起来,去适配画布类项目特有的文本编辑需求,最终落地了一套可用的基础文本编辑能力。
分享到社交平台
将本文分享给你的朋友们
Zhongye