不能用 customelements.define() 直接封装富文本编辑器,因自定义元素不解决 selection、formatting 等核心问题;contenteditable 必须置于 light dom 中,shadow dom 内启用会导致聚焦、选区、粘贴等异常;connectedcallback 仅能做初始化,需 requestanimationframe 延迟绑定事件;innerhtml 不可信,须用 domparser 清洗;光标与撤销逻辑复杂,建议集成 tiptap 或 prosemirror。

不能用 customElements.define() 直接封装出一个可用的富文本编辑器——自定义元素只负责 DOM 结构和生命周期,不解决 selection、formatting、paste 清洗、undo 栈这些核心问题。
为什么 contenteditable 必须放在内部,不能由 custom element 自动接管
自定义元素(如 <rich-text-editor></rich-text-editor>)本身是 Shadow DOM 容器或普通元素,但浏览器对 contenteditable 的支持仅限于 light DOM 中的可聚焦节点。Shadow DOM 内启用 contenteditable 会导致:
- 光标无法稳定聚焦(尤其在 Safari 和旧版 Chrome)
-
window.getSelection()返回空或错位 range - 粘贴事件(
paste)不触发,或event.clipboardData为空 - 键盘导航(Tab、Arrow)在 Shadow 内行为异常
实际做法:自定义元素只作为“壳”,内部必须暴露一个 light DOM 子节点(如 <div contenteditable="true">),且该节点不能被 <code>slot 或 attachShadow 隔离。
connectedCallback 里能做的事很有限
自定义元素挂载时,你只能做初始化准备,不能直接调用格式化逻辑:
- 不能在
connectedCallback里调用document.execCommand()—— 此时编辑区还没 focus,selection 为空 - 不能靠
this.innerHTML = '<div contenteditable>' 就完事,需等元素渲染完成再监听事件(推荐用 <code>requestAnimationFrame延迟一次) - 工具栏按钮绑定命令时,目标 document 必须明确:
document.execCommand()作用于当前 window,若编辑区在 iframe 内则失效
正确姿势示例:
如果你了解HTML,CSS和JavaScript,您已经拥有所需的工具开发Android应用程序。本动手本书展示了如何使用这些开源web标准设计和建造,可适应任何Android设备的应用程序 - 无需使用Java。您将学习如何创建一个在您选择的平台的Android友好的网络应用程序,然后转换与自由PhoneGap框架到一个原生的Android应用程序。了解为什么设备无关的移动应用是未来的潮流,并开始构建应用程序,提供更
connectedCallback() {
this.innerHTML = `<div class="toolbar"><button data-command="bold">B</button></div>
<div contenteditable="true" class="editor"></div>`;
// 等渲染完成
requestAnimationFrame(() => {
const editor = this.querySelector('.editor');
editor.addEventListener('focus', () => this.#syncToolbarState());
this.querySelector('.toolbar').addEventListener('click', e => {
if (e.target.hasAttribute('data-command')) {
document.execCommand(e.target.dataset.command, false, null);
}
});
});
}
输出 HTML 时,innerHTML 不可信
用户粘贴 Word 或网页内容后,editor.innerHTML 会混入:<span style="font-family: Calibri"></span>、<div class="MsoNormal">、多余 <code><br>、嵌套 <font></font>。直接存库或渲染将导致样式污染和语义混乱。
- 必须递归遍历节点,把
<b></b>→<strong></strong>,<font color></font>→<span style="color:"></span> - 过滤掉空
<span></span>、孤立<br>、无意义 class(如Mso*) - 避免用正则替换 HTML 字符串——DOM 解析更可靠:
new DOMParser().parseFromString(html, 'text/html')
简单清洗示意:
getHTML() {
const doc = new DOMParser().parseFromString(this.editor.innerHTML, 'text/html');
const clean = doc.body;
clean.querySelectorAll('b, font, span[style]').forEach(el => {
if (el.tagName === 'B') el.replaceWith(el.outerHTML.replace(/<b>/gi, '<strong>').replace(//gi, ''));
if (el.tagName === 'FONT' || el.hasAttribute('style')) {
const span = document.createElement('span');
span.innerHTML = el.innerHTML;
if (el.hasAttribute('color')) span.style.color = el.getAttribute('color');
el.replaceWith(span);
}
});
return clean.innerHTML;
}</strong></b>
真正卡住人的不是加粗,而是光标和撤销
手写编辑器最难 debug 的永远是:
- 用户按 Ctrl+Z 后,光标停在错误位置(尤其跨
<p></p>选区后撤销) - Shift+Enter 在 iOS 上被忽略,
keydown拦截后event.preventDefault()可能阻断换行 - 选中文字后点加粗,结果只包裹了部分节点(
range.surroundContents()报错,因选区跨块级元素) - 粘贴后光标跳到开头,而不是留在粘贴位置末端
这些不是 API 调用顺序问题,而是 Selection/Range 边界 case 处理缺失。除非团队有人持续维护过这类逻辑,否则建议直接集成 Tiptap 或 ProseMirror——它们已覆盖 90% 以上真实场景的光标与撤销行为。










