You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
前文 JavaScript Lexer 1: TokenKind 讲到了如何分解 Token,那么下一个问题就是如何表示 Token,一般的 Token 至少包含如下两个元素,
前文已经介绍了不同 lexer 处理 TokenKind 的差异,我们继续介绍不同的编译器是如何处理 TokenText 和TokenValue 的。
碰巧的是,Rspack 再次碰到了两类(后面三个属于一类问题)关于 TokenValue 的 bug,后面结合分析
TokenValue 的第一个问题就是如何存储 TokenValue,这里牵扯到一个关键的问题,JavaScript 和 Rust 的 String 采取的 encoding 方式是不同的,JavaScript 采用 UTF16 而 Rust 和 Go 采用 UTF8(事实上 UTF8 就是 Go 语言作者发明的),这里就牵扯到两者的数据格式转换问题。在进一步展开前,我们先需要搞懂 String 相关的几个核心概念
Byte(字节) & Char(字符) & String(字符串)
这三者是字符串的核心三个概念,分别在三个维度表示字符串这个概念
我
爱
🦀
,其本身是一个抽象实体(它既可以是代表我们日常使用的文字,也可以代表表情,甚至一些无意义的控制字符等,我们目前假定存在这一组抽象实体,把这个集合叫做字符集),这个字符集可以有不同的表示方式,如不同的系统里可以有不同的字体呈现,为了交流方便,我们需要一个 id 来标记一个字符(我们把这个 id 叫做 code point,这里仍然不够准确实际上 code point 和 字符本身并不是严格一对一关系,如后面会讲到 Unicode 字符簇,导致一个字符可能有多种 codePoint 表示),目前最通用的 Charcter Encoding System 就是 Unicode,也有一些其他的 Encoding system 如 GBK,BIG5,Latin1 等注意到这里 String 和 Char 本身是和计算机|程序无关的概念,在脱离计算机|程序的语境下,仍然可以适用,比如在电报中我们也可以利用摩斯码来表示字符信息,来发送报文(字符串)
我们可以通过 JS 的
codePointAt
API 来获取每个 Char 的 CodePoint(Unicode 标准,后面无特殊说明所有的 encoding 讨论都是限定在 unicode 标准)又有两个容易混淆的概念,CodePoint ****和 Code Unit 的区别:
129408
('🦀'.CodePointAt(0)
)[\ud83e,\udd80]
('🦀'[0],'🦀'[1]
)一个 code point 在不同编码下可能映射到不同数量的 code unit。
Buffer.from('我爱🦀')
来查看不同 encoding 的结果。我们同时发现
Buffer.from('爱')
和Buffer.from('🦀')
的结果不一致,这是因为 UTF8 是变长编码。这里又有两个相近的概念,Character Encoding System 和 Character Encoding Scheme,其中 Encoding System 是指如何将抽象的字符映射到数字编号(如 Unicode 的 codePoint),而 Encoding Scheme 指的是如何将 CodePoint 映射到 计算机的存储表示,既 Bytes。
编程语言表示
大部分语言里都有 Byte、Char、String 对应的表示,如
C 语言有点特殊,其语言和标准库是没有 String 和 Char 这表示的,我们通常所说的 c 的 string 其实是个 byte array 而 c 里的 char 是 ASCII char 基本可以视为 Byte,至于 char array 如何 encoding & decoding 则交给用户自己决定。
不过虽然 c 本身不支持 utf8 string 这一数据结构,但是其支持 utf8 的 string literal,既上面的
const char *string = "我爱🦀";
SourceFile Encoding
这里有一个问题,编译器是如何处理源码中的 String 的
这里有一个非常容易搞混的概念,就是代码在源码文件里如何存储和编译器如何解释源码文件里的字符串(或者说这个字符串的运行时语义)完全是独立的事情。
比如说 vscode 的默认文本 encode 就是 UTF8,而 JavaScript 里的字符串是 UTF16,所以 JavaScript 的编译器(VM)把文件按照 UTF8 String 读取后,实际需要内部转为 UTF16 String 再进行存储的。
大部分语言都要求其 Source Text 是 UTF8 String,如果你的源代码是按照其他 encoding 存储的,一般要么自己将其转换为编译器要求的 Encoding,要么编译器通过参数支持内部自动完成转换,比如 gcc | clang 提供
-finput-charset
来实现转换。C 语言和其他语言略微不同,其本身并未强制要求源码中的 String Literal 是按照某个具体的 Encoding 的处理,其提供了特殊的 utf8 string 和其他的 encoding string 的表示。
GCC 的两个参数正好能够揭示代码里字符串的文本语义和运行时语义,-finput-charset 是告诉编译器你的输入是采用什么 encoding(如 UTF16),这样编译器就可以调用对应的预处理的工具将 UTF16 转成编译只支持的 UTF8, 而 -fexec-charset 则告诉编译器代码里字符串的运行时应该存储为哪种格式,如
const char* s = "我爱🦀"
的 exec-charset=utf16,那么此时 s 存储的就是 utf16 编码的内容。Escape Sequence
将一个 string 从 utf16 转为 utf8 或者从 utf8 转为 utf16 大部分情况下并不是一个麻烦的事情(先抛开性能),一个很常规的转换思路是将 utf16 字符串先 decode 为 codepoint array,然后再将 codePoint array encode 为 codepoint array。
因为绝大部分字符串本身是和编程语言无关的,因此这个转换就很 trivial,然而 tricky 的事情来了,大部分语言都会支持在字符串里嵌入 Unicode Escape Sequence 来表示 string,但是不同语言的 unicode escape sequence 表示完全不同
*这里再次强调一点,和普通的字符表示不同, *Unicode Escape Sequence 是编程语言支持的特性,且差异巨大,在跨语言交互的时候要非常小心。
这里再次强调,字符串在源码里的文本表示(即上面的
🦀
\u{1F980}
\uD83E\uDD800
区别)和运行时的语义(即 codepoint 代表的表示是不同的,同样的一个 codePoint 对应的字符,可能存在 n 种文本表示UTF8 vs UTF16
JSCompiler 需要处理的一个核心问题就是如何将 UTF8 格式的文本表示的源代码字符串,一些情况下转换为运行时需要的 UTF16 格式的 String。
Surrogate pair
UTF8 和 UTF16 都属于变长编码,如果字符的 codePoint 超过 U+FFFF 的话,就需要额外的字节来表示,UTF16 通过 surrogate pair 来表示超过 U+FFFF 的 codePoint,分别称为
U+D800 ~ U+DBFF
U+DC00 ~ U+DFFF
基于 Surrogate pair 计算 codePoint 的公式如下
另外有一点就是 Surrogate 必须成对出现,单独出现的 High Surrogate 或者 Low Surrogate 都是不合法的,我们称单独出现的 Surrogate 为 Lone Surrogate。
不幸的事情来了,虽然 Lone Surrogate 不合法,但是 JavaScript 的 UTF16 的 string 却允许其存在,既如下的 lone surrogate 是个合法的字符串
而 Rust 里是不允许 lone surrogate 存在的
这直接就带来一个问题,JavaScript String 是无法无损的用 Rust String 表示的,如下的 API 本质上设计不安全的,因为 Rust String 碰到不支持的 unicode sequence 都会处理为 U+FFFD,这意味着 这些包含 lone surrogate 的字符串,在 JS 侧虽然不相等,但是在 Rust 侧却相等。
这意味着在需要处理 lone surrogate 的场景下,用 ****Rust String 存储 JavaScript String 并不是一个好的选择(不过通过一些 hack 的手段仍然可以做到,此处按下不表)
wasm-bindgen 提供了 JsString 这个 API 来处理 Rust String 和 JavaScript String 的桥接,并且说明了用 Rust String 存储 JavaScript String 的风险 https://wasm-bindgen.github.io/wasm-bindgen/reference/types/str.html#utf-16-vs-utf-8
Context-Sensitive Unicode Escape Sequence
对于 JavaScript,事实上并不是源码的任何地方都允许出现 Unicode Escape Sequence(或者说文本是按照 Unicode Escape Sequence 解释的),parser 对于不同地方出现的 string 的处理是不一样的,如如下几个场景对 Unicode Escape Sequence 的处理就不一样
如下是 Rawstring 和 StringLiteral 的差异
大部分的语言都会支持 RawString 的概念,其表示其内部把类似
\u12
等转义序列不当做转义处理而是当做独立的\u
1
2
文本处理。Rspack bug 分析
不支持 emoji 路径
这个 bug 非常有意思,是 n 个 bug 叠加的产物,复现非常容易, https://github.com/hardfist/rspress-emoji-bug
我们发现原始的
🦀.md
被转换为了\uD83E\uDD80.md
,经过一番排查发现这个转换是 swc-loader 里做的,其默认配置了jsc.output.charset=asci
导致所有非 ASCI 的 UTF16 string 都被转换成 ASCI + Unicode Escape Sequence 形式,这种转换在大部分场景是安全的,但是不巧的是 SWC 处理 Unicode Escape Sequence 有 bug,AST Visitor 拿到的 tokenValue 值为被转义的\uD83E\uDD80
,从而导致\uD83E\uDD80.md != 🦀.md
导致路径查找失败。后来 在 web-infra-dev/rspack#11568 里修复了对
jsc.output.charset=asci=utf8
的支持,也意外修复了 rspress 的 bug,支持了 emoji 路径,因为此时 Rust 侧通过 ASTVisitor 拿到的是原始的🦀
,因为🦀=🦀
所以路径匹配成功。这里的逻辑有点绕,但是只需要记住一个核心点,就是字符串比较相等是通过比较其底层的 CodePoint 是否相等,而不是用原始的文本表示来比较即可
剩余的几个 issue 其实和 emoji 支持的问题是同样的问题,就是 swc 处理 escape sequence 时有 bug
JavaScript Compiler 如何处理 String
Parser 如何处理 String,一定程度上取决于 Parser 的需求,需求不同,所需要的实现也不同,Parser 的需求按照从简单到复杂可以分为
因为同样的 codePoint 存在着多种表示,如
🦀
,就能存在如下几种表示🦀
: 原始的 UTF8 表示\uD83E\uDD80
: surrogate pair 表示\u{1f980}
: codePoint scalar value 表示这时候问题来了,对于 codegen 来说,对于不同的 input,应该打印何种输出
不同的工具在这里的处理也差异巨大
esbuild: 取决于 charset 参数
prettier:原样输出
biome: 原样输出
对于 transform 和 minify 来说,保持原样输出不是一个强需求,但是对于 format 来说,保持原样输出却是个强需求,实际上保持原样输出是一个更高的要求,这要求 parser 不仅仅要保留 codePoint 信息,还需要保留原始的文本信息(rawText),实际上 rawText 本身并不属于 estree(AST)层面的强要求(https://github.com/estree/estree/issues/291),支持 raw 属性的 AST 可以视为 Extended AST,当然 CST 本身可以视为 Extended AST
Boa & v8 & Quickjs & Esbuild
这几个的处理方式都比较接近,既然 Rust String 无法存储 lone surrogate,那么我们直接存储 codePoint,即使用 vec来存储 codePoint(实际上 UTF 的 codepoint 会超过 u16,这种情况将其转换为两个相邻的 u16 存储), 这样整个流程非常的清晰,以 boa 为例
这里的 Interner 是个性能优化手段,因为 源码里有很多地方的 string 是确定的 asci string(如各种 keyword),如果已经明确知道这个 string 是 asci string,那么 vec<16>其实是浪费空间,这时候可以用 vec存储
Tsgo
tsgo 似乎目前并不支持 lone surrogate,直接用 go string 存储 js string 的 codePoint,因此碰到 lone surrogate 就会出问题,相关 issue https://github.com/microsoft/typescript-go/issues/1701。tsgo 有个有意思的地方是,虽然他的 token 存储有 bug,但是他的 printer 是正常的,这是因为他在 printer 阶段是使用 sourceFile[node.start:node:End]的方式来 codegen string 的内容,而不使用 token,这样就避开了 token 的问题。
Typesript
因为是 js 写的 parser,js string 自然能存储所有的 js string,因此 tokeValue 直接使用 js string 即可
https://github.com/microsoft/TypeScript/blob/b504a1eed45e35b5f54694a1e0a09f35d0a5663c/src/compiler/scanner.ts#L1707
Biome
biome的处理也比较特殊
这也是 biome 显示正常的原因,因为直接把 unicode esacape sequence当纯文本处理了。
思考一下,似乎tokenValue的解析对于parser阶段来说并非是必须的,swc 是否可以在parse阶段跳过tokenValue的解析,只保留tokenText呢?
OXC
oxc 的处理比较与众不同,他仍然使用了 Rust String 来存储 JS String,但是对 lone surrogate 进行了特殊的处理,对 lone surrogate pair 在转换成 rust string 前将其 encode,如将
\uD800
encode 为\u{FFFD}d800
来防止 Rust string 将\uD800
直接转换为\u{FFFD}
导致丢失信息,响应的在消费的时候,需要主动的将\u{FFFD}d800
decode 回\ud800
,同时为了将 encode 部分的\u{FFFD}
和用户输入的\u{FFFD}
区分,还需要将用户输入的\u{FFFD}
encode 为\u{FFFD}fffd
oxc-project/oxc#10041 (comment)
这个方案会导致如下的问题
SWC
事实上 SWC 的问题是两个有一定关系但是有所区别的问题
\uD800
这种正常的 string 进行了不必要的 escape 为\uD800
导致语义变化修复方式讨论
因为 JS String 和 Rust String 天然的不一致性,把 JS String 当做 Rust String 在 Rspack 和 SWC 内部流转是风险极高的(出了问题难以排查),我们原则上应该将 JS 世界过来的 String 封装为一个 JSString 类(或者其他名字),这也是 nova 的处理方式,目前 Rspack 是缺乏这个抽象的
WTF-8
WTF-8可以视为 UTF8 的一个超集,如果字符串本身不包含 invalid codepoint,那么 WTF-8 和 UTF8 的编码结果完全相同,区别就是 UTF8 会拒绝对 invalid codepoint 进行编码,而 WTF-8 则按照valid codepoint的方式对invalid codepoint进行编码,这个性质使得 WTF-8 成为 UTF8 的一个比较完美的 ABI 兼容方案。
一种修复方式
我们目前理想的修复方式,需要满足如下需求
考虑将 Atom 的底层存储切换为 WTF-8Buf,这样可以保证
同时改造 as_str接口,保证大部分的如果不包含 lone surroage,那么直接输出无损 utf8如果包含 lone surrogate,那么将 invalid ~~~~code point~~~~ 按照String的方式转成 \0xfffd这样可以保证大部分的 API 都不受到影响,在不包含 lone surrogate的代码下仍然能正常工作提供额外的 as_WTF-8 接口,需要使用WTF-8的地方使用as_WTF-8 替代 as_str( 如 identifie & StringLiteral & TemplateString)
缺点
缺失了一定的类型检查,需要自行判断哪里需要将 atom 按照WTF-8 还是 utf8处理,目前已知需要按照WTF-8的处理的地方,几乎只有 stringLiteral & Identifer & template,后期如果发现更多地方需要,则逐步将 as_str修改为 as_WTF-8即可
跨语言的 String 处理
napi-rs & napi
我们使用 napi-rs 将 js 的 string 和 rust string 互相转换的时候,好像并没那么复杂啊,这是因为所有的转换逻辑都封装在 napi api 里了 sys::napi_get_value_string_utf8, 其具体的实现在 napi_get_value_string_utf8,其会自动的替换字符串里的lone surrogate.
字符簇 与 Unicode Normalization Form
与 Lexer & Parser 本身关系不大,但也是个有意思的话题,暂时按下不表, https://go.dev/blog/normalization 有较为详细的解释。
Token Span
伴随着 TokenValue 的就是 TokenSpan,那么 Token Span 的语义是什么?
很不幸,不同的 compiler 处理再次不同,并且没有一个统一的规范(estree 并没有定义 location的计算单位),且不同的 span格 式的转换有着不小的成本,目前 rspack & swc 这些地方需要使用span
目前swc的span的单位是bytepos(即 byte offset),而 babel acorn等工具则是 code unit offset,如果 webpack 里要使用 swc 作为替代parser,那么就涉及到将 byte offset 转换为 code unit offset
参考资料
Beta Was this translation helpful? Give feedback.
All reactions