查看原文
其他

【第3225期】优化 Javascript,寓教于乐并从中获益

飘飘 前端早读课 2024-04-22

前言

总结了 JavaScript 代码优化的常见技巧,包括避免不必要的工作、避免字符串比较、避免不同形状的对象、避免数组 / 对象方法、避免间接性等方面。强调了在优化前进行基准测试的重要性,以及在实际应用中需要权衡性能和可读性之间的关系。还提供了可运行的示例来展示优化技巧的效果。今日前端早读课文章由 @飘飘翻译分享。

正文从这开始~~

我经常认为,JavaScript 代码的运行速度远未达到其潜在的最优水平,这主要是因为代码没有经过恰当的优化处理。以下是我总结的一些常见且有效的优化技巧。需要注意的是,提升代码运行性能往往会牺牲其可读性,因此在性能与可读性之间做出选择,这需要开发者自己权衡。此外,讨论优化不可避免地要涉及到基准测试的问题。如果一个函数在实际运行中只占很小一部分时间,那么花费大量时间去微调这个函数,使其运行速度提升 100 倍,实际上是没有太大意义的。如果我们要进行代码优化,首要且最关键的步骤就是进行基准测试。在后面的内容中,我会更详细地讨论这个话题。同时,我们需要认识到,微基准测试往往存在缺陷,这包括本文中提到的一些测试方法。尽管我已经尽力避免这些常见的错误,但在实际应用这些建议之前,我建议大家一定要先进行基准测试,不要盲目地采纳。

我为所有可行的情况提供了可执行的示例代码。这些示例默认展示了在我的设备(运行在 Arch Linux 的 Brave 122 版本)上运行的结果,但你也可以在自己的环境中运行它们来验证。虽然有些难以启齿,但不得不承认,Firefox 在性能优化方面稍显落后,目前它在流量中所占的份额非常小,因此我不建议大家将 Firefox 上的测试结果作为衡量优化效果的有效参考。

【第1314期】JavaScript 引擎基础:Shapes 和 Inline Caches

0. 减少不必要的工作

这一点虽然显而易见,但却是优化过程中不可或缺的起点:在进行优化时,首先应考虑的是如何减少不必要的工作量。这涉及到记忆化、延迟计算和增量计算等策略。根据具体的应用场景,这些策略的实施方式也会有所不同。以 React 为例,这意味着我们应该使用 memo()useMemo() 等函数来减少组件的不必要渲染,从而提高性能。

1. 避免进行字符串比较

在 Javascript 中,进行字符串比较时,其真实的性能开销往往不易被察觉。如果在 C 语言环境下进行字符串比较,我们会使用 strcmp(a, b) 这样的函数。而在 Javascript 中,我们通常使用严格等于操作符 === 来比较,这样就看不到 strcmp 的存在。然而,字符串比较的成本确实存在,通常情况下(虽然不是绝对)需要逐个字符比较两个字符串,这是一个 O(n) 时间复杂度的操作。在 Javascript 中,一个需要避免的常见做法是使用字符串作为枚举值。不过,随着 TypeScript 的普及,这种做法可以很容易地被避免,因为在 TypeScript 中,枚举默认是整数类型。

// No
enum Position {
TOP = 'TOP',
BOTTOM = 'BOTTOM',
}
// Yeppers
enum Position {
TOP, // = 0
BOTTOM, // = 1
}

这里是成本的比较:

// 1. string compare
const Position = {
TOP: 'TOP',
BOTTOM: 'BOTTOM',
}

let _ = 0
for (let i = 0; i < 1000000; i++) {
let current = i % 2 === 0 ?
Position.TOP : Position.BOTTOM
if (current === Position.TOP)
_ += 1
}
// 2. int compare
const Position = {
TOP: 0,
BOTTOM: 1,
}

let _ = 0
for (let i = 0; i < 1000000; i++) {
let current = i % 2 === 0 ?
Position.TOP : Position.BOTTOM
if (current === Position.TOP)
_ += 1
}

关于基准测试的理解
基准测试的百分比结果是指,在 1 秒内完成的运算次数与得分最高的案例中的运算次数相比较得出的数值。这个百分比越高,表示性能越好。

