likes
comments
collection
share

优化JavaScript:乐趣与收益我常常觉得,JavaScript代码的运行速度远低于其应有的水平,仅仅是因为没有进行

作者站长头像
站长
· 阅读数 20

我常常觉得,JavaScript代码的运行速度远低于其应有的水平,仅仅是因为没有进行适当的优化。以下是我发现的一些有用的常见优化技术总结。需要注意的是,性能的提升往往以可读性为代价,因此何时追求性能,何时追求可读性,这个问题留给读者自行决定。我还要指出,谈论优化必然涉及到基准测试。如果一个函数只占整体运行时间的一小部分,那么花费数小时将其优化到运行速度快100倍是毫无意义的。如果进行优化,第一步也是最重要的一步就是基准测试。我将在后面的内容中讨论这个话题。还要注意的是,微基准测试往往存在缺陷,这里提到的内容也可能包含这些问题。我已经尽力避免这些陷阱,但请不要盲目应用这里提到的任何点,而应先进行基准测试。

0. 避免计算(avoid work)


这听起来就是像是一句”没用的废话“,但它必须放在这。因为没有其他步骤可以作为优化的第一步:如果你正在尝试优化,首先应该考虑避免计算。这包括记忆化、惰性计算和增量计算等概念。这些概念的应用会根据上下文的不同而有所不同。例如,在React中,这意味着应用memo()useMemo()和其他适用的API。

1. 避免字符串比较


JavaScript使得隐藏字符串比较的真实成本变得容易。如果你需要在C中比较字符串,你会使用strcmp(a, b)函数。JavaScript则使用===,所以你看不到strcmp。但它确实存在,字符串比较通常(但并非总是)需要将字符串中的每个字符与另一个字符串中的字符进行比较;字符串比较是O(n)的。一个常见的JavaScript模式是避免使用字符串作为枚举。但随着TypeScript的出现,这应该很容易避免,因为枚举默认是整数。

    // 不推荐
    enum Position {
      TOP    = 'TOP',
      BOTTOM = 'BOTTOM',
    }

    // 推荐
    enum Position {
      TOP,    // = 0
      BOTTOM, // = 1
    }

以下是关于运行耗时的比较demo:


    // 1. 字符串比较
    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. 整数比较
    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
    }

一个现实世界的例子

通过将字符串常量替换为数字,成功地将这个 JSON5 JavaScript 解析器运行速度提高了2倍

2. 避免不同的形状(shape)

JavaScript 引擎尝试通过假设对象具有特定的形状(shape),并且函数将接收相同形状(shape)的对象来优化代码。这使得它们可以为该形状的所有对象存储一次键,并将值存储在单独的扁平数组中。用 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],
]
术语说明

我在这个概念中使用了“形状(shape)”这个词,但请注意,根据引擎的不同,你可能会发现用“隐藏类”或“映射”来描述它。

例如,在运行时,如果以下函数接收到两个形状为 { 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 引擎有 3 种访问模式:单态(1 种形状)、多态(2-4 种形状)和超多态(5 种及以上形状)。

// 设置
let _ = 0

// 1. 单态
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: _ } // 所有形状相同

// 2. 多态
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: _ } // 这个形状不同

// 3. 超多态
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 } // 所有形状不同

// 测试用例
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 组件属性这样微不足道的事情也可能触发这种情况。

例如, 在 React 代码库中发现的简单案例,但几年前他们已经有了一个影响更大的案例,因为他们最初用整数初始化了一个对象,然后后来存储了一个浮点数,改变类型也会改变形状(shape)。在 js 中, number 背后隐藏着整数和浮点数类型。

数字表示

引擎通常可以将整数编码为值。例如,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)

这些方法的问题在于:

  1. 它们需要对数组进行完整复制,而这些副本稍后需要由垃圾收集器释放。我们将在第5节中更详细地探讨内存I/O的问题。
  2. 它们对N个操作循环N次,而for循环则允许只循环一次。
    // 设置:
    const numbers = Array.from({ length: 10_000 }).map(() => Math.random())

    // 1. 函数式
    const result =
      numbers
        .map(n => Math.round(n * 10))
        .filter(n => n % 2 === 0)
        .reduce((a, n) => a + n, 0)

    // 2. 命令式
    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() 也存在类似问题,因为它们也会分配更多数据,而内存访问是所有性能问题的根源。

