← HTML 表单:从控件到提交与校验 / parser 视角:字符就是数字 待审核 4 / 24
charCodeAt · tokenizer

parser 视角:字符就是数字

Babel 的词法分析器里几乎看不到 str[i] === '{' 这种写法——热路径上全是 code === charCodes.leftCurlyBrace:先 charCodeAt 拿到数字,再和数字常量比较。npm 包 charcodes 就是这张常量表(backSpace = 8leftCurlyBrace = 123……),配套的 babel 插件还会把常量名内联成数字字面量,产物里连对象访问都不剩。这页拆它的两个动机:数字比较,而且范围判断(code >= digit0 && code <= digit9)只有数字能写。

1 · 逐字符拆开:每个字符的 code 与 charcodes 名字

改下面的代码片段,看每个字符被 charCodeAt 读成什么数、在 charcodes 里叫什么。配色:蓝色是 charcodes 收录的常量、橙色是空白、红色是超出 BMP 的代理对(surrogate)。

charCodeAt 读的是 UTF-16 码元,不是字符。 试试预设里的 😀:它占两个码元(55357 / 56832,即代理对),charCodeAt 各读一半,要 codePointAt 才能拼回 128512。lexer 能用 charCodeAt 简化处理,是因为语法字符全在 ASCII 区;一旦碰到标识符里的 Unicode,就得换 codePointAt——这一现象在 regex 系列的字符编码里有完整一页。

2 · 为什么不直接比字符串?现场跑一遍

对一段 100 万字符的源码扫一遍,数出所有 {:三种写法各跑 5 轮取最优。数字比较省掉了取单字符串(可能产生临时字符串对象)与字符串相等比较:

codePointAt 为什么反而最慢?多出来的那一眼是什么。 charCodeAt 的契约是「返回第 i 个 UTF-16 码元」,实现上就是一次数组取值;codePointAt 的契约是「返回从 i 开始的完整 code point」。而 BMP 外的字符(如 😀)由 high surrogate (0xD800–0xDBFF) 加 low surrogate (0xDC00–0xDFFF) 两个码元拼成,所以 codePointAt 每读一个位置都得先判断「当前码元是不是 high surrogate」:是,就再读下一个码元、按公式合成出 > 0xFFFF 的码点;不是,才当普通码元原样返回。{(123) 是 ASCII,永远不可能是代理对,但 codePointAt 没有这层先验,这个 surrogate 分支判断对每个字符都要走一遍——一百万字符的热循环里,这一眼就是它落后于 charCodeAt 的来源。反过来也说明 lexer 敢全程用 charCodeAt,正是因为 JS 的语法字符全在 ASCII 区,扫描时根本不会踩到代理对。

下面是 charcodes 在 lexer 里的真实用法(@babel/parser 风格):识别一个数字字面量时,全程只比较 charCodeAt 返回的整数,从不构造子字符串或调用 String.prototype 方法:

import * as charCodes from 'charcodes';

function readNumber(input, pos) {
  // 范围判断只有数字能写 —— 字符串比较做不到 >= <=
  while (isDigit(input.charCodeAt(pos))) pos++;
  if (input.charCodeAt(pos) === charCodes.dot) pos++;       // '.'
  while (isDigit(input.charCodeAt(pos))) pos++;
  return pos;
}

function isDigit(code) {
  return code >= charCodes.digit0 && code <= charCodes.digit9;
}

// babel-plugin 内联后, 产物里只剩数字:
//   if (input.charCodeAt(pos) === 46) pos++;

3 · charcodes 全表(104 个常量)

下表按 code 升序列出全部 104 个导出常量,可按常量名或 code 筛选。它的取舍完全暴露用途是 JS lexer:收了 JS 规范的行终止符 lineSeparator(8232)、paragraphSeparator(8233) 和 Unicode 空白的代表 oghamSpaceMark(5760),却没收键盘上更有名的 escape(27)、delete(127);命名也随性:dash(Unicode 正式名是 hyphen-minus)、dot(正式名 full stop)、equalsTo

4 · 相关链接