JavaScript 类型和代码覆盖率分析
Web Inspector 现在拥有两款强大的工具,旨在让调试 JavaScript 程序变得更简单:代码覆盖率分析器和类型分析器。代码覆盖率分析器直观地显示你的 JavaScript 程序中已执行的具体部分。类型分析器将收集到的类型信息直观地标注在重要的变量上。这两款工具让你在 Web Inspector 中理解和调试 JavaScript 程序变得前所未有的轻松。
代码覆盖率分析
理解程序的工作原理可能复杂且繁琐。要理解程序的工作原理,需要了解程序根据一组输入数据执行了哪些部分。在 Web 应用程序中,我们通常关心程序根据用户与网页的交互执行了哪些部分。Web Inspector 现在有一种方法可以准确地向你展示程序中哪些部分已运行,哪些部分未运行。

准确了解哪些函数已执行,甚至这些函数内部的哪些 if-then
分支已执行,对于理解程序控制流的内部工作原理至关重要。了解某个部分是否已执行,能够为你提供重要的洞察力,有助于发现 Bug 并理解程序的各个部分是如何协同工作的。Web Inspector 的代码覆盖率分析器将告诉你程序中已执行的部分,粒度可达基本块级别。
观看此视频时可以看到一个微妙的细节,即描述 left
和 right
的注释是互换的。代码覆盖率分析器是查找程序和程序文档中细微错误的绝佳工具。
类型分析
Web Inspector 中最酷的新功能之一是 JavaScript 类型分析器。JavaScript 中的所有值都有类型,但仅通过阅读 JavaScript 文件或函数,很难知道每个值的类型会是什么。我们创建类型分析器是因为我们不想再在不了解重要变量类型的情况下阅读 JavaScript 程序了。

JavaScript 是一种动态类型语言。这意味着 JavaScript 程序在编写时无需任何类型声明。任何 JavaScript 程序中的任何变量都可以是任何类型;表达式可以是任何类型;任何函数都可以返回任何类型;以此类推。例如,以下是一个合法的 JavaScript 程序
let x = 20;
print(x);
x = ["Hello", "World", "I am the Type Profiler"];
print(x);
const identity = (x) => { return x; };
identity(10);
identity([1, 2, 3]);
identity("Saam Barati");
这与 Swift 或 Haskell 这样的静态类型语言形成对比。静态类型语言会阻止你混合不匹配的类型。如果程序未能通过其类型检查器,编译器将不允许你运行它。JavaScript 没有这个限制。只要程序没有语法错误,它就会运行。例如,在静态类型语言中,你不能将数字赋值给 Array 类型的变量。JavaScript 没有这样的限制。
这个简单的类型不匹配示例似乎很容易避免。然而,随着你的 JavaScript 程序变得越来越大,类数量的增加,跟踪所有可能的类型并防止其滥用变得难以为继。尽管 JavaScript 不是静态类型语言,但 JavaScript 程序中的变量通常只打算拥有特定的类型(这种趋向于单态性的趋势是 JIT 编译器成功优化 JavaScript 程序的重要原因之一)。例如,考虑这个程序
function add(a, b) {
return a + b;
}
上述函数在编写时可能本意是 a
和 b
都是数字。当 a
和 b
都是数字时,此函数将按预期运行。例如
add(10, 20) === 30
然而,如果它被调用时传入的参数具有非预期类型,它就会开始做出奇怪的事情。例如
add(10, "20") === "1020"
add([1, 2], undefined) === "[1,2]undefined"
结果值奇怪且出乎意料,但根据 JavaScript 规范它们完全有效。尽管程序员的本意是 add
函数只应与数字一起调用,但没有简单直接的方法来强制执行此操作。由于类型安全性难以强制执行,JavaScript 程序经常因为意外类型泄露到不应该出现的地方而产生 Bug。Web Inspector 的类型分析器让查找和调试此类问题变得前所未有的简单。
有时与类型相关的 Bug 很容易诊断,不匹配的类型可能会抛出运行时 TypeError
。但在其他时候,这类问题不会抛出 TypeError
,而是导致难以诊断且可能只在极少数情况下出现的细微 Bug。
Web Inspector 的类型分析器帮助你发现这些细微的 Bug。当在 Web Inspector 中启用类型分析器时,你的 JavaScript 程序的重要变量和函数返回类型旁边会带有类型注解。