4. 避免间接访问

另一个需要寻找优化收益的地方是任何形式的间接访问,我能看到的主要有三种:

    const point = { x: 10, y: 20 }
     
    // 1.
    // 代理对象更难优化,因为它们的get/set函数可能运行自定义逻辑,所以引擎无法做出通常的假设。
    const proxy = new Proxy(point, { get: (t, k) => { return t[k] } })
    // 一些引擎可以使代理成本消失,但这些优化成本高昂且容易破坏。
    const x = proxy.x
     
    // 2.
    // 通常被忽略,但通过`.`或`[]`访问对象也是一种间接访问。在简单情况下,引擎可能能够优化掉这些成本:
    const x = point.x
    // 但每次额外的访问都会增加成本,并使引擎更难对`point`的状态做出假设:
    const x = this.state.circle.center.point.x
     
    // 3.
    // 最后,函数调用也可能有成本。引擎通常擅长内联这些调用:
    function getX(p) { return p.x }
    const x = getX(p)
    // 但这并不保证它们能够内联。特别是如果函数调用不是来自静态函数,而是来自例如参数:
    function Component({ point, getX }) {
      return getX(point)
    }

代理基准测试对 V8 来说尤其残酷。上次我检查时,代理对象总是从 JIT 回退到解释器,从这些结果来看,可能仍然是这种情况。

    // 1. 代理访问
    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. 直接访问
    const point = { x: 10, y: 20 }
    const x = point.x
     
    for (let _ = 0, i = 0; i < 100_000; i++) { _ += x }

展示我也想展示访问深度嵌套对象与直接访问的区别,但引擎在有热循环和常量对象时,通过逃逸分析优化对象访问非常擅长。

    // 1. 嵌套访问
    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. 直接访问
    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中获取内存是缓慢的。为了加快速度,它主要使用了两种优化。

5.1 预取

第一种是预取:它提前获取更多的内存,希望这是你感兴趣的内存。它总是猜测,如果你请求一个内存地址,你会对紧随其后的内存区域感兴趣。因此,按顺序访问数据是关键。在下面的例子中,我们可以观察到按随机顺序访问内存的影响。

    // 设置:
    function shuffle(array) {
      let currentIndex = array.length,  randomIndex;
     
      while (currentIndex > 0) {
        randomIndex = Math.floor(Math.random() * currentIndex);
        currentIndex--;
        [array[currentIndex], array[randomIndex]] = [
          array[randomIndex], array[currentIndex]];
      }
     
      return array;
    }

    // 设置:
    const K = 1024
    const length = 1 * K * K
     
    // 这些点是依次创建的,因此它们在内存中是按顺序分配的。
    const points = new Array(length)
    for (let i = 0; i < points.length; i++) {
      points[i] = { x: 42, y: 0 }
    }
     
    // 这个数组包含与上面相同的数据,但随机打乱了顺序。
    const shuffledPoints = shuffle(points.slice())

    // 1. 顺序访问
    let _ = 0
    for (let i = 0; i < points.length; i++) { _ += points[i].x }

    // 2. 随机访问
    let _ = 0
    for (let i = 0; i < shuffledPoints.length; i++) { _ += shuffledPoints[i].x }
应该对此做些什么?

这方面可能是最难付诸实践的,因为JavaScript没有在内存中放置对象的方式,但你可以利用这些知识,例如在上面的例子中,在重新排序或排序数据之前对其进行操作。你不能假设按顺序创建的对象在一段时间后会保持在同一位置,因为垃圾收集器可能会移动它们。有一个例外,那就是数字数组,最好是TypedArray实例:

    // 从这样
    const points = [{ x: 0, y: 5 }, { x: 0, y: 10 }]
     
    // 到这样
    const points = new Int64Array([0, 5, 0, 10])

有关更详细的示例,请参见此链接。 *注意,它包含了一些现在已经过时的优化,但总体上仍然是准确的。

5.2 L1/2/3缓存

第二种优化是L1/2/3缓存:CPU将频繁访问的数据存储在更快的内存中,以便更快地访问。CPU 使用的第二种优化是 L1/L2/L3 缓存:这些缓存类似于更快的 RAM,但它们也更昂贵,因此它们的容量要小得多。它们包含 RAM 数据,但作为 LRU 缓存工作。数据在“热”(正在处理)时进入缓存,并在需要空间处理新数据时写回主 RAM。因此,关键在于尽可能少地使用数据,以使您的工作数据集保持在快速缓存中。在下面的示例中,我们可以观察到突破每个连续缓存的效果。

