From 7309eca654a775e38e37970cabb888d94a36124b Mon Sep 17 00:00:00 2001 From: kanno <812137533@qq.com> Date: Sat, 28 Oct 2023 02:22:06 +0800 Subject: [PATCH 1/4] refactor: reduce redunant logic --- example/vite.config.js | 3 +- src/index.ts | 165 +++++++++++++++-------------------------- src/interface.ts | 17 +---- 3 files changed, 64 insertions(+), 121 deletions(-) diff --git a/example/vite.config.js b/example/vite.config.js index 5c68d86..2498812 100644 --- a/example/vite.config.js +++ b/example/vite.config.js @@ -9,7 +9,8 @@ export default defineConfig({ cdn({ modules: ['vue', '@fect-ui/vue'] }), compression({ include: [/\.(js)$/, /\.(css)$/], - deleteOriginalAssets: true + deleteOriginalAssets: true, + filename: '[path][base]' }) ] }) diff --git a/src/index.ts b/src/index.ts index daa70ea..6b98297 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ import { createConcurrentQueue } from './task' import type { Algorithm, AlgorithmFunction, - CompressMetaInfo, + GenerateBundle, Pretty, UserCompressionOptions, ViteCompressionPluginConfig, @@ -19,6 +19,7 @@ import type { ViteWithoutCompressionPluginConfigFunction } from './interface' +const VITE_INTERNAL_ANALYSIS_PLUGIN = 'vite:build-import-analysis' const VITE_COPY_PUBLIC_DIR = 'copyPublicDir' const MAX_CONCURRENT = (() => { const cpus = os.cpus() || { length: 1 } @@ -26,9 +27,7 @@ const MAX_CONCURRENT = (() => { return Math.max(1, cpus.length - 1) })() -type OutputOption = string - -function handleOutputOption(conf: ResolvedConfig, outputs: Set) { +function handleOutputOption(conf: ResolvedConfig) { // issue #39 // In some case like vite-plugin-legacy will set an empty output item // we should skip it. @@ -37,10 +36,8 @@ function handleOutputOption(conf: ResolvedConfig, outputs: Set) { // work on monorepo // eg: // yarn --cwd @pkg/website build - // At this time we will point to root directory. So that file with side effect - // can't process. - const prepareAbsPath = (root: string, sub: string) => path.resolve(root, sub) - + const outputs: Set = new Set() + const prepareAbsPath = (root: string, sub: string) => slash(path.resolve(root, sub)) if (conf.build.rollupOptions?.output) { const outputOptions = Array.isArray(conf.build.rollupOptions.output) ? conf.build.rollupOptions.output @@ -49,19 +46,27 @@ function handleOutputOption(conf: ResolvedConfig, outputs: Set) { if (typeof opt === 'object' && !len(Object.keys(opt))) return outputs.add(prepareAbsPath(conf.root, opt.dir || conf.build.outDir)) }) - return + } else { + outputs.add(prepareAbsPath(conf.root, conf.build.outDir)) } - outputs.add(prepareAbsPath(conf.root, conf.build.outDir)) + return outputs } -function makeOutputs(outputs: Set, file: string) { - const dests = [] - const files = [] - outputs.forEach((dest) => { - dests.push(dest) - files.push(slash(path.join(dest, file))) - }) - return { dests, files } +async function hijackGenerateBundle(plugin: Plugin, afterHook: GenerateBundle) { + const hook = plugin.generateBundle + if (typeof hook === 'object' && hook.handler) { + const fn = hook.handler + hook.handler = async function (this, ...args: any) { + await fn.apply(this, args) + await afterHook.apply(this, args) + } + } + if (typeof hook === 'function') { + plugin.generateBundle = async function (this, ...args: any) { + await hook.apply(this, args) + await afterHook.apply(this, args) + } + } } function compression(): Plugin @@ -82,9 +87,7 @@ function compression(opts const filter = createFilter(include, exclude) - const stores = new Map() - - const normalizedOutputs: Set = new Set() + const statics: Array<{ file: string, dests: string[] }> = [] const zlib: { algorithm: AlgorithmFunction @@ -101,16 +104,37 @@ function compression(opts zlib.filename = filename ?? (userAlgorithm === 'brotliCompress' ? '[path][base].br' : '[path][base].gz') const queue = createConcurrentQueue(MAX_CONCURRENT) + const generateBundle: GenerateBundle = async function (_, bundles) { + for (const fileName in bundles) { + if (!filter(fileName)) continue + const bundle = bundles[fileName] + const source = bundle.type === 'asset' ? bundle.source : bundle.code + const size = len(source) + if (size < threshold) continue + queue.enqueue(async () => { + const name = replaceFileName(fileName, zlib.filename) + const compressed = await compress(Buffer.from(source), zlib.algorithm, zlib.options) + if (skipIfLargerOrEqual && len(compressed) >= size) return + // #issue 30 31 + // https://rollupjs.org/plugin-development/#this-emitfile + if (deleteOriginalAssets || fileName === name) Reflect.deleteProperty(bundles, fileName) + this.emitFile({ type: 'asset', fileName: name, source: compressed }) + }) + } + await queue.wait().catch(this.error) + } + return { name: 'vite-plugin-compression', apply: 'build', enforce: 'post', async configResolved(config) { + // hijack vite's internal `vite:build-import-analysis` plugin.So we won't need process us chunks at closeBundle anymore. // issue #26 // https://github.com/vitejs/vite/blob/716286ef21f4d59786f21341a52a81ee5db58aba/packages/vite/src/node/build.ts#L566-L611 // Vite follow rollup option as first and the configResolved Hook don't expose merged conf for user. :( // Someone who like using rollupOption. `config.build.outDir` will not as expected. - handleOutputOption(config, normalizedOutputs) + const normalizedOutputs = handleOutputOption(config) // Vite's pubic build: https://github.com/vitejs/vite/blob/HEAD/packages/vite/src/node/build.ts#L704-L709 // copyPublicDir minimum version 3.2+ const baseCondit = VITE_COPY_PUBLIC_DIR in config.build ? config.build.copyPublicDir : true @@ -121,102 +145,33 @@ function compression(opts if (!filter(assets)) return const { size } = await fsp.stat(assets) if (size < threshold) return - const file = path.relative(publicPath, assets) - const { files, dests } = makeOutputs(normalizedOutputs, file) - stores.set(slash(file), { - effect: true, - file: files, - dest: dests - }) + const file = slash(path.relative(publicPath, assets)) + statics.push({ file, dests: [...normalizedOutputs] }) })) } - }, - // Vite support using object as hooks to change execution order need at least 3.1.0 - // So we should record that with side Effect bundle file. (Because file with dynamic import will trigger vite's internal importAnalysisBuild logic and it will generator vite's placeholder.) - // Vite importAnalysisBuild source code: https://github.com/vitejs/vite/blob/main/packages/vite/src/node/plugins/importAnalysisBuild.ts - // Vite's plugin order see: https://github.com/vitejs/vite/blob/HEAD/packages/vite/src/node/plugins/index.ts#L94-L98 - async generateBundle(_, bundles) { - for (const fileName in bundles) { - if (!filter(fileName)) continue - const bundle = bundles[fileName] - const result = bundle.type === 'asset' ? bundle.source : bundle.code - const size = len(result) - if (size < threshold) continue - const meta: CompressMetaInfo = Object.create(null) - // we think dynamic imports have side effect. - // In vite intenral logic, vite will set a placeholder and consume it after all plugin work done. - // We only process chunk is enough. Other assets will be automatically generator by vite. - if (bundle.type === 'chunk' && len(bundle.dynamicImports)) { - meta.effect = true - const { dests, files } = makeOutputs(normalizedOutputs, fileName) - if (meta.effect) { - meta.dest = dests - meta.file = files - } - const imports = bundle.dynamicImports - imports.forEach((importer) => { - if (!filter(importer)) return - if (importer in bundles) { - const bundle = bundles[importer] - const chunk = bundle.type === 'asset' ? bundle.source : bundle.code - if (len(chunk) < threshold) return - const { dests, files } = makeOutputs(normalizedOutputs, importer) - stores.set(importer, { - effect: true, - file: files, - dest: dests - }) - } - }) - } else { - meta.effect = false - } - - if (!stores.has(fileName) && bundle) stores.set(fileName, meta) - } - const handle = async (file: string, meta: CompressMetaInfo) => { - if (meta.effect) return - const bundle = bundles[file] - const fileName = replaceFileName(file, zlib.filename) - // #issue 31 - // we should pass the handle. Because if we process it . vite internal plugin can't work well - if (file === fileName && bundle.type === 'chunk') { - const { dests, files } = makeOutputs(normalizedOutputs, fileName) - stores.set(file, { effect: true, file: files, dest: dests }) - return - } - const source = Buffer.from(bundle.type === 'asset' ? bundle.source : bundle.code) - const compressed = await compress(source, zlib.algorithm, zlib.options) - if (skipIfLargerOrEqual && len(compressed) >= len(source)) return - // #issue 30 - if (deleteOriginalAssets) Reflect.deleteProperty(bundles, file) - this.emitFile({ type: 'asset', source: compressed, fileName }) - stores.delete(file) - } - stores.forEach((meta, file) => queue.enqueue(() => handle(file, meta))) - await queue.wait().catch(this.error) + const plugin = config.plugins.find(p => p.name === VITE_INTERNAL_ANALYSIS_PLUGIN) + if (!plugin) throw new Error('vite-plugin-compression can\'t be work in versions lower than vite2.0.0') + // we won't need define sideEffect anymore. + hijackGenerateBundle(plugin, generateBundle) }, async closeBundle() { - const handle = async (file: string, meta: CompressMetaInfo) => { - if (!meta.effect) return - for (const [pos, dest] of meta.dest.entries()) { - const f = meta.file[pos] - const buf = await fsp.readFile(f) + statics.forEach(({ file, dests }) => queue.enqueue(async () => { + await Promise.all(dests.map(async (dest) => { + const p = path.join(dest, file) + const buf = await fsp.readFile(p) const compressed = await compress(buf, zlib.algorithm, zlib.options) - if (skipIfLargerOrEqual && len(compressed) >= len(buf)) continue + if (skipIfLargerOrEqual && len(compressed) >= len(buf)) return const fileName = replaceFileName(file, zlib.filename) // issue #30 const outputPath = path.join(dest, fileName) - if (deleteOriginalAssets && outputPath !== f) await fsp.rm(f, { recursive: true, force: true }) + if (deleteOriginalAssets && outputPath !== p) await fsp.rm(p, { recursive: true, force: true }) await fsp.writeFile(outputPath, compressed) - } - } - stores.forEach((meta, file) => queue.enqueue(() => handle(file, meta))) + })) + })) // issue #18 // In somecase. Like vuepress it will called vite build with `Promise.all`. But it's concurrency. when we record the // file fd. It had been changed. So that we should catch the error await queue.wait().catch(e => e) - stores.clear() } } } diff --git a/src/interface.ts b/src/interface.ts index 9d5aac5..1353e9f 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,4 +1,5 @@ import type { BrotliOptions, ZlibOptions } from 'zlib' +import type { HookHandler, Plugin } from 'vite' import type { FilterPattern } from '@rollup/pluginutils' export type Algorithm = 'gzip' | 'brotliCompress' | 'deflate' | 'deflateRaw' @@ -58,18 +59,4 @@ export type ViteCompressionPluginConfig = | ViteCompressionPluginConfigFunction | ViteCompressionPluginConfigAlgorithm -interface BaseCompressMetaInfo { - effect: boolean -} - -interface NormalCompressMetaInfo extends BaseCompressMetaInfo { - effect: false -} - -interface DyanmiCompressMetaInfo extends BaseCompressMetaInfo { - effect: true - file: string[] - dest: string[] -} - -export type CompressMetaInfo = NormalCompressMetaInfo | DyanmiCompressMetaInfo +export type GenerateBundle = HookHandler From e7c1ad48b7570e2637d7060028e36d24a70c212c Mon Sep 17 00:00:00 2001 From: kanno <812137533@qq.com> Date: Sat, 28 Oct 2023 02:28:01 +0800 Subject: [PATCH 2/4] feat: turn optimize options --- __tests__/options.spec.ts | 1 + __tests__/plugin.spec.ts | 9 ++++++--- src/index.ts | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/__tests__/options.spec.ts b/__tests__/options.spec.ts index dee1195..5b2222c 100644 --- a/__tests__/options.spec.ts +++ b/__tests__/options.spec.ts @@ -15,6 +15,7 @@ async function mockBuild( dir: string, single = false ) { + conf.skipIfLargerOrEqual = conf.skipIfLargerOrEqual ?? false const id = getId() await build({ build: { diff --git a/__tests__/plugin.spec.ts b/__tests__/plugin.spec.ts index b04aadb..32d7e91 100644 --- a/__tests__/plugin.spec.ts +++ b/__tests__/plugin.spec.ts @@ -6,9 +6,8 @@ import util from 'util' import type { ZlibOptions } from 'zlib' import test from 'ava' import { build } from 'vite' -import { compression } from '../src' import { len, readAll } from '../src/utils' -import type { Algorithm, ViteCompressionPluginConfig } from '../src' +import { type Algorithm, type ViteCompressionPluginConfig, compression } from '../src' const getId = () => Math.random().toString(32).slice(2, 10) @@ -26,7 +25,11 @@ async function mockBuild async function mockBuild(config: any = {}, dir = 'normal') { const id = getId() - const plugins = Array.isArray(config) ? config.map((conf) => compression(conf)) : [compression(config)] + const configs = Array.isArray(config) ? config : [config] + const plugins = configs.map(conf => { + conf.skipIfLargerOrEqual = conf.skipIfLargerOrEqual ?? false + return compression(conf) + }) await build({ root: path.join(__dirname, 'fixtures', dir), plugins, diff --git a/src/index.ts b/src/index.ts index 6b98297..ff6ef3f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,7 +82,7 @@ function compression(opts filename, compressionOptions, deleteOriginalAssets = false, - skipIfLargerOrEqual = false + skipIfLargerOrEqual = true } = opts const filter = createFilter(include, exclude) From b632f2c7bcd92682088f0e4176d2c816fe52423b Mon Sep 17 00:00:00 2001 From: kanno <812137533@qq.com> Date: Sat, 28 Oct 2023 02:30:18 +0800 Subject: [PATCH 3/4] docs: update document --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 59e3631..60e1ab2 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ export default defineConfig({ | `algorithm` | `string\| function` | `gzip` | The compression algorithm | | `compressionOptions` | `Record` | `{}` | Compression options for `algorithm`(details see `zlib module`) | | `deleteOriginalAssets` | `boolean` | `false` | Whether to delete the original assets or not | -| `skipIfLargerOrEqual` | `boolean` | `false` | Whether to skip the compression if the result is larger than or equal to the original file | +| `skipIfLargerOrEqual` | `boolean` | `true` | Whether to skip the compression if the result is larger than or equal to the original file | | `filename` | `string` | `[path][base].gz` | The target asset filename | ## Q & A From 690fcd413981191498a7f47be0f80dc710632bb7 Mon Sep 17 00:00:00 2001 From: kanno <812137533@qq.com> Date: Sat, 28 Oct 2023 02:43:19 +0800 Subject: [PATCH 4/4] chore: revert example --- example/vite.config.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/example/vite.config.js b/example/vite.config.js index 2498812..5c68d86 100644 --- a/example/vite.config.js +++ b/example/vite.config.js @@ -9,8 +9,7 @@ export default defineConfig({ cdn({ modules: ['vue', '@fect-ui/vue'] }), compression({ include: [/\.(js)$/, /\.(css)$/], - deleteOriginalAssets: true, - filename: '[path][base]' + deleteOriginalAssets: true }) ] })