来试试猫猫夫妇表情包吧!
(本花)期待已久的评论区表情包功能终于上线了
是的,各位(其实只有我)期待许久的表情包评论功能终于上线啦——鼓掌.jpg
所以为什么现在才上线?
不出意外的话,你可以在本文底部的评论区进行体验,一共有整整三套表情包!
第一次加载可能会很慢,因为花墨的服务器性能不高……
表情包原型
接下来我会依次按约稿顺序介绍表情包的原型和来源,正好介绍本站出现的几只猫娘是怎么来的。
再花的棕发猫娘

这是本花在 FF14 中最初的模型和人设,也是本站视觉上的主要灵感来源。
我的网站主色就是取自其发色而成,我认为这种色彩设计在博客中非常少见,也算是为互联网添加一抹色彩了。
我早期的许多约稿都是基于这个外貌,通常穿着女仆装(因为我很喜欢女仆)
例如我很喜欢的一张穿着情趣社情女仆装的约稿:

表情包约稿的基本样式如下(基本同模,放一张就行):

不过我并不会推荐这个稿件的画师。
因为在我约稿该表情包很满意后,想继续给方导的猫娘约稿时,该画师莫名的变得极其敷衍 + 不认真看需求,被我全额退稿了(停留在草稿阶段)
狠狠踩雷!我已经在米画师将她拉黑了。
我的猫娘其实在早期是白发,放一张21年我和方导在游戏中的合照:

而方导的表情包,也是基于这张照片中的猫娘进行约稿的,只不过换了个发型。
方导的猫娘
被画师坑了之后,方导找了另一个画师进行表情包约稿,虽然贵一些,但是真的很可爱很精致,表情包类别也更丰富,放两张我很喜欢的:


真的太可爱咧。
再花的粉毛猫娘
最后就是现在的形象了,准确来说是粉发黑鼻头猫娘。

这是我 7.X 回坑 FF14 后和方导一起洗(更换)的体型,如果说之前属于御姐风的话,现在已经是萌妹了。
通常穿着纯黑色制服,戴着普通的圆框眼镜,头上有一个红缎带,下面放一张对应的约稿:

然后就是这次约稿的表情了,是我在米画师闲逛的时候看到的,感觉样式非常适用于网站评论,这也是开发评论区表情包的灵感来源之一。
作为动态图会更灵动,并且头部占比偏大,在网站上的表现会更优秀一些。
是的我约了这个表情包才想起来重写我可怜的普通文本评论区_(:з」∠)_

表情包画师可看右上角的米画师 ID 哦。
实际上一共有 19 个,这里只展示了一个九宫格图。
棕发猫娘和方导猫娘,这两套表情包其实并不太适用于网站,他们最初的设计是大尺寸表情包,例如QQ和微信,我在微信中就会经常使用方导的表情包:

但其实使用频率和效果比较一般,于是便“废物利用”放在了网站中,也为访客添加更多选择!
方导的黑鼻头猫娘
方导现在的形象已经变了,所以还是需要简单提一下。
刚刚提到,方导和我都变成了黑鼻头猫娘体型,发型也有所改变,只是保持了和原来相同的发色和瞳色。
你可以在关于页面看到她新约稿的头像:

