contenteditable仅提供编辑入口,不解决协同冲突;必须依赖CRDT、OT或oplog等外部机制保障一致性,且光标/选区需单独同步。

contenteditable 本身不解决状态冲突,只提供编辑入口
HTML 的 contenteditable 属性没有版本号、操作序列号或状态快照能力。它只是告诉浏览器“允许用户在此处输入”,不参与任何协同逻辑。多人同时改同一个 div contenteditable="true",结果一定是 DOM 覆盖、光标错位、格式丢失——这不是 bug,是设计使然。
常见错误现象:innerHTML 全量比对后广播 diff,看似同步,实则在并发插入同一位置时完全失效;监听 input 事件后直接发内容到服务端,漏掉粘贴、加粗、拖图等操作,导致远程视图和本地不一致。
- 所有状态一致性必须由外部机制保障:CRDT(如 Yjs)、OT(如 ShareDB)或服务端中心化操作日志(oplog)
-
contenteditable只能作为视图层容器,不能当状态源。真实状态应存在协作引擎实例(如Y.Text或ShareDB.Doc)中 - 编辑器(如 Quill、Tiptap)需解耦:渲染靠 DOM,变更靠协作引擎回调,光标/选区靠单独消息通道同步
为什么直接监听 input + innerHTML 同步会失败
input 事件触发时机不可靠:中文输入法组合字符时不触发、粘贴富文本后不触发、document.execCommand 格式操作也不触发。而 innerHTML 是浏览器解析后的结果,含跨浏览器差异(Chrome 插 <div>,Safari 插 <p>),还可能混入未清洗的 style、onerror 等危险属性。
实操建议:
立即学习“前端免费学习笔记(深入)”;
将调研报告、方案对比、数据分析、需求梳理、会议纪要、设计说明、代码评审、学习笔记等任务产物输出为可在浏览器直接打开的单文件 HTML,替代 Markdown 作为最终交付物。Markdown 在信息密度、视觉清晰度和可分享性上已跟不上 AI 产物的丰富度,HTML 能同时承载排版、表格、SVG 等。
- 放弃
input单一监听,改用组合:监听beforeinput(捕获格式操作)、paste(拦截并净化)、selectionchange(跟踪光标)、blur(兜底提交) - 读取内容时不碰
innerHTML,优先用textContent做基础校验,再用DOMPurify.sanitize()过滤 HTML,限定ALLOWED_TAGS - 服务端收到内容后必须二次校验:过滤非法标签、剥离
data-和内联style,不能信任前端传来的任何结构
协作中光标与选区不同步的根源和应对
本地光标位置是 DOM Range,远程用户操作会改变 DOM 结构,原有 Range 失效。若不主动同步,会出现“别人正在编辑,你却看不到光标”或“你打字,光标跳回开头”。
关键点:
- 光标不是状态的一部分,而是视图副作用。必须单独建模:每个用户光标存为 { anchor: { nodeId, offset }, focus: { nodeId, offset } },带用户 ID 标识
- 协作引擎不处理光标,需额外 WebSocket 通道广播光标消息,且仅广播“当前活跃光标”,避免高频抖动
- 客户端收到远程光标消息后,不能直接
range.selectNodeContents(),要先做 DOM 映射:根据 node ID 找到对应元素,再用RangeAPI 定位 - 移动端尤其脆弱:iOS Safari 在键盘收起瞬间常丢失 selection,需在
blur后 fallback 到末尾定位
父级 contenteditable="false" 无法真正锁死子区域
设 contenteditable="false" 的子元素,在 Firefox 中仍可能被 Backspace 合并删除;在 Chrome 中,若用户全选再粘贴,照样能覆盖内容。它只是禁用焦点和默认编辑行为,不是安全边界。
真正可控的做法:
- 显式加
data-locked="true",并在beforeinput中拦截:判断 target 是否含该属性,是则e.preventDefault() - 配合 CSS
[contenteditable="false"] { pointer-events: none; user-select: none; },但仅作视觉辅助,不能替代 JS 拦截 - 服务端校验必须包含字段级权限:即使前端放开某区域,提交时仍需验证用户是否有该字段修改权,不能只信
contenteditable状态
最易被忽略的是:协作系统里,contenteditable 的值可能被 JS 动态切换,但 DOM 结构变更(如插入新段落)会重置子元素的继承状态,导致原本 locked 的节点意外变可编辑——必须在每次 DOM 更新后重新 apply 锁定逻辑。


















