_compression.ts 3.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. import { promises as fs } from "fs"
  2. import path from "path"
  3. import zlib from "zlib"
  4. import { promisify } from "util"
  5. import type { Plugin, ResolvedConfig } from "vite"
  6. const gzip = promisify(zlib.gzip)
  7. const brotliCompress = promisify(zlib.brotliCompress)
  8. const compressibleFileRE = /\.(js|mjs|json|css|html)$/i
  9. const defaultThreshold = 1025
  10. type CompressionKind = "gzip" | "brotli"
  11. const compressionHandlers: Record<
  12. CompressionKind,
  13. { ext: string; compress: (content: Buffer) => Promise<Buffer> }
  14. > = {
  15. gzip: {
  16. ext: ".gz",
  17. compress: (content) => gzip(content, { level: zlib.constants.Z_BEST_COMPRESSION })
  18. },
  19. brotli: {
  20. ext: ".br",
  21. compress: (content) =>
  22. brotliCompress(content, {
  23. params: {
  24. [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY,
  25. [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT
  26. }
  27. })
  28. }
  29. }
  30. async function collectFiles(rootDir: string): Promise<string[]> {
  31. const entries = await fs.readdir(rootDir, { withFileTypes: true })
  32. const files = await Promise.all(
  33. entries.map(async (entry) => {
  34. const fullPath = path.join(rootDir, entry.name)
  35. if (entry.isDirectory()) {
  36. return collectFiles(fullPath)
  37. }
  38. return compressibleFileRE.test(entry.name) ? [fullPath] : []
  39. })
  40. )
  41. return files.flat()
  42. }
  43. function createCompressionPlugin(kind: CompressionKind): Plugin {
  44. const handler = compressionHandlers[kind]
  45. let config: ResolvedConfig | undefined
  46. return {
  47. name: `local:compression:${kind}`,
  48. apply: "build",
  49. enforce: "post",
  50. configResolved(resolvedConfig) {
  51. config = resolvedConfig
  52. },
  53. async closeBundle() {
  54. const outputDir = path.resolve(process.cwd(), config?.build.outDir ?? "dist")
  55. const files = await collectFiles(outputDir)
  56. const compressedEntries: Array<{ file: string; originalKb: string; compressedKb: string }> =
  57. []
  58. await Promise.all(
  59. files.map(async (filePath) => {
  60. const stat = await fs.stat(filePath)
  61. if (stat.size < defaultThreshold) {
  62. return
  63. }
  64. const content = await fs.readFile(filePath)
  65. const compressed = await handler.compress(content)
  66. const outputFile = `${filePath}${handler.ext}`
  67. await fs.writeFile(outputFile, compressed)
  68. compressedEntries.push({
  69. file: path.relative(outputDir, outputFile).replace(/\\/g, "/"),
  70. originalKb: (stat.size / 1024).toFixed(2),
  71. compressedKb: (compressed.byteLength / 1024).toFixed(2)
  72. })
  73. })
  74. )
  75. if (!compressedEntries.length) {
  76. return
  77. }
  78. compressedEntries.sort((a, b) => a.file.localeCompare(b.file))
  79. config?.logger.info(`\n[compression:${kind}] generated ${compressedEntries.length} files`)
  80. for (const entry of compressedEntries) {
  81. config?.logger.info(
  82. `${path.basename(outputDir)}/${entry.file} ${entry.originalKb}kb -> ${entry.compressedKb}kb`
  83. )
  84. }
  85. config?.logger.info("")
  86. }
  87. }
  88. }
  89. export default (env: Record<string, string>) => {
  90. const { VITE_BUILD_COMPRESS } = env
  91. const plugins: Plugin[] = []
  92. if (!VITE_BUILD_COMPRESS) {
  93. return plugins
  94. }
  95. const compressionList = VITE_BUILD_COMPRESS.split(",").map((item) =>
  96. item.trim()
  97. ) as CompressionKind[]
  98. if (compressionList.includes("gzip")) {
  99. plugins.push(createCompressionPlugin("gzip"))
  100. }
  101. if (compressionList.includes("brotli")) {
  102. plugins.push(createCompressionPlugin("brotli"))
  103. }
  104. return plugins
  105. }