React表情输入框:用contentEditable实现所见即所得,解决光标与安全难题
前阵子有位同行让我帮忙看一个站内评论框说用户反复反馈同一个问题输入[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里检测字符后弹用户列表选中后插入一个带有>

相关新闻

ai项目:双卡 RTX 6000 部署 大模型智能问答 + 中医数字人全链路:从环境到跑通【可以导入自己的知识资料变成模拟您:回答问题+起个题目便可以一键生成专属您个人的数字人】

ai项目:双卡 RTX 6000 部署 大模型智能问答 + 中医数字人全链路:从环境到跑通【可以导入自己的知识资料变成模拟您:回答问题+起个题目便可以一键生成专属您个人的数字人】

--- > 本文基于真实落地项目整理,系统已打通"素材导入 → ASR/VL理解 → 知识校对 → 声纹/形象提取 → LoRA风格训练 → 配音 → 数字人视频合成"的原链路,完整架构与代码见仓库。 --- ## 1. 产品定位与业务链路 本系统面向中医场景,有两套相互关联但不可…

2026/10/15 5:55:38 阅读更多 →
Freerouting 国际化翻译指南:LLM 驱动的 i18n 流水线、术语词表与多语言维护实践

Freerouting 国际化翻译指南:LLM 驱动的 i18n 流水线、术语词表与多语言维护实践

硬件开发桌面应用 【免费下载链接】freerouting Advanced PCB auto-router 项目地址: https://gitcode.com/gh_mirrors/fr/freerouting 点击查看 免费下载 本文是 Freerouting 开源自动布线器的官方翻译工作流指南,完整讲解其基于 LLM(Gemin…

2026/10/15 5:55:38 阅读更多 →
AI短剧生成平台开源:技术架构、部署与批量生产实战

AI短剧生成平台开源:技术架构、部署与批量生产实战

1. 从"几分钟出片"说起:AI短剧生成到底在解决什么问题第一次看到"AI短剧生成平台完全开源"这个说法,我的反应是怀疑。短剧这个赛道在过去两年里确实火,但真正做过内容的人都知道,一条能看的短剧,从…

2026/10/15 5:54:37 阅读更多 →

最新新闻

LangChain DeepAgent 多 Agent 架构原理学习:从 SubAgent 到 Middleware 的协作链路拆解

LangChain DeepAgent 多 Agent 架构原理学习:从 SubAgent 到 Middleware 的协作链路拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 13:43:13 阅读更多 →
多轮交互标注和单轮对话标注的区别是什么,标注重点关注什么?

多轮交互标注和单轮对话标注的区别是什么,标注重点关注什么?

多轮交互标注关注的是完整对话过程中的上下文衔接、意图变化和最终任务完成情况;单轮对话标注则主要判断一次提问与回答是否匹配。两者的核心差异,不在于对话轮数多少,而在于是否需要结合历史信息进行连续判断。 一、单轮与多轮标注的核心区别…

2026/10/15 13:43:13 阅读更多 →
SMAA抗锯齿原理与OpenGL/Vulkan实现详解

SMAA抗锯齿原理与OpenGL/Vulkan实现详解

简介:smaaDemo是一份面向图形渲染开发者与图形学学习者的OpenGL、Vulkan抗锯齿演示工程,聚焦亚像素形态抗锯齿(SMAA)这一后期处理技术,帮助读者理解边缘检测、邻域搜索与色彩混合三阶段算法,并对比MSAA、SS…

2026/10/15 13:43:13 阅读更多 →
HyperFrames超帧:多传感器融合中时序对齐与数据组织的工程实践

HyperFrames超帧:多传感器融合中时序对齐与数据组织的工程实践

第一次听说 hyperframes 这个概念,是我在调一套车端多传感器融合数据的时候。当时手头有激光雷达、摄像头和惯性测量单元,三个传感器各报各的账:激光雷达 10 赫兹,摄像头 30 赫兹,惯性测量单元直接跑到 200 赫兹。单独…

2026/10/15 13:43:13 阅读更多 →
回溯算法详解:从基础到实战应用

回溯算法详解:从基础到实战应用

1. 回溯算法基础概念回溯算法是一种通过探索所有可能的候选解来找出所有解的算法。如果候选解被确认不是一个解(或者至少不是最后一个解),回溯算法会通过在上一步进行一些变化来丢弃该解,即"回溯"并尝试其他可能性。回溯…

2026/10/15 13:43:13 阅读更多 →
DMOA优化CNN-LSTM-Attention超参数:Matlab故障诊断实战指南

DMOA优化CNN-LSTM-Attention超参数:Matlab故障诊断实战指南

做故障诊断这块的同行应该都有体会,模型的骨架搭起来并不难,难的是怎么让它稳定到“能打”的状态。CNN提取特征、LSTM抓时序依赖、Attention分配注意力,这三件套用在轴承、齿轮箱、电机这类旋转机械的故障诊断里,早已是主流打法。…

2026/10/15 13:42:12 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 9:49:14 阅读更多 →