但方导目前的形象并没有约稿表情包,后续也应该不会约,因为没什么使用场景。
不过方导也是从御姐变成了萌妹了捏嘿嘿嘿,黑鼻头猫娘就是萌萌哒!
技术实现
接下来我会讲一讲我的实现思路,尽量减少技术相关描述,即使不会 Angular 也可以看懂。
当然,不感兴趣的可以略过!对实现思路有兴趣的可以接着往下看。
注意以下内容会缺少“粉毛再花”系列表情包,因为本文写于粉毛再花表情包成稿前。
需求分析
我的需求其实很简单,在前端项目中存放表情包静态文件,在主站和后台都可以通过“[再花:表情包]”的文本来渲染指定的表情包。
同时我为评论区新增了markdown格式支持,并支持预览。
开发过程
明确需求后,我便直接开始开发。
markdown支持
我使用的是 ngx-markdown 库,也就是博客渲染所使用的库。
这样我不必再重新引入其他 markdown 支持,保持组件统一。
另外,因为曾经的评论是 textarea 标签,用户输入的每个换行都可以看见,而 markdown 会把单个换行识别为空格,产生功能倒退。
正确处理方式是打开应用级选项 breaks: true ,但这样会影响博客正文的渲染结果,于是改用内容层处理:
把段内的单换行转成 Markdown 硬换行(行尾两个空格),空行(段落分隔)不动,围栏代码块内部跳过。
const FENCED_CODE_RE = /(```[\s\S]*?```|~~~[\s\S]*?~~~)/g;
segment.replace(/[ \t]*\n(?!\n)/g, ' \n') // (?!\n) 避开空行
表情包渲染支持
首先我通过一个 js 脚本扫描客户端的静态资源目录 assets/再花/ 和 assets/方长/生成表情包对应清单,建立表情包 token。
清单一共需要生成两份,分别是主站的相对地址清单(
assets/再花/…)和后台的绝对地址清单(https://flowersink.com/assets/再花/…)
markdown 会在渲染的时候通过正则表达式扫描出类似表情包 token的格式,然后对照清单进行白名单渲染
const TOKEN_RE = /\[([^[\]:\n]{1,16}):([^[\]:\n]{1,24})\]/g;
所以如果出现 [再花:不存在的表情包],则会被渲染为常规字符。
而符合清单白名单的表情包 token,则会被返回对应的图片地址:
return ``;
文本和图片样式
主要有两个关键点。
一个是行高恒定。
我尝试过文本 + 表情包底部对齐的方式,但我并不满意。
于是我建立了统一的行高标准,让文本的行高与表情包高度保持一致,不会产生文本之间间距乱套的情况。
.fl-comment-content { line-height: 28px; } /* = 表情高度 */
.fl-comment-content img[src*='#fl-emoji'] {
height: 28px;
vertical-align: bottom; /* 关键 */
}
另一个是视觉重心修正。
因为目前的表情包顶部是空白部分+猫耳,而底部则是占据较满的身体,会导致图片的上下重心有一定差距。
于是我通过alpha通道计算了加权重心
| 表情包 | 包围盒中心 | α加权重心 | 28px 下偏低 |
|---|---|---|---|
| 再花 | 54.5% | 58.0% | 2.2px |
| 方长 | 51.7% | 55.5% | 1.5px |
什么是 alpha 通道?
图像常见格式是 RGBA —— 红、绿、蓝加一个 alpha。
前三个通道决定颜色,alpha 决定这个像素有多不透明。
通过一定算法计算出每张表情包每个像素的权重,把不透明度当成质量,通过物理中的重心公式
垂直重心 = Σ(α × y) / Σα (y 是行号,α 是该像素的不透明度 0~1)即可算出眼睛所能感知到的重心位置,也就是比较合理的视觉对齐参考指标。
然后采用一个比较平均的值进行对齐( 2.2 和 1.5 之间取个整数 2 ):
transform: translateY(var(--fc-emoji-lift, -2px));
用 transform 而不是 margin/top,是因为它不参与布局 —— 所以上一步行高恒定不会被破坏。
加载速度优化
在具体尺寸上,再花表情包是 2288×2288,方长表情包是 750×750
而表情包实际显示尺寸极小,最大为 50px
所以完全没必要使用原图进行渲染,可以进行一定的有损压缩。
采用方法:Pillow 的 LANCZOS 等比缩放到 160px(覆盖 50px 显示的 3 倍左右),保留透明底。
Pillow: Python 的图像处理库。
LANCZOS: 一个高质量重采样滤镜算法。
为什么不用 NestJS 的sharp?
我现有 API 中的 Sharp 图片压缩逻辑不适合直接拿来批量处理本地静态文件。
我想直接在本地对所有静态文件进行有损高质量压缩,完成后直接替换掉源文件,使用 Python 会更方便。
体积降幅约 88.5%,即使在没有CDN的加持下,初次访问也能慢慢加载完成。
在优化前,会加载时间过长,甚至超时。
安全性防护
安全方面主要有两个威胁。
一个是 HTML 注入
由于评论内容由访客提交,最终以 HTML 形式注入页面,所以必须进行一定净化操作。
但好在 ngx-markdown 自带一条净化链路,当文本进入 Angular 的实际渲染阶段的时候,会移除绝大部分 HTML 相关内容(除img、a、target等白名单)
另一个是 外链图片
虽然我的评论有审核功能,恶意图片并不会展示在主站上,但我担心有人会上传血腥暴力等图片污染我的眼睛。
所以我设置了图片外链白名单,只允许 flowersink.com 域名下的图片进行评论提交,并覆盖了多种 markdown 图片写法
引用式定义只在真的被图片引用到时才检查,定义但未被引用的图片地址是不会受到检测的。
仿冒域名例如 evilflowersink.com 也是不可行的,必须是
.flowersink.com或主站域名才可通行。
除了前端即时反馈外,我在 API 端也做了一个防护闸门,让用户无法绕开前端直接发送请求。
细节设计
为了保证用户的使用体验,我做了一些细节设计。
历史记录
因为表情包很多很杂,每次都要去进行挑选着实有点使用不便,毕竟偶尔才用一次,也很难记得自己喜欢的表情包具体在哪个位置。
于是我通过 localStorage 做了一个历史功能,用来记录你使用过的表情包,你可以在“历史”标签找到它们。
历史中的表情包都是从新到旧排列的,并且不会重复。
例如你使用表情包的顺序是 A B C D,那么在历史中则会显示 D C B A,当你再次使用一次表情包 B,历史的顺序就会变为 B D C A。
标签记忆功能
因为每个人对于表情包的喜好肯定有所不同,例如我自己可能更喜欢方导的这一套,但如果每次打开都是默认“再花”标签页就需要进行额外的标签切换步骤哎呀再花真烦
花墨的交互理念是在保证功能的情况下追求极简的交互
翻译成人话就是能让用户一步解决的事情,就不要用两步
于是我通过 localStorage 对标签进行了记忆,当用户选中某个表情的时候触发保存(而不是切换分页时)。
function resolveInitialPackKey(): string {
const fallback = EMOJI_PACKS[0]?.key ?? '';
const saved = readSavedPackKey();
return saved && EMOJI_PACKS.some(p => p.key === saved) ? saved : fallback;
}
例如你选中了“方长”的某个表情包,然后切换至“再花”分页,但没有选择表情包。
下次打开的时候会直接显示“方长”标签(“历史”标签也是同理)
选中动画
我为表情包的 hover 效果添加了背景色变化 + 轻微的上移动画
选中表情包的反馈和体验更好,且简单高效。
0 延迟 tooltip
ng-zorro 的 nz-tooltip 默认有一定延迟,会导致用户在选择表情包的时候,某些表情包被 tooltip 遮挡,体验不佳。
于是我手写了一个 0 延迟的 tooltip,表情弹窗悬停立刻显示名字,移除后立刻消失。
哎呀,小小的一个评论区居然有这么多东西,再花真厉害(不要脸)。
最后
你可以理解为花墨里的各个猫娘都是我在网络上的虚拟形象,无论是头像还是回到顶部按钮还是表情包。
就像我玩游戏的时候会代入自己,游戏中的角色是我,花墨中的猫娘也是我。
以虚拟人设代表自己,在网站中放入属于自己的各种特色元素,我觉得是很有意思的一件事。
以后如果有新的约稿,我也会尝试放在花墨的各个地方,将再花猫猫头发扬光大!
希望你会喜欢我更新的表情包——