如果你负责过一个带聊天功能的Web应用大概率收到过这类需求用户想把一张自制表情包图片当作Emoji一样发出去。我最近在某协作平台重做了这个功能从上传、解析到渲染踩了一串坑也把整套实现思路理顺了这篇文章就是完整记录。所谓“使用自定义图片作为Emoji表情”核心并不是在字符集里发明新Emoji而是建立一套“短代码到图片URL”的映射机制用户在输入框写:yyds:系统把它渲染成一张图发给别人后看到的同样是这张图。这套方案适合谁如果你的项目是Web端的聊天工具、评论区、直播弹幕、协作平台需要让用户上传自定义表情并参与展示可以直接参考。不过这里的方案默认你已经有了基础的用户体系、文件存储和CDN。1. 自定义Emoji的本质不是发明新字符而是建立短代码图片映射1.1 核心模型短代码 图片资源自定义Emoji和系统Emoji有本质区别。系统Emoji是Unicode字符字符集里就那么多个前端渲染由操作系统字体完成自定义Emoji必须走“短代码 图片资源”的映射方案。短代码就是用户在文本里输入的令牌比如:ok:、:tq:、:yyds:系统在文本解析阶段识别出这种固定格式去映射表里查到对应图片URL然后渲染成一张img。这里面有一个设计决策很关键消息真正存储的应该是包含短代码的纯文本而不是渲染后的HTML。也就是说用户在输入框里写:yyds:发送给后端时消息体里存的就是:yyds:这几个字符而不是img src...。等到展示消息时前端再把短代码替换成图片。这个决策初看会多一次解析但好处在后面会逐渐体现出来消息体体积小存储和传输都省。历史消息在不同客户端上表现一致只要映射表还在随时可以重新渲染。做审核、导出、迁移时看到的都是可读文本不会被一堆HTML标签干扰。自定义表情被删除时历史消息至少能退化成一段文本不至于变成一张“死图”。所以整个系统的核心文件其实就是一张映射表shortcode - image_url。所有上传、删除、渲染、缓存都围绕这张表转。1.2 为什么不直接用Unicode私有区字符有人会问Emoji在Unicode里有码位为什么不把自定义图片直接映射到Unicode的私有区字符这样消息体里传的就是一个“字符”多优雅。这个思路听起来可行实际不可用。Unicode私用区PUA确实预留了部分码位给字体和软件内部使用但它的问题在于PUA字符没有跨设备、跨字体的标准语义。你在你的客户端里把某个PUA码位渲染成自制的图片但别人的设备上没有对应的字体或渲染规则看到的只会是“豆腐块”。就算你们团队能自研一套字体推到所有客户端那也意味着每次新增表情都要升级客户端而且还存在不同客户端字体版本不一致的问题。所以业界通行做法始终是文本短代码 图片URL映射。短代码本身是普通ASCII字符串不存在编码兼容问题图片URL是标准HTTP资源任何客户端都能拉取。这个方案把自定义Emoji从“字符层”降到了“协议层”反而获得了最大的兼容性。1.3 动手前需要敲定的四个边界条件在写第一行代码之前建议先把下面四个问题定了否则后面每一个都会变成返工点。短代码命名规范。我这边定的是只允许字母、数字、下划线、加号、减号长度1到32位大小写敏感。例如:good!这种带感叹号的直接拒绝:a_b-c1可以接受。规范定严一点解析正则就简单也能避免用户故意搞出一些奇奇怪怪的文本。如果你非要做国际化可以在正则里额外允许中文但排序和冲突处理都要跟着改不建议一上来就放开。作用域。自定义Emoji是全局通用还是每个团队、每个频道独立我强烈建议按“租户”隔离。比如一个协作平台上不同团队都有自己的表情习惯某团队上传了:dog:另一个团队也想上传自己的:dog:如果不隔离就会冲突。数据库里所有查询都要带上tenant_id短代码唯一性也只在租户范围内保证。尺寸与格式。统一目标尺寸最好是512x512格式用WebP。原因后面展开但这一条要在上传接口里就强制掉不能指望用户自觉压缩。审核与删除策略。自定义表情本质是UGC图片不能上传后直接全平台可见。至少要有一个“上传后先进入待审核审核通过后才被其他人看到”的流程。删除时也要想清楚短代码被删除后还能不能立刻被别人重新注册历史消息里这个表情要怎么显示不提前想上线后会被用户投诉。2. 服务端流水线上传校验、图片转码与存储策略2.1 上传接口的整体流程有了模型和边界条件服务端就比较好设计了。上传接口接收一个multipart/form-data请求包含file文件和shortcode字段。整体流程可以拆成下面几步校验短代码格式和租户内是否已被占用。校验文件大小默认限制在5MB以内。嗅探文件真实类型判断是不是PNG/JPEG/GIF/WebP。用图像处理库打开、缩放、转码成WebP。生成存储文件名上传到对象存储。更新数据库记录和Redis缓存。返回短代码和图片URL。对应到Flask代码大概是这个样子bp.post(/api/emojis) def upload_emoji(): file request.files.get(file) shortcode request.form.get(shortcode) tenant_id current_user.tenant_id if not file or not shortcode: return {code: 400, msg: 缺少文件或短代码} if not valid_shortcode(shortcode): return {code: 400, msg: 短代码格式不合法} if emoji_repo.exists(tenant_id, shortcode): return {code: 409, msg: 短代码已被占用} raw file.read(MAX_UPLOAD_SIZE 1) if len(raw) MAX_UPLOAD_SIZE: return {code: 400, msg: 文件不能超过5MB} image_type sniff_image_type(raw) if image_type not in (png, jpeg, gif, webp): return {code: 400, msg: 仅支持PNG/JPEG/GIF/WebP图片} webp_bytes convert_to_webp(raw, image_type) if webp_bytes is None: return {code: 400, msg: 图片无法解析} image_key upload_to_oss(webp_bytes, emoji, webp) image_url build_cdn_url(image_key, version1) emoji_repo.create( tenant_idtenant_id, shortcodeshortcode, image_keyimage_key, image_urlimage_url, creator_idcurrent_user.id, ) cache.delete_emoji_field(tenant_id, shortcode) return {code: 0, data: {shortcode: shortcode, url: image_url}}这里的核心不是Flask本身而是那些辅助函数有没有做到位。下面重点说说校验和转码。2.2 图片校验与安全底线文件校验最常见的坑是只信HTTP头里的Content-Type。攻击者完全可以传一个文件Content-Type写image/png内容其实是一段可执行脚本。所以服务端必须做“内容嗅探”直接读文件头几个字节判断真实格式。def sniff_image_type(raw: bytes): if raw.startswith(b\x89PNG\r\n\x1a\n): return png if raw.startswith(b\xff\xd8\xff): return jpeg if raw.startswith(bGIF87a) or raw.startswith(bGIF89a): return gif if raw.startswith(bRIFF) and raw[8:12] bWEBP: return webp return None格式确认之后用图像处理库解析。这里有一个安全点是“解压炸弹”用户上传的PNG可能只有几百KB但解压后宽高是20000x20000直接把内存打满。所以不能盲目Image.open之后thumbnail要先检查原始尺寸超过阈值直接拒绝。from PIL import Image, ImageSequence import io MAX_EDGE 2048 def convert_to_webp(raw_bytes: bytes, image_type: str): try: img Image.open(io.BytesIO(raw_bytes)) except Exception: return None if img.width MAX_EDGE or img.height MAX_EDGE: return None if image_type gif: return convert_gif_to_webp(img) img img.convert(RGBA if has_transparency(img) else RGB) img.thumbnail((512, 512), Image.LANCZOS) out io.BytesIO() img.save(out, formatWEBP, quality78) return out.getvalue()还有一个建议不要允许上传SVG。SVG本质是XML文本里面可以嵌入脚本和外链资源虽然现代浏览器在img标签里不会执行其中的脚本但SVG仍可能被用作钓鱼、跟踪或者触发外部网络请求。与其花大量精力过滤SVG不如直接在格式白名单里排除它。图片转码完成之后还要考虑内容审核。个人项目和中小团队可能没有专门的审核人员建议接一个第三方图片审核接口或者把上传的图片先标记为pending状态只有审核通过后才出现在表情选择器和渲染映射里。这个流程虽然多了一步但能避免很多麻烦。2.3 数据表与缓存设计数据库表结构我最后是这么设计的CREATE TABLE custom_emoji ( id BIGINT AUTO_INCREMENT PRIMARY KEY, tenant_id BIGINT NOT NULL, shortcode VARCHAR(32) NOT NULL, image_key VARCHAR(255) NOT NULL, image_url VARCHAR(512) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审核 1已通过 2已下架, creator_id BIGINT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, deleted_at DATETIME NULL, UNIQUE KEY uk_tenant_shortcode (tenant_id, shortcode) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意uk_tenant_shortcode是复合唯一索引短代码冲突只能在租户维度判断。deleted_at用来做软删除保留历史信息。image_url里我一开始存的是不带版本号的URL后来发现更新图片时CDN缓存很难处理索性改成image_url存的是带版本号的完整URL每次重新上传同一个短代码都生成新的image_keyURL里的版本参数也跟着递增。这样旧消息还能正常显示旧图新消息显示新图互不干扰。缓存层用Redis的Hash结构比较合适Keyemoji:{tenant_id}FieldshortcodeValueimage_url查询时先读缓存缓存未命中再查数据库查到后回填缓存并设置过期时间。def get_emoji_url(tenant_id: int, shortcode: str): key femoji:{tenant_id} url redis.hget(key, shortcode) if url: return url row db.fetchone( SELECT image_url FROM custom_emoji WHERE tenant_id%s AND shortcode%s AND status1 AND deleted_at IS NULL, (tenant_id, shortcode), ) if row: redis.hset(key, shortcode, row[image_url]) redis.expire(key, 3600) return row[image_url] return None有一点容易忽略消息量大的时候每个消息可能包含多个表情如果每条消息都去查一次Redis那也不划算。更好的做法是前端在进入会话页时批量拉取租户下的全部表情映射构建本地Map渲染时直接查本地Map。Redis只作为服务端兜底和选择器接口的数据源。2.4 存储文件名与CDN的注意点对象存储的文件名千万不要用用户原始文件名。用户上传的可能是表情包.png、CON(x).gif这种带空格和特殊字符的名字直接存会有各种问题。我这边统一用uuid4().hex加扩展名例如emoji/202501/a3f9c2d0e1b4f5a6....webp好处很明显存储路径不可预测、避免文件名冲突、减少中文和特殊字符带来的URL编码问题。文件命名和业务数据解耦之后你想迁移存储、做冷热分层都很方便。CDN方面有一点要特别注意如果同一个URL对应的图片内容变了CDN边缘节点会因为缓存长期不刷新而继续吐旧图。这也是我在第二版把版本号加进URL的原因。上传新图时不再复用原文件名而是生成新的image_key新URL必然不同CDN缓存天然不会串。如果因为历史原因不得不复用URL那就只能在删除或更新后主动调用CDN刷新接口同时把URL里的版本参数也改掉。两件事一起做才能保证边缘节点和源站一致。3. 前端渲染与输入体验让用户无感使用自定义表情3.1 文本解析把:shortcode:变成img前端的核心任务是把纯文本消息里的短代码替换成图片。这里有两条路在服务端把消息渲染成HTML再下发给前端。优点是客户端轻量但XSS面更大而且不同端要各做一套HTML渲染。在客户端解析纯文本消息。优点是灵活、安全边界清晰我采用的是这种。我推荐用DOM层面的文本节点遍历来做而不是直接用innerHTML 拼接。原因很简单直接拼HTML字符串很容易让恶意内容混进src、onerror这些属性里即使加了过滤也容易漏。用DOM API创建节点天然避免了大部分XSS问题。function renderEmojiInTextNodes(root, emojiMap) { const walker document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const textNodes []; while (walker.nextNode()) textNodes.push(walker.currentNode); const regex /:([a-zA-Z0-9_\-]{1,32}):/g; for (const node of textNodes) { const text node.nodeValue; if (!text.includes(:)) continue; const frag document.createDocumentFragment(); let lastIndex 0; let m; while ((m regex.exec(text)) ! null) { if (m.index lastIndex) { frag.appendChild(document.createTextNode(text.slice(lastIndex, m.index))); } const url emojiMap[m[1]]; if (!url) { frag.appendChild(document.createTextNode(m[0])); } else { const img document.createElement(img); img.src url; img.alt m[0]; img.title m[0]; img.dataset.shortcode m[1]; img.className custom-emoji; frag.appendChild(img); } lastIndex m.index m[0].length; } if (lastIndex text.length) { frag.appendChild(document.createTextNode(text.slice(lastIndex))); } if (frag.childNodes.length 0) { node.parentNode.replaceChild(frag, node); } } }这段代码的核心逻辑是遍历页面里的文本节点找到:xxx:这种模式查映射表命中就替换成img没命中就保留原文。emojiMap是进入页面时一次性从/api/emojis接口拉取并构建的本地映射对象。为什么用TreeWalker遍历文本节点而不是直接操作整个innerHTML因为消息列表里可能嵌套了链接、引用块、代码块你只希望替换普通文本中的表情短代码而不希望破坏原有的HTML结构。遍历文本节点可以做到最小干预。3.2 行内对齐和换行细节自定义Emoji渲染成图片之后最直观的问题往往是“图片和文字对不齐”。开发时看着还行用户一上线就反馈表情偏高或偏低。这其实和CSS的垂直对齐有关。文字的基线对齐方式比较特殊图片默认按基线底部对齐所以需要在样式里做微调.custom-emoji { display: inline-block; width: 1.2em; height: 1.2em; margin: 0 0.05em; vertical-align: -0.2em; object-fit: contain; }这里有几个细节值得解释。第一用em而不是px。em是相对当前字体大小的单位用户把聊天字体调大时表情会跟着等比放大不会出现文字很大、表情很小的失调感。第二vertical-align: -0.2em是经验值让图片中心稍微低于文字中心视觉上才稳。不同字体基线略有差异可以自己微调到-0.15em或-0.25em。第三object-fit: contain保证非正方形图片不会被拉伸变形多余部分留透明边。如果你希望所有头像表情都铺满一个方形容器那在服务端做图片处理时就应该用ImageOps.fit裁剪成正方形而不是只thumbnail。我后来选择了“存储时保留原始比例、渲染时用contain”的方案因为用户上传的表情不全是正方形强行裁剪会切掉重要内容体验反而差。3.3 表情选择器与数据加载光有解析还不够产品需要一个入口让用户发现并插入自定义表情。表情选择器我建议做成一个独立的弹窗数据按租户拉取。选择器数据接口大致长这样GET /api/emojis?tenant_idxxxcursorxxxlimit100返回字段{ items: [ { shortcode: yyds, url: https://cdn.example.com/emoji/xxx.webp?v1 } ], next_cursor: }前端拿到数据后把系统Emoji和自定义Emoji分成两个区域。系统Emoji直接展示Unicode字符自定义Emoji展示图片。点击某个自定义表情时实际上是在输入框的光标位置插入一段文本:yyds:而不是插入HTML。这里有一个交互细节如果输入框是contenteditable插入文本节点比插入图片更稳妥。因为自定义Emoji在消息传输协议里始终是文本只有在消息渲染成静态展示时才是图片。插入文本节点也方便用户继续输入、退格删除。表情数量多的时候注意两点自定义表情接口做分页或者懒加载一次不要拉几千个。表情选择器里加一个搜索框按短代码前缀过滤。用户如果记得短代码直接打字触发匹配会快很多。3.4 复制粘贴、Markdown与跨端回退用户会把消息复制到别的地方也会从别的地方复制内容到你的输入框。这两条链路都会遇到自定义Emoji。先看复制。用户在消息列表里选中一段包含自定义表情的文本按CtrlC如果系统直接复制HTML那粘贴到笔记软件里时可能是一张带着CDN链接的图片或者更糟粘贴后变成一个离线的图片链接。更好的做法是复制时检测到custom-emoji图片就用它的>document.addEventListener(copy, (e) { const selection window.getSelection(); if (!selection || selection.rangeCount 0) return; const container document.createElement(div); for (let i 0; i selection.rangeCount; i) { container.appendChild(selection.getRangeAt(i).cloneContents()); } container.querySelectorAll(img.custom-emoji).forEach((img) { const code img.dataset.shortcode; if (code) { const span document.createElement(span); span.textContent :${code}:; img.replaceWith(span); } }); const plainText container.textContent.replace(/\n{3,}/g, \n\n); e.clipboardData.setData(text/plain, plainText); e.preventDefault(); });再看跨端回退。自定义Emoji终究依赖图片CDN图片会加载失败、审核会下架、网络可能断开。这时如果界面上还是渲染一个空img体验很糟。所以我要求前端监听img.error事件加载失败时用短代码文本替代img.addEventListener(error, () { const fallback document.createElement(span); fallback.className custom-emoji-fallback; fallback.textContent img.alt || :${img.dataset.shortcode}:; img.replaceWith(fallback); }, { once: true });Markdown场景也要留意。如果你的消息支持Markdown:shortcode:这种写法不会和Markdown语法冲突但要注意代码块里的内容不应该被解析成表情。比如用户贴了一段代码git commit -m :smile: fix bug代码块里的:smile:应该原样展示不能渲染成图片。处理方法是在解析前识别代码块边界或者在解析渲染时跳过pre内部的文本节点。用TreeWalker遍历时可以检查所在父节点是不是PRE或CODE是就跳过。4. 上线后的真实状况我踩过的坑与对策4.1 短代码冲突引发社区乱象第一个坑来得很快。我们最初把短代码设计成全局唯一结果某团队上传了:赞:另一个团队也想用同样的短代码直接被接口拒绝。用户以为系统出bug了反复提交工单。根因是作用域没想清楚。不同租户、不同团队本来就有自己的表情文化凭什么全局只有一个:赞:可如果完全放开又会出现一个团队的表情被另一个团队看到时出现完全不同的图消息语义被破坏。我最终采用的方案是“租户内唯一 展示时带上租户上下文”。也就是说A团队看到的:赞:是A团队上传的那张图B团队看到的:赞:是B团队自己的那张图。所有用户都在自己租户的映射表里查询不会混。删除时还有一个隐蔽问题短代码刚被删除另一个人立刻注册了同一个短代码那历史消息里原本指向旧图的消息重新渲染时会变成新图语义完全变了。这个问题比冲突更严重。我在删除逻辑里加了“短代码冷却期”默认30天。短代码删除后进入deleted_at状态30天内不允许被重新注册30天之后如果历史消息早已被归档影响就小很多。4.2 图片体积失控导致CDN费用飙高第二个坑是图片体积。上线初期我们没有严格执行最大边长用户上传了一张5000x5000的表情转码后还用质量90保存结果一张静态图WebP也有1MB多。表情这东西使用频率极高一条消息只要带了一个表情每个会话成员都要从CDN拉一次几万日活就把流量费用顶上去了。定位问题靠的是监控里的CDN回源和流量统计我发现某个短代码图片的请求峰值特别高去对象存储里一查发现图片边长4000多WebP体积巨大。对策是双管齐下上传层限制原始图片最大边不超过2048转码目标尺寸不超过512。WebP压缩质量固定为78静态图透明通道保留。优化后的数据可以给个参考一张常见表情包PNG原始体积800KB转成512x512的WebP后大约12KB到30KB一张GIF动图原始体积2MB压缩到256边长、限制40帧后大约200KB。对用户体感来说图还是清晰的但流量成本降了一个数量级。4.3 动图表情让客户端内存吃紧第三个坑和动图有关。自定义表情里大量是GIF动图转成WebP动图后虽然体积小了但客户端的解码开销依然存在。聊天页面一屏可能展示20条消息每条消息带两三个动图表情页面的CPU和内存占用直接飙升滚动起来卡顿明显。这个问题不能只靠限制帧数解决。帧数从60帧降到15帧能缓解但页面里几十个动图同时播放帧率还是会掉。后来我采用的方案是“静态预览 点击播放”。列表页渲染时优先使用转码时生成的静态首帧图用户点击表情或者进入详情页后才加载完整的WebP动图。服务端转码时每次都会生成两个产物xxx_static.webp静态首帧尺寸512用于列表和预览。xxx_animated.webp动图尺寸256限制40帧用于点击后的播放。虽然实现上多了一道转换但对客户端性能的提升是立竿见影的。这个策略尤其适合聊天列表这种高频渲染场景。4.4 审核、删除与缓存失效第四类问题集中在运营侧。自定义表情是UGC必须考虑违规内容。我们一开始没有审核流程结果有人上传了带广告信息的图被其他用户截图传播迫使我们紧急下架。删除看起来简单——“把数据库status置为2”就行了。但CDN缓存还在图片URL还能直接访问等于没有真正下架。而且已经渲染到客户端页面里的图片浏览器不会因为数据库状态变了就立刻消失。我最后确定的方案是上传后进入pending状态调用第三方内容审核接口没通过的直接拒绝。审核通过后选择器和渲染映射才可见。运营人员下架表情时不仅修改数据库状态还调用CDN刷新接口清除对应URL缓存。已经渲染在用户页面里的图片通过前端定时拉取“下架短代码列表”来做软移除——发现短代码被下架就用文本替代图片。图片存储在对象存储里不立即删除保留一段时间便于追溯和申诉。这套流程下来至少能保证“新消息看不到、旧消息快速回退、源站不可直链”这三个目标。如果你一开始就把审核队列设计成独立模块后续接任何AI审核、人工审核都很方便。5. 如果从零做一套可扩展的自定义Emoji系统这些设计可以抄5.1 模块划分建议经历过一轮重构之后我建议把自定义Emoji拆成四个模块各管一摊emoji-api上传、列表、删除、审核状态变更等HTTP接口。emoji-store对象存储上传、CDN刷新、文件名生成。emoji-renderer文本解析、DOM渲染、复制粘贴转换。这个模块可以做成独立SDK或Web Component。emoji-admin管理后台支持搜索、下架、重新审核。模块之间只通过短代码和URL通信。emoji-renderer不应该知道数据库和存储细节这样以后你想在微信小程序、React Native等端复用渲染逻辑只需要重写一个解析器即可。工程目录大致是这样service/ emoji/ handlers.py # 上传、查询、删除接口 validator.py # 短代码校验、文件嗅探 converter.py # 图片缩放、转码、动图静态首帧提取 repository.py # 数据库读写 cache.py # Redis缓存管理 renderer/ emoji_parser.js # 文本节点遍历与替换 emoji.css # 行内对齐样式5.2 关键参数与配置参考把最容易踩坑的参数汇总成一张表可以直接抄参数建议值说明短代码字符集[a-zA-Z0-9_\-]避免符号干扰正则和Markdown短代码长度1~32太短容易冲突太长不便于输入存储图片最大边长512px兼顾清晰度和加载体积上传原始图片最大边长2048px拦截解压炸弹静态图格式WebP体积小支持透明通道动态图最大边长256px动图解码开销大不宜太大动态图最大帧数40帧超出后丢弃尾部或降帧率WebP质量78表情类图片主观质量损失不明显单文件大小上限5MB防止用户上传超大原图缓存Key结构emoji:{tenant_id}按租户隔离Redis数据缓存TTL3600秒防止脏数据长期残留CDN版本号URL参数v更新图时强制新URL下架短代码冷却期30天避免历史消息语义错乱5.3 上线后看哪些指标最后说说运营指标。自定义Emoji不是实现完就结束了你得知道它到底有没有被用起来。我上线后重点看五个指标自定义表情使用率含有:xxx:文本的消息数占总消息数的比例。如果连1%都不到说明入口太深或者上传太少。上传成功率用户上传到审核通过的比例。如果大量卡在尺寸限制或格式校验可能是产品引导没做足。图片平均体积每周统计一次所有已上传Emoji的平均KB数用来评估CDN成本趋势。加载失败率img.error事件上报的比例。如果数字偏高大概率是审核下架后前端没有及时回退。审核驳回率看驳回率能判断是不是有人恶意上传也能帮你决定要不要加更严格的预检。这些指标不用一次全做第一版先做使用率和失败率就够了。等到表情库过百、日活几千人的时候再回头补整套监控也不迟。如果让我重新做一次我会在第一天就把审核队列、短代码冷却期和缓存版本号设计好。上传时同步生成静态首帧/缩略图CDN签名URL也直接做进去。这些事看起来都很小但等用户量上来之后再改牵一发动全身。希望这些记录能帮你绕开我踩过的坑。