从结果来看,性能差异可能相当明显。这种差异不完全是因为 strcmp 函数的开销,因为 JavaScript 引擎有时会利用字符串池并通过引用来比较字符串,而是因为整数在 JavaScript 引擎中通常是按值传递的,而字符串总是作为指针传递,这样的内存访问成本较高(详见第 5 部分)。在处理大量字符串的代码中,这一点尤其能显著影响性能。

举一个实际例子,我通过将字符串常量替换为数字,使得一个 JSON5 JavaScript 解析器的运行速度提升了两倍。然而,遗憾的是,这个优化并没有被接受合并到项目中,这也是开源项目的常态。

2. 保持对象形状一致

Javascript 引擎优化代码的方式之一是假定对象有固定的形状,并且函数接收的参数对象形状相同。这样做的好处是,引擎可以为所有这种形状的对象一次性存储它们的键,并在一个单独的数组中存储对应的值。这样一来,引擎在处理这些对象时就可以更加高效。在 Javascript 中,这意味着我们应该尽量保持传递给函数的对象具有一致的结构和属性。

const objects = [
{
name: 'Anthony',
age: 36,
},
{
name: 'Eckhart',
age: 42
},
]
const shape = [
{ name: 'name', type: 'string' },
{ name: 'age', type: 'integer' },
]

const objects = [
['Anthony', 36],
['Eckhart', 42],
]

术语使用说明
在讨论这个概念时,我使用了 “形状” 这个词,但您应该意识到,根据不同的 JavaScript 引擎,您可能会遇到 “隐藏类” 或者 “映射表”(map)这样的术语来描述相同的概念。

举个例子,当 JavaScript 引擎在执行一个函数时,如果它接收到两个对象,这两个对象的结构都是 { x: number, y: number },引擎会假设后续传入的对象也将遵循这一结构。基于这样的假设,引擎会生成专门优化过的机器代码,以便更高效地处理具有这种特定结构的对象。

function add(a, b) {
return {
x: a.x + b.x,
y: a.y + b.y,
}
}

如果传递给函数的对象不是 { x, y } 这种结构,而是 { y, x } 这样的不同结构,引擎就必须重新调整它的优化策略,这会导致函数执行速度显著下降。在这里,我将不再深入解释,如果你对这个话题感兴趣并希望了解更多细节,推荐你阅读 mraleph 的精彩文章。但我要强调的是,V8 引擎特别针对不同数量的形状优化提供了三种不同的模式:单态(1 种形状)、多态(2-4 种形状)和超态(5 种以上形状)。如果你想要尽可能保持代码的高效运行,最好保持对象的形状一致,因为一旦从单态变为多态或超态,性能的下降会非常显著。

// setup
let _ = 0
// 1. monomorphic
const o1 = { a: 1, b: _, c: _, d: _, e: _ }
const o2 = { a: 1, b: _, c: _, d: _, e: _ }
const o3 = { a: 1, b: _, c: _, d: _, e: _ }
const o4 = { a: 1, b: _, c: _, d: _, e: _ }
const o5 = { a: 1, b: _, c: _, d: _, e: _ } // all shapes are equal
// 2. polymorphic
const o1 = { a: 1, b: _, c: _, d: _, e: _ }
const o2 = { a: 1, b: _, c: _, d: _, e: _ }
const o3 = { a: 1, b: _, c: _, d: _, e: _ }
const o4 = { a: 1, b: _, c: _, d: _, e: _ }
const o5 = { b: _, a: 1, c: _, d: _, e: _ } // this shape is different
// 3. megamorphic
const o1 = { a: 1, b: _, c: _, d: _, e: _ }
const o2 = { b: _, a: 1, c: _, d: _, e: _ }
const o3 = { b: _, c: _, a: 1, d: _, e: _ }
const o4 = { b: _, c: _, d: _, a: 1, e: _ }
const o5 = { b: _, c: _, d: _, e: _, a: 1 } // all shapes are different
// test case
function add(a1, b1) {
return a1.a + a1.b + a1.c + a1.d + a1.e +
b1.a + b1.b + b1.c + b1.d + b1.e }

let result = 0
for (let i = 0; i < 1000000; i++) {
result += add(o1, o2)
result += add(o3, o4)
result += add(o4, o5)
}

面对这种情况,我应该怎么做才好呢?

虽然听起来很简单,但实际上要做到这一点并不容易:在创建对象时确保它们的形状完全一致。哪怕是像在编写 React 组件的 props 时改变参数的顺序这样看似微不足道的细节,也可能成为触发性能问题的原因。

