@提及高亮吞掉半句中文——空白符分词器在 CJK 场景下的过捕获陷阱
给一个内部协作工具实现了 @提及 高亮功能,英文场景测试全过,上线没多久就收到反馈:「在评论里 at 别人,后面那一大段文字全变蓝了。」复现一看,收到 @张三,正在排查根因,预计今天内给结论。 这句话,@张三 之后的所有字都被渲染成了一个蓝色 chip。
奇怪的是,请 @李四 @王五 跟进 却完全正常——两个提及各自独立显示。
现象
- 提及后跟 ASCII 空格(
@bob hi):chip 正确截断,只高亮@bob - 提及后跟中文全角逗号(
@张三,):chip 从@一路延伸到句末,整句后半段全变色 - 用英文输入法打
@bob,hi(半角逗号)也正常截断;换成全角逗号立刻复现
根因
分词扫描的终止条件只检查空白符:
// 问题代码
let j = i + prefix.length;
while (j < value.length && !/\s/.test(value[j])) j++;
// 等价写法:/@(\S+)/ 或 /@([^\s]+)/\s 只匹配 ASCII 空白(空格、\t、\n 等),而中文全角标点——,。、;:!?…""「」()《》 ——不在 \s 的匹配集内。
英文用户在 @bob 后习惯打一个空格再继续写,空格让 \s 及时触发,token 截断正确。中文写作习惯是「标点紧跟词尾,不加空格」,所以 @张三,正在… 里从 张 到句末都不含空白,while 循环一直跑到字符串结尾,把整个后半句都算进 token 范围。
中文、日文、韩文(CJK)书写系统不用空格分隔词汇。全角标点(,。!? 等)均不被 \s 匹配,导致扫描器一路越过这些标点,把标点之后的文字一并吃进当前 token。同一套代码,英文输入完全正常,中文输入整句变色——这种「英文过了 CI、中文线上爆」的 bug 尤其隐蔽。
正解
把「边界字符集」从「仅空白」扩展为「空白 + 中英文标点」,同时明确保留 _、-、. 作为合法的名称内字符(确保 @john.doe、@user_name 不被误截断)。
定义一个共享常量,显示扫描和输入触发检测两处都用它,保证口径一致:
// 一个 token 在「边界字符」处结束:空白或中英文标点。
// 放行 _ - . 作为名字内字符(@john.doe / @user_name / @a-b 保持完整)。
const TOKEN_BOUNDARY =
/[\s,;:!?'"`()\[\]{}<>|\\/,。、;:!?…·""''「」『』()【】《》〈〉]/u;
// 显示端扫描:
while (j < value.length && !TOKEN_BOUNDARY.test(value[j])) j++;
// 输入端触发检测:遇到边界字符则关闭当前活跃 token
if (TOKEN_BOUNDARY.test(currentChar)) return null;几个实现细节:
- 字符类内部
[]\需要转义(写成\[\]\\);/在字符类中可以不转义,但转了也无妨 - 加
u标志(Unicode 模式),为将来引入 emoji 等星际字符留好余地 - 不要用
\p{P}(Unicode 标点通用类)——它包含_(Pc)、-(Pd)、.(Po),会意外截断带这些字符的用户名
抽出 TOKEN_BOUNDARY 正则常量,显示端的 while 循环和输入端的触发检测都引用它。这样「何时认为一个 token 结束」只有一处定义,避免两侧逻辑漂移,中文全角标点自然截断。
验证
修复后补充 CJK 回归测试,确认 token 不再吞后文,且含 .、-、_ 的用户名完整保留:
expect(segmentMentions("收到 @张三,正在排查根因。", "@")).toEqual([
{ text: "收到 ", mention: false },
{ text: "@张三", mention: true },
{ text: ",正在排查根因。", mention: false },
]);
// _ - . 依然是合法名字字符
expect(segmentMentions("@john.doe-x_y 提交", "@")[0].text).toBe("@john.doe-x_y");一句话外卖
用空白做 token 边界是「英文假设」——中文书写不留空格,正确做法是把空白和中英文标点一起列入终止符集合,并用单一常量统一显示端与输入端的口径。