⭐ 推荐:社区规则条款 V1.0

contenteditable 富文本

juming
juming 归队站长新人站长
发布于 2026-10-13 01:06 ·3 浏览 ·2 回复
内容摘要

讲解用 contenteditable 搭配 execCommand 搭建可加粗、插列表、安全粘贴和存取 HTML 的迷你富文本编辑器,并说明属性、焦点、plaintext-only 与可编辑继承等要点,适合轻量场景,复杂需求应更换方案。

跟着做完,你会得到一个能加粗、能插列表、能安全粘贴、能存取 HTML 的迷你富文本编辑器,并且清楚它什么时候该被换掉。

第一步:把普通 div 变成可编辑区域

这一步是给一个普通 <div> 加上 contenteditable,做完之后你在页面上就能直接打字、删字、选中文字,浏览器会自动帮你处理光标和输入法。

新建一个 index.html,内容如下:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <title>迷你富文本</title>
  <style>
    #editor {
      min-height: 160px;
      padding: 12px;
      border: 1px solid #ccc;
      border-radius: 6px;
      outline: none;              /* 去掉聚焦时的默认蓝框,自己画 */
      white-space: pre-wrap;      /* 保留换行和连续空格 */
      word-break: break-word;
      line-height: 1.7;
    }
    #editor:focus { border-color: #4a90d9; }
  </style>
</head>
<body>
  <div id="editor" contenteditable="true"></div>
</body>
</html>

双击文件用 Chrome 打开,点一下灰色框,就能直接输入了。

几个必须记住的点:

  • 属性名是 contenteditable,值必须是字符串 "true" 或 "false",不是布尔值。
  • 用 JS 设置时属性名是 contentEditable(大写 E):document.getElementById('editor').contentEditable = 'true'。
  • outline: none 一定要自己补一个聚焦样式,否则键盘用户看不到焦点在哪。
  • white-space: pre-wrap 不加的话,用户连按两次空格只能看到一个空格。

如果只想允许纯文本(禁止粘贴带格式的内容),把属性写成 contenteditable="plaintext-only"。这个值 Chrome、Edge、Safari 都支持,Firefox 从 136 版(2025 年 3 月)开始支持。

注意:contenteditable 是会继承的。你在编辑器里插入一个 <div>,它默认可编辑;但如果给它设了 contenteditable="false",那块区域就变成不可编辑的"原子块",光标只能停在它前后——这是做代码块、卡片、@ 提及的常用手法。

第二步:接一条工具栏,实现加粗、斜体、列表

这一步是加一排按钮,点一下就能改选中的文字,做完之后编辑器就有了最基础的富文本能力。

在编辑器上面加一条工具栏:

<div id="toolbar">
  <button data-cmd="bold"><b>B</b></button>
  <button data-cmd="italic"><i>I</i></button>
  <button data-cmd="insertUnorderedList">• 列表</button>
  <button data-cmd="insertOrderedList">1. 列表</button>
  <button data-cmd="undo">撤销</button>
</div>
<div id="editor" contenteditable="true"></div>
const editor = document.getElementById('editor');
const toolbar = document.getElementById('toolbar');

// 关键:按钮按下时阻止默认行为,否则焦点跑到按钮上,选区就丢了
toolbar.addEventListener('mousedown', (e) => {
  if (e.target.closest('button')) e.preventDefault();
});

toolbar.addEventListener('click', (e) => {
  const btn = e.target.closest('button');
  if (!btn) return;
  editor.focus();
  document.execCommand(btn.dataset.cmd, false, null);
});

// 让回车在编辑器里产生 <p> 而不是 <div> 或 <br>
document.execCommand('defaultParagraphSeparator', false, 'p');

刷新页面,选中一段文字点 B,文字就会加粗。

document.execCommand 在规范里已经被标记为废弃(deprecated),但 Chrome、Edge、Safari、Firefox 到今天仍然完整实现它。它不是长期方案,但是"五分钟能跑起来"的方案。不要把它当作能撑三年的技术选型。

按钮高亮状态要跟着光标走:

document.addEventListener('selectionchange', () => {
  toolbar.querySelectorAll('button').forEach((btn) => {
    const cmd = btn.dataset.cmd;
    if (cmd === 'bold' || cmd === 'italic') {
      btn.classList.toggle('active', document.queryCommandState(cmd));
    }
  });
});

注意:selectionchange 必须监听在 document 上,不能监听在 editor 上。多数浏览器不会在元素上派发这个事件,监听错了会一直不触发。

第三步:读取内容和写回内容

这一步是把编辑器里的内容拿出来存起来,以及把存好的内容塞回去。

// 读:拿到 HTML 字符串
const html = editor.innerHTML;

// 写:回填
editor.innerHTML = savedHtml;

存到后端之前建议统一做一次清洗:把 <div> 全部换成 <p>、去掉 style 属性里的 position、background-image 等危险值、剔除 <script> 和所有 on* 事件属性。

function sanitize(html) {
  const doc = new DOMParser().parseFromString(html, 'text/html');
  doc.querySelectorAll('script,style,iframe,object').forEach(n => n.remove());
  doc.querySelectorAll('*').forEach((el) => {
    [...el.attributes].forEach((attr) => {
      const name = attr.name.toLowerCase();
      if (name.startsWith('on') || name === 'srcdoc') el.removeAttribute(attr.name);
    });
  });
  return doc.body.innerHTML;
}

注意:绝对不要写 editor.innerHTML += '<p>新内容</p>'。这会重建整个 DOM,光标会被弹回开头,输入法状态丢失,浏览器的撤销栈也会被清空。

第四步:处理粘贴,别让外部格式污染编辑器

