图片表情的类型、分组与标记文本
【这是一篇写在此项目开发期的文章】 评论区的表情面板本来只有两组写死的 Emoji 和颜文字,所以我打算给它加自定义表情包。做完在页面上试的时候遇到了这样的问题:图片表情插进输入框只剩一串 URL,发出去也还是那串 URL;翻后端,发现没有 image 类型,表情也不分组织。这篇记录如何解决遇到的以上问题,以及评论字数按码元算、URL 里不能有方括号这些约束。

评论表情包重做
想加一套自己的表情包
评论区的表情面板以前只有 Emoji 和颜文字两组,是写死在代码里的数组。我想放几个自己惯用的图片表情进去,所以干脆把这块做成可配置的:新建 emoji 表,配上模型、接口和后台的表情管理页,前台几处写死的数组也改成从接口动态拉取。
图片表情一起做了,面板里能显示,后台也能上传,但是在我测试时就遇到问题了。
先是插入。当时的实现是往 content 后面拼字符串,但 content 当中会存 URL :
const insertReplyEmoji = (text: string) => {
replyForm.content += text;
replyEmojiOpen.value = false;
};
输入框是 el-input type="textarea",它只认纯文本,所以插入图片表情之后,输入框里出现的是:
https://cdn.example.com/emoji/xxx.webp
评论列表用的是 v-html 渲染,但当时只做了一件事:把 @用户名 包一层高亮。
// @提及渲染:将 @用户名 高亮
const renderContent = (content: string) => {
if (!content) return "";
return content.replace(/(@[^\s@,,。!?!?]+)/g, '<span class="mention">$1</span>');
};
所以评论里显示的就是 URL 了。
我又检查了后端,发现还缺两样东西,都在数据层:
- 表情类型里没有「图片」。
emoji.type只有emoji/kaomoji两个值,图片表情是当作普通 emoji 存进去的,靠前端按内容是不是 URL 判断出来。后端和表结构都不知道有这种东西,后台按类型筛选自然也挑不出来。 - 表情不分组织。面板上写死两个 tab(Emoji、颜文字),博主自定义的表情不管有多少个,全部平铺在第一个 tab 里。表里也没有「分组」这个概念。
解决方案
数据层
表情分组本身就是一个「名字 + 标识 + 排序」的实体,单开一张表最清楚。核心字段就三个(myblog-1.1.sql):
CREATE TABLE `emoji_group` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '分组ID',
`name` varchar(50) ... NOT NULL COMMENT '分组名称',
`cover` varchar(500) ... NULL DEFAULT NULL COMMENT '分组标识(Emoji 文本或图片URL)',
`sort_order` int NOT NULL DEFAULT 0 COMMENT '排序',
...
) ENGINE = InnoDB ... COMMENT = '表情分组(名称 + 标识 + 排序)';
cover 是 tab 上显示的那个小标识。它和表情内容是同一种东西:可以是 Emoji 文本,也可以是一张图片的 URL。所以字段类型跟 emoji.content 保持一致,都是 varchar(500)。
emoji 表这边加一个可空外键,并给 type 补上 image:
`type` enum('emoji','kaomoji','image') ... COMMENT '类型:emoji/颜文字/图片',
`group_id` int NULL DEFAULT NULL COMMENT '所属分组ID(NULL=未分组)',
CONSTRAINT `fk_emoji_group` FOREIGN KEY (`group_id`) REFERENCES `emoji_group` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
ON DELETE SET NULL 是为了删掉一个分组时,组里的表情不应该跟着消失,它们退回到「未分组」就行,博主再重新归类即可。
存量库要跑一个迁移脚本(scripts/migrateEmojiGroup.js),做四件事:建表、加列、加索引和外键、把 type 枚举扩开。需要注意的是,外键指向的表必须先存在,所以建表要放在加外键之前;枚举扩展要先判断当前值,不然在同一次执行里改两遍会多跑一次 ALTER。
const typeCol = await columnType(conn, "emoji", "type");
if (typeCol && !typeCol.includes("image")) {
console.log("[migrateEmojiGroup] 扩展 emoji.type 枚举,加入 image ...");
await conn.query(`ALTER TABLE emoji MODIFY COLUMN ...`);
} else if (typeCol) {
console.log("[migrateEmojiGroup] emoji.type 已含 image,跳过");
}
最后是存量数据的回填:以前被塞进 emoji 类型的图片表情,内容其实是 http(s) 地址,按这个特征改过来。
UPDATE emoji SET type = 'image'
WHERE type = 'emoji' AND content REGEXP '^https?://'
类型由内容决定
加完 image 之后,写接口会收到前端传的 type,后端也需要做验证。
const EMOJI_TYPES = ["emoji", "kaomoji", "image"];
const normalizeType = (content, type) => {
if (type !== undefined && type !== null && type !== "") {
if (!EMOJI_TYPES.includes(type)) {
return { error: "type 必须是 emoji / kaomoji / image" };
}
}
return { type: resolveEmojiType(content, type) };
};
/** 依据内容自动判定表情类型:http(s) URL → image,否则回退到传入类型 */
const resolveEmojiType = (content, type) => {
if (isHttpUrl((content || "").trim())) {
return "image";
}
return type || "emoji";
};
type 传了值就必须是三个枚举之一,但最终落库的类型由 resolveEmojiType 决定。
表情内容和分组标识共用一套校验
上面那个 emojiValidation.js 是这次新建的,因为多了一个和表情内容同规则的字段:分组标识。
加自定义表情包时的校验写在 emojiController.js 里一个叫 validateEmojiContent 的函数。这次分组标识上来了,它同样可以填 Emoji 文本或图片地址,规则一模一样。如果两边各写一份,很容易只拦住一边,另一边就成了绕过校验的口子。比如 javascript: 这种,只要某个入口没拦,它就能存进去,而 tab 上的 cover 是会渲染成 <img src> 的。
所以这次直接抽成一个共用函数,两处都调它:
// 图片 URL 白名单(仅 http/https,无引号/尖括号/空白)
const URL_PATTERN = /^https?:\/\/[^\s"'<>\\]+$/i;
// 危险 HTML 字符(防 XSS)
const UNSAFE_CHARS = /[<>"'`]/;
const validateTextOrImageUrl = (value, options = {}) => {
const { maxLength = 500, label = "内容" } = options;
// ... 非空、长度检查
// 形似协议 / URL 的:必须是 http(s),否则拒绝(防 javascript:/data:/vbscript: 等)
if (trimmed.includes(":") || trimmed.includes("/") || trimmed.includes(".")) {
if (!isHttpUrl(trimmed)) {
return "图片 URL 格式不正确(仅支持 http/https)";
}
return null;
}
// 纯文本:不允许出现 HTML 危险字符
if (UNSAFE_CHARS.test(trimmed)) {
return `${label}包含非法字符(不允许 HTML 标记字符)`;
}
return null;
};
判定顺序是从宽到窄。先看内容里有没有 :、/、.,有就当成「像 URL 的东西」,必须过 http(s) 白名单;没有这些字符才认为是纯文本,只检查有没有 HTML 标记字符。纯文本那一支我故意放宽,允许任意 Unicode,因为颜文字里全是各种符号,卡太死会误伤。
调用处传 label 区分提示语,一处是表情内容,一处是分组标识:
const validContent = validateTextOrImageUrl(content, { label: "表情内容" });
const validCover = validateTextOrImageUrl(cover, { label: "分组标识", maxLength: 500 });
评论内容存「标记文本」,不存 HTML
输入框要能显示图片,就得用富文本编辑器,而富文本编辑器的默认输出是 HTML。如果直接存 HTML,会有三个问题:
- 后端评论
content上限 1000 字符(middleware/validator.js里isLength({ min: 1, max: 1000 })),HTML 标签会把长度吃掉一截,让本来没超的字数被判超长。 - 评论区是用
v-html渲染的,存 HTML 就等于把整个内容的信任度交出去。项目里没有 DOMPurify。 - 邮件通知和后台评论列表都在读这个字段,存 HTML 就得跟着一起改。
我给这个字段定了自己的协议:提交时序列化成标记文本,只有图片会变成标记。
/**
* 数据契约(关键决策):
* - 提交时序列化为「标记文本」,**不存 HTML**:
* 图片节点 → `[img:https://...]`
* 段落 / 换行 → `\n`
* 文本原样输出
* - 边界:标记以 `[img:` 开头、`]` 结尾,故 **URL 内不能含 `]`**(正常 URL 不会)。
*/
const IMG_MARKER = /\[img:(https?:\/\/[^\s\]]+)\]/gi;
渲染时默认全部转义,只放行白名单里认得的两种东西。
export const renderCommentContent = (markup?: string | null): string => {
const content = markup || "";
if (!content) return "";
const parts: string[] = [];
const re = new RegExp(IMG_MARKER.source, "gi");
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = re.exec(content)) !== null) {
if (match.index > lastIndex) {
parts.push(escapeAndMention(content.slice(lastIndex, match.index)));
}
const url = match[1];
if (isSafeImageUrl(url)) {
parts.push(
`<img class="comment-markup-img" src="${escapeHtml(url)}" alt="表情" loading="lazy" />`,
);
} else {
// 非法地址:退化为转义后的原样文本
parts.push(escapeHtml(match[0]));
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < content.length) {
parts.push(escapeAndMention(content.slice(lastIndex)));
}
return parts.join("");
};
能变成 HTML 的只有两处:一个是 isSafeImageUrl 通过校验的图片地址,另一个是 @xxx 提及外面套的 <span class="mention">。其余全部走 escapeHtml。@ 这个字符不在转义列表里,所以「先转义再高亮」的顺序是安全的。
同时 isEmojiImage 这个判定被前端渲染和后端校验同时引用,两边用同一条规则,不会出现「前端认为是图片、后端认为是文本」的分歧。
/** 图片 URL 白名单(与 useEmoji / 后端校验一致) */
export const isEmojiImage = (content: string) =>
/^https?:\/\/[^\s"'<>\\]+$/i.test((content || "").trim());
配了 14 项单测,盯的是危险输入和边界。下面两条是核心:
it("HTML 字符被转义(XSS 白名单渲染)", () => {
const html = renderCommentContent("<script>alert(1)</script>");
expect(html).not.toContain("<script>");
expect(html).toContain("<script>");
});
it("非 http(s) 的伪协议标记退化为转义文本,不产出 img", () => {
const html = renderCommentContent("[img:javascript:alert(1)]");
expect(html).not.toContain("<img");
expect(html).toContain("[img:javascript:alert(1)]");
});
输入框换成 tiptap 最小集
标记文本和编辑器文档之间需要一层互转,我写在同一个 utils/commentRender.ts 里,这样协议的两端在同一个文件,改一处不会漏另一处。往外发声的那一半是文档转标记,按段落拼 \n、把图片节点写成 [img:url]:
/** 内联节点数组 → 单行标记文本 */
export const inlineNodesToMarkup = (nodes: any[]): string =>
(nodes || [])
.map((node) => {
if (node.type === "text") return node.text || "";
if (node.type === "image") {
const src = node.attrs?.src;
return isSafeImageUrl(src) ? `[img:${src}]` : "";
}
if (node.type === "hardBreak") return "\n";
return "";
})
.join("");
反向的 markupToDoc 就是把标记文本按 \n 切行、每行解析成内联节点,供编辑器初始化用。
编辑器本身只要一个能力:插图片。所以扩展列表我压到最小,没有格式化工具栏。
editor.value = new Editor({
content: markupToDoc(props.modelValue),
extensions: [
Document,
Paragraph,
Text,
Image.configure({ inline: true, allowBase64: false }),
Placeholder.configure({ placeholder: props.placeholder }),
UndoRedo,
],
// ...
onUpdate: ({ editor: instance }) => {
emit("update:modelValue", serialize(instance));
},
});
allowBase64: false 是有必要的。允许 base64 图片就意味着内容里可以塞进任意体量的数据,而这个字段只有 1000 字符上限,真塞进去反而更糟,还不如直接禁掉。
插入表情的时候按内容分流,图片走图片节点,文本走文本:
/** 插入表情:图片 URL 插图片节点,其余按文本插入 */
const insertEmoji = (content: string) => {
const instance = editor.value;
if (!instance || !content) return;
if (isEmojiImage(content)) {
instance.chain().focus("end").setImage({ src: content }).run();
} else {
instance.chain().focus("end").insertContent(content).run();
}
};
编辑器依赖 DOM,所以外面套了 <ClientOnly>,组件挂载时才创建实例,避免 SSR 阶段报错。
前台分组:内置两组固定在最前
后端加了一个公开接口,按分组返回启用中的表情。这里有两个前端要处理的情况:
一是未归属任何分组的表情。后端的做法是给它们一个 id = 0 的容器,名字叫「默认」。但前端本来就有一组内置的默认表情,名字会撞,所以我在 useEmoji.ts 里把它改了名,顺便过滤掉空组和内容为空的表情:
const normalizeGroups = (data: EmojiGroupData[]): EmojiGroupView[] =>
data
.map((group) => ({
id: group.id,
// 后端 id=0 是「未归属任何分组」的表情容器,改名避免与内置「默认」分组重名
name: group.id === 0 ? "未分组" : group.name,
cover: group.cover || "",
emojis: (group.emojis || [])
.map((item) => ({ id: item.id, content: (item.content || "").trim(), type: item.type }))
.filter((item) => item.content),
}))
.filter((group) => group.emojis.length > 0);
二是内置表情和自定义分组的顺序。内置的 Emoji 和颜文字我始终放在最前面,博主的自定义分组追加在后面。内置组用负数 id,这样不会和数据库里自增的分组 id 撞上。
// 内置基础表情分组:**始终保留在 tab 栏最前**。
// 后端无数据时它就是全部;博主建了自定义分组时,自定义分组追加在其后。
// id 用负数,避免与后端分组 id 撞车。
const buildBuiltinGroups = (): EmojiGroupView[] => [
{ id: -1, name: "默认", cover: "😀", emojis: ... },
{ id: -2, name: "颜文字", cover: "(。・ω・。)", emojis: ... },
];
还有个实际问题是请求次数。表情面板在文章评论区、评论回复框、留言板三处都用得着,如果每个实例各自拉一次接口,一篇文章页就会打好几次。所以我把状态提到了模块级:
// 模块级单例状态:多个 picker 实例共用,避免各拉一次接口
const groups = ref<EmojiGroupView[]>(buildBuiltinGroups());
const loaded = ref(false);
let inflight: Promise<void> | null = null;
inflight 是为了挡住并发:两个 picker 同时挂载时,第一个请求还没回来,第二个会复用同一个 Promise,而不是再发一次。
后台:两个 Tab
后台的表情管理拆成了「表情列表」和「分组管理」两个 Tab。列表那边加了类型、分组、状态三个筛选,编辑弹窗里能选分组。
分组管理这边,删除要提示后果:
`确定删除分组「${row.name}」吗?组内 ${row.emojiCount} 个表情将退回未分组。`
后台的评论列表也跟着改了。以前 content 是纯文本列,现在得按同一套规则解析,不然管理员在后台看到的就是 [img:https://...] 这种标记。我在 CommentManage.vue 里复刻了一份渲染逻辑(后台项目不引用前台的 utils),规则保持完全一致。
邮件通知那边更简单,图片标记统一换成可读文字:
// 评论/留言内容是「标记文本」(图片 → [img:url]),邮件里把标记可读化为 [图片]
const COMMENT_IMG_MARKER = /\[img:https?:\/\/[^\s\]]+\]/gi;
const formatCommentContent = (value = "") =>
escapeHtml(String(value).replace(COMMENT_IMG_MARKER, "[图片]"));
踩到的几个坑
ProseMirror 会在内联图片旁边插一个空 img
换成 tiptap 之后,图片是能显示了,但每张图片旁边都多出一点空隙,还带着一圈边框。查了一下,ProseMirror 在处理内联图片时会插入一个没有 src 的辅助元素:
<img class="ProseMirror-separator" alt="">
它被我自己写的那条图片规则命中了。而这条规则本来是用来限制表情尺寸的,结果这个 1px 的辅助元素也被撑成了 120px。所以给图片设样式的时候必须把它排除掉。同一轮里还有两个同源的样式,都是框架不加、得自己补的:ProseMirror 依赖 white-space: pre-wrap 处理换行和光标;Placeholder 扩展只往节点上写 data-placeholder,提示文字的样式得自己画。
/* ProseMirror 会为内联图片插入一个无 src 的占位 <img>,须按官方规则归零 */
.comment-input :deep(img.ProseMirror-separator) {
display: inline !important;
border: none !important;
margin: 0 !important;
width: 1px !important;
height: 1px !important;
}
.comment-input :deep(.comment-input__body img:not(.ProseMirror-separator)) {
max-width: 120px;
max-height: 120px;
vertical-align: middle;
}
.comment-input :deep(.ProseMirror-trailingBreak) {
display: none;
}
/* 占位提示(Placeholder 扩展只加 data-placeholder,样式需自行提供) */
.comment-input :deep(.comment-input__body p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
float: left;
height: 0;
color: var(--text-muted);
pointer-events: none;
}
另外这个组件的样式全是 :deep() 打头的。ProseMirror 的内容区是子组件渲染出来的,scoped 样式选不中里面的元素,不加 :deep() 不会生效。
tab 上的颜文字被裁成残句
分组 tab 的结构是「标识 + 名称」两列。标识这一列如果是图片,固定 18×18 就够;但如果是文本,颜文字 (づ。◕‿‿◕。)づ 这种放进 18px 里就被砍掉大半,看起来像乱码。
所以标识列不能是固定宽度,得给一个伸展区间:
/* 标识列:图片固定 18×18;文本(如颜文字)按内容伸展到上限,避免被裁成残句 */
.emoji-tab__cover {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
min-width: 18px;
height: 18px;
max-width: 64px;
...
}
在 flex 容器里给文本设 text-overflow: ellipsis 是不生效的,因为 flex 容器的文本项是匿名的,overflow 和 text-overflow 加不到它身上。所以要额外包一层元素:
/* 文本须是独立元素:flex 容器上的 text-overflow 对匿名文本项不生效 */
.emoji-tab__cover-text {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
min-width: 0 也不能漏。flex 子项的 min-width 默认是 auto,会按内容撑开,不给 0 的话省略号出不来。
还有个兜底:图片标识加载失败时,退回文本显示。回退顺序是「文本标识 → 组内第一个文本表情 → 分组名的第一个字」。而且失败集合要用新 Set 赋值,直接 add 不会触发更新。
字数按码元算,表情算两个
后端的长度校验是 isLength({ min: 1, max: 1000 }),它按 JavaScript 字符串长度算,也就是 UTF-16 码元数。一个 Emoji 是 2 个码元,颜文字可能更多。
前端如果按「肉眼看到的字符数」统计,就会和后端对不上,比如说显示 499,提交却被判超长。所以前台的计数口径要和后端一致,直接按标记文本的长度算。
/** 字数口径:按标记文本长度计(后端 isLength 按 UTF-16 码元,emoji 计 2) */
export const countMarkupLength = (markup?: string | null): number =>
(markup || "").length;
URL 里不能有 ]
标记的边界是 [img: 开头、] 结尾,靠这个规则切分文本。所以如果 URL 里出现 ],解析就会提前结束,后半截会变成普通文本显示出来。
目前先靠这条约束把它挡住:校验的时候直接排除掉含 ] 的地址。
/** 安全的图片地址(http/https 且不含 `]`,避免标记歧义) */
const isSafeImageUrl = (url: string) => isEmojiImage(url) && !url.includes("]");
单测里也留了一条。
it("URL 含 ] 时标记失效,退化为转义文本(避免解析歧义)", () => {
const html = renderCommentContent("[img:https://a.com/x]y.png]");
// 标记内的 URL 到第一个 ] 结束 → 该段是合法 http(s),剩余 `y.png]` 作为文本
expect(html).toContain("<img");
expect(html).toContain("y.png]");
});
这个约束也给 docToMarkup 用上了:isSafeImageUrl 不通过,图片节点在序列化时会被直接丢掉,不会写出一个半截的标记。
还没确定的地方
分组标识校验失败时的提示语还是写死的「图片 URL 格式不正确」,没有跟着 label 走。我在后台把表情内容填成 javascript: 时提示是正常的,但如果在分组标识那一栏填,提示会说「图片 URL」,实际填的是颜文字的场景下这句话就很奇怪。label 参数已经传进去了,只是这条分支没用上,我就还没改。
useEmoji 的状态是模块级单例,好处是三个输入框只发一次请求,但是「刷新表情列表」现在没有入口。后台改了表情配置,前台得刷新整页才能看到。我还没想好是加一个手动刷新,还是在面板打开时按时间戳判断要不要重拉。
后台 CommentManage.vue 里的渲染函数是复刻前台的,不是共用。后台项目不引前台代码,抽公共包对现在这个规模又太重。两份规则现在是一致的,但以后容易改了一处忘了另一处。先记着吧。



