Skip to content

JS 开发者写编译器必须懂的编码 #61

@zhangxiang958

Description

@zhangxiang958

当我们在开发一个编译器的时候,针对编译器的前端部分,本质上是对于一个字符串先做词法分析,后做语法分析的过程。而对于词法分析来说,我们需要对于字符串来做一系列的字符分析。

在做字符分析的时候,我们通常会遇到下面这种情况:

we\023\x090\u56AD

针对一些非常规的字符表达比如八进制的表示法,或者 unicode 形式的表示,我们需要知晓这些编码知识才能针对这些特殊情况编写出鲁棒性更强的编译前端模块。在此之前,我们需要先明确有多少中字符表示类型。

字符不同的编码类型

八进制表示法

这种进制表示还是相对比较简单的,我们只需要检测到字符 \ 后面是否跟着数字,将跟在 \ 的后面三位数字集合起来就可以得到这个八进制原本表示的字符了,下面写了一个函数来帮助转码的:

function octal2String(str: string): string {
	return String(str);
}

console.log(octal2String('\155') === 'm')

能够使用八进制表示的一般都是 ASCII 范围内低于 256(十进制)的字符。八进制表示的字符都能使用十六进制来表示,但是我们做编译器的前端解析需要尽可能考虑多的方面。

十六进制表示法

十六进制和八进制是类似的,十六进制的表示法以 \x 开头进行表示,后面跟着两位十六进制表示的字符,下面这个函数可以帮助把十六进制转成字符串:

function hex2String(str: string): string {
	return String(str);
}

console.log(hex2String('\xD3') === 'Ó');

Unicode 表示

unicode 表示分为两种,我们使用 C 语言中的字符常量来说,根据维基百科上面的资料,C 语言中 unicode 的编码是有两种表示方式的,一种是 \u{\d\d\d\d},这种是 16 位的,还有一种是 \U 后面跟 8 位十六进制表示的。也就是说,编译器前端在面对输入的字符串的时候,是有可能接收到如下这样的字符串的:

\u263A\U000003A8

这个时候,我们该怎么正确地去解析这样的字符串呢?在 JavaScript 引擎中是可以正确地解析 USC-2 编码(如不清楚可以查看附录链接),而对于 \U 后面跟着 8 位十六进制表示的这种形式,属于 UTF-32 即 USC-4 编码,使用 4 个字节来表示字符,这个时候我们把这 8 位的十六进制转化为十进制,然后再将这个十进制数字转为 2 个字节的十六进制表示,这个时候就可以简单转化了:

function USC4ToUSC2(str: string): string {
	if (!str) return str;
	const usc4NumRegex = /^\U([0-9a-fA-F]{8})$/;
	const matched = str.match(usc4NumRegex);
	if (matched && matched[1]) {
		let _4BytesNumInHex = matched[1];
		let _numInTen = parseInt(_4BytesNumInHex, 16);
		let _2BytesNumInHex = _numInTen.toString(16);
		let _zero = '0'.repeat(4 - _2BytesNumInHex.length);
		return `\\u${_zero}${_2BytesNumInHex}`;
	}
	return str;
}

我们为什么需要知道这些编码信息?当然是为了更好地写出兼容性更强的代码,比如判断是否是空格,我们不能只是判断 \r\t\n 这样的字符,我们还需要判断一下这个字符是不是空白字符的码点:

function isSpace(str) {
	return /\s/.test(
		typeof str === 'nmber' ?
		String.fromCharCode(str) :
		str.charAt(0)
	);
}

码点、charCodeAt、codePointAt

什么是码点?unicode 编码将很多字符集合起来,并且赋予这些字符一个独一无二的数字,这个数字就是代码点(codePoint),你可以理解为一个大数组里面的下标。

codePointAt 能够更好地兼容增补字符,返回的数字就是码位也就是 codePoint,而 charCodeAt 对于 65535 以内的字符是可以应对的,但是对于增补字符没有办法很好地解析,如果是一个增补字符,charCodeAt 会返回代理对的第一位表示。

const emojiStr = '😂';

console.log(emojiStr.codePointAt(0)); // 128514

console.log(emojiStr.charCodeAt(0)); // 55357

以上面说的字符来说明,上面这个的码点是 128514,这样的话比 65535 大,说明是增补字符,这样转换为代理对的步骤如下:

  1. 先使用码点减去 65536,得到 62978,使用二进制表示变成 1111011000000010
  2. 左边补 0 直到补齐到 20 位,然后分为两个部分,0000111101(61) 和 1000000010 (514)
  3. 然后编码成 1101100000111101 和 1101111000000010 两个,这两个就是代理字符,我们可以看到第一个字符的范围是 0xD800 到 0xDBFF,第二个字符是 0xDC00 到 0xDFFF
  4. 根据上面这个步骤反推就可以得到一个字符的码点了
function getCodePoint(str: string) {
	const code = str.charCodeAt(0);
	if (code >= 0xD800 && code <= 0xDBFF) {
		const high = code;
		const low = str.charCodeAt(1);
		// 这里因为之前取高位了,刚好相当于二进制表示右移 10 位,所以需要乘以 2 的十次方也就是 1024
		return ((high - 0xD800) * 0x400) + (low - 0xDC00)  + 0x10000;
	}
	return code;
}

注:根据 UTF-16 的编码规则:

110110 yyyyyyyyyy 这种模板的范围显然是
110110 0000000000 - 110110 1111111111 (0xD800-0xDBFF)

110111 xxxxxxxxxx 这种模板的范围显然是
110111 0000000000 - 110111 1111111111 (0xDC00-0xDFFF)

References:

  1. https://mathiasbynens.be/notes/javascript-escapes#octal
  2. https://segmentfault.com/a/1190000006960642
  3. https://www.shuzhiduo.com/A/1O5EZ1Zrd7/
  4. https://2ality.com/2013/09/javascript-unicode.html

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions