信创环境wangEditor跨平台文档同步实战:从存储原理到版本冲突处理

发布时间:2026/10/3 14:48:47
信创环境wangEditor跨平台文档同步实战:从存储原理到版本冲突处理
前阵子做一个跑在国产化平台上的办公系统适配开发同事扔过来一个需求“文档编辑用 wangEditor但是用户在A机器上写的材料换到B机器打开怎么没了”乍一听是个简单的保存问题但把它放到信创环境里展开里面全是坑国产操作系统、国产浏览器、内网部署、数据权限、图片上传、版本冲突……这篇文章就把这次完整复盘写出来。如果你是做信创项目适配、办公系统开发或者文档类产品的技术选型应该能省掉不少弯路。先把结论放在前面wangEditor 只是一个编辑器它本身不负责“把内容存到哪里”。跨平台文档同步的本质是把编辑器的内容从“页面内存”搬到一个所有终端都能访问的地方并且要保证搬的过程中不丢、不串、不乱。所谓“同步难题”百分之八十的复杂度来自工程链路而不是编辑器本身。1. 先想明白信创环境下的“跨平台同步”难点在哪1.1 同步的本质是文档生命周期管理很多人一上来就找 wangEditor 的同步 API其实方向就错了。富文本编辑器本质上是“内容生产工具”它承担的是编辑体验不是存储。用一句话类比它像一个只能在当前页面工作的画板画完的内容如果不及时拍下来交到中央仓库换个房间就找不到了。跨平台同步就是把“画板里的画”持久化到服务端再在另一个终端取回来渲染。在信创办公场景里用户的使用路径通常是在A机器的浏览器里打开文档编辑保存然后换到B机器继续编辑。这里的核心链路是编辑器初始化时从服务端拉取最新内容内容变化后写回服务端。而“换一台机器”只是这条链路的自然结果。所以你的项目里不需要为“跨平台”单独做一套神秘机制只需要把“打开-编辑-保存-再打开”这条基础链路做好同步问题就解决了一大半。1.2 本地缓存为什么救不了跨平台不少团队图省事直接把内容存进 localStorage 或者 IndexedDB。这在纯离线草稿场景确实能解决问题但放到跨平台同步里会有三个致命问题存储与用户绑定在同一浏览器换机器、换浏览器、清缓存都会丢本地存储的数据没法做权限控制和审计不适合正式办公要求一旦用户同时在两台机器上编辑本地和远端的数据冲突完全无法调和。所以信创项目里我一般不建议把 localStorage 当作正式同步手段最多只做“未登录时的草稿暂存”登录或者上线后还是要把内容推到服务端。IndexedDB 适合存比较大的附件用来做离线缓存但不是文档同步的主角。真正能承担跨平台同步的必须是所有终端都能访问的服务端存储。1.3 国产软硬件环境给同步链路增加的约束首先要明确一点信创环境不等于“一个浏览器”而是一整套软硬件组合。从操作系统麒麟、统信UOS、芯片飞腾、鲲鹏、龙芯、海光、兆芯到浏览器奇安信、360安全、红莲花等每一层都可能影响编辑器运行。比如我遇到过一个场景在某个信创浏览器上测 wangEditor v5工具栏的图标字体加载不出来点按钮没反应排查到最后发现浏览器内核版本偏旧对某些 ES2020 语法支持不到位编译产物直接报错。所以在做跨平台同步方案时不要把注意力只放在接口和数据库上前端的兼容性也是同步链路的一部分。我的建议是前端代码保持一个比较保守的编译目标至少兼容到 ES2017或者提供动态垫片静态资源尽量走内网访问路径避免跨域限制字体不依赖系统预设把 Noto Sans CJK、文泉驿等字体直接打包进资源后端接口要能兼容老内核浏览器的 CORS 和请求头。如果这些地方没有把控经常会出现“开发机上是好的用户机器上同步就是不行”的诡异问题而这些问题是信创项目中排查成本最高的。2. 整体方案设计选型与架构思路2.1 wangEditor 版本怎么选v4 还是 v5在开始写同步代码之前先确认你用的是哪个版本。wangEditor 目前主流有两代差别很大对比项v4v5依赖简单体积小基于 slate包体积大一些内容格式主要取 HTML支持 JSON / HTMLAPI简单直接更完整但学习成本高协同扩展弱相对容易底层是操作模型老内核浏览器兼容更省心需要确认内核版本如果你是在老项目里已经用了 v4可以继续用同步方案不依赖具体版本只依赖“能拿到 HTML 内容”这个能力。如果是新项目我更建议直接上 v5。v5 的getHtml()、setHtml()、onChange天然适合对接同步服务而且它底层是 slate很多操作是结构化数据后续如果要往实时协同方向走基础更好。从信创适配角度看v4 依赖少在老内核浏览器上更省心v5 则必须确认目标浏览器内核在 Chromium 78 以上否则要做额外的垫片处理。2.2 三种同步方案怎么选我把常见的同步做法排了个序纯本地缓存localStorage / IndexedDB实现最快但只适合单机草稿。数据只存在当前浏览器换机器、换浏览器、清缓存都会丢。文件导入导出导出 HTML/JSON 再导入适合备份与手工迁移但用户操作成本高没办法无感同步。服务端同步 版本号将服务端作为唯一数据源前端通过接口读写依靠版本号避免互相覆盖。这是目前信创办公系统里最稳妥的方案也是本文重点。说到底“跨平台文档同步”的答案是服务端而不是某个客户端插件。设计时记住三个关键词版本号version、更新时间updated_at、内容快照content。版本号用来判断数据新旧更新时间用来展示“最后修改时间”内容快照就是文档正文。这三个字段足够覆盖绝大多数办公场景。2.3 技术栈与信创适配要点前端我用的是 Vue3 wangeditor/editor后端是 Node.jsJava 也一样数据库用的是支持 CLOB/TEXT 类型的关系型数据库比如人大金仓、达梦、openGauss 这类信创项目里常见的数据库。内容正文直接存 HTML 字符串因为 wangEditor 的getHtml()返回值就是 HTML反过来setHtml()也能直接渲染简单直接。有一点要提前说不要把同步逻辑和编辑器组件写死在一起。编辑器只负责触发保存动作而“什么时候保存、保存到哪个接口、冲突了怎么办”这些应该抽到独立模块。我当时做了一个DocSyncService它封装了保存、拉取、版本校验、冲突回调四个能力。这样以后换富文本编辑器比如换成 Quill 或 TinyMCE同步层完全不用动。另外信创环境里很多项目是内网离线部署npm 包不能用公网源拉。我们当时的做法是把前端依赖全部打进离线构建包或者在内网搭一套私有 npm 仓库。这个看起来是部署问题但如果不提前规划到现场才发现拉不了包整个适配工期就卡住了。3. 核心实现从编辑器初始化到多端同步落地3.1 初始化编辑器并加载远端文档以 Vue3 wangEditor v5 为例先看初始化代码import { onBeforeUnmount, onMounted, shallowRef } from vue import { createEditor, createToolbar } from wangeditor/editor import type { IDomEditor } from wangeditor/editor import wangeditor/editor/dist/css/style.css const editorRef shallowRefIDomEditor() let isLocalEditing false onMounted(async () { const editor createEditor({ selector: #editor-container, html: , config: { placeholder: 请输入正文..., onBlur() { isLocalEditing false } }, mode: default, }) const toolbar createToolbar({ editor, selector: #toolbar-container, mode: default, config: { excludeKeys: [group-video, fullScreen], }, }) editorRef.value editor // 从服务端拉取最新内容 await loadDocument(editor) }) onBeforeUnmount(() { const editor editorRef.value if (editor) editor.destroy() })注意几个细节selector对应的 DOM 元素要存在而且不要和 Vue 的响应式渲染冲突建议在onMounted之后再初始化不要把编辑器放在频繁切换的v-if分支里反复创建销毁。用shallowRef而不是ref避免 wangEditor 内部对象被 Vue 深度代理后出现性能问题或意外的只读现象。编辑器销毁要在组件卸载前执行否则页面上会残留事件监听。初始化完成后从服务端拉取文档async function loadDocument(editor: IDomEditor) { const res await fetch(/api/doc/${docId}, { headers: { Authorization: Bearer ${token} }, }) const data await res.json() if (data.content) { editor.setHtml(data.content) } localVersion.value data.version lastSavedAt.value data.updatedAt }这里有两个关键点第一setHtml要在编辑器创建之后调用不能在onMounted之前否则内容塞不进去第二服务端返回的内容不要直接当 HTML 拼接渲染应该通过编辑器的setHtml进入因为需要经过 schema 校验避免异常内容被当作正文执行。3.2 自动保存防抖、全量覆盖与版本回传跨平台同步最基础的行为是自动保存。用户不会每次点“保存”按钮你要监听编辑器的onChange在停顿一段时间后自动提交。这个防抖时间我一般设 600ms既能保证及时性又不会在中文输入法组合输入过程中频繁发请求。let saveTimer: ReturnTypetypeof setTimeout | null null let composing false function handleEditorChange(editor: IDomEditor) { if (composing) return if (saveTimer) clearTimeout(saveTimer) saveTimer setTimeout(() saveDocument(editor), 600) }保存时我采用“全量覆盖 版本号校验”的策略。所谓全量覆盖就是把当前getHtml()结果完整提交过去版本号就是乐观锁。接口设计如下async function saveDocument(editor: IDomEditor) { const html editor.getHtml() const text editor.getText() const res await fetch(/api/doc/${docId}, { method: PUT, headers: { Content-Type: application/json, Authorization: Bearer ${token}, }, body: JSON.stringify({ content: html, text, version: localVersion.value, }), }) if (res.status 409) { handleConflict(editor) return } const data await res.json() localVersion.value data.version lastSavedAt.value data.updatedAt showToast(已保存) }为什么保存整个 HTML而不做逐个字符的增量因为在绝大多数办公文档场景里单篇文档的 HTML 也就几十 KB全量提交的额外耗时远小于增量算法带来的复杂度。做增量同步需要处理光标位置、选区、DOM 差异工程成本很高收益却不明显。真正需要连续高频同步的场景只有两种多人正在编辑同一篇文档或者文档大到数 MB 级别那时候再考虑操作日志级别的同步。3.3 服务端版本校验乐观锁是怎么工作的服务端的核心逻辑就是“更新前比版本号”。我用伪 SQL 表示最小实现UPDATE doc SET content #{content}, text #{text}, version version 1, updated_at now() WHERE id #{id} AND version #{version};在 Node.js 或其他后端里只需要看这条 SQL 影响的行数如果affectedRows 1说明版本一致更新成功如果affectedRows 0说明当前数据库里的版本已经不是前端传上来的版本说明有人先保存过此时返回 409 让前端处理冲突。这个方案非常实用它本质上是一个乐观锁。在“读多写少”的办公场景下几乎不会触发冲突而一旦触发就是真实的“多端并发编辑”。我在实际项目里遇到最多的情况是用户在 A 机器保存后忘了关又去 B 机器编辑等回到 A 机器再输入时B 机器已经更新过文档了。此时 A 机器本地版本号落后保存时就会 409 拦截避免旧内容覆盖新内容。很多“文档白改了”的问题用这一个字段就能解决。前端收到 409 后不要直接把用户内容覆盖。我当时的处理是弹出一个面板显示“文档已被其他终端更新”给出三个按钮查看最新拉取服务端最新内容用户可以选择要不要把当前编辑内容合并过去覆盖保存强制提交当前内容版本号置为最新另存为把当前内容保存成一个新文档避免破坏原文档。 自动合并我没做因为富文本的语义合并太容易出错先让用户人工选择比做一个看起来很聪明但经常弄错的算法靠谱。3.4 多端拉取最新内容轮询和版本变更通知只做保存还不够“跨平台同步”的体验闭环是在 B 机器上能及时看到 A 机器刚保存的内容。最简单可靠的办法是轮询。let isLocalEditing false editor.on(focus, () (isLocalEditing true)) editor.on(blur, () (isLocalEditing false)) setInterval(async () { if (isLocalEditing) return const res await fetch(/api/doc/${docId}/version).then((r) r.json()) if (res.version localVersion.value) return const full await fetch(/api/doc/${docId}).then((r) r.json()) editorRef.value?.setHtml(full.content) localVersion.value full.version }, 5000)这里有一个关键细节轮询时如果用户正在编辑要选择“只更新版本号但不刷新内容”否则正在打字的过程中内容被强制替换体验非常差。我用的判断是isLocalEditing标记从editor.on(focus)和editor.on(blur)设置。如果正在编辑中可以弹一个小提示“文档有更新请稍后查看”用户保存时再走版本冲突逻辑。轮询间隔建议 3~5 秒对办公场景足够服务器压力也不大。如果后续想做得更好一点可以接 WebSocket 或 SSE服务端在文档版本变化后向前端推送一个“版本更新”事件前端再拉取内容。3.5 只读模式wangeditor 怎么设置只读这里要专门说下只读模式因为很多人问也是信创办公系统里高频需求预览、审批、历史版本查看。wangEditor v5 里实现只读特别简单// 方式一初始化时设置为只读 const editor createEditor({ selector: #editor-container, html: content, config: { readOnly: true, }, mode: default, }) // 方式二运行时切换 editor.disable() // 进入只读 editor.enable() // 恢复可编辑注意几个细节readOnly是初始化配置后续想切回来要调用editor.enable()改配置对象本身不会生效。只读模式下工具栏按钮虽然大部分还能点但编辑区不可输入最好同时把工具栏隐藏或置灰避免用户困惑。真正的权限控制不能只靠前端只读服务端接口也要判断用户角色只读用户调用保存接口时直接返回 403否则别人通过接口就能改数据。如果只读状态下还需要保持滚动和文本选择没问题wangEditor 默认支持但需要完全禁止鼠标框选时要额外写user-select: none一般办公预览不需要这么激进。3.6 图片和附件同步blob地址和dataURL不能跨平台信创项目里最容易被忽略的是图片。用户在 A 机器上粘贴了一张截图wangEditor 默认会把它变成data:image/png;base64,...或者blob:地址。这种地址只在当前浏览器内存里有效换一台机器、甚至刷新一下页面都可能消失。所以做跨平台同步图片必须走上传通道。我配置的是自定义上传const editorConfig { MENU_CONF: { uploadImage: { async customUpload(file: File, insertFn: (url: string, alt: string, href: string) void) { const formData new FormData() formData.append(file, file) const res await fetch(/api/file/upload, { method: POST, headers: { Authorization: Bearer ${token} }, body: formData, }) const data await res.json() insertFn(data.url, file.name, data.url) }, }, }, }insertFn会把返回的 URL 写入编辑器那么保存到服务端的 HTML 里就是类似https://内网域名/files/xxx.png无论在哪台机器上都能加载。同理如果是粘贴外部图片需要处理网络图片的跨域和防盗链。我在信创适配时还踩过一个坑上传接口返回的域名是服务器内网 IP但用户浏览器访问时用的是不同的内网端口图片显示不出来最后统一改成基于location.host拼接资源地址才解决。如果没有独立文件服务至少要把上传的图片存储到后端指定的磁盘或对象存储然后把访问路径回填到编辑器里。直接存 base64 进数据库的省事方案只适合单机个人笔记不适合正式办公系统因为数据库会很快变得臃肿查询和备份都很痛苦。4. 往实时协同走一步轮询和WebSocket的实现思路4.1 轮询同步怎么做得更稳上面已经给出了轮询的基础代码这里补充一下“更稳”的细节。轮询接口不要每次都返回完整文档可以先请求极轻量的版本接口GET /api/doc/:id/version返回{ version, updatedAt }。前端发现版本变化后再去拿完整内容。这样轮询频率哪怕调到 2 秒一次对数据库的压力也小得多。还要处理并发边界同一次编辑可能触发多次版本变化但拉取时只需要最后一次内容因为setHtml是全量替换。所以轮询拉取时加一个“正在拉取”的原子标记避免上一次请求还没回来下一次又发出去。我当时用的是简单的布尔变量isPulling在请求开始时置为 true结束时置为 false请求内判断这个标记做不到就跳过一次。这个细节不复杂但对稳定性很有帮助。4.2 WebSocket 通知的落地路径如果不想让用户看到最坏 5 秒的延迟可以引入 WebSocket。服务端要维护一张“文档-在线连接”的表当文档 version 变化后向该文档相关的连接推送一条轻量消息{ type: docUpdated, docId: 123, version: 42 }前端收到消息后走和轮询相同的拉取逻辑。这样做的好处是即时坏处是长连接在信创网络环境里偶尔会被防火墙掐断。我的实践是保留 WebSocket 作为加速通道同时保留一个低频轮询比如 30 秒一次作为兜底。一旦 WebSocket 连接断开重连成功后要主动上报当前文档版本避免期间漏掉更新。如果你不想引入 WebSocketSSE 也是一个更轻的替代方案服务端通过 HTTP 长连接推送事件前端用 EventSource 接收代码量更少只是单向推送对“文档更新通知”这种场景完全够用。4.3 增量同步与多人协同的取舍如果需求只是“多平台同步”别轻易上 CRDT/OT 那套实时协同。它们解决的场景是“多人同时编辑同一个文档并且要保留所有人的操作意图”这在工程上是一个非常大的课题。普通同步用“全文覆盖 版本号”已经能覆盖 90% 的场景。如果确实需要多人同时编辑可以考虑两条路基于 yjs 的 CRDT 方案但 yjs 需要引入额外的同步协议和依赖信创离线环境下要提前评估依赖包体积与私有化部署难度基于操作日志的 OT 方案实现和维护成本更高不适合小团队。 对绝大多数信创办公系统我更建议先做“文档锁 全量覆盖”。具体玩法是用户打开文档时申请编辑锁其他人打开时只能只读保存时释放锁。这样可以彻底避免冲突实现成本非常低。缺点是同一时间只能一个人编辑但办公文档场景往往可以接受。5. 常见问题与排查技巧实录5.1 内容没丢但是换个浏览器看到的还是旧版本这个问题的根源通常是缓存。服务端返回 200 的文档内容被浏览器缓存换了浏览器打开时命中缓存旧数据。解决办法接口请求头加Cache-Control: no-cache或者给 URL 加时间戳参数前端拿到版本号后除了存内存还要和sessionStorage里的版本号做对比如果“本地内存版”比服务端还新先走合并逻辑如果用了 CDN要给文档接口配置不缓存策略或者设置较短的缓存时间。信创浏览器对缓存策略的处理差异比较大有的浏览器即使收到no-cache也会在极端情况下缓存内容所以在接口路径里加版本号参数是最保险的。5.2 中文输入法导致的自动保存问题在中文输入法拼音组合过程中onChange可能会多次触发如果每次都保存轻则请求频繁重则把组合中的半截内容保存进去。我在代码里加了composing标记editorContainer.addEventListener(compositionstart, () (composing true)) editorContainer.addEventListener(compositionend, () { composing false queueSave(editorRef.value!) })因为 wangEditor 内部封装了编辑器根节点事件监听可以直接挂在editorRef.value对应的 DOM 上。同时防抖时间要设置得合适实测下来 600ms 是一个比较舒服的中间值。如果用户输入速度很快且每个字都触发 onChange600ms 可以减少大量无效请求如果防抖时间太长比如 2 秒用户可能已经切到其他页面保存没来得及发出造成“刚写的内容丢了”的错觉。5.3 只读模式点击编辑区没反应但工具栏还是可以点这种情况通常是你只在初始化时传了readOnly: true但工具栏仍然渲染出来了。要彻底进入只读状态可以这样做用v-if只渲染#editor-container不渲染#toolbar-container或者参考 3.5 的方式调用editor.disable()并在 UI 层隐藏工具栏。还可以用 CSS 控制工具栏pointer-events: none但这种方式比较粗暴不推荐在正式项目里用。在信创项目里我还遇到过一种情况用户通过权限系统只能只读但页面初始化时先被创建成可编辑状态等权限数据返回后才调editor.disable()。这个时序如果没有处理好用户会在窗口期里看到工具栏一闪而过体验不好。建议在拿到权限之前不要创建编辑器或者直接根据权限参数决定是否渲染工具栏。5.4 图片在A机器正常在B机器裂图先看 HTML 里img标签的src如果是data:开头跨平台基本废了需要重新上传如果是blob:开头只存在于当前页面刷新都没了如果是/files/xxx.png这种相对路径要看 B 机器访问的域名和端口是否一致如果是完整 URL还要确认服务端静态资源目录是否在信创浏览器安全策略下被拦截。 排查顺序浏览器开发工具里看图片请求是否 404再看 src 属于哪一类对前两类重新走上传逻辑。实际操作里还有一个很容易忽略的点图片上传接口如果返回的是内网 IP 地址在用户浏览器里可能因为 HTTPS 混合内容策略被拦截最好的办法是让文件服务与业务系统同域名或者统一配置网关规则。5.5 信创浏览器工具栏加载不出图标wangEditor 的图标是字体文件如果部署后.woff2加载失败界面就是一片方框。原因很可能是内网服务器的 MIME 配置问题或者字体文件被安全策略拦截。排查方式打开网络面板确认字体文件状态码确认响应头Content-Type是font/woff2或font/woff如果静态资源和页面跨域给服务器配置正确的 CORS 头。顺便说信创环境里浏览器安全策略比较保守有时会把跨域字体当资源隔离最好的办法是让页面和静态资源同源。如果用的是打包工具还需要确认字体文件被正确打进产物而不是被 hash 名字绕过。5.6 同步冲突问题速查表现象可能原因处理办法换机器内容还是旧的服务端没保存成功或浏览器缓存查保存接口返回加版本号与 no-cache保存提示版本冲突两台终端同时编辑过查看最新后再人工合并自动保存请求频率高防抖没生效或中文输入法干扰加 composition 判断、加大防抖时间图片在其他终端裂图图片地址是 data/blob/相对路径改走自定义上传回填完整URL只读功能没生效只初始化配置未禁用编辑器调用 editor.disable() 或隐藏工具栏工具栏图标方框字体文件 MIME 或跨域检查 woff2 加载与服务器配置最后再说一句这整套方案没有用什么高深算法核心就是“服务端做唯一数据源版本号做乐观锁轮询或 WebSocket 做更新通知”。我个人的体会是wangEditor 的跨平台同步不是编辑器需要解决的能力而是业务系统需要具备的基础设施能力。在信创环境里尤其如此先把基础链路做稳再去琢磨那些看起来高级的协同功能。如果你也在做类似的适配建议从“最小闭环”开始编辑器能拉取、能保存、版本号不打架就已经解决了 80% 的用户抱怨。剩下的“实时协同”和“增量合并”等真正有需求再上别让过度设计拖垮项目周期。