举个例子,我在 React 的代码库中发现了一些简单的例子,但几年前他们实际上已经处理过一个更严重的问题,因为他们在初始化对象时使用了整数类型,随后又存储了浮点数。确实,类型的变化会导致形状的改变。没错,数字类型背后实际上区分了整数和浮点数。面对这个现实,我们需要找到解决方案。

数字在引擎中的表示方式
不同的 JavaScript 引擎在处理数字时采用了不同的表示方法。以 V8 引擎为例,它使用 32 位的空间来存储数值,其中整数被编码成紧凑的 Smi(小型整数)格式。而对于浮点数和超出一定范围的大整数,则与字符串及对象一样,通过指针的方式来传递。另一方面,JSC 引擎采用 64 位的编码方式,结合双重标记技术,确保所有数字都以值的形式传递,这一点与 SpiderMonkey 引擎的做法相同。其他类型的数据则继续使用指针传递。

3. 尽量减少数组和对象的方法使用

虽然我和许多人一样热衷于函数式编程,但在大多数编程环境中,除非是 Haskell、OCaml 或 Rust 这类将函数式代码直接编译成高效机器代码的语言,否则函数式编程通常比命令式编程要慢。

const result =
[1.5, 3.5, 5.0]
.map(n => Math.round(n))
.filter(n => n % 2 === 0)
.reduce((a, n) => a + n, 0)

这些方法存在的主要问题包括:

  • 首先,它们在处理数组时需要创建一个完整的数组副本,而这些副本在不再需要时必须由垃圾回收器来清理。关于内存输入输出的问题,我们将在第 5 部分进行更深入的讨论。

  • 其次,这些方法在执行操作时往往需要进行 N 次循环,每次循环完成一个操作,而如果使用传统的 for 循环,我们可以一次性完成所有的 N 个操作。

// setup:
const numbers = Array.from({ length: 10_000 }).map(() => Math.random())
// 1. functional
const result =
numbers
.map(n => Math.round(n * 10))
.filter(n => n % 2 === 0)
.reduce((a, n) => a + n, 0)
// 2. imperative
let result = 0
for (let i = 0; i < numbers.length; i++) {
let n = Math.round(numbers[i] * 10)
if (n % 2 !== 0) continue
result = result + n
}

类似于数组方法,对象方法如 Object.values ()、Object.keys () 和 Object.entries () 也面临着同样的问题。这些方法在执行过程中会分配额外的数据,而内存的访问正是导致性能问题的核心所在。我非常确定这一点,不信的话,我会在第 5 部分用事实和数据来证明给你看。

4. 减少间接操作

在寻求性能优化的途径时,我们应该关注间接操作的各个方面,这类操作通常有以下三个主要的来源:

const point = { x: 10, y: 20 }

// 1.
// Proxy objects are harder to optimize because their get/set function might
// be running custom logic, so engines can't make their usual assumptions.
const proxy = new Proxy(point, { get: (t, k) => { return t[k] } })
// Some engines can make proxy costs disappear, but those optimizations are
// expensive to make and can break easily.
const x = proxy.x

// 2.
// Usually ignored, but accessing an object via `.` or `[]` is also an
// indirection. In easy cases, the engine may very well be able to optimize the
// cost away:
const x = point.x
// But each additional access multiplies the cost, and makes it harder for the
// engine to make assumptions about the state of `point`:
const x = this.state.circle.center.point.x

// 3.
// And finally, function calls can also have a cost. Engine are generally good
// at inlining these:
function getX(p) { return p.x }
const x = getX(p)
// But it's not garanteed that they can. In particular if the function call
// isn't from a static function but comes from e.g. an argument:
function Component({ point, getX }) {
return getX(point)
}

当前的代理基准测试对 V8 引擎来说特别具有挑战性。据我上次的了解,代理对象在执行时总是会从 JIT 编译模式退回到解释器模式,根据这些结果来看,这种情况可能仍然没有改变。

// 1. proxy access
const point = new Proxy({ x: 10, y: 20 }, { get: (t, k) => t[k] })

for (let _ = 0, i = 0; i < 100_000; i++) { _ += point.x }
// 2. direct access
const point = { x: 10, y: 20 }
const x = point.x

for (let _ = 0, i = 0; i < 100_000; i++) { _ += x }

