React表情输入框:用contentEditable实现所见即所得,解决光标与安全难题
发布时间:2026/10/15 5:55:36 锦皓数字建站

前阵子有位同行让我帮忙看一个站内评论框说用户反复反馈同一个问题输入[doge]、[笑 cry]这类表情代号发出后倒是能变成图片可输入过程中屏幕上始终是一串冷冰冰的字符。很多人第一次用根本不知道这些代号长什么样拼错了也毫无感知直到消息发出去了才看到自己发了个寂寞。这种发出去才见分晓的体验放在现在的产品里已经很难让人接受了。这篇文章就聊聊我在 React 项目里实现表情包所见即所得输入框的完整过程。核心思路是用一个contentEditable的 div 替换掉普通的textarea让用户在打字时直接看到表情图片。文章会从最基础的架构讲起然后重点拆解光标位置、粘贴过滤、数据安全、移动端适配这些真正决定好不好用的细节。如果你正在写评论框、聊天输入框、内容发布编辑器或者单纯想搞清楚contentEditable和 React 到底怎么相处这篇文章应该能给你省下不少踩坑的时间。1. 别再用文本代号骗用户了表情框的第一性原理1.1 一个让人哭笑不得的典型翻车场景先说真实场景。某个交流社区的评论模块产品经理最初的设计是输入[doge]发布后替换成图片表情。这个方案看着简单实际上问题一堆用户根本记不住全套表情代号输入面板里也没做联想提示大家只能点开别人发的消息照着抄。输入框里看到的永远是一串中括号加英文表情的情感表达在打字阶段完全失效。一旦打错字母比如[dog]少写一个 e发布后就是一段裸文本让用户觉得产品很粗糙。这些问题表面上是交互细节没做好本质上是输入模型选错了。我当时的判断是表情本质上是一种带视觉语义的内容它必须让用户在输入阶段就能看到、感受到而不是等提交之后才渲染。这也是所见即所得在表情场景里不是锦上添花而是刚需的原因。1.2 三条实现路线的成本与体验对比在动手写代码前我把可行方案摆在一起做了个对比。这里直接给结论适合大多数轻量表情输入场景方案输入阶段可视实现复杂度可定制性潜在问题textarea 提交后替换否很低低输入反馈差表情无法内嵌在文字中间实时预览contentEditable div 自研是中等高要自己处理光标、粘贴、安全过滤引入现成富文本编辑器是低接入成本中包体积大、样式难定制、表情能力往往需要二次开发先说textarea路线。虽然可以在输入时把[doge]文本用图片替换显示但那需要复杂的背景图定位或者字符映射技巧而且文本框高度、换行、删除键都会变成不可控因素基本等于把所见即所得用最别扭的方式硬做出来体验反而更差。再说第三方富文本编辑器。像一些成熟的编辑器确实有表情插件但它们是给长文章排版设计的对于一个轻量的评论框来说明显偏重初始化慢、工具栏一大堆用不到的功能、皮肤风格和自家产品很难完全统一。更麻烦的是很多编辑器的表情是作为特殊字符处理的插入后排版灵活性不如直接操作 HTML。我最后选了contentEditable div 自研这条路。它的核心优势在于浏览器原生支持在 div 内部编辑富文本内容图片就是一个img标签插进去用户立刻能看到光标、换行、删除等基本编辑能力浏览器全包了开发者的工作重心可以放在什么时候插入、插在哪里、如何安全地取走内容这几个更明确的问题上。1.3 contentEditable 的白纸和贴纸contentEditable这个属性给任何 HTML 元素加上之后它就变成一个可编辑区域。用户在里面敲字浏览器会自动维护innerHTML光标位置也是浏览器原生管理的我们不需要像操作textarea那样手算字符索引。用个生活化的比方textarea是一张只能写字的白纸任何非文字内容图片、彩色标签想放进去都得先翻译成文字代号contentEditable则是一块带背胶的留言板你可以写字也可以直接往上贴照片、贴贴纸贴的时候还不会把旁边写好的字挤坏——因为浏览器会帮你把内容当作连续文档的一部分自然排版。所以这篇魔法的本质就是换一块更适合的输入介质。理解了这一点后面所有的操作都是围绕如何在这块留言板上优雅地贴表情展开的。2. 最小可落地的表情即所得基础架构2.1 先解决 React 和 contentEditable 的受控打架第一个坑几乎所有从textarea迁移过来的同学都会踩试图用 state 管理contentEditable的内容。比如这样写const [html, setHtml] useState(); div contentEditable dangerouslySetInnerHTML{{ __html: html }} onInput{(e) setHtml(e.currentTarget.innerHTML)} /这么写的直接后果是光标会不定期跳回起点输入法还会出现候选词上屏错乱。原因很简单React 的受控组件要求 state 与视图完全一致每次setHtml都会触发重新渲染然后 React 用新的dangerouslySetInnerHTML去整体重写 div 的内容。重写之后浏览器原本维护的光标位置就失效了于是光标被冲掉严重的时候甚至连中文输入法的组合状态都会被打断。我的做法是非受控 ref input 事件单向同步。contentEditable的内容由浏览器自己维护React 不试图控制它只是在一个onInput回调里把最新内容读出来同步给父组件或者表单状态。这样既保证了输入流畅也能拿到内容。import { useRef } from react; interface SyncData { html: string; text: string; } interface EmojiInputProps { onChange?: (data: SyncData) void; } export default function EmojiInput({ onChange }: EmojiInputProps) { const editorRef useRefHTMLDivElement | null(null); const syncState () { const el editorRef.current; if (!el) return; onChange?.({ html: el.innerHTML, text: el.innerText, }); }; return ( div ref{editorRef} contentEditable suppressContentEditableWarning classNameemoji-editor onInput{syncState} / ); }这里有个细节suppressContentEditableWarning是 React 的专用属性。因为 React 会警告你同时用了 children 和 contentEditable但在非受控模式下我们根本不传 children这个警告本身就是多余的加上这个属性可以静默它。新手经常被这个 warning 迷惑还以为自己写错了。2.2 完整的最小组件能插表情也能拿走内容有了基础编辑区接下来要做的就是把表情插进光标所在的位置。我先把完整的最小组件写出来后面再拆解每一步的意图import { useRef } from react; const EMOJI_MAP: Recordstring, string { dog: https://cdn.example.com/emoji/dog.png, cat: https://cdn.example.com/emoji/cat.png, happy: https://cdn.example.com/emoji/happy.png, angry: https://cdn.example.com/emoji/angry.png, }; interface EmojiInputProps { onChange?: (data: { html: string; text: string }) void; } export default function EmojiInput({ onChange }: EmojiInputProps) { const editorRef useRefHTMLDivElement | null(null); const syncState () { const el editorRef.current; if (!el) return; onChange?.({ html: el.innerHTML, text: el.innerText, }); }; const insertEmoji (key: string) { const el editorRef.current; if (!el) return; const url EMOJI_MAP[key]; if (!url) return; const img document.createElement(img); img.src url; img.alt key; img.className chat-emoji; img.setAttribute(data-emoji, key); const selection window.getSelection(); if (!selection || selection.rangeCount 0) { // 编辑器从未被聚焦过直接追加到末尾 el.appendChild(img); el.appendChild(document.createTextNode(\u200b)); syncState(); return; } const range selection.getRangeAt(0); range.deleteContents(); range.insertNode(img); // 在图片后面补一个零宽空格光标不会尴尬地吸附在图片上 const spacer document.createTextNode(\u200b); const newRange document.createRange(); newRange.setStartAfter(img); newRange.collapse(true); newRange.insertNode(spacer); // 最终把光标定位到零宽空格之后 const finalRange document.createRange(); finalRange.setStartAfter(spacer); finalRange.collapse(true); selection.removeAllRanges(); selection.addRange(finalRange); el.focus(); syncState(); }; return ( div classNameemoji-composer div ref{editorRef} contentEditable suppressContentEditableWarning classNameemoji-editor >onChange?.({ html: el.innerHTML, // 富文本用于完整还原 text: el.innerText, // 纯文本用于消息通知、搜索、摘要 })text其实有很多用途。比如别人 你时的通知文案如果你的消息详情里只显示摘要就不需要渲染 HTML还有内容审核系统很多关键词过滤基于纯文本做匹配拿 HTML 去匹配会出现标签干扰。存一份纯文本兜底等于给后端、给其他业务线留了一条最稳的路。我还额外约定表情图片不直接存原始 URL而是存一个>const lastRangeRef useRefRange | null(null); const handleBlur () { const selection window.getSelection(); if (!selection || selection.rangeCount 0) return; const range selection.getRangeAt(0); const editorEl editorRef.current; // 只保存编辑器内部的光标 if (editorEl editorEl.contains(range.commonAncestorContainer)) { lastRangeRef.current range.cloneRange(); } };然后在insertEmoji开头判断如果getRangeAt(0)拿不到编辑器内的 Range就尝试lastRangeRef.current有的话直接恢复使用。这个小改动看起来很不起眼但对用户先点了别处再回来点表情这种高频操作路径是决定流畅度的关键。没有它很多用户会莫名发现表情跑到了最后面然后陷入手动挪光标的崩溃循环。4. 真实项目必踩的坑粘贴爆炸、脏数据与安全过滤4.1 从网页复制一段文字格式全乱了用上contentEditable的第二天我就收到测试同学反馈从某个网页复制一段文字再粘进来字体、颜色、行距全都变了。这个问题相当典型——浏览器在粘贴时会携带原格式的 HTML如果直接让它进来一个评论框瞬间变成五颜六色的画板。处理方案是监听paste事件强制只取纯文本const handlePaste (e: React.ClipboardEventHTMLDivElement) { e.preventDefault(); const plainText e.clipboardData.getData(text/plain); const el editorRef.current; if (!el) return; const selection window.getSelection(); if (!selection || selection.rangeCount 0) return; const range selection.getRangeAt(0); range.deleteContents(); range.insertNode(document.createTextNode(plainText)); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); syncState(); };如果你希望允许部分格式比如加粗、链接可以做白名单式的 HTML 粘贴但那个复杂度高不少。我的经验是表情输入场景默认纯文本粘贴只有编辑器需要支持加粗/斜体/链接三件套时才考虑放开部分标签。对于纯文本粘贴用户反而会觉得干净、符合预期而且可以避免后续一堆样式污染问题。4.2 表情也要守规矩数据格式与图片规范既然允许插入img就得对图片引入规范。我见过不少项目把表情直接拼成img src任意用户输入这是很危险的做法。前面提到的>import DOMPurify from dompurify; const ALLOWED_TAGS [p, br, strong, em, a, img, span]; const ALLOWED_ATTR [href, src, alt, data-emoji, class]; export const sanitizeContent (html: string) DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR, ALLOW_DATA_ATTR: true, });ALLOW_DATA_ATTR要被设置为true不然>.emoji-editor { -webkit-user-select: text; user-select: text; outline: none; min-height: 40px; overflow-y: auto; } .emoji-editor:empty::before { content: attr(data-placeholder); color: #9ca3af; pointer-events: none; }min-height和overflow-y特别重要因为contentEditable在内容很少时高度塌陷会很难点击。:empty::before则是给 div 模拟placeholder的标准做法。5.3 再往前走联想面板、提及与 GIF 扩展这套架构跑通之后扩展空间其实比想象中大。我后面还加了三个能力表情联想监听输入当用户打出[时弹出候选表情面板方向键和 Tab 可以快速跳过回车插入。这个对重度斗图用户非常友好甚至比点击选择器还快。提及在同一个contentEditable里检测字符后弹用户列表选中后插入一个带有>
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。