先记住这个答案
Unicode 属性转义就是在正则里写 \p{属性},让引擎查 Unicode 数据库判断一个字符有没有这个属性。比如 \p{Script=Han} 匹配所有汉字,\p{Letter} 匹配任何语言的字母,\p{Number} 匹配各种数字,大写的 \P{…} 取反。它必须和 u 标志(ES2024 起也可以是 v 标志)一起用,否则 \p 会被当成普通字母 p,整条正则静默地匹配错东西。
\p{Script=Han}匹配汉字,比手写[一-龥]覆盖更全\p{Letter}是所有语言的字母,中英文都算- 大写
\P{…}表示取反 - 不加
u标志,\p{L}只会匹配字面量p{L}
从一段中英混排文本里提取字符
最常见的需求是从一段混排文本里挑出汉字、字母或数字。以前的写法是手写区间,比如 [一-龥],现在直接按属性写,意图也更清楚。
const text = '订单Order-42号,价格¥99'
console.log(JSON.stringify(text.match(/\p{Script=Han}+/gu))) // 只要汉字
console.log(JSON.stringify(text.match(/\p{Letter}+/gu))) // 所有语言的字母
console.log(JSON.stringify(text.match(/\p{Decimal_Number}+/gu))) // 十进制数字
console.log(JSON.stringify(text.match(/\P{Letter}+/gu))) // 取反:不是字母的查看输出与解释
["订单","号","价格"]
["订单Order","号","价格"]
["42","99"]
["-42",",","¥99"]第一行只挑汉字,所以 Order 被跳过了。第二行的 \p{Letter} 范围更宽,汉字在 Unicode 里也属于字母类别(Lo,即其他字母),所以「订单Order」连在一起成了一个匹配。这一点常被问到:想只要英文字母,要写 \p{Script=Latin},不能写 \p{Letter}。
第四行用大写 \P 取反,连字符、中文逗号、人民币符号和数字都被挑了出来。
为什么比手写 [一-龥] 靠谱
手写区间 [一-龥] 对应 U+4E00 到 U+9FA5,只覆盖了基本区里的一段汉字。像「𠮷」这种扩展区汉字,码点在 U+20BB7,远超这个区间,直接漏掉。再比如「〇」,它在 Unicode 里属于汉字脚本,但也不在这个区间。
\p{Script=Han} 不看码点落在哪里,而是查每个字符的属性表,扩展区、兼容区的汉字都能认出来。而且属性表跟着 Unicode 版本更新,新收录的字不用改正则。
- 扩展区汉字的对比
/[一-龥]/.test('𠮷')返回false,换成/\p{Script=Han}/u.test('𠮷')就返回true。这里还有一个细节:「𠮷」在 JavaScript 字符串里占两个 UTF-16 单元(代理对),没有u标志时正则是按单元逐个看的,根本不知道这两个单元是一个字。u标志让正则按码点工作,这也是\p{…}必须依赖它的原因之一。
不加 u 会发生什么
这是最容易掉的坑,因为它不报错。没有 u 标志时,为了兼容老代码,\p 是一个「无意义的转义」,等于字母 p,后面的 {L} 也被当成普通字符。
- 静默匹配错误
/\p{L}/.test('p{L}')返回true,而/\p{L}/.test('a')返回false。也就是说,这条正则在找字面量「p{L}」,而不是字母。表单校验里写成这样,所有正常输入都会被判为不合法,排查时很难第一时间想到是少了一个标志。加上u之后,语法变严格,写错属性名(比如\p{Hanzi})会直接抛SyntaxError,反而更容易发现问题。
容易答错的地方
- 以为
\p{Letter}只匹配英文字母 \p{Letter}是 Unicode 的通用类别,覆盖所有文字里的字母,汉字、日文假名、阿拉伯字母都算。只要拉丁字母,写\p{Script=Latin};只要 ASCII 的 a 到 z,老老实实写[a-zA-Z]。- 把
Script当成语言检测 \p{Script=Latin}只能说明字符属于拉丁字母,英语、法语、越南语都用它。同理,\p{Script=Han}也分不出中文和日文里的汉字。要识别语言得用专门的检测库,正则做不到。- 以为属性转义能处理 emoji
\p{Emoji}看着像能匹配 emoji,实际上数字 1 也带Emoji属性,/^\p{Emoji}$/u.test('1')是true。而一个家庭 emoji 由几个人像加零宽连接符组成,属性转义只能一个码点一个码点地看,切不出完整的一个。
面试官还会怎么问?
怎么校验用户名只能是中文、字母、数字和下划线?
如果只允许中文和 ASCII 字母数字,写成 /^[\p{Script=Han}a-zA-Z0-9_]+$/u。有人图省事写成 /^[\p{Letter}\p{Number}_]+$/u,这样俄文、希腊文、日文假名也都能通过,全角数字也算 \p{Number},要先确认业务能不能接受。
\p{Script} 和 \p{Script_Extensions} 有什么区别?
有些字符会被多种文字共用,比如中日文都用的句号「。」和顿号「、」。Script 只给每个字符一个归属,这类共用字符被归为 Common,所以 /\p{Script=Han}/u.test('。') 是 false。Script_Extensions(可简写为 scx)列出字符能出现在哪些文字里,/\p{scx=Han}/u.test('。') 就是 true。想连中文标点一起匹配时,用 scx 更合适。
怎么按用户看到的「一个字」来切分字符串?
用 Intl.Segmenter,粒度设成 grapheme,它按字素簇切分,组合 emoji、带变音符号的字母都会被当成一个整体。正则的 \p{…} 和 u 标志只能做到码点级别,比字素簇细。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。