我本意是想通过比较来展示直接访问与访问深度嵌套对象之间的性能差异,但是引擎在处理热循环中的常量对象时,通过逃逸分析来优化对象访问的能力非常强。为了避免这种优化影响测试结果,我特意引入了一些间接性。

// 1. nested access
const a = { state: { center: { point: { x: 10, y: 20 } } } }
const b = { state: { center: { point: { x: 10, y: 20 } } } }
const get = (i) => i % 2 ? a : b

let result = 0
for (let i = 0; i < 100_000; i++) {
result = result + get(i).state.center.point.x }
// 2. direct access
const a = { x: 10, y: 20 }.x
const b = { x: 10, y: 20 }.x
const get = (i) => i % 2 ? a : b

let result = 0
for (let i = 0; i < 100_000; i++) {
result = result + get(i) }

5. 减少缓存未命中

要理解这一点,我们需要了解一些计算机底层的工作原理,尽管如此,它对于 Javascript 性能优化同样具有重要意义。从 CPU 的视角来看,从 RAM 中读取数据是一个相对较慢的过程。为了提高效率,CPU 通常会采用两种主要的优化策略。

5.1 数据预取

第一种策略是数据预取:它会提前加载可能需要的内存数据,基于你会对连续内存地址中的数据感兴趣这一假设。因此,顺序访问数据是提高效率的关键。在接下来的示例中,我们将看到随机访问内存顺序对性能的影响。

// setup:
const K = 1024
const length = 1 * K * K

// Theses points are created one after the other, so they are allocated
// sequentially in memory.
const points = new Array(length)
for (let i = 0; i < points.length; i++) {
points[i] = { x: 42, y: 0 }
}

// This array contains the *same data* as above, but shuffled randomly.
const shuffledPoints = shuffle(points.slice())
// 1. sequential
let _ = 0
for (let i = 0; i < points.length; i++) { _ += points[i].x }
// 2. random
let _ = 0
for (let i = 0; i < shuffledPoints.length; i++) { _ += shuffledPoints[i].x }

面对这种情况,我应该怎么办?

在所有优化措施中,这个方面可能是最难实施的,因为 Javascript 并不支持直接控制对象在内存中的存放位置。你无法预期顺序创建的对象会始终位于内存中的相同位置,因为垃圾回收机制可能会在清理过程中移动它们。不过,有一个特例值得注意,那就是数值数组,特别是 TypedArray 类型的实例:

// from this
const points = [{ x: 0, y: 5 }, { x: 0, y: 10 }]

// to this
const points = new Int64Array([0, 5, 0, 10])

想要查看更加详尽的示例,请访问这个链接。 需要注意的是,虽然链接中的某些优化内容可能已经过时,但整体上仍然具有参考价值。

5.2 利用 L1/L2/L3 缓存

CPU 采用的第二种性能提升手段是 L1、L2 和 L3 级别的缓存:这些缓存可以看作是速度更快的 RAM,但由于成本较高,它们的容量相对较小。这些缓存中存储着来自 RAM 的数据,并且按照 LRU(最近最少使用)原则进行管理。当数据正在被频繁使用时,它们会被加载到缓存中,而当有新的数据需要被处理时,这些数据会被写回主 RAM。因此,关键在于尽可能减少使用的数据量,以保持你的工作集在这些快速的缓存中。在接下来的示例中,我们将看到破坏每一级缓存对性能的具体影响。

// setup:
const KB = 1024
const MB = 1024 * KB

// These are approximate sizes to fit in those caches. If you don't get the
// same results on your machine, it might be because your sizes differ.
const L1 = 256 * KB
const L2 = 5 * MB
const L3 = 18 * MB
const RAM = 32 * MB

// We'll be accessing the same buffer for all test cases, but we'll
// only be accessing the first 0 to `L1` entries in the first case,
// 0 to `L2` in the second, etc.
const buffer = new Int8Array(RAM)
buffer.fill(42)

const random = (max) => Math.floor(Math.random() * max)
// 1. L1
let r = 0; for (let i = 0; i < 100000; i++) { r += buffer[random(L1)] }
// 2. L2
let r = 0; for (let i = 0; i < 100000; i++) { r += buffer[random(L2)] }
// 3. L3
let r = 0; for (let i = 0; i < 100000; i++) { r += buffer[random(L3)] }
// 4. RAM
let r = 0; for (let i = 0; i < 100000; i++) { r += buffer[random(RAM)] }