// 设置:
const KB = 1024
const MB = 1024 * KB

// 这些是适合这些缓存的大致大小。如果您在您的机器上没有得到相同的结果,可能是因为您的大小不同。
const L1  = 256 * KB
const L2  =   5 * MB
const L3  =  18 * MB
const RAM =  32 * MB

// 我们将为所有测试用例访问同一个缓冲区,但在第一个用例中,我们只访问前 0 到 `L1` 个条目,
// 在第二个用例中,我们访问前 0 到 `L2` 个条目,依此类推。
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% 程序的瓶颈。另一个好的策略是将工作分成小块,并确保每次只处理一小部分数据集。

有关 CPU 和内存的更多详细信息,请参阅此链接

关于不可变数据结构

不可变性对于清晰性和正确性非常有用,但在性能方面,更新不可变数据结构意味着复制容器,这会带来更多的内存 I/O,从而刷新缓存。应尽可能避免使用不可变数据结构。

关于 { ...spread } 操作符

它非常方便,但每次使用它时都会在内存中创建一个新对象。更多的内存 I/O,更慢的缓存!

避免大对象

如第 2 节所述,引擎使用形状(shape)来优化对象。然而,当形状变得太大时,引擎别无选择,只能使用常规的哈希表(如 Map 对象)。正如我们在第 5 节中看到的,缓存未命中会显著降低性能。哈希表容易出现这种情况,因为它们的数据通常在它们占用的内存区域中随机且均匀地分布。让我们看看这个按用户 ID 索引的用户映射的行为。

// 设置:
const USERS_LENGTH = 1_000

// 设置:
const byId = {}
Array.from({ length: USERS_LENGTH }).forEach((_, id) => {
  byId[id] = { id, name: 'John'}
})
let _ = 0

// 1. [] 访问
Object.keys(byId).forEach(id => { _ += byId[id].id })

// 2. 直接访问
Object.values(byId).forEach(user => { _ += user.id })

我们还可以观察到随着对象大小的增长,性能如何持续下降:

// 设置:
const USERS_LENGTH = 100_000

// 设置:
const byId = {}
Array.from({ length: USERS_LENGTH }).forEach((_, id) => {
  byId[id] = { id, name: 'John'}
})
let _ = 0

// 1. [] 访问
Object.keys(byId).forEach(id => { _ += byId[id].id })

// 2. 直接访问
Object.values(byId).forEach(user => { _ += user.id })
我该怎么办?

无情地消除每一个可以消除的数据或内存分配。您的数据集越小,程序运行得越快。内存 I/O 占据了程序绝大多数的瓶颈。一个好的策略是将工作分成小块,并确保每次只处理一小部分数据集。如上所示,避免频繁地对大型对象进行索引操作。最好事先将对象转换为数组。将数据组织成在模型中包含ID的形式会有所帮助,因为你可以使用Object.values()而不必通过键映射来获取ID。

7. 使用 eval

某些JavaScript模式对于引擎来说很难优化,而通过使用eval()或其衍生方法,你可以使这些模式消失。在这个例子中,我们可以观察到使用eval()如何避免创建具有动态对象键的对象的成本:

    // 设置:
    const key = 'requestId'
    const values = Array.from({ length: 100_000 }).fill(42)

    // 1. 不使用 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. 使用 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的浏览器页面。

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'])
     
    // 看,没有数组复制!
    const message = concat(first, second)

