程序性能相关整理
第 18 章 - 结束
18.4.2 WebGL 基础
取得 WebGL 上下文后,就可以开始 3D 绘图了。如前所述,因为 WebGL 是 OpenGL ES 2.0 的 Web版,所以本节讨论的概念实际上是 JavaScript 所实现的 OpenGL 概念。可以在调用 getContext()取得 WebGL 上下文时指定一些选项。这些选项通过一个参数对象传入,选项就是参数对象的一个或多个属性。
- alpha:布尔值,表示是否为上下文创建透明通道缓冲区,默认为 true。
- depth:布尔值,表示是否使用 16 位深缓冲区,默认为 true。
- stencil:布尔值,表示是否使用 8 位模板缓冲区,默认为 false。
- antialias:布尔值,表示是否使用默认机制执行抗锯齿操作,默认为 true。
- premultipliedAlpha:布尔值,表示绘图缓冲区是否预乘透明度值,默认为 true。
- preserveDrawingBuffer:布尔值,表示绘图完成后是否保留绘图缓冲区,默认为 false。
建议在充分了解这个选项的作用后再自行修改,因为这可能会影响性能。
可以像下面这样传入 options 对象:
let drawing = document.getElementById("drawing"); // 确保浏览器支持<canvas> if (drawing.getContext) { let gl = drawing.getContext("webgl", { alpha: false }); if (gl) { // 使用 WebGL } }
这些上下文选项大部分适合开发高级功能。多数情况下,默认值就可以满足要求。
第 20 章 JavaScript API
随着 Web 浏览器能力的增加,其复杂性也在迅速增加。从很多方面看,现代 Web 浏览器已经成为构建于诸多规范之上、集不同 API 于一身的“瑞士军刀”。浏览器规范的生态在某种程度上是混乱而无 序的。一些规范如 HTML5,定义了一批增强已有标准的 API 和浏览器特性。而另一些规范如 Web Cryptography API 和 Notifications API,只为一个特性定义了一个API。不同浏览器实现这些新 API 的情况也不同,有的会实现其中一部分,有的则干脆尚未实现。
最终,是否使用这些比较新的 API 还要看项目是支持更多浏览器,还是要采用更多现代特性。有些API 可以通过腻子脚本来模拟,但腻子脚本通常会带来性能问题,此外也会增加网站 JavaScript 代码的体积。
20.1.2 原子操作基础
任何全局上下文中都有 Atomics 对象,这个对象上暴露了用于执行线程安全操作的一套静态方法,其中多数方法以一个 TypedArray 实例(一个 SharedArrayBuffer 的引用)作为第一个参数,以相关操作数作为后续参数。
任何全局上下文中都有 Atomics 对象,这个对象上暴露了用于执行线程安全操作的一套静态方法,其中多数方法以一个 TypedArray 实例(一个 SharedArrayBuffer 的引用)作为第一个参数,以相关操作数作为后续参数。
以下代码演示了所有算术方法:
// 创建大小为 1 的缓冲区 let sharedArrayBuffer = new SharedArrayBuffer(1); // 基于缓冲创建 Uint8Array let typedArray = new Uint8Array(sharedArrayBuffer); // 所有 ArrayBuffer 全部初始化为 0 console.log(typedArray); // Uint8Array[0] const index = 0; const increment = 5; // 对索引 0 处的值执行原子加 5 Atomics.add(typedArray, index, increment); console.log(typedArray); // Uint8Array[5] // 对索引 0 处的值执行原子减 5 Atomics.sub(typedArray, index, increment); console.log(typedArray); // Uint8Array[0]
Atomics API 还提供了 Atomics.isLockFree()方法。不过我们基本上应该不会用到。这个方法在高性能算法中可以用来确定是否有必要获取锁。
规范中的介绍如下:Atomics.isLockFree()是一个优化原语。基本上,如果一个原子原语(compareExchange、load、store、add、sub、and、or、xor 或 exchange)在 n 字节大小的数据上的原子步骤 在不调用代理在组成数据的n字节之外获得锁的情况下可以执行,则Atomics.isLockFree(n)会返回 true。高性能算法会使用 Atomics.isLockFree 确定是否在关键部分使用锁或原子 操作。如果原子原语需要加锁,则算法提供自己的锁会更高效。
Atomics.isLockFree(4)始终返回 true,因为在所有已知的相关硬件上都是支持的。能够如此假设通常可以简化程序。
20.3 Encoding API
Encoding API 主要用于实现字符串与定型数组之间的转换。规范新增了 4 个用于执行转换的全局类:TextEncoder、TextEncoderStream、TextDecoder TextDecoderStream。
注意 相比于批量(bulk)的编解码,对流(stream)编解码的支持很有限。
20.3.1 文本编码
Encoding API 提供了两种将字符串转换为定型数组二进制格式的方法:批量编码和流编码。把字符串转换为定型数组时,编码器始终使用 UTF-8。
- 批量编码
所谓批量,指的是 JavaScript 引擎会同步编码整个字符串。对于非常长的字符串,可能会花较长时间。批量编码是通过 TextEncoder 的实例完成的:
const textEncoder = new TextEncoder(); 这个实例上有一个 encode()方法,该方法接收一个字符串参数,并以 Uint8Array 格式返回每个字符的 UTF-8 编码: const textEncoder = new TextEncoder(); const decodedText = 'foo'; const encodedText = textEncoder.encode(decodedText); // f 的 UTF-8 编码是 0x66(即十进制 102) // o 的 UTF-8 编码是 0x6F(即二进制 111) console.log(encodedText); // Uint8Array(3) [102, 111, 111] 编码器是用于处理字符的,有些字符(如表情符号)在最终返回的数组中可能会占多个索引: const textEncoder = new TextEncoder(); const decodedText = '☺'; const encodedText = textEncoder.encode(decodedText); // ☺的 UTF-8 编码是 0xF0 0x9F 0x98 0x8A(即十进制 240、159、152、138) console.log(encodedText); // Uint8Array(4) [240, 159, 152, 138] 编码器实例还有一个 encodeInto()方法,该方法接收一个字符串和目标 Unit8Array,返回一个字典,该字典包含 read 和 written 属性,分别表示成功从源字符串读取了多少字符和向目标数组写入了多少字符。如果定型数组的空间不够,编码就会提前终止,返回的字典会体现这个结果: const textEncoder = new TextEncoder(); const fooArr = new Uint8Array(3); const barArr = new Uint8Array(2); const fooResult = textEncoder.encodeInto('foo', fooArr); const barResult = textEncoder.encodeInto('bar', barArr); console.log(fooArr); // Uint8Array(3) [102, 111, 111] console.log(fooResult); // { read: 3, written: 3 } console.log(barArr); // Uint8Array(2) [98, 97] console.log(barResult); // { read: 2, written: 2 } encode()要求分配一个新的 Unit8Array,encodeInto()则不需要。对于追求性能的应用,这个差别可能会带来显著不同。
注意 文本编码会始终使用 UTF-8 格式,而且必须写入 Unit8Array 实例。使用其他类型数组会导致 encodeInto()抛出错误。
20.10 计时 API
页面性能始终是 Web 开发者关心的话题。Performance 接口通过 JavaScript API 暴露了浏览器内部的度量指标,允许开发者直接访问这些信息并基于这些信息实现自己想要的功能。这个接口暴露在 window.performance 对象上。所有与页面相关的指标,包括已经定义和将来会定义的,都会存在于这个对象上。
Performance 接口由多个 API 构成:
- High Resolution Time API
- Performance Timeline API
- Navigation Timing API
- User Timing API
- Resource Timing API
- Paint Timing API
有关这些规范的更多信息以及新增的性能相关规范,可以关注 W3C 性能工作组的 GitHub 项目页面。
20.10.2 Performance Timeline API
Performance Timeline API 使用一套用于度量客户端延迟的工具扩展了 Performance 接口。性能度量将会采用计算结束与开始时间差的形式。这些开始和结束时间会被记录为 DOMHighResTimeStamp值,而封装这个时间戳的对象是 PerformanceEntry 的实例。浏览器会自动记录各种 PerformanceEntry 对象,而使用 performance.mark()也可以记录自定义的 PerformanceEntry 对象。在一个执行上下文中被记录的所有性能条目可以通过 performance. getEntries()获取:
console.log(performance.getEntries()); // [PerformanceNavigationTiming, PerformanceResourceTiming, ... ] 这个返回的集合代表浏览器的性能时间线(performance timeline)。每个 PerformanceEntry 对象都有 name、entryType、startTime 和 duration 属性: const entry = performance.getEntries()[0]; console.log(entry.name); // "https://foo.com" console.log(entry.entryType); // navigation console.log(entry.startTime); // 0 console.log(entry.duration); // 182.36500001512468 不过,PerformanceEntry 实际上是一个抽象基类。所有记录条目虽然都继承 PerformanceEntry,但最终还是如下某个具体类的实例: - PerformanceMark - PerformanceMeasure - PerformanceFrameTiming - PerformanceNavigationTiming - PerformanceResourceTiming - PerformancePaintTiming 上面每个类都会增加大量属性,用于描述与相应条目有关的元数据。每个实例的 name 和 entryType属性会因为各自的类不同而不同。 1. User Timing API User Timing API 用于记录和分析自定义性能条目。如前所述,记录自定义性能条目要使用 performance.mark()方法: performance.mark('foo'); console.log(performance.getEntriesByType('mark')[0]); // PerformanceMark { // name: "foo", // entryType: "mark", // startTime: 269.8800000362098, // duration: 0 // } 在计算开始前和结束后各创建一个自定义性能条目可以计算时间差。最新的标记(mark)会被推到getEntriesByType()返回数组的开始: performance.mark('foo'); for (let i = 0; i < 1E6; ++i) {} performance.mark('bar'); const [endMark, startMark] = performance.getEntriesByType('mark'); console.log(startMark.startTime - endMark.startTime); // 1.3299999991431832 除了自定义性能条目,还可以生成 PerformanceMeasure(性能度量)条目,对应由名字作为标识的两个标记之间的持续时间。PerformanceMeasure 的实例由 performance.measure()方法生成: performance.mark('foo'); for (let i = 0; i < 1E6; ++i) {} performance.mark('bar'); performance.measure('baz', 'foo', 'bar'); const [differenceMark] = performance.getEntriesByType('measure'); console.log(differenceMark); // PerformanceMeasure { // name: "baz", // entryType: "measure", // startTime: 298.9800000214018, // duration: 1.349999976810068 // }
- Resource Timing API Resource Timing API 提供了高精度时间戳,用于度量当前页面加载时请求资源的速度。浏览器会在加载资源时自动记PerformanceResourceTiming。这个对象会捕获大量时间戳,用于描述资源加载的速度。
下面的例子计算了加载一个特定资源所花的时间:
const performanceResourceTimingEntry = performance.getEntriesByType('resource')[0]; console.log(performanceResourceTimingEntry); // PerformanceResourceTiming { // connectEnd: 138.11499997973442 // connectStart: 138.11499997973442 // decodedBodySize: 33808 // domainLookupEnd: 138.11499997973442 // domainLookupStart: 138.11499997973442 // duration: 0 // encodedBodySize: 33808 // entryType: "resource" // fetchStart: 138.11499997973442 // initiatorType: "link" // name: "https://static.foo.com/bar.png", // nextHopProtocol: "h2" // redirectEnd: 0 // redirectStart: 0 // requestStart: 138.11499997973442 // responseEnd: 138.11499997973442 // responseStart: 138.11499997973442 // secureConnectionStart: 0 // serverTiming: [] // startTime: 138.11499997973442 // transferSize: 0 // workerStart: 0 // } console.log(performanceResourceTimingEntry.responseEnd – performanceResourceTimingEntry.requestStart); // 493.9600000507198
通过计算并分析不同时间的差,可以更全面地审视浏览器加载页面的过程,发现可能存在的性能瓶颈。
20.12.2 使用 SubtleCrypto 对象
Web Cryptography API 重头特性都暴露在了 SubtleCrypto 对象上,可以通过 window.crypto.subtle 访问:
console.log(crypto.subtle); // SubtleCrypto {}
这个对象包含一组方法,用于执行常见的密码学功能,如加密、散列、签名和生成密钥。因为所有密码学操作都在原始二进制数据上执行,所以 SubtleCrypto 的每个方法都要用到 ArrayBuffer 和 ArrayBufferView 类型。由于字符串是密码学操作的重要应用场景,因此 TextEncoder 和TextDecoder 是经常与 SubtleCrypto 一起使用的类,用于实现二进制数据与字符串之间的相互转换。
注意 SubtleCrypto 对象只能在安全上下文(https)中使用。在不安全的上下文中,subtle 属性是 undefined。
- 生成密码学摘要
计算数据的密码学摘要是非常常用的密码学操作。这个规范支持 4 种摘要算法:SHA-1 和 3 种SHA-2。
- SHA-1(Secure Hash Algorithm 1):架构类似 MD5 的散列函数。接收任意大小的输入,生成160 位消息散列。由于容易受到碰撞攻击,这个算法已经不再安全。
- SHA-2(Secure Hash Algorithm 2):构建于相同耐碰撞单向压缩函数之上的一套散列函数。规范支持其中 3 种:SHA-256、SHA-384 和 SHA-512。生成的消息摘要可以是 256 位(SHA-256)、 384 位(SHA-384)或 512 位(SHA-512)。这个算法被认为是安全的,广泛应用于很多领域和协议,包括 TLS、PGP 和加密货币(如比特币)。
- CryptoKey 与算法
如果没了密钥,那密码学也就没什么意义了。SubtleCrypto 对象使用 CryptoKey 类的实例来生成密钥。CryptoKey 类支持多种加密算法,允许控制密钥抽取和使用。CryptoKey 类支持以下算法,按各自的父密码系统归类。
- RSA(Rivest-Shamir-Adleman):公钥密码系统,使用两个大素数获得一对公钥和私钥,可用于签名/验证或加密/解密消息。RSA 的陷门函数被称为分解难题(factoring problem)。
- RSASSA-PKCS1-v1_5:RSA 的一个应用,用于使用私钥给消息签名,允许使用公钥验证签名。
- SSA(Signature Schemes with Appendix),表示算法支持签名生成和验证操作。
- PKCS1(Public-Key Cryptography Standards #1),表示算法展示出的 RSA 密钥必需的数学特性。
- RSASSA-PKCS1-v1_5 是确定性的,意味着同样的消息和密钥每次都会生成相同的签名。
- RSA-PSS:RSA 的另一个应用,用于签名和验证消息。
- PSS(Probabilistic Signature Scheme),表示生成签名时会加盐以得到随机签名。
- 与 RSASSA-PKCS1-v1_5 不同,同样的消息和密钥每次都会生成不同的签名。
- 与 RSASSA-PKCS1-v1_5 不同,RSA-PSS 有可能约简到 RSA 分解难题的难度。
- 通常,虽然 RSASSA-PKCS1-v1_5 仍被认为是安全的,但 RSA-PSS 应该用于代替RSASSA-PKCS1-v1_5。
- RSA-OAEP:RSA 的一个应用,用于使用公钥加密消息,用私钥来解密。
- OAEP(Optimal Asymmetric Encryption Padding),表示算法利用了 Feistel 网络在加密前处理未加密的消息。
- OAEP 主要将确定性 RSA 加密模式转换为概率性加密模式。
- ECC(Elliptic-Curve Cryptography):公钥密码系统,使用一个素数和一个椭圆曲线获得一对公钥和私钥,可用于签名/验证消息。ECC 的陷门函数被称为椭圆曲线离散对数问题(elliptic curve discrete logarithm problem)。ECC 被认为优于 RSA。虽然 RSA 和 ECC 在密码学意义上都很强,但 ECC 密钥比 RSA 密钥短,而且 ECC 密码学操作比 RSA 操作快。
- ECDSA(Elliptic Curve Digital Signature Algorithm):ECC 的一个应用,用于签名和验证消息。这个算法是数字签名算法(DSA,Digital Signature Algorithm)的一个椭圆曲线风格的变体。
- ECDH(Elliptic Curve Diffie-Hellman):ECC 的密钥生成和密钥协商应用,允许两方通过公开通信渠道建立共享的机密。这个算法是 Diffie-Hellman 密钥交换(DH,Diffie-Hellman key exchange)协议的一个椭圆曲线风格的变体。
- AES(Advanced Encryption Standard):对称密钥密码系统,使用派生自置换组合网络的分组密码加密和解密数据。AES 在不同模式下使用,不同模式算法的特性也不同。
- AES-CTR:AES 的计数器模式(counter mode)。这个模式使用递增计数器生成其密钥流,其行为类似密文流。使用时必须为其提供一个随机数,用作初始化向量。AES-CTR 加密/解密可以并行。
- AES-CBC:AES 的密码分组链模式(cipher block chaining mode)。在加密纯文本的每个分组之前,先使用之前密文分组求 XOR,也就是名字中的“链”。使用一个初始化向量作为第一个分组 的 XOR 输入。
- AES-GCM:AES 的伽罗瓦/计数器模式(Galois/Counter mode)。这个模式使用计数器和初始化向量生成一个值,这个值会与每个分组的纯文本计算 XOR。与 CBC 不同,这个模式的 XOR 输 入不依赖之前分组密文。因此 GCM 模式可以并行。由于其卓越的性能,AES-GCM 在很多网络安全协议中得到了应用。
- AES-KW:AES 的密钥包装模式(key wrapping mode)。这个算法将加密密钥包装为一个可移植且加密的格式,可以在不信任的渠道中传输。传输之后,接收方可以解包密钥。与其他 AES 模 式不同,AES-KW 不需要初始化向量。
- HMAC(Hash-Based Message Authentication Code):用于生成消息认证码的算法,用于验证通过不可信网络接收的消息没有被修改过。两方使用散列函数和共享私钥来签名和验证消息。
- KDF(Key Derivation Functions):可以使用散列函数从主密钥获得一个或多个密钥的算法。KDF能够生成不同长度的密钥,也能把密钥转换为不同格式。
- HKDF(HMAC-Based Key Derivation Function):密钥推导函数,与高熵输入(如已有密钥)一起使用。
- PBKDF2(Password-Based Key Derivation Function 2):密钥推导函数,与低熵输入(如密钥字符串)一起使用。
注意 CryptoKey 支持很多算法,但其中只有部分算法能够用于 SubtleCrypto 的方法。要了解哪个方法支持什么算法,可以参考 W3C 网站上 Web Cryptography API 规范的“Algorithm Overview”
第 24 章 网络请求与远程资源
注意 POST 请求相比 GET 请求要占用更多资源。从性能方面说,发送相同数量的数据,GET 请求比 POST 请求要快两倍。
第 25 章 客户端存储
25.1.5 使用 cookie 的注意事项
还有一种叫作 HTTP-only 的 cookie。HTTP-only 可以在浏览器设置,也可以在服务器设置,但只能在服务器上读取,这是因为 JavaScript 无法取得这种 cookie 的值。因为所有 cookie 都会作为请求头部由浏览器发送给服务器,所以在 cookie 中保存大量信息可能会影响特定域浏览器请求的性能。保存的 cookie 越大,请求完成的时间就越长。即使浏览器对 cookie 大小有限制,最好还是尽可能只通过 cookie 保存必要信息,以避免性能问题。对 cookie 的限制及其特性决定了 cookie 并不是存储大量数据的理想方式。因此,其他客户端存储技术出现了。
第 26 章 模块
26.1.4 入口
相互依赖的模块必须指定一个模块作为入口(entry point),这也是代码执行的起点。这是理所当然的,因为 JavaScript 是顺序执行的,并且是单线程的,所以代码必须有执行的起点。入口模块也可能依赖其他模块,其他模块同样可能有自己的依赖。于是模块化 JavaScript 应用程序的所有模块会构成依赖图。
图中的箭头表示依赖方向:模块 A 依赖模块 B 和模块 C,模块 B 依赖模块 D 和模块 E,模块 C 依赖模块 E。因为模块必须在依赖加载完成后才能被加载,所以这个应用程序的入口模块 A 必须在应用程序的其他部分加载后才能执行。
在 JavaScript 中,“加载”的概念可以有多种实现方式。因为模块是作为包含将立即执行的 JavaScript代码的文件实现的,所以一种可能是按照依赖图的要求依次请求各个脚本。
对于前面的应用程序来说,下面的脚本请求顺序能够满足依赖图的要求:
<script src="moduleE.js"></script> <script src="moduleD.js"></script> <script src="moduleC.js"></script> <script src="moduleB.js"></script> <script src="moduleA.js"></script>
模块加载是“阻塞的”,这意味着前置操作必须完成才能执行后续操作。每个模块在自己的代码到达浏览器之后完成加载,此时其依赖已经加载并初始化。不过,这个策略存在一些性能和复杂性问题。为 一个应用程序而按顺序加载五个 JavaScript 文件并不理想,并且手动管理正确的加载顺序也颇为棘手。
26.1.5 异步依赖
因为 JavaScript 可以异步执行,所以如果能按需加载就好了。换句话说,可以让 JavaScript 通知模块系统在必要时加载新模块,并在模块加载完成后提供回调。
在代码层面,可以通过下面的伪代码来实现:
// 在模块 A 里面 load('moduleB').then(function(moduleB) { moduleB.doStuff(); });
模块 A 的代码使用了 moduleB 标识符向模块系统请求加载模块 B,并以模块 B 作为参数调用回调。模块 B 可能已加载完成,也可能必须重新请求和初始化,但这里的代码并不关心。这些事情都交给了模块加载器去负责。
如果重写前面的应用程序,只使用动态模块加载,那么使用一个<script>
标签即可完成模块 A 的加载。模块 A 会按需请求模块文件,而不会生成必需的依赖列表。这样有几个好处,其中之一就是性能,因为在页面加载时只需同步加载一个文件。
这些脚本也可以分离出来,比如给<script>
标签应用 defer 或 async 属性,再加上能够识别异步脚本何时加载和初始化的逻辑。此行为将模拟在 ES6 模块规范中实现的行为,本章稍后会对此进行讨论。
工作者线程
27.2.4 配置 Worker 选项
Worker()构造函数允许将可选的配置对象作为第二个参数。该配置对象支持下列属性。
- name:可以在工作者线程中通过 self.name 读取到的字符串标识符。
- type:表示加载脚本的运行方式,可以是"classic"或"module"。"classic"将脚本作为常规脚本来执行,"module"将脚本作为模块来执行。
- credentials:在 type 为"module"时,指定如何获取与传输凭证数据相关的工作者线程模块脚本。值可以是"omit"、"same-orign"或"include"。这些选项与 fetch()的凭证选项相同。在 type 为"classic"时,默认为"omit"。
注意 有的现代浏览器还不完全支持模块工作者线程或可能需要修改标志才能支持。
27.2.5 在 JavaScript 行内创建工作者线程
工作者线程需要基于脚本文件来创建,但这并不意味着该脚本必须是远程资源。专用工作者线程也可以通过 Blob 对象 URL 在行内脚本创建。这样可以更快速地初始化工作者线程,因为没有网络延迟。下面展示了一个在行内创建工作者线程的例子。
// 创建要执行的 JavaScript 代码字符串 const workerScript = ` self.onmessage = ({data}) => console.log(data); `; // 基于脚本字符串生成 Blob 对象 const workerScriptBlob = new Blob([workerScript]); // 基于 Blob 实例创建对象 URL const workerScriptBlobUrl = URL.createObjectURL(workerScriptBlob); // 基于对象 URL 创建专用工作者线程 const worker = new Worker(workerScriptBlobUrl); worker.postMessage('blob worker script'); // blob worker script
在这个例子中,通过脚本字符串创建了 Blob,然后又通过 Blob 创建了对象 URL,最后把对象 URL传给了 Worker()构造函数。该构造函数同样创建了专用工作者线程。
如果把所有代码写在一块,可以浓缩为这样:
const worker = new Worker(URL.createObjectURL(new Blob([`self.onmessage = ({data}) => console.log(data);`]))); worker.postMessage('blob worker script'); // blob worker script
工作者线程也可以利用函数序列化来初始化行内脚本。这是因为函数的 toString()方法返回函数代码的字符串,而函数可以在父上下文中定义但在子上下文中执行。来看下面这个简单的例子:
function fibonacci(n) { return n < 1 ? 0 : n <= 2 ? 1 : fibonacci(n - 1) + fibonacci(n - 2); } const workerScript = ` self.postMessage( (${fibonacci.toString()})(9) ); `; const worker = new Worker(URL.createObjectURL(new Blob([workerScript]))); worker.onmessage = ({data}) => console.log(data); // 34
这里有意使用了斐波那契数列的实现,将其序列化之后传给了工作者线程。该函数作为 IIFE 调用并传递参数,结果则被发送回主线程。虽然计算斐波那契数列比较耗时,但所有计算都会委托到工作者 线程,因此并不会影响父上下文的性能。
注意 像这样序列化函数有个前提,就是函数体内不能使用通过闭包获得的引用,也包括全局变量,比如 window,因为这些引用在工作者线程中执行时会出错。
第 28 章 最佳实践
28.2 性能
相比 JavaScript 刚问世时,目前每个网页中 JavaScript 代码的数量已有极大的增长。代码量的增长也带来了运行时执行 JavaScript 的性能问题。JavaScript 一开始就是一门解释型语言,因此执行速度比编译型语言要慢一些。Chrome 是第一个引入优化引擎将 JavaScript 编译为原生代码的浏览器。随后,其他主流浏览器也紧随其后,实现了 JavaScript 编译。即使到了编译 JavaScript 时代,仍可能写出运行慢的代码。不过,如果遵循一些基本模式,就能保证写出执行速度很快的代码。
28.2.1 作用域意识
第 4 章讨论过 JavaScript 作用域的概念,以及作用域链的工作原理。随着作用域链中作用域数量的增加,访问当前作用域外部变量所需的时间也会增加。访问全局变量始终比访问局部变量慢,因为必须 遍历作用域链。任何可以缩短遍历作用域链时间的举措都能提升代码性能。
- 避免全局查找
改进代码性能非常重要的一件事,可能就是要提防全局查询。全局变量和函数相比于局部值始终是最费时间的,因为需要经历作用域链查找。来看下面的函数:
function updateUI() { let imgs = document.getElementsByTagName("img"); for (let i = 0, len = imgs.length; i < len; i++) { imgs[i].title = '${document.title} image ${i}'; } let msg = document.getElementById("msg"); msg.innerHTML = "Update complete."; }
这个函数看起来好像没什么问题,但其中三个地方引用了全局 document 对象。如果页面的图片非常多,那么 for 循环中就需要引用 document 几十甚至上百次,每次都要遍历一次作用域链。通过在 局部作用域中保存 document 对象的引用,能够明显提升这个函数的性能,因为只需要作用域链查找。
通过创建一个指向 document 对象的局部变量,可以通过将全局查找的数量限制为一个来提高这个函数的性能:
function updateUI() { let doc = document; let imgs = doc.getElementsByTagName("img"); for (let i = 0, len = imgs.length; i < len; i++) { imgs[i].title = '${doc.title} image ${i}'; } let msg = doc.getElementById("msg"); msg.innerHTML = "Update complete."; }
这里先把 document 对象保存在局部变量 doc 中。然后用 doc 替代了代码中所有的 document。这样调用这个函数只会查找一次作用域链,相对上一个版本,肯定会快很多。
因此,一个经验规则就是,只要函数中有引用超过两次的全局对象,就应该把这个对象保存为一个局部变量。
- 不使用 with 语句
在性能很重要的代码中,应避免使用 with 语句。与函数类似,with 语句会创建自己的作用域,因此也会加长其中代码的作用域链。在 with 语句中执行的代码一定比在它外部执行的代码慢,因为作 用域链查找时多一步。实际编码时很少有需要使用 with 语句的情况,因为它的主要用途是节省一点代码。大多数情况下,使用局部变量可以实现同样的效果,无须增加新作用域。
下面看一个例子:
function updateBody() { with(document.body) { console.log(tagName); innerHTML = "Hello world!"; } }
这段代码中的 with 语句让使用 document.body 更简单了。使用局部变量也可以实现同样的效果,如下:
function updateBody() { let body = document.body; console.log(body.tagName); body.innerHTML = "Hello world!"; }
虽然这段代码多了几个字符,但比使用 with 语句还更容易理解了,因为 tagName 和 innerHTML属于谁很明确。这段代码还通过把 document.body 保存在局部变量中来省去全局查找。
28.2.2 选择正确的方法
与其他语言一样,影响性能的因素通常涉及算法或解决问题的方法。经验丰富的开发者知道用什么方法性能更佳。通常很多能在其他编程语言中提升性能的技术和方法同样也适用于 JavaScript。
- 避免不必要的属性查找
在计算机科学中,算法复杂度使用大 O 表示法来表示。最简单同时也最快的算法可以表示为常量值或 O(1)。然后,稍微复杂一些的算法同时执行时间也更长一些。下表列出了 JavaScript 中常见算法的类型。
表 示 法 名 称 说 明 O(1) 常量 无论多少值,执行时间都不变。表示简单值和保存在变量中的值 O(logn) 对数 执行时间随着值的增加而增加,但算法完成不需要读取每个值。例子:二分查找 O(n) 线性 执行时间与值的数量直接相关。例子:迭代数组的所有元素 O(n2 ) 二次方 执行时间随着值的增加而增加,而且每个值至少要读取 n 次。例子:插入排序
常量值或 O(1),指字面量和保存在变量中的值,表示读取常量值所需的时间不会因值的多少而变化。读取常量值是效率极高的操作,因此非常快。
来看下面的例子:
let value = 5; let sum = 10 + value; console.log(sum);
以上代码查询了 4 次常量值:数值 5、变量 value、数值 10 和变量 sum。整体代码的复杂度可以认为是 O(1)。在 JavaScript 中访问数组元素也是 O(1)操作,与简单的变量查找一样。
因此,下面的代码与前面的例子效率一样:
let values = [5, 10]; let sum = values[0] + values[1]; console.log(sum);
使用变量和数组相比访问对象属性效率更高,访问对象属性的算法复杂度是 O(n)。访问对象的每个属性都比访问变量或数组花费的时间长,因为查找属性名要搜索原型链。简单来说,查找的属性越多, 执行时间就越长。
来看下面的例子:
let values = { first: 5, second: 10 }; let sum = values.first + values.second; console.log(sum);
这个例子使用两次属性查找来计算 sum 的值。一两次属性查找可能不会有明显的性能问题,但几百上千次则绝对会拖慢执行速度。特别要注意避免通过多次查找获取一个值。
例如,看下面的例子:
let query = window.location.href.substring(window.location.href.indexOf("?"));
这里有 6 次属性查找:3 次是为查找 window.location.href.substring(),3 次是为查找window.location.href.indexOf()。通过数代码中出现的点号数量,就可以知道有几次属性查找。以上代码效率特别低,这是因为使用了两次 window.location.href,即同样的查找执行了两遍。只要使用某个 object 属性超过一次,就应该将其保存在局部变量中。第一次仍然要用 O(n)的复杂 度去访问这个属性,但后续每次访问就都是 O(1),这样就是质的提升了。
例如,前面的代码可以重写为如下:
let url = window.location.href; let query = url.substring(url.indexOf("?"));
这个版本的代码只有 4 次属性查找,比之前节省了约 33%。在大型脚本中如果能这样优化,可能就会明显改进性能。通常,只要能够降低算法复杂度,就应该尽量通过在局部变量中保存值来替代属性查找。另外,如果实现某个需求既可以使用数组的数值索引,又可以使用命名属性(比如 NodeList 对象),那就都应 该使用数值索引。
- 优化循环
循环是编程中常用的语法构造,因此在 JavaScript 中也十分常见。优化这些循环是性能优化的重要内容,因为循环会重复多次运行相同的代码,所以运行时间会自动增加。其他语言有很多关于优化循环的研究,这些技术同样适用于 JavaScript。优化循环的基本步骤如下。
(1) 简化终止条件。因为每次循环都会计算终止条件,所以它应该尽可能地快。这意味着要避免属性查找或其他 O(n)操作。
(2) 简化循环体。循环体是最花时间的部分,因此要尽可能优化。要确保其中不包含可以轻松转移到循环外部的密集计算。(3) 使用后测试循环。最常见的循环就是 for 和 while 循环,这两种循环都属于先测试循环。
do-while就是后测试循环,避免了对终止条件初始评估 ,因此应该会更快。注意 在旧版浏览器中,从循环迭代器的最大值开始递减至 0 的效率更高。之所以这样更快,是因为 JavaScript 引擎用于检查循环分支条件的指令数更少。在现代浏览器中,正序 还是倒序不会有可感知的性能差异。因此可以选择最适合代码逻辑的迭代方式。以上优化的效果可以通过下面的例子展示出来。
这是一个简单的 for 循环:
for (let i = 0; i < values.length; i++) { process(values[i]); }
这个循环会将变量 i 从 0 递增至数组 values 的长度。假设处理这些值的顺序不重要,那么可以将循环变量改为递减的形式,如下所示:
for (let i = values.length - 1; i >= 0; i--) { process(values[i]); }
这一次,变量 i 每次循环都会递减。在这个过程中,终止条件的计算复杂度也从查找 values.length的 O(n)变成了访问 0 的 O(1)。循环体只有一条语句,已不能再优化了。不过,整个循环可修改为后测试循环:
let i = values.length-1; if (i > -1) { do { process(values[i]); }while(--i >= 0); }
这里主要的优化是将终止条件和递减操作符合并成了一条语句。然后,如果再想优化就只能去优化process()的代码,因为循环已没有可以优化的点了。使用后测试循环时要注意,一定是至少有一个值需要处理一次。如果这里的数组是空的,那么会浪费一次循环,而先测试循环就可以避免这种情况。
- 展开循环
如果循环的次数是有限的,那么通常抛弃循环而直接多次调用函数会更快。仍以前面的循环为例,如果数组长度始终一样,则可能对每个元素都调用一次 process()效率更高:
// 抛弃循环 process(values[0]);、 process(values[1]); process(values[2]);
这个例子假设 values 数组始终只有 3 个值,然后分别针对每个元素调用一次 process()。像这样展开循环可以节省创建循环、计算终止条件的消耗,从而让代码运行更快。如果不能提前预知循环的次数,那么或许可以使用一种叫作达夫设备(Duff’s Device)的技术。该技术是以其发明者 Tom Duff 命名的,他最早建议在 C 语言中使用该技术。在 JavaScript 实现达夫设备的人是 Jeff Greenberg。达夫设备的基本思路是以 8 的倍数作为迭代次数从而将循环展开为一系列语句。
来看下面的例子:
// 来源:Jeff Greenberg 在 JavaScript 中实现的达夫设备 // 假设 values.length > 0 let iterations = Math.ceil(values.length / 8); let startAt = values.length % 8; let i = 0; do { switch(startAt) { case 0: process(values[i++]); case 7: process(values[i++]); case 6: process(values[i++]); case 5: process(values[i++]); case 4: process(values[i++]); case 3: process(values[i++]); case 2: process(values[i++]); case 1: process(values[i++]); } startAt = 0; } while (--iterations > 0);
这个达夫设备的实现首先通过用 values 数组的长度除以 8 计算需要多少次循环。Math.ceil()用于保证这个值是整数。startAt 变量保存着仅按照除以 8 来循环不会处理的元素个数。第一次循环执 行时,会检查 startAt 变量,以确定要调用 process()多少次。例如,假设数组有 10 个元素,则 startAt变量等于 2,因此第一次循环只会调用 process()两次。第一次循环末尾,startAt 被重置为 0。于是后续每次循环都会调用 8 次 process()。这样展开之后,能够加快大数据集的处理速度。
Andrew B. King 在 Speed Up Your Site 一书中提出了更快的达夫设备实现,他将 do-while 循环分成了两个单独的循环,
如下所示:
// 来源:Speed Up Your Site(New Riders,2003) let iterations = Math.floor(values.length / 8); let leftover = values.length % 8; let i = 0; if (leftover > 0) { do { process(values[i++]); } while (--leftover > 0); } do { process(values[i++]); process(values[i++]); process(values[i++]); process(values[i++]); process(values[i++]); process(values[i++]); process(values[i++]); process(values[i++]); } while (--iterations > 0);
在这个实现中,变量 leftover 保存着只按照除以 8 来循环不会处理,因而会在第一个循环中处理的次数。处理完这些额外的值之后进入主循环,每次循环调用 8 次 process()。这个实现比原始的实现快约 40%。
展开循环对于大型数据集可以节省很多时间,但对于小型数据集来说,则可能不值得。因为实现同样的任务需要多写很多代码,所以如果处理的数据量不大,那么显然没有必要。
- 避免重复解释
重复解释的问题存在于 JavaScript 代码尝试解释 JavaScript 代码的情形。在使用 eval()函数或Function 构造函数,或者给setTimeout()传入字符串参数时会出现这种情况。
下面是几个例子:
// 对代码求值:不要 eval("console.log('Hello world!')"); // 创建新函数:不要 let sayHi = new Function("console.log('Hello world!')"); // 设置超时函数:不要 setTimeout("console.log('Hello world!')", 500);
在上面所列的每种情况下,都需要重复解释包含 JavaScript 代码的字符串。这些字符串在初始解析阶段不会被解释,因为代码包含在字符串里。这意味着在 JavaScript 运行时,必须启动新解析器实例来解析这些字符串中的代码。实例化新解析器比较费时间,因此这样会比直接包含原生代码慢。这些情况都有对应的解决方案。很少有情况绝对需要使用 eval(),因此应该尽可能不使用它。此时,只要把代码直接写出来就好了。对于 Function 构造函数,重写为常规函数也很容易。而调用 setTimeout()时则可以直接把函数作为第一个参数。
比如:
// 直接写出来 console.log('Hello world!'); // 创建新函数:直接写出来 let sayHi = function() { console.log('Hello world!'); }; // 设置超时函数:直接写出来 setTimeout(function() { console.log('Hello world!'); }, 500);
为了提升代码性能,应该尽量避免使用要当作 JavaScript 代码解释的字符串。
- 其他性能优化注意事项
在评估代码性能时还有一些地方需要注意。下面列出的虽然不是主要问题,但在使用比较频繁的时候也可能有所不同。
原生方法很快。应该尽可能使用原生方法,而不是使用 JavaScript 写的方法。原生方法是使用 C 或 C++等编译型语言写的,因此比 JavaScript 写的方法要快得多。JavaScript中经常被忽视的是Math 对象上那些执行复杂数学运算的方法。这些方法总是比执行相同任务的 JavaScript 函数快得多,比如求正弦、余弦等。
switch 语句很快。如果代码中有复杂的 if-else 语句,将其转换成 switch 语句可以变得更快。然后,通过重新组织分支,把最可能的放前面,不太可能的放后面,可以进一步提升性能。
位操作很快。在执行数学运算操作时,位操作一定比任何布尔值或数值计算更快。选择性地将某些数学操作替换成位操作,可以极大提升复杂计算的效率。像求模、逻辑 AND 与和逻辑 OR 或都很适合替代成位操作。
28.2.3 语句最少化
JavaScript 代码中语句的数量影响操作执行的速度。一条可以执行多个操作的语句,比多条语句中每个语句执行一个操作要快。那么优化的目标就是寻找可以合并的语句,以减少整个脚本的执行时间。为此,可以参考如下几种模式。
- 多个变量声明
声明多个变量时很容易出现多条语句。比如,下面使用多个 let 声明多个变量的情况很常见:
// 有四条语句:浪费 let count = 5; let color = "blue"; let values = [1,2,3]; let now = new Date();
在强类型语言中,不同数据类型的变量必须在不同的语句中声明。但在 JavaScript 中,所有变量都可以使用一个 let 语句声明。
前面的代码可以改写为如下:
// 一条语句更好 let count = 5, color = "blue", values = [1,2,3], now = new Date();
这里使用一个 let 声明了所有变量,变量之间以逗号分隔。这种优化很容易做到,且比使用多条语句执行速度更快。
- 插入迭代性值
任何时候只要使用迭代性值(即会递增或递减的值),都要尽可能使用组合语句。来看下面的代码片段:
let name = values[i]; i++;
前面代码中的两条语句都只有一个作用:第一条从 values 中取得一个值并保存到 name 中,第二条递增变量 i。
把迭代性的值插入第一条语句就可以将它们合并为一条语句:
let name = values[i++];
这一条语句完成了前面两条语句完成的事情。因为递增操作符是后缀形式的,所以 i 在语句其他部分执行完成之前是不会递增的。只要遇到类似的情况,就要尽量把迭代性值插入到上一条使用它的语句中。
- 使用数组和对象字面量
本书代码示例中有两种使用数组和对象的方式:构造函数和字面量。使用构造函数始终会产生比单纯插入元素或定义属性更多的语句,而字面量只需一条语句即可完成全部操作。
来看下面的例子:
// 创建和初始化数组用了四条语句:浪费 let values = new Array(); values[0] = 123; values[1] = 456; values[2] = 789; // 创建和初始化对象用了四条语句:浪费 let person = new Object(); person.name = "Nicholas"; person.age = 29; person.sayName = function() { console.log(this.name); };
在这个例子中,分别创建和初始化了一个数组和一个对象。两件事都用了四条语句:一条调用构造函数,三条添加数据。这些语句很容易转换成字面量形式:
// 一条语句创建并初始化数组 let values = [123, 456, 789]; // 一条语句创建并初始化对象 let person = { name: "Nicholas", age: 29, sayName() { console.log(this.name); } };
重写后的代码只有两条语句:一条创建并初始化数组,另一条创建并初始化对象。相对于前面使用了 8 条语句,这里使用两条语句,减少了 75%的语句量。对于数千行的 JavaScript 代码,这样的优化效果可能更明显。
应尽可能使用数组或对象字面量,以消除不必要的语句。
注意 减少代码中的语句量是很不错的目标,但不是绝对的法则。一味追求语句最少化,可能导致一条语句容纳过多逻辑,最终难以理解。
28.2.4 优化 DOM 交互
在所有 JavaScript 代码中,涉及 DOM 的部分无疑是非常慢的。DOM 操作和交互需要占用大量时间,因为经常需要重新渲染整个或部分页面。此外,看起来简单的操作也可能花费很长时间,因为 DOM 中携带着大量信息。理解如何优化 DOM 交互可以极大地提升脚本的执行速度。
- 实时更新最小化
访问 DOM 时,只要访问的部分是显示页面的一部分,就是在执行实时更新操作。之所以称其为实时更新,是因为涉及立即(实时)更新页面的显示,让用户看到。每次这样的更新,无论是插入一个字 符还是删除页面上的一节内容,都会导致性能损失。这是因为浏览器需要为此重新计算数千项指标,之后才能执行更新。实时更新的次数越多,执行代码所需的时间也越长。反之,实时更新的次数越少,代码执行就越快。
来看下面的例子:
let list = document.getElementById("myList"), item; for (let i = 0; i < 10; i++) { item = document.createElement("li"); list.appendChild(item); item.appendChild(document.createTextNode('Item ${i}'); }
以上代码向列表中添加了 10 项。每添加 1 项,就会有两次实时更新:一次添加
元素,一次为它添加文本节点。因为要添加 10 项,所以整个操作总共要执行 20 次实时更新。为解决这里的性能问题,需要减少实时更新的次数。有两个办法可以实现这一点。第一个办法是从页面中移除列表,执行更新,然后再把列表插回页面中相同的位置。这个办法并不可取,因为每次更新 时页面都会闪烁。第二个办法是使用文档片段构建 DOM 结构,然后一次性将它添加到 list 元素。这个办法可以减少实时更新,也可以避免页面闪烁。
比如:
let list = document.getElementById("myList"), fragment = document.createDocumentFragment(), item; for (let i = 0; i < 10; i++) { item = document.createElement("li"); fragment.appendChild(item); item.appendChild(document.createTextNode("Item " + i)); } list.appendChild(fragment);
这样修改之后,完成同样的操作只会触发一次实时更新。这是因为更新是在添加完所有列表项之后一次性完成的。文档片段在这里作为新创建项目的临时占位符。最后,使用 appendChild()将所有项 目都添加到列表中。别忘了,在把文档片段传给 appendChild()时,会把片段的所有子元素添加到父元素,片段本身不会被添加。只要是必须更新 DOM,就尽量考虑使用文档片段来预先构建 DOM 结构,然后再把构建好的 DOM结构实时更新到文档中。
在页面中创建新 DOM节点的方式有两种:使用 DOM方法如 createElement()和 appendChild(),以及使用 innerHTML。对于少量 DOM 更新,这两种技术区别不大,但对于大量 DOM 更新,使用 innerHTML 要比使用标准 DOM 方法创建同样的结构快很多。在给 innerHTML 赋值时,后台会创建 HTML 解析器,然后会使用原生 DOM 调用而不是 JavaScript 的 DOM 方法来创建 DOM 结构。原生 DOM 方法速度更快,因为该方法是执行编译代码而非解释代码。前面的例子如果使用 innerHTML 重写就是这样的:
let list = document.getElementById("myList"), html = ""; for (let i = 0; i < 10; i++) { html += '<li>Item ${i}</li>'; } list.innerHTML = html;
以上代码构造了一个HTML字符串,然后将它赋值给list.innerHTML,结果也会创建适当的 DOM结构。虽然拼接字符串也会有一些性能损耗,但这个技术仍然比执行多次 DOM 操作速度更快。与其他 DOM 操作一样,使用 innerHTML 的关键在于最小化调用次数。例如,下面的代码使用innerHTML 的次数就太多了:
let list = document.getElementById("myList"); for (let i = 0; i < 10; i++) { list.innerHTML += '<li>Item ${i}</li>'; // 不要 }
这里的问题是每次循环都会调用 innerHTML,因此效率极低。事实上,调用 innerHTML 也应该看成是一次实时更新。构建好字符串然后调用一次 innerHTML 比多次调用 innerHTML 快得多。注意 使用 innerHTML 可以提升性能,但也会暴露巨大的 XSS 攻击面。无论何时使用它填充不受控的数据,都有可能被攻击者注入可执行代码。此时必须要当心。
大多数 Web 应用程序会大量使用事件处理程序实现用户交互。一个页面中事件处理程序的数量与页面响应用户交互的速度有直接关系。为了减少对页面响应的影响,应该尽可能使用事件委托。事件委托利用了事件的冒泡。任何冒泡的事件都可以不在事件目标上,而在目标的任何祖先元素上处理。基于这个认知,可以把事件处理程序添加到负责处理多个目标的高层元素上。只要可能,就应该 在文档级添加事件处理程序,因为在文档级可以处理整个页面的事件。
let images = document.getElementsByTagName("img"); for (let i = 0, len = images.length; i < len; i++) { // 处理 }
这里的关键是把 length 保存到了 len 变量中,而不是每次都读一次 HTMLCollection 的 length属性。在循环中使用 HTMLCollection 时,应该首先取得对要使用的元素的引用,如下面所示。
这样才能避免在循环体内多次调用。
HTMLCollection:
let images = document.getElementsByTagName("img"), image; for (let i = 0, len=images.length; i < len; i++) { image = images[i]; // 处理 }
这段代码增加了 image 变量,用于保存当前的图片。有了这个局部变量,就不需要在循环中再访问 images HTMLCollection 了。编写 JavaScript 代码时,关键是要记住,只要返回 HTMLCollection 对象,就应该尽量不访问它。以下情形会返回 HTMLCollection:
28.3 部署
任何 JavaScript 解决方案最重要的部分可能就是把网站或 Web 应用程序部署到线上环境了。在此之前我们已完成了很多工作,包括架构方面和优化方面的。现在到了把代码移出开发环境,发布到网上,让用户去使用它的时候了。不过,在发布之前,还需要解决一些问题。
28.3.1 构建流程
准备发布 JavaScript 代码时最重要一环是准备构建流程。开发软件的典型模式是编码、编译和测试。换句话说,首先要写代码,然后编译,之后运行并确保它能够正常工作。但因为 JavaScript 不是编译型语言,所以这个流程经常会变成编码、测试。你写的代码跟在浏览器中测试的代码一样。这种方式的问题在于代码并不是最优的。你写的代码不应该不做任何处理就直接交给浏览器,原因如下。
为此,需要为 JavaScript 文件建立构建流程。
构建流程首先定义在源代码控制中存储文件的逻辑结构。最好不要在一个文件中包含所有JavaScript 代码。相反,要遵循面向对象编程语言的典型模式,把对象和自定义类型保存到自己独立的 文件中。这样可以让每个文件只包含最小量的代码,让后期修改更方便,也不易引入错误。此外,在使用并发源代码控制系统(如 Git、CVS 或 Subversion)的环境中,这样可以减少合并时发生冲突的风险。
注意,把代码分散到多个文件是从可维护性而不是部署角度出发的。对于部署,应该把所有源文件合并为一个或多个汇总文件。Web 应用程序使用的 JavaScript 文件越少越好,因为 HTTP 请求对某些 Web应用程序而言是主要的性能瓶颈。而且,使用<script>
标签包含 JavaScript 是阻塞性操作,这导致代码下载和执行期间停止所有其他下载任务。因此,要尽量以符合逻辑的方式把 JavaScript 代码组织到部署文件中。
如果要把大量文件组合成一个应用程序,很可能需要任务运行器自动完成一些任务。任务运行器可以完成代码检查、打包、转译、启动本地服务器、部署,以及其他可以脚本化的任务。
很多时候,任务运行器要通过命令行界面来执行操作。因此你的任务运行器可能仅仅是一个辅助组织和排序复杂命令行调用的工具。从这个意义上说,任务运行器在很多方面非常像.bashrc 文件。其他情况下,要在自动化任务中使用的工具可能是一个兼容的插件。
如果你使用 Node.js 和 npm 打印 JavaScript 资源,Grunt 和 Gulp 是两个主流的任务运行器。它们非常稳健,其任务和指令都是通过配置文件,以纯 JavaScript 形式指定的。使用 Grunt 和 Gulp 的好处是它们分别有各自的插件生态,因此可以直接使用 npm 包。关于这两个工具插件的详细信息可以参考本书附录。
摇树优化(tree shaking)是非常常见且极为有效的减少冗余代码的策略。正如第 26 章介绍模块时所提到的,使用静态模块声明风格意味着构建工具可以确定代码各部分之间的依赖关系。更重要的是,摇树优化还能确定代码中的哪些内容是完全不需要的。实现了摇树优化策略的构建工具能够分析出选择性导入的代码,其余模块文件中的代码可以在最终打包得到的文件中完全省略。
假设下面是个示例应用程序:
import { foo } from './utils.js'; console.log(foo); export const foo = 'foo'; export const bar = 'bar'; // unused
这里导出的 bar 就没有被用上,而构建工具可以很容易发现这种情况。在执行摇树优化时,构建工具会将 bar 导出完全排除在打包文件之外。静态分析也意味着构建工具可以确定未使用的依赖,同样也会排除掉。通过摇树优化,最终打包得到的文件可以瘦身很多。
以模块形式编写代码,并不意味着必须以模块形式交付代码。通常,由大量模块组成的 JavaScript代码在构建时需要打包到一起,然后只交付一个或少数几个 JavaScript 文件。模块打包器的工作是识别应用程序中涉及的 JavaScript 依赖关系,将它们组合成一个大文件,完成对模块的串行组织和拼接,然后生成最终提供给浏览器的输出文件。能够实现模块打包的工具非常多。Webpack、Rollupt 和 Browserify 只是其中的几个,可以将基于模块的代码转换为普遍兼容的网页脚本。
28.3.2 验证
即使已出现了能够理解和支持 JavaScript 的 IDE,大多数开发者仍通过在浏览器中运行代码来验证自己的语法。这种方式有很多问题。首先,如此验证不容易自动化,也不方便从一个系统移植到另一个系统。其次,除了语法错误,只有运行的代码才可能报错,没有运行到的代码则无法验证。有一些工具可以帮我们发现 JavaScript 代码中潜在的问题,最流行的是 Douglas Crockford 的 JSLint 和 ESLint。这些代码检查工具可以发现 JavaScript 代码中的语法错误和常见的编码错误。下面是它们会报告的一些问题:
28.3.3 压缩
谈到 JavaScript 文件压缩,实际上主要是两件事:代码大小(code size)和传输负载(wire weight)。代码大小指的是浏览器需要解析的字节数,而传输负载是服务器实际发送给浏览器的字节数。在 Web开发的早期阶段,这两个数值几乎相等,服务器发送给浏览器的是未经修改的源文件。而今天,这两个数值不可能相等,实际上也不应该相等。
JavaScript 不是编译成字节码,而是作为源代码传输的,所以源代码文件通常包含对浏览器的JavaScript 解释器没有用的额外信息和格式。JavaScript 压缩工具可以把源代码文件中的这些信息删除,并在保证程序逻辑不变的前提下缩小文件大小。注释、额外的空格、长变量或函数名都能提升开发者的可读性,但对浏览器而言这些都是多余的字 节。
压缩工具可以通过如下操作减少代码大小:
类似于最小化,JavaScript 代码编译通常指的是把源代码转换为一种逻辑相同但字节更少的形式。与最小化的不同之处在于,编译后代码的结构可能不同,但仍然具备与原始代码相同的行为。编译器通过输入全部 JavaScript 代码可以对程序流执行稳健的分析。编译
可能会执行如下操作:
我们提交到项目仓库中的代码与浏览器中运行的代码不一样。ES6、ES7 和 ES8 都为 ECMAScript规范扩充增加了更好用的特性,但不同浏览器支持这些规范的步调并不一致。通过 JavaScript 转译,可以在开发时使用最新的语法特性而不用担心浏览器的兼容性问题。转译可以将现代的代码转换成更早的 ECMAScript 版本,通常是 ES3 或 ES5,具体取决于你的需求。这样可以确保代码能够跨浏览器兼容。本书附录将介绍一些转译工具。
注意 “转译”(transpilation)和“编译”(compilation)经常被人当成同一个术语混用。编译是将源代码从一种语言转换为另一种语言。转译在本质上跟编译是一样的,只是目标语言与源语言是一种语言的不同级别的抽象。因此,把 ES6/ES7/ES8 代码转换为 ES3/ES5代码从技术角度看既是编译也是转译,只是转译更为确切一些。
传输负载是从服务器发送给浏览器的实际字节数。这个字节数不一定与代码大小相同,因为服务器和浏览器都具有压缩能力。所有当前主流的浏览器(IE/Edge、Firefox、Safari、Chrome 和 Opera)都支持客户端解压缩收到的资源。服务器则可以根据浏览器通过请求头部(Accept-Encoding)标明自己支持的格式,选择一种用来压缩 JavaScript 文件。在传输压缩后的文件时,服务器响应的头部会有字段(Content-Encoding)标明使用了哪种压缩格式。
浏览器看到这个头部字段后,就会根据这个压缩格式进 行解压缩。结果是通过网络传输的字节数明显小于原始代码大小。
例如,使用 Apache 服务器上的两个模块(mod_gzip 和 mod_deflate)可以减少原始 JavaScript文件的约 70%。这很大程度上是因为JavaScript的代码是纯文件,所以压缩率非常高。减少通过网络传输的数据量意味着浏览器能更快收到数据。注意,服务器压缩和浏览器解压缩都需要时间。不过相比于 通过传入更少的字节数而节省的时间,整体时间应该是减少的。
注意 大多数 Web 服务器(包括开源的和商业的)具备 HTTP 压缩能力。关于如何正确地配置压缩,请参考相关服务器的文档
28.4 小结
随着 JavaScript 开发日益成熟,最佳实践不断涌现。曾经的业余爱好如今也成为了正式的职业。因此,前端开发也需要像其他编程语言一样,注重可维护性、性能优化和部署。为保证 JavaScript 代码的可维护性,可以参考如下编码惯例。
- 其他语言的编码惯例可以作为添加注释和确定缩进的参考,但 JavaScript 作为一门适合松散类型的语言也有自己的一些特殊要求。
- 由于 JavaScript 必须与 HTML 和 CSS 共存,因此各司其职尤为重要:JavaScript 负责定义行为,HTML 负责定义内容,而 CSS 负责定义外观。
- 如果三者职责混淆,则可能导致难以调试的错误和可维护性问题。随着 Web 应用程序中 JavaScript 代码量的激增,性能也越来越重要。因此应该牢记如下这些事项。
- 执行 JavaScript 所需的时间直接影响网页性能,其重要性不容忽视。
- 很多适合 C 语言的性能优化策略同样也适合 JavaScript,包括循环展开和使用 switch 语句而不是 if 语句。
- 另一个需要重视的方面是 DOM 交互很费时间,因此应该尽可能限制 DOM 操作的数量。开发 Web 应用程序的最后一步是上线部署。以下是本章讨论的相关要点。
- 为辅助部署,应该建立构建流程,将 JavaScript 文件合并为较少的(最好是只有一个)文件。
- 构建流程可以实现很多源代码处理任务的自动化。例如,可以运行 JavaScript 验证程序,确保没有语法错误和潜在的问题。
- 压缩可以让文件在部署之前变得尽量小。
- 启用 HTTP 压缩可以让网络传输的 JavaScript 文件尽可能小,从而提升页面的整体性能。
-- end --