面对这个问题,我应该怎么做?

我们需要毫不留情地剔除所有不必要的数据和内存分配,因为数据集越紧凑,程序的运行效率就越高。在大多数程序中,内存的输入输出(I/O)是性能瓶颈的主要原因。此外,一个有效的策略是将任务分解成小块,确保每次只处理一小块数据集。

想要深入了解 CPU 和内存的相关知识,可以访问这个链接获取更多信息。

不可变数据结构的优点
不可变数据结构在提高代码的清晰性和正确性方面表现得非常出色,但在性能优化的背景下,更新这类数据结构需要创建容器的副本,这会增加额外的内存输入输出操作,从而可能导致缓存失效。因此,在性能敏感的场景下,我们应该尽量避免使用不可变数据结构。

关于展开运算符(...)
展开运算符在日常编程中非常实用,但需要注意的是,每次使用它都会在内存中生成一个新的对象。这样一来,就会增加更多的内存 I/O 负担,进而影响缓存的效率。因此,虽然展开运算符用起来很方便,但在追求性能优化时,我们应该谨慎使用,以避免不必要的性能损失。

6. 避免使用大型对象

在第 2 部分中我们提到,JavaScript 引擎依赖对象的形状来实现优化。但是,一旦对象的形状变得过于庞大,引擎就不得不转而使用普通的哈希表(类似于 Map 对象)来进行处理。我们在第 5 部分了解到,缓存未命中会大幅度降低程序的运行性能。哈希表由于其数据通常在内存区域中随机且均匀地分布,因此更容易出现缓存未命中的问题。接下来,我们通过一个示例来看看,使用哈希表来存储按 ID 索引的用户信息,其性能表现如何。

// setup:
const USERS_LENGTH = 1_000
// setup:
const byId = {}
Array.from({ length: USERS_LENGTH }).forEach((_, id) => {
byId[id] = { id, name: 'John'}
})
let _ = 0
// 1. [] access
Object.keys(byId).forEach(id => { _ += byId[id].id })
// 2. direct access
Object.values(byId).forEach(user => { _ += user.id })

此外,我们还可以注意到,随着对象体积的增大,性能会持续恶化:

// setup:
const USERS_LENGTH = 100_000

面对这种情况,我应该怎么办?

正如前面展示的,我们应该尽量避免频繁地在大型对象中进行索引操作。一个更好的方法是预先将对象转换为数组形式。在组织数据时,将 ID 直接放在模型中也是一种有效的方法,这样一来,我们可以直接使用 Object.values () 方法,而无需通过键映射来访问 ID。

7. 利用 eval 函数

有些 Javascript 编程模式对于引擎来说优化起来相当困难,而通过使用 eval() 函数或其变体,我们可以消除这些难以优化的模式。例如,在这个例子中,我们可以观察到,通过使用 eval(),我们避免了创建带有动态对象键的对象所带来的额外开销。

// setup:
const key = 'requestId'
const values = Array.from({ length: 100_000 }).fill(42)
// 1. without eval
function createMessages(key, values) {
const messages = []
for (let i = 0; i < values.length; i++) {
messages.push({ [key]: values[i] })
}
return messages
}

createMessages(key, values)
// 2. with eval
function createMessages(key, values) {
const messages = []
const createMessage = new Function('value',
`return { ${JSON.stringify(key)}: value }`
)
for (let i = 0; i < values.length; i++) {
messages.push(createMessage(values[i]))
}
return messages
}

createMessages(key, values)

eval 函数的另一个有效用例是在编译过滤条件函数时,可以去除那些确定不会被执行的分支。通常情况下,任何在一个高频循环中运行的函数,都是进行这种优化的理想选择。

当然,使用 eval() 时需要遵循一些常规的安全警告:不要轻信用户输入,对传递给 eval() 函数的任何内容进行适当的清洗,避免产生 XSS 攻击的风险。同时,也要注意到某些环境可能不允许使用 eval(),例如实施了内容安全策略(CSP)的浏览器页面。在使用 eval() 时,我们必须确保代码的安全性,防止潜在的安全漏洞。

8. 谨慎处理字符串

正如之前讨论的,字符串的操作成本远高于我们的直观感受。这里有一个既包含好消息也包含坏消息的情况,我将按照逻辑顺序(先坏消息,后好消息)来说明:字符串的结构比看上去要复杂,但如果正确使用,它们也能带来相当高的效率。

