我经常觉得JavaScript代码通常运行得慢仅仅是因为它没有得到适当的优化。下面是我发现有用的常用优化技术的总结。
性能优化的权衡通常是可读性,因此何时选择性能还是可读性是留给读者的问题。讨论优化必然需要讨论基准测试。如果一个函数只代表了实际运行时间的一小部分,那么花几个小时对函数进行微优化以使其运行速度提高100倍是毫无意义的。如果要进行优化,第一步也是最重要的一步是基准测试。
我已经为所有的场景提供了可运行的示例。默认显示的是在我的机器上得到的结果(brave 122 on archlinux),你可以自己运行它们。这里不建议使用Firefox上的结果作为参考指标。
如果你需要在C中比较字符串,你可以使用strcmp(a, b)
函数。JavaScript使用===
,所以你看不到strcmp
。但是它在那里,字符串比较通常需要将字符串中的每个字符与另一个字符串中的字符进行比较,字符串比较是O(n)
。要避免的一种常见JavaScript模式是字符串枚举。但随着TypeScript的出现,这应该很容易避免,因为枚举默认为整数。
以下是比较成本:
// 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成本,因为引擎有时可以使用字符串池并通过引用进行比较,但这也是由于整数通常在JS引擎中通过值传递,而字符串总是作为指针传递,并且内存访问是昂贵的。在字符串密集的代码中,这可能会产生巨大的影响。
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], ]
我使用了“ shapes 形状”这个词来描述这个概念,但要注意,您可能也会发现“隐藏类”或“映射”用于描述它。
例如运行时,如果下面的函数接收到两个具有形状{ x: number, y: number }
的对象,则引擎将推测未来的对象将具有相同的形状,并生成针对该形状优化的机器代码。
function add(a, b) { return { x: a.x + b.x, y: a.y + b.y, } }
如果传递的对象不是形状{ x, y }
而是形状{ y, x }
,则引擎将需要撤销其推测,并且函数将突然变得相当慢。我要强调的是,V8特别有3种模式用于访问:单态(1个形状),多态(2-4个形状),和megamorphic(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) }
我和其他人一样喜欢函数式编程,但是除非你在Haskell/OCaml/Rust中工作,函数式代码被编译成高效的机器代码,否则函数式总是比命令式慢。
// 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()
这样的对象方法也有类似的问题,因为它们也分配了更多的数据,而内存访问是所有性能问题的根源。
另一个寻找优化收益的地方是避免任何间接来源,通过以下数据可以看出差距。
// 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) }
这一点需要一些低级的知识,但即使在JavaScript中也有含义。从CPU的角度来看,从RAM中检索内存是很慢的。为了加快速度,它主要使用两种优化。
第一个是预取:它提前获取更多的内存,希望它是你需要的内存。它会猜测你请求一个内存地址后,你会对紧接着的内存区域有需要。所以顺序访问数据是关键。在下面的例子中,我们可以观察到以随机顺序访问内存的影响。
// 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 }
CPU使用的第二个优化是L1/L2/L3缓存:它们就像更快的RAM,但它们也更昂贵,所以它们要小得多。它们包含RAM数据,但充当LRU缓存。当新的工作数据需要空间时,数据被写回主RAM。因此,这里的关键是使用尽可能少的数据来将工作数据集保留在快速缓存中。在下面的例子中,我们可以观察到破坏每个连续缓存的效果。
// setup: const KB = 1024 const MB = 1024 * KB const L1 = 256 * KB const L2 = 5 * MB const L3 = 18 * MB const RAM = 32 * MB 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是95%程序的瓶颈。另一个好的策略是将您的工作分成块,并确保您一次处理一个小数据集。
如第2节所述,引擎使用固定形状来优化对象。然而当对象变得太大时,引擎别无选择,只能使用常规的散列表(如Map对象)。正如我们在第5节中看到的,缓存未命中会显著降低性能。哈希图很容易出现这种情况,因为它们的数据通常随机均匀地分布在它们所占用的内存区域中。
// 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。
有一些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()代码的任何内容,不要创建任何XSS可能性。还要注意,某些环境不允许访问eval(),例如带有CSP的浏览器页面。
我不会详细介绍数据结构,因为它们需要单独说明。但是请注意,为您的用例使用不正确的数据结构可能会比上面的任何优化都产生更大的影响。我建议你熟悉本地的,比如Map和Set,并学习链表,优先级队列,树(RB和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 } }
正如你所看到的,数据结构的选择产生了非常大的影响。
本文主要讨论了JavaScript代码性能优化的技巧。许多JavaScript代码的性能没有达到最佳,主要是因为没有进行适当的优化。
在优化时要考虑性能和可读性之间的权衡,并建议在优化前进行基准测试。也要考虑实际的运行环境,并使用合适的工具和方法来测试和验证优化效果。希望你能学到一些有用的技巧。