摘要:聊天框里的一条短消息,既要限制长度,也要按普通文字显示。本文区分字符串长度、字素数量与渲染方式,用 JavaScript 示例说明表情计数、输入边界和纯文本输出。
一句"大家好"显示很正常,换成带表情的句子,字数提示却突然多了几位;再粘贴一段很长的内容,聊天栏被撑开,最后几条消息也找不到了。房间聊天看起来只是一个输入框,真正需要约定的却不止"最多八十个字"。
从开发角度看,我会把三个问题分开:请求最多能带多少数据,玩家看到的文字按什么单位计数,以及收到的内容如何放到界面上。它们各有作用,不能用前端 maxlength 或删几个尖括号代替整条边界。

配图为 AI 辅助制作的完整游戏房间 UI 概念图,展示聊天面板、短消息和发送入口,不是本文程序运行截图。下面使用浏览器 DOM 的纯文本输出示例,Cocos 富文本组件或原生控件需要按各自接口另行接入。
JavaScript 字符串的 length 不是玩家眼里"几个字"的统一答案。一个表情可能占多个 UTF-16 代码单元,带组合符号的文字和家庭表情也可能由多个部分组成。直接 slice 到某个长度,可能把一个可见字符切开。即使用展开运算符按码点遍历,也不等同于按完整字素处理。
MDN 对 Intl.Segmenter 的说明中,文本可以按字素、词或句子分段。本文选择 grapheme 作为可见输入计数的近似单位,约定最多八十个字素;另外在分段前设置 2048 个代码单元的硬上限。后者是本例减少处理工作量的边界,不是网络报文大小限制,也不是通用安全阈值。
javascript
const segmenter = new Intl.Segmenter('zh', {
granularity: 'grapheme'
});
export function chatText(value, limit = 80) {
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
throw new RangeError('limit');
}
if (typeof value !== 'string' || value.length > 2048) return null;
const text = value.trim();
if (!text || /[\u0000-\u001f\u007f]/u.test(text)) return null;
let count = 0;
for (const _ of segmenter.segment(text)) {
if (++count > limit) return null;
}
return text;
}
这里的 trim 是明确的产品选择:去掉首尾空白,但保留中间空格。内部换行和这段表达式覆盖的控制字符会被拒绝,不会被悄悄删掉后拼成另一句话。它并不覆盖全部 Unicode 控制或格式字符,也没有做内容审核。若业务需要处理双向文本、不可见字符或特定字符集合,应独立制定规则,不能把这条正则称作万能清洗。
函数返回 null 表示不符合格式,合法时返回规范化后的文字。它不截断超长消息,因为直接截断后发送容易改变原意。界面可以提示"消息过长",让玩家自己调整;展示历史消息时的省略则是另一种用途,不应复用成发送前的静默修改。
Intl.Segmenter 也需要检查目标运行环境。示例假设环境提供该 API,没有在缺失时自动退回另一种计数方式。前后端如果用不同 Unicode 数据版本或不同分段库,极端组合文字可能有差异,协议与测试样本需要一致。服务器还应限制请求体字节数,不能只看字素数量,因为一个字素可能包含很多组合成分。
通过格式检查以后,内容仍然是用户提供的普通文字。即使它包含看起来像标签的片段,也不应该因此交给 HTML 解析。对于不支持富文本的聊天栏,可以创建固定的结构,再通过 textContent 填入内容。
javascript
function appendChat(list, message) {
const text = chatText(message.text);
if (text === null) return false;
const row = document.createElement('li');
row.className = 'chat-row';
const name = document.createElement('strong');
const body = document.createElement('span');
name.textContent = message.displayName; // 另有昵称长度与格式约定
body.textContent = text;
row.append(name, document.createTextNode(':'), body);
list.append(row);
while (list.children.length > 100) {
list.firstElementChild.remove();
}
return true;
}
MDN 对 textContent 和 innerHTML 的区分很直接:前者处理文本,后者处理 HTML。上面不会因为消息里出现<b>就主动生成一个加粗节点。昵称也不能绕开这条输出方式,因为攻击者未必只控制消息正文。示例假设 displayName 已经经过类型和长度校验;真实接入时,昵称应由可信身份信息关联,不接受客户端随便冒充另一个座位。
固定保留一百个 DOM 行,是示例的界面容量约定,不代表聊天历史只能保存一百条。这样能避免长时间开着房间不断追加节点。历史存储、分页和审计保留期限由业务另行决定;不要为了调试把完整聊天记录随意写入日志,也不要把删除界面节点误认为服务器数据已经删除。
css
.chat-row {
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.5;
}
纯文本输出还需要排版配合。很长的连续字符即使不被当成代码,也可能把布局撑开;换行策略和容器宽度要一起看。本文拒绝内部换行,因此 pre-wrap 主要保留中间空格。若产品以后允许多行,应同时修改格式检查与高度限制,不要只把 CSS 改了。
如果要支持表情贴图或特殊颜色,最好传递独立的结构化类型,例如固定表情编号,再由客户端映射到内置资源。不要让用户直接传资源路径、任意标签和样式片段。纯文本和受控表情可以共存,但每个分支都要说明允许什么,而不是最后统一塞进富文本解析器碰运气。
输入法合成期间也不宜把回车立即视为发送。拼音候选词尚未确认时,按键行为与普通输入不同。实际界面应结合合成状态判断,并在真正提交前再次使用同一校验函数。这个输入体验问题与文本是否安全显示不同,不能因为输出使用了 textContent 就跳过。
服务端收到聊天请求后,应重新检查类型、请求大小、房间成员身份和发送频率,再按约定验证文字。客户端的八十字素提示只是帮助用户编辑,不是权限机制。返回给房间的发送者身份、时间和消息编号,也应由服务器决定。本文没有实现限流、广播和消息持久化,不把一个文本函数写成完整聊天系统。
本文在 Node 中检查了首尾空白、家庭表情、组合字符、八十与八十一字素边界、空消息、错误类型、控制字符和硬长度上限。测试还确认看似标签的内容会作为普通字符串保留下来,但这只证明输入函数没有改写它;没有运行真实 DOM 渲染与浏览器安全测试,不能声称整站已经通过安全审计。代码采用的 textContent 输出方式按官方接口说明选择,仍需在实际界面验证布局和交互。
聊天框的稳定性来自几个清楚的约定:数据大小有限,计数单位一致,用户文字不进入不必要的解析器,身份与房间权限交给服务器。把这些边界写明白,后面增加表情、历史消息或跨端显示时才不容易反复推倒重来。