由于 JavaScript 的上下文特性,字符串操作是其核心功能的一部分。为了优化那些包含大量字符串的代码,引擎必须变得富有创造性。这意味着,根据使用场景的不同,它们必须在 C++ 中以多种方式来表示 String 对象。有两个主要的情况需要我们注意,因为它们不仅适用于 V8 引擎(目前最普遍的引擎),也普遍适用于其他引擎。

首先,使用 + 符号进行字符串拼接时,并不会复制原始的两个字符串。这个操作实际上是为每个子字符串创建了一个指针。如果在 TypeScript 中实现,它可能类似于以下的形式:

class String {
abstract value(): char[] {}
}

class BytesString {
constructor(bytes: char[]) {
this.bytes = bytes
}
value() {
return this.bytes
}
}

class ConcatenatedString {
constructor(left: String, right: String) {
this.left = left
this.right = right
}
value() {
return [...this.left.value(), ...this.right.value()]
}
}

function concat(left, right) {
return new ConcatenatedString(left, right)
}

const first = new BytesString(['H', 'e', 'l', 'l', 'o', ' '])
const second = new BytesString(['w', 'o', 'r', 'l', 'd'])

// See ma, no array copies!
const message = concat(first, second)

其次,字符串的切片操作实际上也无需创建新的副本:它们可以直接引用另一个字符串中的特定区间。延续之前的例子:

class SlicedString {
constructor(source: String, start: number, end: number) {
this.source = source
this.start = start
this.end = end
}
value() {
return this.source.value().slice(this.start, this.end)
}
}

function substring(source, start, end) {
return new SlicedString(source, start, end)
}

// This represents "He", but it still contains no array copies.
// It's a SlicedString to a ConcatenatedString to two BytesString
const firstTwoLetters = substring(message, 0, 2)

然而,这里存在一个问题:当你需要开始修改这些字节时,就是你开始承担复制成本的时刻。例如,如果我们回到 String 类,并尝试实现一个 .trimEnd 方法:

class String {
abstract value(): char[] {}

trimEnd() {
// `.value()` here might be calling
// our Sliced->Concatenated->2*Bytes string!
const bytes = this.value()

const result = bytes.slice()
while (result[result.length - 1] === ' ')
result.pop()
return new BytesString(result)
}
}

接下来,让我们通过一个实例来比较,在操作中使用字符串变异与仅使用字符串连接两种方法的差异:

// setup:
const classNames = ['primary', 'selected', 'active', 'medium']
// 1. mutation
const result =
classNames
.map(c => `button--${c}`)
.join(' ')
// 2. concatenation
const result =
classNames
.map(c => 'button--' + c)
.reduce((acc, c) => acc + ' ' + c, '')

面对这种情况,我应该怎么做?

通常来说,我们应该尽可能地推迟对字符串进行变异操作。这包括使用如 .trim ()、.replace () 等方法。我们需要思考如何避免使用这些可能导致性能问题的方法。在某些 JavaScript 引擎中,字符串模板的使用速度可能比简单的加法操作(+)还要慢。目前 V8 引擎就是这样,但这可能会随着引擎的更新而改变,因此,始终进行基准测试以确认最佳实践是很有必要的。

另外,关于上述提到的 SlicedString,需要注意的是,如果一个较小的子字符串来源于一个很大的字符串,并且这个子字符串仍然被内存中的程序所使用,那么这可能会阻碍垃圾回收器回收原始的大字符串。如果你在处理大量文本数据,并从中提取出小的字符串片段,这可能会导致大量的内存泄漏。在这种情况下,我们需要特别注意内存管理,确保在不需要时及时释放资源。

const large = Array.from({ length: 10_000 }).map(() => 'string').join('')
const small = large.slice(0, 50)
// ^ will keep `large` alive

解决这个问题的方法是巧妙地运用变异方法。如果我们在一个小对象上应用变异方法,比如调用一个修改字符串的操作,这将触发创建一个新的副本,而原先指向大对象的指针将会失效:

// replace a token that doesn't exist
const small = small.replace('#'.repeat(small.length + 1), '')

想要深入了解字符串的具体实现,可以参考 V8 引擎的 string.h 文件或者 JavaScriptCore 的 JSString.h 文件。