这一步是拦截 paste 事件,把从 Word、网页、微信复制过来的内容变干净。做完之后,用户怎么粘都不会把乱码样式带进来。

只保留纯文本的写法最省事:

editor.addEventListener('paste', (e) => {
  e.preventDefault();
  const text = (e.clipboardData || window.clipboardData).getData('text/plain');
  document.execCommand('insertText', false, text);
});

用 insertText 而不是直接改 DOM,好处是浏览器的撤销栈(Ctrl+Z)仍然认得这一步。

如果想保留部分格式(比如只要加粗和斜体),就读 text/html 再走白名单:

editor.addEventListener('paste', (e) => {
  e.preventDefault();
  const html = e.clipboardData.getData('text/html');
  const text = e.clipboardData.getData('text/plain');
  if (!html) {
    document.execCommand('insertText', false, text);
    return;
  }
  const doc = new DOMParser().parseFromString(html, 'text/html');
  const allow = new Set(['P','BR','STRONG','B','EM','I','U','UL','OL','LI','A','CODE','PRE']);
  const walk = (node) => {
    [...node.childNodes].forEach((child) => {
      if (child.nodeType === 1) {
        if (!allow.has(child.tagName)) {
          child.replaceWith(...child.childNodes);   // 拆掉标签,保留文字
        } else {
          [...child.attributes].forEach(a => {
            if (!(child.tagName === 'A' && a.name === 'href')) child.removeAttribute(a.name);
          });
          walk(child);
        }
      }
    });
  };
  walk(doc.body);
  document.execCommand('insertHTML', false, doc.body.innerHTML);
});

注意:e.clipboardData 里的数据只在事件回调同步执行期间可读。

版权声明:本文来自 GJ站长论坛《contenteditable 富文本》
原文链接:https://www.gj0.com/thread-1804.html
转载请注明出处并保留本声明;内容仅代表作者观点,与本站立场无关。若本文涉嫌侵权,请联系本站处理。
他们都看过 1 人浏览过
GJ论坛站长

全部回复 2

满月居于空
满月居于空 版主新人站长 1楼 2026-10-13 01:28

这套 contenteditable 做原型够用,但真正决定它能不能上线的,是粘贴净化和存储安全这两件事,不是工具栏。

工具栏这块先提一句:document.execCommand 虽然早就被标记废弃,但浏览器短期内不会删,Chrome/Safari/Firefox 都还支持,做原型绰绰有余。有两个坑要提前处理——点击按钮会让编辑器失焦、选区丢失,所以按钮的 mousedown 里要 e.preventDefault() 保住选区;另外要用 queryCommandState('bold') 之类的接口同步按钮的高亮状态,不然光标移到加粗文字上按钮不会亮,用户会以为没生效。

粘贴净化是重点。在 paste 事件里 e.preventDefault(),然后从 e.clipboardData 取 'text/html' 走白名单过滤(自己写正则基本都会翻车,直接上 DOMPurify,allowedTags 限定在 p/br/strong/em/ul/ol/li/a 就够了),或者干脆只取 'text/plain',用 insertText 插进去。两种策略选一种,别混着来。

存取这块最容易出事:innerHTML 存进数据库没问题,但渲染回来的时候千万别直接 innerHTML 或 v-html 塞回去,这是标准 XSS 入口。要么渲染前再过一遍 DOMPurify,要么入库时就只存白名单结构。同步内容监听 input 事件即可,注意输入法组合期间(compositionstart / compositionend)别急着取值,否则中文会被截断。

什么时候该换掉:协同编辑、无限撤销重做、任意嵌套结构、表格、图片上传,这几项里只要占了两个,就别自己造了,Tiptap / Lexical / Quill 直接上,底层同样是 contenteditable,只是把上面这些坑都替你填了。

另外楼主第二步的代码断在 <bu 就没了,方便的话补全一下,跟着做的人会卡在这里。

域名注册
域名注册 版主归队站长新人站长 👑年卡会员 #1128 2楼 2026-10-13 01:47
满月居于空:这套 contenteditable 做原型够用,但真正决定它能不能上线的,是粘贴净化和存储安全这两件事,不是工具栏。 工具栏这块先提一句:`document…

他说的这三点确实是分水岭——粘贴净化和回显安全才是决定这套东西能不能上线的东西,工具栏反而是最不容易出事的部分。

选区这块再补一个坑:mousedown 里 preventDefault() 只解决"点一下就丢焦点",但工具栏里只要出现下拉或弹窗(选颜色、选字号、插入链接),鼠标一按到弹层上选区照样没了。稳妥做法是监听 selectionchange,把 range 存进一个变量,执行命令前先 sel.removeAllRanges(); sel.addRange(saved) 恢复回来。另外 execCommand('insertText') 有个隐藏好处——它走的是浏览器的编辑栈,Ctrl+Z 能撤销;直接 textContent 或 appendChild 塞进去,用户按撤销是没反应的。

净化环节再提一点:白名单里放 a 标签时,除了标签名还要校验 href 协议,javascript: 和 data: 必须拦,顺手统一补 rel="noopener noreferrer"。还有个容易忽略的——浏览器加粗产物并不统一,Chrome 多数情况出 <b>,但某些粘贴或 execCommand 路径会出 <span style="font-weight:bold">,白名单要是只认 b,回显时样式就丢了。建议在净化阶段做一次标签归一化,把常见 style 写法折算成语义标签。

存储上我倾向直接存结构化 JSON(Tiptap 那套),比存 HTML 字符串更好做版本对比和回显,也不用每次渲染都赌一次净化。

换掉的判断标准可以再加一条:如果需要 iOS 上选区、键盘弹起行为一致,也算一票。另外楼主第二步确实断在 <bu,补全的话就是 <button data-cmd="underline"><u>U</u></button> 这一类。