我是如何优化vite-plugin-compression的

2024-06-30 11:12:50/57天之前

前言

代码的优化永远是永无止境的,随着当事人掌握的东西越多接触到的越多对不同时间的产物有着不一样的思考,这会影响着当事人对以前/现在项目架构的思考。而插件历经2年迈入stable 阶段我觉得还是有必要记录的。 (这篇文章在我实现了tar-mini 后重新组织了文章)

为什么是vite-plugin-compression2

如你所见目前社区存在2个vite-plugin-compression插件一个是vben的一个是我编写的vite-plugin-compression2 这2个插件在功能上大体不差无非是Option设计的风格的差异(vben的那个已经不在维护建议迁移),但是我还是想谈谈我认为2者不同的地方以及为什么vite-plugin-compression2更快更好用。

减少不必要的文件IO/内存分配

我们在学习的路上不止一次的听到人们对于文件IO的描述,比如在实际场景下后端的瓶颈大部分是数据库的写入,因此我们得以窥探文件IO的代价。这也是我在编写2代插件需要注重的点,如何避免不必要的 文件IO呢?尽管NodeJS拥有优秀的事件池但是在面对大量复杂的文件的时候使用fast-glob或者手动编写一个基于BFS的检索算法去收集目录显然是不够明智的。我们利用rollup中的generateBundle 这个钩子拿到文件内容就能减少不必要的io,并且充分利用rollup的hook调用机制以免阻塞单一的钩子。基于这一点我们就可以不需要额外的内存去维护一组文件名对应文件内容的哈希表了。只需要在这个钩子内 处理掉我们需要处理的模块。

function generateBundle(_, bundles) {
  for (const fileName in bundles) {
    // filter logic
    const bundle = bundles[fileName]
    // do compression
    this.emit({
      type: 'asset'
      // rest options.
    })
  }
}

这样我们就能轻松的把文件添加到rollup自己的bundle context上 这样rollup只需要调用一次文件IO就能进行写入。(有关一些针对vite的hack逻辑在这不做描述,感兴趣可以翻阅源码)在这个阶段 我们已经处理了大量需要处理的文件内容,至于剩下的静态资源部分我们则可以在其他的钩子通过手动编写一个简单的BFS文件检索算法去搜寻剩下为数不多的文件路径。(注意我们不需要读取文件内容)

使用队列避免繁重的计算拖垮速度

相比于1代简单粗暴的Promise.all 在面对简单的场景我们确实可以这样用,但是compression本身是计算密集型任务我们应该避免同时执行过多任务拖慢整体运行速率。这里我们可以实现一个类似p-limit这样的任务队列方便我们做任务有限制的并发。

import { len } from './shared'

class Queue {
  maxConcurrent: number
  queue: Array<() => Promise<void>>
  running: number
  errors: Array<Error>
  constructor(maxConcurrent: number) {
    this.maxConcurrent = maxConcurrent
    this.queue = []
    this.errors = []
    this.running = 0
  }

  enqueue(task) {
    this.queue.push(task)
    this.run()
  }

  async run() {
    while (this.running < this.maxConcurrent && this.queue.length) {
      const task = this.queue.shift()
      this.running++
      try {
        await task()
      } catch (error) {
        this.errors.push(error)
      } finally {
        this.running--
        this.run()
      }
    }
  }

  async wait() {
    while (this.running) {
      await new Promise((resolve) => setTimeout(resolve, 0))
    }
    if (len(this.errors)) throw new AggregateError(this.errors, 'task failed')
  }
}

export function createConcurrentQueue(max: number) {
  return new Queue(max)
}

const queue = createConcurrentQueue(10)

queue.enqueue(async () => {
  // task
})

通过限制并发我们避免了资源的竞争以及计算过载的可能保证了插件的稳定性。同时减少了IO的等待时间。

使用流避免内存的压力

结合我们之前实现的并发队列,我们使用一个简单的流在closeBundle阶段对静态资源进行写入通过这种方式尽管他没显著地提升差异但是理由也是和上面一样的。

自己维护Archive更好

在插件迈入stable阶段,插件提供了一个tarball的功能,他是基于tar-stream的,但是tar-stream兼容的版本过低导致我们有不少的不必要依赖污染了node_modules, 为此我们需要单独编写一个更轻量更符合我们场景的第三方库来解决这个问题。而自己根据openorggnu-tar文档我们更容易写入利于我们维护的代码。如果你对tar-mini 感兴趣可以自行查阅相关源码。

CC BY-NC-SA 4.02024-PRESENT © Kanno