程序性能相关整理 第 18 章 - 结束
取得 WebGL 上下文后,就可以开始 3D 绘图了。如前所述,因为 WebGL 是 OpenGL ES 2.0 的 Web版,所以本节讨论的概念实际上是 JavaScript 所实现的 OpenGL 概念。可以在调用 getContext()取得 WebGL 上下文时指定一些选项。这些选项通过一个参数对象传入,选项就是参数对象的一个或多个属性。
建议在充分了解这个选项的作用后再自行修改,因为这可能会影响性能。
可以像下面这样传入 options 对象:
let drawing = document.getElementById("drawing");
// 确保浏览器支持<canvas>
if (drawing.getContext) {
let gl = drawing.getContext("webgl", { alpha: false });
if (gl) {
// 使用 WebGL
}
}
这些上下文选项大部分适合开发高级功能。多数情况下,默认值就可以满足要求。
随着 Web 浏览器能力的增加,其复杂性也在迅速增加。从很多方面看,现代 Web 浏览器已经成为构建于诸多规范之上、集不同 API 于一身的“瑞士军刀”。浏览器规范的生态在某种程度上是混乱而无 序的。一些规范如 HTML5,定义了一批增强已有标准的 API 和浏览器特性。而另一些规范如 Web Cryptography API 和 Notifications API,只为一个特性定义了一个API。不同浏览器实现这些新 API 的情况也不同,有的会实现其中一部分,有的则干脆尚未实现。
最终,是否使用这些比较新的 API 还要看项目是支持更多浏览器,还是要采用更多现代特性。有些API 可以通过腻子脚本来模拟,但腻子脚本通常会带来性能问题,此外也会增加网站 JavaScript 代码的体积。
任何全局上下文中都有 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,因为在所有已知的相关硬件上都是支持的。能够如此假设通常可以简化程序。
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()抛出错误。
页面性能始终是 Web 开发者关心的话题。Performance 接口通过 JavaScript API 暴露了浏览器内部的度量指标,允许开发者直接访问这些信息并基于这些信息实现自己想要的功能。这个接口暴露在 window.performance 对象上。所有与页面相关的指标,包括已经定义和将来会定义的,都会存在于这个对象上。Performance 接口由多个 API 构成:
有关这些规范的更多信息以及新增的性能相关规范,可以关注 W3C 性能工作组的 GitHub 项目页面。
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
// }
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
通过计算并分析不同时间的差,可以更全面地审视浏览器加载页面的过程,发现可能存在的性能瓶颈。
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。
如果没了密钥,那密码学也就没什么意义了。SubtleCrypto 对象使用 CryptoKey 类的实例来生成密钥。CryptoKey 类支持多种加密算法,允许控制密钥抽取和使用。CryptoKey 类支持以下算法,按各自的父密码系统归类。
注意 CryptoKey 支持很多算法,但其中只有部分算法能够用于 SubtleCrypto 的方法。要了解哪个方法支持什么算法,可以参考 W3C 网站上 Web Cryptography API 规范的“Algorithm Overview”
注意 POST 请求相比 GET 请求要占用更多资源。从性能方面说,发送相同数量的数据,GET 请求比 POST 请求要快两倍。
还有一种叫作 HTTP-only 的 cookie。HTTP-only 可以在浏览器设置,也可以在服务器设置,但只能在服务器上读取,这是因为 JavaScript 无法取得这种 cookie 的值。因为所有 cookie 都会作为请求头部由浏览器发送给服务器,所以在 cookie 中保存大量信息可能会影响特定域浏览器请求的性能。保存的 cookie 越大,请求完成的时间就越长。即使浏览器对 cookie 大小有限制,最好还是尽可能只通过 cookie 保存必要信息,以避免性能问题。对 cookie 的限制及其特性决定了 cookie 并不是存储大量数据的理想方式。因此,其他客户端存储技术出现了。
相互依赖的模块必须指定一个模块作为入口(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 文件并不理想,并且手动管理正确的加载顺序也颇为棘手。
因为 JavaScript 可以异步执行,所以如果能按需加载就好了。换句话说,可以让 JavaScript 通知模块系统在必要时加载新模块,并在模块加载完成后提供回调。在代码层面,可以通过下面的伪代码来实现:
// 在模块 A 里面
load('moduleB').then(function(moduleB) {
moduleB.doStuff();
});
模块 A 的代码使用了 moduleB 标识符向模块系统请求加载模块 B,并以模块 B 作为参数调用回调。模块 B 可能已加载完成,也可能必须重新请求和初始化,但这里的代码并不关心。这些事情都交给了模块加载器去负责。
如果重写前面的应用程序,只使用动态模块加载,那么使用一个<script>
标签即可完成模块 A 的加载。模块 A 会按需请求模块文件,而不会生成必需的依赖列表。这样有几个好处,其中之一就是性能,因为在页面加载时只需同步加载一个文件。
这些脚本也可以分离出来,比如给<script>
标签应用 defer 或 async 属性,再加上能够识别异步脚本何时加载和初始化的逻辑。此行为将模拟在 ES6 模块规范中实现的行为,本章稍后会对此进行讨论。
Worker()构造函数允许将可选的配置对象作为第二个参数。该配置对象支持下列属性。
注意 有的现代浏览器还不完全支持模块工作者线程或可能需要修改标志才能支持。
工作者线程需要基于脚本文件来创建,但这并不意味着该脚本必须是远程资源。专用工作者线程也可以通过 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,因为这些引用在工作者线程中执行时会出错。
相比 JavaScript 刚问世时,目前每个网页中 JavaScript 代码的数量已有极大的增长。代码量的增长也带来了运行时执行 JavaScript 的性能问题。JavaScript 一开始就是一门解释型语言,因此执行速度比编译型语言要慢一些。Chrome 是第一个引入优化引擎将 JavaScript 编译为原生代码的浏览器。随后,其他主流浏览器也紧随其后,实现了 JavaScript 编译。即使到了编译 JavaScript 时代,仍可能写出运行慢的代码。不过,如果遵循一些基本模式,就能保证写出执行速度很快的代码。
第 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 语句的情况,因为它的主要用途是节省一点代码。大多数情况下,使用局部变量可以实现同样的效果,无须增加新作用域。下面看一个例子:
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 保存在局部变量中来省去全局查找。
与其他语言一样,影响性能的因素通常涉及算法或解决问题的方法。经验丰富的开发者知道用什么方法性能更佳。通常很多能在其他编程语言中提升性能的技术和方法同样也适用于 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 代码中语句的数量影响操作执行的速度。一条可以执行多个操作的语句,比多条语句中每个语句执行一个操作要快。那么优化的目标就是寻找可以合并的语句,以减少整个脚本的执行时间。为此,可以参考如下几种模式。
声明多个变量时很容易出现多条语句。比如,下面使用多个 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 代码,这样的优化效果可能更明显。
应尽可能使用数组或对象字面量,以消除不必要的语句。
注意 减少代码中的语句量是很不错的目标,但不是绝对的法则。一味追求语句最少化,可能导致一条语句容纳过多逻辑,最终难以理解。
在所有 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);
在页面中创建新 DOM节点的方式有两种:使用 DOM方法如 createElement()和 appendChild(),以及使用 innerHTML。对于少量 DOM 更新,这两种技术区别不大,但对于大量 DOM 更新,使用 innerHTML 要比使用标准 DOM 方法创建同样的结构快很多。在给 innerHTML 赋值时,后台会创建 HTML 解析器,然后会使用原生 DOM 调用而不是 JavaScript 的 DOM 方法来创建 DOM 结构。原生 DOM 方法速度更快,因为该方法是执行编译代码而非解释代码。前面的例子如果使用 innerHTML 重写就是这样的:
以上代码构造了一个HTML字符串,然后将它赋值给list.innerHTML,结果也会创建适当的 DOM结构。虽然拼接字符串也会有一些性能损耗,但这个技术仍然比执行多次 DOM 操作速度更快。与其他 DOM 操作一样,使用 innerHTML 的关键在于最小化调用次数。例如,下面的代码使用innerHTML 的次数就太多了:
这里的问题是每次循环都会调用 innerHTML,因此效率极低。事实上,调用 innerHTML 也应该看成是一次实时更新。构建好字符串然后调用一次 innerHTML 比多次调用 innerHTML 快得多。注意 使用 innerHTML 可以提升性能,但也会暴露巨大的 XSS 攻击面。无论何时使用它填充不受控的数据,都有可能被攻击者注入可执行代码。此时必须要当心。
let images = document.getElementsByTagName("img");
for (let i = 0, len = images.length; i < len; i++) {
// 处理
}
let images = document.getElementsByTagName("img"),
image;
for (let i = 0, len=images.length; i < len; i++) {
image = images[i];
// 处理
}
28.3.1 构建流程
为此,需要为 JavaScript 文件建立构建流程。
注意,把代码分散到多个文件是从可维护性而不是部署角度出发的。对于部署,应该把所有源文件合并为一个或多个汇总文件。Web 应用程序使用的 JavaScript 文件越少越好,因为 HTTP 请求对某些 Web应用程序而言是主要的性能瓶颈。而且,使用<script>
标签包含 JavaScript 是阻塞性操作,这导致代码下载和执行期间停止所有其他下载任务。因此,要尽量以符合逻辑的方式把 JavaScript 代码组织到部署文件中。
很多时候,任务运行器要通过命令行界面来执行操作。因此你的任务运行器可能仅仅是一个辅助组织和排序复杂命令行调用的工具。从这个意义上说,任务运行器在很多方面非常像.bashrc 文件。其他情况下,要在自动化任务中使用的工具可能是一个兼容的插件。
摇树优化(tree shaking)是非常常见且极为有效的减少冗余代码的策略。正如第 26 章介绍模块时所提到的,使用静态模块声明风格意味着构建工具可以确定代码各部分之间的依赖关系。更重要的是,摇树优化还能确定代码中的哪些内容是完全不需要的。实现了摇树优化策略的构建工具能够分析出选择性导入的代码,其余模块文件中的代码可以在最终打包得到的文件中完全省略。假设下面是个示例应用程序:
这里导出的 bar 就没有被用上,而构建工具可以很容易发现这种情况。在执行摇树优化时,构建工具会将 bar 导出完全排除在打包文件之外。静态分析也意味着构建工具可以确定未使用的依赖,同样也会排除掉。通过摇树优化,最终打包得到的文件可以瘦身很多。
28.3.2 验证
28.3.3 压缩
JavaScript 不是编译成字节码,而是作为源代码传输的,所以源代码文件通常包含对浏览器的JavaScript 解释器没有用的额外信息和格式。JavaScript 压缩工具可以把源代码文件中的这些信息删除,并在保证程序逻辑不变的前提下缩小文件大小。注释、额外的空格、长变量或函数名都能提升开发者的可读性,但对浏览器而言这些都是多余的字 节。压缩工具可以通过如下操作减少代码大小:
我们提交到项目仓库中的代码与浏览器中运行的代码不一样。ES6、ES7 和 ES8 都为 ECMAScript规范扩充增加了更好用的特性,但不同浏览器支持这些规范的步调并不一致。通过 JavaScript 转译,可以在开发时使用最新的语法特性而不用担心浏览器的兼容性问题。转译可以将现代的代码转换成更早的 ECMAScript 版本,通常是 ES3 或 ES5,具体取决于你的需求。这样可以确保代码能够跨浏览器兼容。本书附录将介绍一些转译工具。
传输负载是从服务器发送给浏览器的实际字节数。这个字节数不一定与代码大小相同,因为服务器和浏览器都具有压缩能力。所有当前主流的浏览器(IE/Edge、Firefox、Safari、Chrome 和 Opera)都支持客户端解压缩收到的资源。服务器则可以根据浏览器通过请求头部(Accept-Encoding)标明自己支持的格式,选择一种用来压缩 JavaScript 文件。在传输压缩后的文件时,服务器响应的头部会有字段(Content-Encoding)标明使用了哪种压缩格式。浏览器看到这个头部字段后,就会根据这个压缩格式进 行解压缩。结果是通过网络传输的字节数明显小于原始代码大小。
注意 大多数 Web 服务器(包括开源的和商业的)具备 HTTP 压缩能力。关于如何正确地配置压缩,请参考相关服务器的文档
随着 JavaScript 开发日益成熟,最佳实践不断涌现。曾经的业余爱好如今也成为了正式的职业。因此,前端开发也需要像其他编程语言一样,注重可维护性、性能优化和部署。为保证 JavaScript 代码的可维护性,可以参考如下编码惯例。
-- end --