这允许你直观地检查 JavaScript 程序中的类型,而无需插入 console.log
语句或在调试器中暂停。console.log
和调试器是重要的调试工具,但类型分析器非常适合追溯性地查找与类型相关的 Bug。类型分析器名如其名:它分析程序中值的类型。这意味着它向你显示的信息始终是流经你程序的类型。它既不显示某个东西可能是什么类型(在 JavaScript 中可以是任何类型,这没有帮助),也不根据它已有的信息推断某个东西会是什么类型。
类型分析器实时更新。随着新信息被引入你的程序,类型分析器会更新其注解。正如你在视频中看到的,当 announceAnimal
函数第二次被调用时,为 animal
参数显示的类型从 Dog
更新为 Animal
。这之所以发生,是因为类型分析器会跟踪它分析的值的继承链。类型分析器还以智能的方式显示其他聚合类型。当你使用类型分析器时,你会发现它向你显示的类型既直观又实用。
类型分析器也是一种很好的文档形式。使用类型分析器将帮助你熟悉新代码。在阅读不熟悉的代码时看到类型注解,可以大大简化对代码的理解。它还有助于你更好地理解自己的代码行为。

你可以在上图中看到,类型分析器将类型名称显示为 String?
和 Number?
。类型分析器擅长显示可选参数。任何与 Undefined
或 Null
混合的 [Type]
都将显示为 [Type]?
。类型分析器还会识别何时一个具有凝聚力的类型名称可能具有欺骗性。这可能发生在许多不相关的类型被赋值给同一个变量、作为参数传递或从函数返回时。在这种情况下,类型分析器将类型名称显示为 (many)
。当遇到 (many)
时,只需将鼠标悬停在类型标记上,即可获取构成该 (many)
的所有类型的信息。
关于编译的说明
以一种朴素的方式实现类型分析器并不会非常困难。你可以想象一个朴素的实现,它通过使用抽象语法树转换来重写 JavaScript 源代码,并用所需的类型分析代码包装必要的语言构造。然而,这样的实现性能会非常慢,导致无法使用。它可能会导致 20 倍或更高的性能下降。在开发类型分析器时,我们致力于尽可能地优化其开销。平均而言,类型分析器将使 JavaScript 代码的速度降低 2 倍。
这意味着类型分析器与 JavaScriptCore 的 JIT 编译基础设施深度集成。以下是 JavaScriptCore(JSC)编译器管道工作原理的快速概述。

