《你不知道的JavaScript》
You Don’t Know JS
[美] 凯尔辛普森
上卷:作用域和闭包,this和对象原型
中卷:类型和语法,异步和性能
下卷:深入编程,ES6和未来
你不知道的JavaScript(上卷)
作用域和闭包
在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。
- 分词/词法分析(Tokenizing/Lexing)
这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。 - 解析/语法分析(Parsing)
这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree, AST)。 - 代码生成
将AST转换为可执行代码的过程被称为代码生成。这个过程与语言、目标平台等息息相关。
作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用LHS查询;如果目的是获取变量的值,就会使用RHS查询。
LHS = Left Hand Side - set
RHS = Right Hand Side - get
词法作用域
作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法作用域,另外一种叫作动态作用域,仍有一些编程语言在使用(比如Bash脚本、Perl中的一些模式等)。
词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。
eval(..)函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而with声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。
JavaScript中的作用域就是词法作用域(事实上大部分语言都是基于词法作用域的)。
词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。
函数作用域和块作用域
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。
作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块(通常指{ .. }内部)。
最小特权原则中引申出来的,也叫最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的API设计。
区分函数声明和表达式最简单的方法是看function关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。如果function是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。
作用域闭包
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的词法作用域。
模块模式需要具备两个必要条件。
1.必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
2.封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
this和对象原型
this实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。
实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。
使用new来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
1.创建(或者说构造)一个全新的对象。
2.这个新对象会被执行[[Prototype]]连接。
3.这个新对象会绑定到函数调用的this。
4.如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。
如果要判断一个运行中函数的this绑定,就需要找到这个函数的直接调用位置。找到之后就可以顺序应用下面这四条规则来判断this的绑定对象。
1.由new调用?绑定到新创建的对象。
2.由call或者apply(或者bind)调用?绑定到指定的对象。
3.由上下文对象调用?绑定到那个上下文对象。
4.默认:在严格模式下绑定到undefined,否则绑定到全局对象。
一定要注意,有些调用可能在无意中使用默认绑定规则。如果想“更安全”地忽略this绑定,你可以使用一个DMZ对象,比如ø = Object.create(null),以保护全局对象。
ES6中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定this,具体来说,箭头函数会继承外层函数调用的this绑定(无论this绑定到什么)。这其实和ES6之前代码中的self = this机制一样。
DMZ = Demilitarized Zone 非军事化区/隔离区
混合对象 “类”
JavaScript中只有对象,并不存在可以被实例化的“类”。一个对象并不会被复制到其他对象,它们会被关联起来。
类意味着复制。
传统的类被实例化时,它的行为会被复制到实例中。类被继承时,行为也会被复制到子类中。
多态(在继承链的不同层次名称相同但是功能不同的函数)看起来似乎是从子类引用父类,但是本质上引用的其实是复制的结果。
JavaScript并不会(像类那样)自动创建对象的副本。
原型
继承意味着复制操作,JavaScript(默认)并不会复制对象属性。相反,JavaScript会在两个对象之间创建一个关联,这样一个对象就可以通过委托访问另一个对象的属性和函数。
虽然这些JavaScript机制和传统面向类语言中的“类初始化”和“类继承”很相似,但是JavaScript中的机制有一个核心区别,那就是不会进行复制,对象之间是通过内部的[[Prototype]]链关联的。
行为委托
行为委托认为对象之间是兄弟关系,互相委托,而不是父类和子类的关系。JavaScript的[[Prototype]]机制本质上就是行为委托机制。
[[Prototype]]机制就是指对象中的一个内部链接引用另一个对象。
这一系列对象的链接被称为“原型链”。换句话说,JavaScript中这个机制的本质就是对象之间的关联关系。
对象关联可以更好地支持关注分离(separation of concerns)原则,创建和初始化并不需要合并为一个步骤。
对象关联(对象之前互相关联)是一种编码风格,它倡导的是直接创建和关联对象,不把它们抽象成类。对象关联可以用基于[[Prototype]]的行为委托非常自然地实现。
匿名函数没有name标识符,这会导致:
1.调试栈更难追踪;
2.自我引用(递归、事件(解除)绑定,等等)更难;
3.代码(稍微)更难理解。
“鸭子类型”。这个术语源自这句格言“如果看起来像鸭子,叫起来像鸭子,那就一定是鸭子。”
你不知道的JavaScript(中卷)
JavaScript是唯一一门可以先用后学的编程语言。
类型和语法
类型
对语言引擎和开发人员来说,类型是值的内部特征,它定义了值的行为,以使其区别于其他值。
JavaScript中的变量是没有类型的,只有值才有。变量可以随时持有任何类型的值。
换个角度来理解就是,JavaScript不做“类型强制”;也就是说,语言引擎不要求变量总是持有与其初始值同类型的值。一个变量可以现在被赋值为字符串类型值,随后又被赋值为数字类型值。
JavaScript有七种内置类型:null、undefined、boolean、number、string、object和symbol,可以使用typeof运算符来查看。
不同的对象在底层都表示为二进制,在JavaScript中二进制前三位都为0的话会被判断为object类型,null的二进制表示是全0,自然前三位也是0,所以执行typeof时会返回“object”。
值
undefined类型只有一个值,即undefined。null类型也只有一个值,即null。它们的名称既是类型也是值。
undefined和null常被用来表示“空的”值或“不是值”的值。二者之间有一些细微的差别。例如:
- null指空值(empty value),undefined指没有值(missing value)
- 或者:null指曾赋过值但是目前没有值,undefined指从未赋值
null是一个特殊关键字,不是标识符,我们不能将其当作变量来使用和赋值。然而undefined却是一个标识符,可以被当作变量来使用和赋值。
按惯例我们用void 0来获得undefined(这主要源自C语言,当然使用void true或其他void表达式也是可以的)。void 0、void 1和undefined之间并没有实质上的区别。
NaN意指“不是一个数字”(not a number),这个名字容易引起误会。将它理解为“无效数值”“失败数值”或者“坏数值”可能更准确些。
null类型只有一个值null, undefined类型也只有一个值undefined。所有变量在赋值之前默认值都是undefined。void运算符返回undefined。
数字类型有几个特殊值,包括NaN(意指“not a number”,更确切地说是“invalid number”)、+Infinity、-Infinity和-0。
简单标量基本类型值(字符串和数字等)通过值复制来赋值/传递,而复合值(对象等)通过引用复制来赋值/传递。JavaScript中的引用和其他语言中的引用/指针不同,它们不能指向别的变量/引用,只能指向值。
原生函数
直接使用封装对象来“提前优化”代码反而会降低执行效率。
一般情况下,我们不需要直接使用封装对象。最好的办法是让JavaScript引擎自己决定什么时候应该使用封装对象。
强制类型转换
值从一种类型转换为另一种类型通常称为类型转换(type casting),这是显式的情况;隐式的情况称为强制类型转换(coercion)。
JavaScript中的强制类型转换总是返回标量基本类型值,如字符串、数字和布尔值,不会返回对象和函数。
类型转换发生在静态类型语言的编译阶段,而强制类型转换则发生在动态类型语言的运行时(runtime)。
ToString:
基本类型值的字符串化规则为:null转换为”null”, undefined转换为”undefined”, true转换为”true”。数字的字符串化则遵循通用规则,极小和极大的数字使用指数形式。
ToNumber:
其中true转换为1, false转换为0。undefined转换为NaN, null转换为0。
ToNumber对字符串的处理基本遵循数字常量的相关规则/语法。
ToBoolean:
这些是假值:undefined、null、false、+0/-0/NaN、””
假值的布尔强制类型转换结果为false。
在JavaScript开源社区中,一元运算+被普遍认为是显式强制类型转换。
一元运算符+的另一个常见用途是将日期(Date)对象强制类型转换为数字,返回结果为Unix时间戳,以毫秒为单位(从1970年1月1日00:00:00 UTC到当前时间)。
a = b || “something”和a && b()用到了“短路”机制。
既然返回的不是true和false,为什么a && (b || c)这样的表达式在if和for中没出过问题?
这或许并不是代码的问题,问题在于这些条件判断表达式最后还会执行布尔值的隐式强制类型转换。
==允许在相等比较中进行强制类型转换,而===不允许。==检查的是允许类型转换情况下的值的相等性,而===检查不允许类型转换情况下的值的相等性;因此,===经常被称为“严格相等”。
语法
句子”(sentence)是完整表达某个意思的一组词,由一个或多个“短语”(phrase)组成,它们之间由标点符号或连接词(and和or等)连接起来。
短语可以由更小的短语组成。有些短语是不完整的,不能独立表达意思;有些短语则相对完整,并且能够独立表达某个意思。这些规则就是英语的语法。
JavaScript的语法也是如此。语句相当于句子,表达式相当于短语,运算符则相当于标点符号和连接词。
JavaScript中表达式可以返回一个结果值。
语句都有一个结果值(statement completion value, undefined也算)。
代码块的结果值就如同一个隐式的返回,即返回最后一个语句的结果值。
&&和||运算符的“短路”(short circuiting)特性。
对&&和||来说,如果从左边的操作数能够得出结果,就可以忽略右边的操作数。我们将这种现象称为“短路”(即执行最短路径)。
ES6规范定义了一个新概念,叫作TDZ(Temporal Dead Zone,暂时性死区)。
TDZ指的是由于代码中的变量还没有初始化而不能被引用的情况。
JavaScript语法规则之上是语义规则(也称作上下文)。例如,{}在不同情况下的意思不尽相同,可以是语句块、对象常量、解构赋值(ES6)或者命名函数参数(ES6)。
ASI(Automatic Semicolon Insertion,自动分号插入)是JavaScript引擎的代码解析纠错机制,它会在需要的地方自动插入分号来纠正解析错误。问题在于这是否意味着大多数的分号都不是必要的(可以省略),或者由于分号缺失导致的错误是否都可以交给JavaScript引擎来处理。
JavaScript中有很多错误类型,分为两大类:早期错误(编译时错误,无法被捕获)和运行时错误(可以通过try…catch来捕获)。所有语法错误都是早期错误,程序有语法错误则无法运行。
附录:混合环境JavaScript
一个广为人知的JavaScript的最佳实践是:不要扩展原生原型。
一个名为“art4theSould”的StackOverflow用户将这些保留字编成了一首有趣的小诗(http://stackoverflow.com/questions/26255/reserved-keywords-in-javascript/12114140#12114140):
Let this long package float,
Goto private class if short.
While protected with debugger case,
Continue volatile interface.
Instanceof super synchronized throw,
Extends final export throws.
Try import double enum?
-False, boolean, abstract function,
Implements typeof transient break!
Void static, default do,
Switch int native new.
Else, delete null public var
In return for const, true, char
…Finally catch byte.
异步和性能
异步:现在与将来
事实上,程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。
JavaScript引擎本身所做的只不过是在需要的时候,在给定的任意时刻执行程序中的单个代码块。
JavaScript引擎本身并没有时间的概念,只是一个按需执行JavaScript任意代码片段的环境。“事件”(JavaScript代码执行)调度总是由包含它的环境进行。
异步是关于现在和将来的时间间隙,而并行是关于能够同时发生的事情。
Promise的异步特性是基于任务的。
实际上,JavaScript程序总是至少分为两个块:第一块现在运行;下一块将来运行,以响应某个事件。尽管程序是一块一块执行的,但是所有这些块共享对程序作用域和状态的访问,所以对状态的修改都是在之前累积的修改之上进行的。
一旦有事件需要运行,事件循环就会运行,直到队列清空。事件循环的每一轮称为一个tick。用户交互、IO和定时器会向事件队列中加入事件。
任意时刻,一次只能从队列中处理一个事件。执行事件的时候,可能直接或间接地引发一个或多个后续事件。
并发是指两个或多个事件链随时间发展交替执行,以至于从更高的层次来看,就像是同时在运行(尽管在任意时刻只处理一个事件)。
回调
很多人(特别是A型人)可能不愿意承认,但我们更多是单任务执行者。实际上,在任何特定的时刻,我们只能思考一件事情。
控制反转(inversion of control),也就是把自己程序一部分的执行控制交给某个第三方。在你的代码和第三方工具(一组你希望有人维护的东西)之间有一份并没有明确表达的契约。
为了更优雅地处理错误,有些API设计提供了分离回调(一个用于成功通知,一个用于出错通知),ES6 Promise API使用的就是这种分离回调设计。
还有一种常见的回调模式叫作“error-first风格”(有时候也称为“Node风格”,因为几乎所有Node.js API都采用这种风格),其中回调的第一个参数保留用作错误对象(如果有的话)。如果成功的话,这个参数就会被清空/置假(后续的参数就是成功数据)。不过,如果产生了错误结果,那么第一个参数就会被置起/置真(通常就不会再传递其他结果)。
回调函数是JavaScript异步的基本单元。
永远异步调用回调,即使就在事件循环的下一轮,这样,所有回调就都是可预测的异步调用了。
地缘政治原则:“信任,但要核实。”
Promise
Promise是一种封装和组合未来值的易于复用的机制。
从另外一个角度看待Promise的决议:一种在异步任务中作为两个或更多步骤的流程控制机制,时序上的this-then-that。
Promise最本质的一个特征是:Promise只能被决议一次(完成或拒绝)。
在经典的编程术语中,门(gate)是这样一种机制要等待两个或更多并行/并发的任务都完成才能继续。它们的完成顺序并不重要,但是必须都要完成,门才能打开并让流程控制继续。
生成器
for…of循环在每次迭代中自动调用next(),它不会向next()传入任何值,并且会在接收到done:true之后自动停止。
生成器是通过暂停自己的作用域或状态实现它的“魔法”的。
生成器为异步代码保持了顺序、同步、阻塞的代码模式,这使得大脑可以更自然地追踪代码。
获得Promise和生成器最大效用的最自然的方法就是yield出来一个Promise,然后通过这个Promise来控制生成器的迭代器。
对编程来说,抽象并不总是好事,很多时候它会增加复杂度以换取简洁性。
你用一个函数定义封装函数调用,包括需要的任何参数,来定义这个调用的执行,那么这个封装函数就是一个形实转换程序。之后在执行这个thunk时,最终就是调用了原始的函数。
举例来说:
function foo(x, y) { return x + y; }
function fooThunk() { return foo(3,4); }
console.log(fooThunk()); // 7
程序性能
Web Worker通常应用于:
- 处理密集型数学计算
- 大数据集排序
- 数据处理(压缩、音频分析、图像处理等)
- 高流量网络
单指令多数据(SIMD = Single Instruction Multiple Data)是一种数据并行(data parallelism)方式,与Web Worker的任务并行(task parallelism)相对,因为这里的重点实际上不再是把程序逻辑分成并行的块,而是并行处理数据的多个位。
对JavaScript性能影响最大的因素是内存分配、垃圾收集和作用域访问。
性能测试与调优
任何有意义且可靠的性能测试都应该基于统计学上合理的实践。
我们应该关注优化的大局,而不是担心这些微观性能的细微差别。
怎么知道什么是大局呢?首先要了解你的代码是否运行在关键路径上。如果不在关键路径上,你的优化就很可能得不到很大的收益。
尾调用优化(TCO = Tail Call Optimization)。
尾调用就是一个出现在另一个函数“结尾”处的函数调用。这个调用结束后就没有其余事情要做了(除了可能要返回结果值)。
尾调用优化是ES6要求的一种优化方法。它使JavaScript中原本不可能的一些递归模式变得实际。TCO允许一个函数在结尾处调用另外一个函数来执行,不需要任何额外资源。这意味着,对递归算法来说,引擎不再需要限制栈深度。
你不知道的JavaScript(下卷)
深入编程
代码注释
以下这些观察结论和指导原则是很有用的。
· 没有注释的代码不是最优的。
· 过多注释(比如每行一个)可能是拙劣代码的征兆。
· 代码应该解释为什么,而非是什么。如果编写的代码特别容易令人迷惑的话,那么注释也可以解释一下实现原理。
旧与新
有一部分特性是新增的,旧版浏览器不一定会支持这样的特性。实际上,标准中的部分最新特性甚至还没有在哪个稳定版的浏览器中实现。
那么,应该怎样对待这些新特性呢?
你可以使用两种主要的技术,即polyfilling和transpilling,向旧版浏览器“引入”新版的JavaScript特性。
1.单词“polyfill”是由Remy Sharp发明的一个新术语(https://remysharp.com/2010/10/08/what-is-a-polyfill),用于表示根据新特性的定义,创建一段与之行为等价但能够在旧的JavaScript环境中运行的代码。
polyfill,就是我们常说的刮墙用的腻子。即衬垫代码或者补充代码,用来补充当前运行环境中缺失的功能。polyfill代码主要用于旧浏览器的兼容,比如说在旧的浏览器中并没有内置bind函数,因此可以使用polyfill代码在旧浏览器中实现新的功能。
2.语言中新增的语法是无法进行polyfilling的。新语法在旧版JavaScript引擎上会抛出未识别/无效错误。
因此,更好的方法是,通过工具将新版代码转换为等价的旧版代码。这个过程通常被称为“transpiling”。它是由transforming(转换)和compiling(编译)组合而成的术语。
从本质上来说,你的源码是用新语法形式编写的,但部署在浏览器上的是编译转换后的旧语法形式。通常会在构建过程中插入transpiler工具,类似于代码linter或者minifier。
有很多很棒的transpiler可供选择。以下是编写本部分时几个很好的选择:
- Babel(https://babeljs.io/,从6到5)从ES6+编译转换到ES5
- Traceur(https://github.com/google/traceur-compiler)将ES6、ES7及后续版本转换到ES5
ES6和未来
代码组织
在代码组织方面,ES6引入了几个新特性。
- 迭代器提供了对数组或运算的顺序访问。可以通过像for…of和…这些新语言特性来消耗迭代器。
- 生成器是支持本地暂停/恢复的函数,通过迭代器来控制。它们可以用于编程(也是交互地,通过yield/next(..)消息传递)生成供迭代消耗的值。
- 模块支持对实现细节的私有封装,并提供公开导出API。模块定义是基于文件的单例实例,在编译时静态决议。
- 类对基于原型的编码提供了更清晰的语法。新增的super也解决了[[Prototype]]链中相对引用的棘手问题。
- 迭代器
迭代器是一种有序的、连续的、基于拉取的用于消耗数据的组织方式。
- 生成器
生成器可以在执行当中暂停自身,可以立即恢复执行也可以过一段时间之后恢复执行。
生成器使用的两个主要模式:
- 产生一系列值
这个用法可以很简单(比如随机字符串或者递增数),也可以表示更结构化的数据访问(比如在数据库查询返回的行上迭代)。
不管怎样,我们使用迭代器来控制生成器,所以可以在每次调用next(..)的时候触发某些逻辑。数据结构上的普通迭代器只是取出值而没有控制逻辑。 - 顺序执行的任务队列
这种用法通常表示算法中步骤的流控制,其中每个步骤要求从某个外部源获得数据。每部分数据的完成可以是即时的,也可以是异步延迟的。
从生成器内部代码的角度来看,在yield点同步或异步这样的细节是完全透明的。而且,这些细节是故意被抽象出去的,这样就不会被诸如实现复杂性模糊了步骤的自然顺序表达。抽象也意味着实现可以在无需修改生成器内部代码的情况下被替换/重构。
- 类
新的ES6类机制的核心是关键字class,表示一个块,其内容定义了一个函数原型的成员。
类方法是不可枚举的,而对象方法默认是可枚举的。
集合
- TypedArray提供了对二进制数据buffer的各种整型类型“视图”,比如8位无符号整型和32位浮点型。对二进制数据的数组访问使得运算更容易表达和维护,从而可以更容易操纵视频、音频、canvas数据等这样的复杂数据。
- Map是键-值对,其中的键不只是字符串/原生类型,也可以是对象。
- Set是成员值(任意类型)唯一的列表。
- WeakMap也是map,其中的键(对象)是弱持有的,因此当它是对这个对象的最后一个引用的时候,GC(垃圾回收)可以回收这个项目。
- WeakSet也是set,其中的值是弱持有的,也就是说如果其中的项目是对这个对象最后一个引用的时候,GC可以移除它。
- WeakMap
WeakMap是map的变体,二者的多数外部行为特性都是一样的,区别在于内部内存分配(特别是其GC)的工作方式。
WeakMap(只)接受对象作为键。这些对象是被弱持有的,也就是说如果对象本身被垃圾回收的话,在WeakMap中的这个项目也会被移除。然而我们无法观测到这一点,因为对象被垃圾回收的唯一方式是没有对它的引用了。但是一旦不再有引用,你也就没有对象引用来查看它是否还存在于这个WeakMap中了。
- WeakSet
像WeakMap弱持有它的键(对其值是强持有的)一样,WeakSet对其值也是弱持有的(这里并没有键)。
WeakSet的值必须是对象,而并不像set一样可以是原生类型值。
元编程
元编程是指操作目标是程序本身的行为特性的编程。换句话说,它是对程序的编程的编程。
元编程是指把程序的逻辑转向关注自身(或自身的运行时环境),要么是为了查看自己的结构,要么是为了修改它。元编程的主要价值是扩展语言的一般机制来提供额外的新功能。
元编程关注以下一点或几点:代码查看自身、代码修改自身、代码修改默认语言特性,以此影响其他代码。
特性测试:测试程序的运行环境,然后确定程序行为方式,这是一种元编程技术。
ES6之后
- 异步函数
async function本质上就是生成器+promise+run(...)模式的语法糖;它们底层的运作方式是一样的!
- Object.observe(…)
通过工具Object.observe(…)直接添加到语言中的支持。本质上说,这个思路就是你可以建立一个侦听者(listener)来观察对象的改变,然后在每次变化发生时调用一个回调。例如,你可以据此更新DOM。
可以观察的改变有6种类型:
- add
- update
- delete
- reconfigure
- setPrototype
- preventExtensions
默认情况下,你可以得到所有这些类型的变化的通知,也可以进行过滤只侦听关注的类型。
- WebAssembly (WASM)
WASM为其他语言在浏览器运行时环境中运行提供了一条新路径,不需要先通过JavaScript。本质上说,如果WASM发布,JavaScript引擎将会获得执行二进制格式代码的新能力,这种格式某种程度上类似于字节码(bytecode,就像JVM上运行的那样)。
WASM提出了一种代码的高度压缩AST(语法树)二进制表示格式,然后可以直接向JavaScript引擎发出指令,而它的基础结构,不需要通过JavaScript解析,甚至不需要符合JavaScript的规则。像C或C++这样的语言可以被直接编译为WASM格式而不是ASM.js,这样通过跳过JavaScript解析会获得额外的速度优势。