regex · Unicode 字符模型与一个自研引擎
一个 😀 在 JS 里 .length === 2,一面 🇨🇳 是两个区域码拼的,一个 👨👩👧 由五个 code point 用 ZWJ 粘成——emoji 正则难,是因为「一个用户眼里的字符」常常是一串 code point。本系列从 code point ⇄ UTF-16 / UTF-8 两套存储层讲起,认识 \p{…} 属性类、emoji 序列家族、grapheme cluster(字素簇)与归一化 / 大小写 / Han 统一 / 排序等通用 Unicode 概念,最后用本仓库 @vega/parsing/regex 自研引擎拆开 AST 与字节码。本系列从零讲起,不需要前置知识;「语法速览」页会把正则编译成 NFA/DFA 状态机,这部分的背景概念(Thompson 构造 / 子集构造)在 automata 系列有完整讲解。
编码 · 码点怎么存与怎么写
先把逻辑层的 code point 与两套存储层(UTF-16 / UTF-8)分开,回看 Unicode 之前的中文字节编码(GB / Big5),再看同一个码位在源码里怎么写出来(HTML / CSS / JS 三套转义)。
字符的编码:code point、UTF-16、UTF-8
分清 code point(逻辑层)与两套存储层:UTF-16 在内存里每格 16 bit、补充平面用代理对;UTF-8 在网络与磁盘上变长 1~4 字节,前缀位标长度且自同步抗错。
中文编码:GB2312 / GBK / GB18030 与 Big5
Unicode 之前,中文靠国家与地区编码落进字节:大陆 GB2312 → GBK → GB18030 层层超集,台港 Big5 另起一套。同一个字在五种编码下的字节并排摊开。
三套转义:HTML / CSS / JS 怎么写出一个码位
知道了码位,怎么在源码里把它打出来?😀、\1F600、\u{1F600} 三套长得像、规则各不同,各有一个易错点。
码点的档案 · \p{…} 属性类
每个 code point 在 UCD 里有一份档案,\p{…} 就是按档案匹配。先认 General_Category / Script / 二元属性三条线,再把 \p{P} 标点、\p{S} 符号两大类展开到子类,顺带看「不是用来写字」的特殊码点。
\p{…} 属性类:用码点的 Unicode 档案来匹配
每个码点在 UCD 里有一份档案,\p{…} 就是按档案匹配。三条线是 General_Category、Script(含 sc 与 scx 之辨)与二元属性;Block 那一栏 JS 不支持。
\p{Math} 全量速查:Unicode 眼里的「数学用」码点
用原生正则逐码点实测,枚举 Unicode 17.0 全部 2322 个 Math=Yes 码点,按 block 分组可搜索。看清「数学符号」这概念如何横跨 Sm、字母、标点几个 gc。
\p{P} 标点七子类:脚注符号到底归哪一类
标点切成 Pc Pd Ps Pe Pi Pf Po 七个子类,每个码点恰好属一个。脚注符号全落在 catch-all 的 Po;开闭括号与前后引号数量为什么配不齐。
\p{S} 符号四子类:竖线是符号、脱字符不是重音、emoji 归哪一类
符号切成 Sm Sc Sk So 四子类,每个码点恰好属一个。反直觉的归类集中在此:算符是 Sm、脱字符是 Sk、emoji 挤在 catch-all 的 So,而百分号根本不是符号。
不是字的图形:用形状符号在文本里画图
画框的 Box Drawing、填色的 Block Elements、方圆三角的 Geometric Shapes 都是 So——没有读音、不属任何 Script,但等宽下各占一格,于是能拼出进度条与 TUI。
不是用来写字的码点:边界、保留、私用与回退记号
码空间里有的码点是边界、有的永久保留给机制(代理区)、有的标准永不定义(私用区与非字符)、有的是控制或回退记号。一个探针把它们逐一点出来。
竖排朝向:同一个字,横排立着、竖排可能要躺倒
每个码点在 UCD 里记了一栏 Vertical_Orientation,由 UAX #50 定义,横排时毫无作用、一进竖排就逐字决定朝向。四个取值里 Tu / Tr 把最终样子交给了字体。
Emoji · 一个字符是一串码点
emoji 难,是因为「一个用户眼里的字符」常常是一串 code point。从单码点的 Basic_Emoji 起,到一个码点身上的六个 emoji 属性,再到 RGI 序列家族(国旗 / Tag / 肤色)。
Basic_Emoji:变体选择符与 ZWJ
emoji 的最底一档 \p{Basic_Emoji} 只有两种形态:单码点,或单码点加变体选择符 U+FE0F。这一档之上的所有序列,都由 VS16 与 \p{Join_Control} 的两个零宽码点粘起来。
码点身上的 emoji 标签:六个属性
任意一个 code point 被 Unicode 打了哪几个 emoji 标签,由六个码点级二元属性回答。三个属性是 Emoji 的子集,另两个各成一轴;全都能用 u 模式实测。
RGI:国旗、子地区旗与肤色序列
RGI 是各平台都能可靠显示的 emoji 子集。国旗由两个区域指示符拼成,子地区旗靠 tag 字符贴标签,肤色由底字符加修饰符构成,\p{RGI_Emoji} 把这几类一并收下。
检测 emoji 支持:字体里有没有这个字形
一串码点合不合法与这台设备画不画得出来是两件事。抛开可伪装的 User-Agent,直接量渲染结果:字形是否等于豆腐块、序列宽度是否塌缩、彩色像素占比多少。
一个字符到底多长 · grapheme 与切分
把 emoji 的现象收束成通用概念:用户眼里的「一个字符」= grapheme cluster(字素簇);切分不止字素簇一种粒度(词 / 句 / 断行);叠在字母上的组合记号 \p{M} 同样是「多码点 = 一个字符」。
字素簇:一个字符到底多长
同一个 🤦🏼♂️,Rust 数出 17、JS 数出 7、Python 数出 5、Swift 数出 1。差别只在按哪一层数,而人眼感知的「一个字符」是最上那层:grapheme cluster。
分词与断行:词、句与换行机会
Intl.Segmenter 除字素簇外还能按 word 与 sentence 切,中文这种没有空格的文字靠词典切词。哪里允许换行是另一层规则(UAX #14),它不在 Intl.Segmenter 的取值里。
组合记号:叠在字母上的点和圈
é 可以是一个预组合码点,也可以是 e 加一个自己不占位的组合记号。\p{M} 的三个子类、可无限叠加的 Zalgo,以及 canonical combining class 定下的标准次序。
同义写法与同码点多形 · 拍平与展开
同一个字常有多种写法,同一个码点也可能不止一个含义。归一化 / 大小写把「同义写法」拍平;Han 统一 / 拼音则相反——同一码点的字形 / 读音不写在码点里;排序规则同样在码点之外。
归一化:同一个 Å 的几种写法
同一个字符常有不止一种码点写法,渲染一样、字节不同,=== 判不等。四种归一化形式把这些写法收敛到一形,其中带 K 的两种还会改字形。
大小写:映射与折叠是两套规则
大小写转换会改长度、依赖 locale,而且「给人看的转换」与「给比较用的折叠」是两套规则。正则的 i 标志走的是后者,认得 K 等于 k。
Han 统一:同一码点的中日韩字形
U+5203 只有一个码点,却在中日韩字体里画成不同的字。字形交给字体与 lang,而繁简这类「不同码点同一个词」的关系记在 Unihan,归一化一概不管。
给汉字注音:读音不在码点里
一个汉字码点不带读音。多音字读哪个音取决于词,靠词典与分词而非字符映射;注音串里的带调元音又把归一化那套坑重演一遍。
排序:为什么 sort 把 Z 排在 a 前
默认 sort() 比的是 UTF-16 码元值,不是字母序。按语言的字典序由 UCA 定义,Intl.Collator 的 sensitivity 决定忽略到哪一层,numeric 管数字怎么比。
实战 · 把字符模型合进工具
把前面拆过的字符模型合进真实工具:一个检视器逐字符拆码点(读),一个按名反查工具按 Unicode 名找码点(查),一个受控输入框按字形限长、注入控制字符(写)。
code point 检视器:逐字符拆码点
把本系列拆过的几层字符模型合到一个工具里:任意文本逐个 code point 摊开,字素簇用虚线框聚拢,顶部并列字形、码点、码元、字节四个口径。
按名反查:输入 heart 找出码点
每个码点在 UCD 里有一个固定的英文名。给出名字的子串、码位 hex 或直接粘一个字符,就能反查出对应码点,按 block 分组列出。
受控输入框:按字形限长与注入
限长按字素簇而非 .length,看不见的控制字符要能受控注入也要能被察觉,emoji 想跨平台同形就换 twemoji 的 SVG。
安全 · 看不见的攻击面
同一段文本,「逐字节」和「屏幕渲染」可以完全不同——bidi 重排与同形字是两类看不见的攻击面(Trojan Source / homograph attack),都建立在前面的字符模型(bidi class / Script)之上。
方向控制字符:看不见的重排
存储顺序与显示顺序不一定相同。UAX #9 的双向算法负责重排,而一小撮看不见的控制字符能人为操纵它,Trojan Source 就建在这上面。
同形字:看着是 apple.com
西里尔 а 与拉丁 a 字形一样、码点不同,可拼出肉眼无异的假域名。UTS #39 用 skeleton 折叠加混合脚本检测来防。
正则语法本身
回到正则语法本身:本仓库 @vega/parsing/regex 基本都覆盖。先过一遍各种写法,再把几个最易踩的点(. 与 \s / 有状态的 g / 命名捕获 vs 反向引用 / 量词下的分组)单独掰开。
正则语法速览:一处试通各种写法
字面量、字符类、量词、分组、锚点、反向引用、断言与标志位,本仓库的自研引擎基本都支持。同一条 pattern 还能现场编译成 NFA、DFA 与最小化 DFA。
空白与换行:点号与 \s 各管什么
. 默认不匹配四个行终止符,\s 匹配的远不止一个空格——连 BOM 都算,零宽空格反而不算。这类看不见的字符是一大类难定位 bug 的源头。
零宽匹配:正则站在字符之间
"good".match(/o*/g) 凭空多出三个空串。因为正则匹配的不是字符而是位置,长度 n 的串有 n+1 个位置,匹配到的内容允许是零长度的。
有状态的全局匹配:g 与 lastIndex
g 让一个 regex 对象背上可变游标 lastIndex,这是一连串经典 bug 的源头,也同时解释了空匹配为何不死循环、matchAll 为何强制要 g。
命名捕获与反向引用:两种共用
命名捕获共用的是 pattern 里那个组的名字,不改变匹配能力;反向引用共用的是那个组实际吃到的文本,因此超出正则语言、要回溯。
量词下的分组:只剩最后一次
(\d)+ 匹配 "123",$1 却只有 "3"。一个捕获组只有一个槽,量词每重复一次就覆盖上一次,别的语言对这一点各有取舍。
自研引擎 · @vega/parsing/regex
最后拆本仓库 @vega/parsing/regex 的实现:pattern → AST → Pike VM 或回溯两条引擎;对比 combinator 与递归下降两种解析写法;以及回溯引擎的 ReDoS 风险与线性引擎为何贴地。
自研引擎:AST 与两条执行路径
pattern 先 parse 成 AST,再交给两条独立引擎:线性时间的 Pike VM 与全功能的回溯引擎。默认自动派发,含断言或反向引用才回退。
parser combinator 与递归下降
组合子把小 parser 拼成大 parser,文法是声明出来的;递归下降把每条规则写成一个函数,控制流自己写。两条路产出同一棵 AST。
灾难性回溯:一条正则耗尽 CPU
嵌套量词在匹配失败时会让回溯引擎把指数级的拆法全试一遍。实测每加一个字符步数翻倍,而线性引擎对同一条正则始终贴地。
@vega/parsing/regex 的 Pike VM 正是 有限自动机里 Thompson 构造(regex → NFA)的工程落地;它的线性匹配与 字符串匹配一脉相承。emoji 部分则回答了那些系列里没细讲的「一个字符到底是什么」。🔗 相关链接
- The Absolute Minimum Every Developer Must Know About Unicode · tonsky.me 通用 Unicode 必读:code point、UTF-8/16、字素簇、归一化一篇讲透。本系列的 UTF-8 / 字素簇 / 归一化几页正是把它的核心做成了可上手的 demo。
-
UTS #51 · Unicode Emoji(权威规范)
· unicode.org
emoji 半边的源头标准:
Emoji/Emoji_Presentation等属性、ZWJ 序列、肤色 modifier、国旗与 Tag、RGI 子集全部定义于此。本系列 Basic_Emoji / 六属性 / RGI 几页讲的规则,出处就是这份报告。 -
UAX #29 · Unicode Text Segmentation(权威规范)
· unicode.org
字素簇 / 词 / 句切分的源头标准:
Grapheme_Cluster_Break属性值与边界规则表(GB1~GB999)全部定义于此。本系列字素簇一页的「手写规则引擎」就是照这份报告实现Intl.Segmenter的 grapheme 切分。 -
UAX #14 · Unicode Line Breaking Algorithm(权威规范)
· unicode.org
「哪里允许换行」的源头标准:每个码点的
Line_Break属性值与折行规则全部定义于此。本系列分词与断行一页里浏览器实测出的折点,背后跑的就是这套规则。 -
UTS #10 · Unicode Collation Algorithm(权威规范)
· unicode.org
排序的源头标准:多级权重(primary/secondary/tertiary)与 DUCET 默认权重表全部定义于此。本系列排序一页用的
Intl.Collator正是它的工程实现,locale 差异则来自 CLDR 的 tailoring。 -
UAX #50 · Unicode Vertical Text Layout(权威规范)
· unicode.org
竖排朝向的源头标准:
Vertical_Orientation(vo)属性的四个取值 U/R/Tu/Tr、未分配码点的默认区间、与字体竖排替代字形的关系全部定义于此。本系列竖排朝向一页每格的vo值就照它的VerticalOrientation.txt实现。 - UTS #39 · Unicode Security Mechanisms(权威规范) · unicode.org 同形混淆与脚本混用的源头标准:confusables 表、skeleton 算法、mixed-script 检测全部定义于此。本系列同形字一页的 skeleton 与混合脚本判定,出处就是这份报告。
- Codepoints · 逐码点数据库 · codepoints.net 查任意 code point 的属性大全:名称、General_Category、所属 block/script、各编码字节、组合/大小写映射、相关字符。验证本系列各页拆出来的码点、属性时一键可查。
- Unicode Code Charts · 官方字符表 · unicode.org 最权威的源头:按 Block 一份份 PDF,逐个 code point 列出官方字形与名称——「特殊用意的码点」那页的 Block / 边界 / 私用区,在这里能找到每段的官方定义图表。
- Full Emoji List · emoji 全表 · unicode.org 官方 emoji 总表:逐个列出 code point、CLDR 名称,并排展示各厂商(Apple/Google/Microsoft…)的实际渲染。对照本系列 Basic_Emoji / RGI / ZWJ 序列拆出的码点最直观——一眼看清同一序列在不同平台长什么样。
- Emojipedia · emoji 百科 · emojipedia.org 逐个 emoji 的词条:code point、shortcode、各平台 / 各 Unicode 版本的历代渲染、ZWJ 组成与含义流变。比 Full Emoji List 更适合「查某一个 emoji 的来龙去脉」。
- RegexLearn · 分步交互课程 · regexlearn.com 从零开始的闯关式教程,每步即时校验;有中文,适合系统补一遍语法。
- learn-regex · 图解速成(中文) · github.com 用图把元字符 / 量词 / 分组 / lookaround 讲清楚的速查仓库,这里直达其中文翻译。
- 正则表达式 30 分钟入门教程 · deerchao.cn 流传多年的中文经典长文,从字符匹配一路讲到分组 / 反向引用 / lookaround,术语对照清楚,适合一口气通读入门。
- RegexBuddy · 正则构建与调试工具 · regexbuddy.com 桌面端正则 IDE:逐节点解析正则、单步调试匹配过程、跨多种 flavor(PCRE / JS / .NET / Java…)对照语法差异,并生成各语言调用代码。出自 regular-expressions.info 同一作者。
- The Typing of the Regex · 正则打字游戏 · thetypingoftheregex.com 敌人是一串文本,你得现写正则把它们「打」掉——边玩边练手感与直觉。