其次,字符串切片也不需要创建副本:它们可以简单地指向另一个字符串中的范围。继续上面的例子:```typescript

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)
    }
     
    // 这表示 "He",但它仍然不包含任何数组副本。
    // 它是一个指向 ConcatenatedString 的 SlicedString,而 ConcatenatedString 又指向两个 BytesString
    const firstTwoLetters = substring(message, 0, 2)

但这里有一个问题:一旦你需要开始修改这些字节,那就是你开始支付复制成本的时刻。假设我们回到 String 类,并尝试添加一个 .trimEnd 方法:

class String {
      abstract value(): char[] {}
     
      trimEnd() {
        // 这里的 `.value()` 可能会调用
        // 我们的 Sliced->Concatenated->2*Bytes 字符串!
        const bytes = this.value()
     
        const result = bytes.slice()
        while (result[result.length - 1] === ' ')
          result.pop()
        return new BytesString(result)
      }
    }

所以让我们跳到一个例子,比较使用突变操作与仅使用连接操作的情况:

// 设置:
const classNames = ['primary', 'selected', 'active', 'medium']

// 1. 突变
const result =
  classNames
    .map(c => `button--${c}`)
    .join(' ')

// 2. 连接
const result =
  classNames
    .map(c => 'button--' + c)
    .reduce((acc, c) => acc + ' ' + c, '')
我该怎么办?

总的来说,尽量尽可能长时间地避免突变。这包括 .trim().replace() 等方法。考虑如何避免这些方法。在某些引擎中,字符串模板可能比 + 更慢。目前 V8 就是这种情况,但未来可能会有所改变,所以一如既往,进行基准测试。

关于上面的 SlicedString,需要注意的是,如果一个小的子字符串指向一个非常大的字符串,它可能会阻止垃圾收集器收集大字符串!如果你正在处理大文本并从中提取小字符串,你可能会泄漏大量内存。

const large = Array.from({ length: 10_000 }).map(() => 'string').join('')
const small = large.slice(0, 50)
//    ^ 将保持 `large` 存活

这里的解决方案是利用突变方法。如果我们对 small 使用其中一个方法,它将强制复制,并且旧的 large 指针将丢失:

// 替换一个不存在的标记
const small = small.replace('#'.repeat(small.length + 1), '')

更多详情,请参阅 V8 的 string.hJavaScriptCore 的 JSString.h

关于字符串的复杂性

我快速浏览了一些内容,但字符串的实现细节增加了许多复杂性。通常每个字符串表示都有最小长度。例如,对于非常小的字符串可能不会使用连接字符串。或者有时有避免指向子字符串的子字符串的限制。阅读上面链接的 C++ 文件可以很好地了解实现细节,即使只是阅读注释。

9. 使用专业化

性能优化中的一个重要概念是专业化:使你的逻辑适应特定用例的约束。这通常意味着找出哪些条件对你的情况可能为真,并为这些条件编写代码。

假设我们是一个有时需要为产品列表添加商家的标签。根据经验,我们知道我们的标签通常是空的。知道这些信息后,我们可以为这种情况专门化我们的函数:

代码优化示例

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

// 将产品转换为字符串,如果适用则添加标签
function productsToString(description, tags) {
  let result = '';
  description.forEach(product => {
    result += product;
    if (tags[product]) result += tags[product];
    result += ', ';
  });
  return result;
}

// 现在进行专门化处理
function productsToStringSpecialized(description, tags) {
  // 我们知道 `tags` 很可能为空,所以提前检查一次
  // 然后我们可以从内部循环中移除 `if` 检查
  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. 未专门化
for (let i = 0; i < 100; i++) {
  productsToString(descriptions, someTags);
  productsToString(descriptions, noTags);
  productsToString(descriptions, noTags);
  productsToString(descriptions, noTags);
  productsToString(descriptions, noTags);
}

// 2. 专门化
for (let i = 0; i < 100; i++) {
  productsToStringSpecialized(descriptions, someTags);
  productsToStringSpecialized(descriptions, noTags);
  productsToStringSpecialized(descriptions, noTags);
  productsToStringSpecialized(descriptions, noTags);
  productsToStringSpecialized(descriptions, noTags);
}

这种优化可以带来适度的改进,但这些改进会累积起来。它们是对更关键的优化(如形状和内存I/O)的一个很好的补充。但请注意,如果条件发生变化,专门化可能会适得其反,因此在应用时要小心。

分支预测和无分支代码

从代码中移除分支可以极大地提高性能。有关分支预测器的更多详细信息,请阅读经典的 Stack Overflow 回答 为什么处理排序数组比处理未排序数组更快

10. 数据结构

我不会详细讨论数据结构,因为它们需要单独的文章。但请注意,为您的用例使用错误的数据结构可能会比上述任何优化产生更大的影响。我建议您熟悉像 MapSet 这样的原生数据结构,并学习链表、优先队列、树(红黑树和B+树)以及前缀树。

但作为一个快速示例,让我们比较一下 Array.includesSet.has 在小规模列表上的表现:

// 设置:
const userIds = Array.from({ length: 1_000 }).map((_, i) => i);
const adminIdsArray = userIds.slice(0, 10);
const adminIdsSet = new Set(adminIdsArray);

// 1. 数组
let _ = 0;
for (let i = 0; i < userIds.length; i++) {
  if (adminIdsArray.includes(userIds[i])) { _ += 1 }
}

// 2. 集合
let _ = 0;
for (let i = 0; i < userIds.length; i++) {
  if (adminIdsSet.has(userIds[i])) { _ += 1 }
}

数据结构的选择会产生非常大的差异。

作为一个真实的例子,我们曾经通过将数组替换为链表,将函数的运行时间从5秒减少到22毫秒。

11. 基准测试

基准测试是评估代码性能的重要工具。通过基准测试,您可以量化优化前后的性能差异,并确保您的优化确实有效。我把这段Markdown内容翻译成中文如下:

我把这一部分留到最后是有原因的:我需要通过上面的有趣部分来建立可信度。现在,我希望我已经做到了这一点,让我告诉你,基准测试是优化过程中最重要的一部分。它不仅是最重要的,而且也是最难的。即使有20年的经验,我仍然有时会创建有缺陷的基准测试,或者错误地使用分析工具。所以,无论你做什么,请尽最大努力正确地进行基准测试

11.0 从顶部开始

你的首要任务应该是优化那些占用运行时间最多的函数/代码段。如果你花时间优化其他部分,那只是在浪费时间。

11.1 避免微基准测试

在生产模式下运行你的代码,并根据这些观察结果进行优化。JS引擎非常复杂,在微基准测试中的表现往往与真实场景不同。例如,看看这个微基准测试:


    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 } 专门化这个函数。但在你的真实用例中,这是否成立?ab 是否总是那种形状,或者你会接收到任何形状?如果你在生产中接收到许多形状,这个函数的行为将会有所不同。

11.2 怀疑你的结果

如果你刚刚优化了一个函数,现在它的运行速度快了100倍,请怀疑它。尝试反驳你的结果,在生产模式下尝试,向它扔东西。同样,也要怀疑你的工具。仅仅是用开发者工具观察基准测试就可能改变其行为。

11.3 选择你的目标

不同的引擎会对某些模式进行更好或更差的优化。你应该针对与你相关的引擎进行基准测试,并优先考虑哪个更重要。这里有一个现实世界的例子,在Babel中,改进V8意味着降低JSC的性能。

12. 分析与工具

关于分析和开发者工具的各种注意事项。

12.1 浏览器的陷阱

如果你在浏览器中进行分析,请确保使用干净的浏览器配置文件。我甚至为此使用了一个单独的浏览器。如果你在进行分析时启用了浏览器扩展,它们可能会干扰测量结果。特别是React开发者工具会显著影响结果,渲染代码可能会比用户在镜像中看到的要慢。

12.2 采样与结构化分析

浏览器分析工具是基于采样的分析器,它们会在固定的时间间隔内获取堆栈样本。这有一个很大的缺点:非常小但非常频繁的函数可能在样本之间被调用,并且在你会得到的堆栈图中可能被大大低估。可以利用Chrome开发者工具进行CPU节流来缓解这个问题。

12.3 工具的选择

除了常规的浏览器开发者工具外,了解以下选项可能会有所帮助:* Chrome DevTools 有许多实验性标志,可以帮助你找出为什么某些操作会变慢。当你需要调试浏览器中的样式/布局重新计算时,样式无效化跟踪器非常有用。 github.com/iamakulov/d…

  • deoptexplorer-vscode 扩展允许你加载 V8/Chromium 日志文件,以了解你的代码何时触发去优化,例如当你向函数传递不同形状的参数时。你不需要扩展来读取日志文件,但它使体验更加愉快。 github.com/microsoft/d…

  • 你总是可以为每个 JS 引擎编译调试外壳,以更详细地了解其工作原理。这允许你运行 perf 和其他低级工具,还可以检查每个引擎生成的字节码和机器码。 V8 示例 | JSC 示例

原文链接:romgrk.com/posts/optim…

转载自:https://juejin.cn/post/7414040999184465929
评论
请登录