返回博客

@提及高亮吞掉半句中文——空白符分词器在 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 范围。

坑:/\s/ 做 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 边界是「英文假设」——中文书写不留空格,正确做法是把空白和中英文标点一起列入终止符集合,并用单一常量统一显示端与输入端的口径。