字符串实现的复杂性
我在之前的回答中简要提及了一些内容,但实际上字符串的实现包含了许多复杂的细节。例如,不同的字符串表示方式往往有最小长度的要求。在某些情况下,对于非常短的字符串,可能不会采用字符串拼接的方式。此外,还存在一些限制条件,比如避免创建指向子字符串的子字符串。通过研读上述链接中的 C++ 源文件,即使只是浏览其中的注释,也能对字符串的底层实现有一个较为全面的了解。

9. 应用功能特化

性能提升的一个关键策略是功能特化:根据具体的应用场景和需求,调整代码逻辑以适应这些特定条件。这通常涉及到识别出在你的场景中最可能出现的情况,并针对这些特定情况编写代码。

例如,假设我们经营一家商店,偶尔需要给商品列表添加标签。根据我们的经验,我们知道这些标签通常是空的。有了这个信息,我们就可以针对这种常见情况优化我们的函数:

// setup:
const descriptions = ['apples', 'oranges', 'bananas', 'seven']
const someTags = {
apples: '::promotion::',
}
const noTags = {}

// Turn the products into a string, with their tags if applicable
function productsToString(description, tags) {
let result = ''
description.forEach(product => {
result += product
if (tags[product]) result += tags[product]
result += ', '
})
return result
}

// Specialize it now
function productsToStringSpecialized(description, tags) {
// We know that `tags` is likely to be empty, so we check
// once ahead of time, and then we can remove the `if` check
// from the inner loop
if (isEmpty(tags)) {
let result = ''
description.forEach(product => {
result += product + ', '
})
return result
} else {
let result = ''
description.forEach(product => {
result += product
if (tags[product]) result += tags[product]
result += ', '
})
return result
}
}
function isEmpty(o) { for (let _ in o) { return false } return true }
// 1. not specialized
for (let i = 0; i < 100; i++) {
productsToString(descriptions, someTags)
productsToString(descriptions, noTags)
productsToString(descriptions, noTags)
productsToString(descriptions, noTags)
productsToString(descriptions, noTags)
}
// 2. specialized
for (let i = 0; i < 100; i++) {
productsToStringSpecialized(descriptions, someTags)
productsToStringSpecialized(descriptions, noTags)
productsToStringSpecialized(descriptions, noTags)
productsToStringSpecialized(descriptions, noTags)
productsToStringSpecialized(descriptions, noTags)
}

这种优化手段虽然可能只带来中等程度的性能提升,但积少成多,其效果是值得关注的。它们是对更为关键的优化措施(比如对象形状和内存输入输出管理)的良好补充。然而,需要注意的是,如果你的业务逻辑或数据模式发生变化,过度特化可能会成为负担,因此在实施这种优化时需要谨慎。

分支预测和无分支代码编写
消除代码中的分支对于提升性能来说极其有效。想要深入了解分支预测器的工作原理,推荐阅读 StackOverflow 上的经典回答 “为什么处理已排序的数组会更快”。

10. 选择合适的数据结构

虽然我在这里不会深入讨论各种数据结构,因为它们值得单独撰写一篇文章来详细阐述,但我们需要意识到,针对特定应用场景选择不当的数据结构可能对性能产生比上述所有优化更大的影响。我建议你熟悉 JavaScript 内置的数据结构,如 Map 和 Set,并了解链表、优先队列、各种树结构(红黑树和 B+ 树)以及字典树等高级数据结构。

为了提供一个简单的例子,让我们来比较一下在处理一个小数据集时,Array.includes 方法与 Set.has 方法的性能差异:

// setup:
const userIds = Array.from({ length: 1_000 }).map((_, i) => i)
const adminIdsArray = userIds.slice(0, 10)
const adminIdsSet = new Set(adminIdsArray)
// 1. Array
let _ = 0
for (let i = 0; i < userIds.length; i++) {
if (adminIdsArray.includes(userIds[i])) { _ += 1 }
}
// 2. Set
let _ = 0
for (let i = 0; i < userIds.length; i++) {
if (adminIdsSet.has(userIds[i])) { _ += 1 }
}

从结果来看,选择合适的数据结构对性能有着极其重要的影响。

举一个实际例子,我曾经遇到一个情况,通过将数组替换为链表,我们将一个函数的执行时间从 5 秒大幅缩短到了仅 22 毫秒。

11. 基准测试的重要性

