contenteditable 元素不能依赖 focusout 可靠保存,因其在 ios safari、iframe 和富文本库中常失效,且 dom 未更新;必须用 800ms 手写防抖 + 脏标记 + 编辑器 api 取值 + dom 就绪后恢复。

contenteditable 元素不能靠 focusout 主动触发可靠保存,防抖必须独立于失焦事件存在,且要配合脏标记、编辑器 API 取值和 DOM 就绪恢复逻辑。
focusout 在 contenteditable 上为什么不可靠
focusout 是冒泡事件,但对 contenteditable 元素的失焦捕获非常脆弱:iOS Safari 点击键盘「完成」后常不触发;iframe 内嵌编辑器(如 Quill)焦点流完全隔离,外部监听无效;部分富文本库(如 Slate)会主动 preventDefault() 失焦行为以维持光标。更关键的是,focusout 触发时 DOM 往往还没更新——中文输入法上屏延迟、虚拟 DOM 未同步、innerText 读到的仍是旧值。
常见错误现象包括:
- 用户打完字点空白处关页,草稿没存进去
- 移动端反复切后台再回来,
focusout完全静默 - 存进 localStorage 的是拼音碎片(如
"zhon"),不是最终内容
防抖保存必须手写 setTimeout + clearTimeout
不能依赖第三方防抖函数或 setInterval 轮询——前者可能绕过上下文隔离,后者无法响应“停笔即存”的真实节奏。800ms 是经过验证的合理延迟,既覆盖中文输入法选词周期,又留出关页前最后写入窗口。
实操要点:
- 每次
input事件中先clearTimeout(timerId),再timerId = setTimeout(() => { /* 保存逻辑 */ }, 800) - 保存前严格比对:
if (el.innerText !== lastSavedValue && el.innerText.trim() !== '') - 键名必须带上下文:
`draft-${location.pathname}-${el.id || 'main'}`,避免多页覆盖 - 存结构化对象:
{ content: el.innerText, savedAt: Date.now() },并用JSON.stringify()序列化
恢复草稿必须等 DOM 就绪且手动派发事件
脚本一加载就去读 localStorage.getItem('draft-xxx') 并赋值,大概率失败——此时 el 还是 null,或 Vue/React 组件尚未挂载。即使 DOM 存在,直接 el.innerText = draft.content 也不会触发响应式更新。
正确做法:
- 恢复逻辑放在
DOMContentLoaded后执行,Vue 用mounted,React 用useEffect(() => {}, []) - 先校验时间戳:
Date.now() - draft.savedAt - 赋值后必须调用:
el.dispatchEvent(new Event('input', { bubbles: true })) - 对富文本编辑器,别读
innerText,改用其 API:editor.getHTML()或editor.getValue()
beforeunload 只能提示,不能存数据
beforeunload 回调里不允许异步操作,await fetch() 或 localStorage.setItem() 都会被忽略。它唯一合法用途是返回字符串触发浏览器确认弹窗。
真正防丢稿的组合策略是:
- 全局变量
hasUnsavedChanges = false,在防抖回调存成功后设为false -
input或编辑器变更事件中设为true -
beforeunload中只判断:if (hasUnsavedChanges) return '' - 真正写入动作必须在防抖函数内完成,且要有降级兜底(如
sessionStorage+ 日志)
最易被忽略的一点:移动端键盘收起、页面切后台这些场景下,focusout 基本失效,必须叠加 visibilitychange 和 window.addEventListener('resize', ...) 监听窗口高度突变,才能补全信号。
前端入门到VUE实战笔记:立即使用
在学习笔记中,你将探索 前端 的入门与实战技巧!