JSC 首先将 JavaScript 文本解析成抽象语法树 (AST)。从这个 AST,JSC 生成字节码。这种字节码是一种在较低级别表示 JavaScript 操作的方式。字节码的级别并非低到无法从字节码中破译原始程序。JSC 随后将解释这些字节码,并在 JSC 的低级解释器 (LLInt) 内部收集分析信息。如果某个特定函数已执行足够多次,JSC 将在 JSC 的 Baseline JIT 内部将此字节码直接编译为机器代码。如果此函数继续执行多次,JSC 将使用其优化器DFG 和 FTL JIT 对其进行编译。
让我们来看一个 JavaScript 函数的例子,并查看其对应的字节码表示
function add(a, b) {
return a + b;
}
JSC 字节码
function add: 10 m_instructions; 3 parameter(s); 1 variable(s)
[ 0] enter
[ 1] get_scope loc0
[ 3] add loc1, arg1, arg2
[ 8] ret loc1
这个字节码很简单明了。它表示将函数的两个参数相加,将结果存储在 loc1
中,然后 return loc1
。为了了解类型分析器如何改变 JSC 的字节码,让我们看看启用类型分析器后同一个函数的字节码。(内联添加了注释,描述类型分析器正在做什么。)
function add: 40 m_instructions; 3 parameter(s); 1 variable(s)
[ 0] enter
[ 1] get_scope loc0
// Profile the parameters upon entering the function.
[ 3] op_profile_type arg1
[ 9] op_profile_type arg2
// Profile the operands to the add expression.
[15] op_profile_type arg1
[21] op_profile_type arg2
[27] add loc1, arg1, arg2
// Profile the return statement to gather return type information for this function.
[32] op_profile_type loc1
[38] ret loc1
由于类型分析机制被编译到 JSC 的字节码中,JSC 因此能够利用其多层编译器基础设施来优化类型分析的开销。JSC 的 DFG JIT 能够成功优化 JavaScript 代码,很大程度上归因于大多数 JavaScript 代码在编写时都考虑了特定类型。因此,DFG JIT 可以根据收集到的分析信息推测性地转换其 IR。然后,在执行这段推测性代码时,如果它发现运行时某个假设被打破,DFG 将OSR exit 回到 JSC 的基线 JIT。字节码操作 op_profile_type
是一个非常昂贵的操作,并且在启用类型分析器时它出现得非常频繁。当我们把这个字节码操作转换成 DFG IR 时,我们通常能够通过将 ProfileType
从执行代码中完全移除来优化其开销。我们能够做到这一点,因为当我们决定对字节码流进行 DFG 编译时,很可能在 Baseline JIT 和 LLInt 中分析的类型与 DFG 将推测性编译的类型是相同的。例如,如果一个 op_profile_type
操作已经观察到一个类型为 Integer
的值,它就不需要再次观察这个类型,因为这样做不会添加任何新信息。如果 DFG 推测一个节点的类型是 Integer
,并且类型分析信息已经将该节点与 Integer
关联起来,那么 DFG 将完全移除 ProfileType
节点。如果这个假设在运行时被打破,DFG 将退出到基线 JIT,在那里将记录这个新的类型信息。例如,这是当字节码 IR 转换为 DFG IR 时,使用 DFG IR 表示的相同 add
函数。
0: SetArgument(this)
1: SetArgument(arg1)
2: SetArgument(arg2)
3: JSConstant(JS|PureInt, Undefined)
4: MovHint(@3, loc0)
5: SetLocal(@3, loc0)
6: JSConstant(JS|PureInt, Weak:Cell: 0x10f458ca0 Function)
7: JSConstant(JS|PureInt, Weak:Cell: 0x10f443800 GlobalScopeObject)
8: MovHint(@7, loc0)
9: SetLocal(@7, loc0)
10: GetLocal(JS|MustGen|PureInt, arg1)
11: ProfileType(@10)
12: GetLocal(JS|MustGen|PureInt, arg2)
13: ProfileType(@12)
14: ProfileType(@10)
15: ProfileType(@12)
16: ValueAdd(@10, @12, JS|MustGen|PureInt)
17: MovHint(@16, loc1)
18: SetLocal(@16, loc1)
19: ProfileType(@16)
20: Return(@16)
这个 IR 中仍然有很多 ProfileType
操作。这些操作可能非常昂贵。然而,DFG 推测此函数的参数是整数。正因为如此,在生成机器代码时,DFG IR 能够在假设此函数具有整数参数的前提下,移除所有 ProfileType
操作。
1: SetArgument(arg1)
2: SetArgument(arg2)
3: JSConstant(JS|PureInt, Undefined)
4: MovHint(@3, loc0)
7: JSConstant(JS|PureInt, Weak:Cell: 0x10f443800 GlobalScopeObject)
8: MovHint(@7, loc0)
10: GetLocal(@1, arg1)
12: GetLocal(@2, arg2)
16: ArithAdd(Int32:@10, Int32:@12)
17: MovHint(@16, loc1)
20: Return(@16)
如果没有这些优化,在调试代码时使用类型分析器会变得异常缓慢,无法使用。
如果你有兴趣了解更多关于类型分析器、代码覆盖率分析器,或者想为 JSC 或 WebKit 贡献代码,请联系:@saambarati。还有许多有趣的工作尚待完成,我很乐意为你指明方向。你也可以联系@jonathandavis,提出任何其他问题和意见。