我将基准测试这一节放在最后,是因为它在优化过程中扮演着至关重要的角色。基准测试不仅是最核心的部分,而且实施起来颇具挑战性。即便拥有 20 年的丰富经验,我在创建基准测试或使用性能分析工具时仍会犯错。因此,无论你的优化工作如何进行,请务必投入最大的精力来确保基准测试的准确性。

11.0 优先处理关键部分

你的首要任务应该是优化那些占用最长时间运行的部分。如果你在其他任何地方花费时间进行优化,而不是集中在最耗时的部分,那么你的努力可能就会白费。

11.1 避免微观基准测试

你应该在生产环境下运行代码,并以此为依据进行优化。JavaScript 引擎的复杂性意味着它们在微观基准测试中的表现往往与实际应用场景大相径庭。例如,考虑以下这个微观基准测试:

const a = { type: 'div', count: 5, }
const b = { type: 'span', count: 10 }

function typeEquals(a, b) {
return a.type === b.type
}

for (let i = 0; i < 100_000; i++) {
typeEquals(a, b)
}

如果你之前已经关注了相关内容,你会认识到引擎会针对特定形状 {type: string, count: number} 来专门化函数。但是,在你的实际应用场景中,这种情况是否总是成立呢?变量 a 和 b 是否总是这种形状,或者你会接收到各种不同形状的数据?如果在实际生产环境中,你会遇到多种不同的形状,那么这个函数的表现可能就会有所不同。

11.2 对结果持怀疑态度

如果你刚刚完成了一个函数的优化,现在它的运行速度提升了 100 倍,这时候应该保持怀疑。尝试去证伪你的优化结果,在生产环境中进行测试,对它进行各种压力测试。同时,对你所使用的工具也要保持怀疑。仅仅通过开发者工具进行基准测试观察,就有可能改变代码的运行行为。

11.3 明确你的优化目标

不同的 JavaScript 引擎对某些模式的优化效果可能会有好有坏。你应该针对那些对你来说重要的引擎进行基准测试,并确定哪个引擎的优化对你更为关键。这里有一个 Babel 的真实案例,提高 V8 引擎的性能反而可能导致 JSC 引擎的性能下降。这说明在进行优化时,我们需要明确目标引擎,并根据它们的特点来进行针对性的优化。

12. 性能分析与开发工具

这里有一些关于性能分析和开发工具的小贴士和建议。

12.1 浏览器使用注意事项

如果你在浏览器中进行性能分析,请确保使用一个没有其他数据的全新浏览器配置文件。我个人甚至建议为此目的使用一个独立的浏览器。如果你在分析时启用了浏览器扩展,它们可能会干扰你的测量结果。特别是 React 开发者工具会对结果产生显著影响,使得渲染的代码看起来比你用户实际感知的要慢得多。

12.2 采样分析与结构分析

浏览器的性能分析工具通常是基于采样的,它们会定期对你的调用栈进行快照。这种方法的一个缺点是:那些非常小但调用频率极高的函数可能在两次采样之间被调用,这会导致它们在分析结果中的调用栈图中被低估。可以通过使用 Firefox 开发工具自定义采样间隔,或者使用 Chrome 开发工具的 CPU 节流功能来减轻这个问题。

12.3 专业工具介绍

除了浏览器内置的常规开发工具之外,以下这些工具也可能对你有所帮助:

  • Chrome 开发工具提供了许多实验性功能,可以帮助你诊断性能瓶颈。特别是样式失效跟踪器,在调试浏览器中的样式和布局重计算问题时非常有用。https://github.com/iamakulov/devtools-perf-features

  • deoptexplorer-vscode 扩展可以让你加载 V8/Chromium 的日志文件,以便了解你的代码何时触发了去优化事件,例如当你向函数传递了不同形状的参数时。虽然不使用这个扩展也能阅读日志文件,但使用它会让整个过程更加便捷愉快。https://github.com/microsoft/deoptexplorer-vscode

  • 你还可以为每个 JavaScript 引擎编译一个调试版本的 shell,这样可以更深入地了解引擎的工作原理。这使得你能够运行性能分析和其他低级工具,同时还能检查每个引擎生成的字节码和机器代码。V8 示例 | JSC 示例 | SpiderMonkey 示例(缺失)

关于本文
译者:@飘飘
作者:@romgrk
原文:https://romgrk.com/posts/optimizing-javascript

这期前端早读课
对你有帮助,帮” 
 “一下,
期待下一期,帮”
 在看” 一下 。

继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存