@zhaoshijun/compress 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 zhaoshijun
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # @zhaoshijun/compress
2
+
3
+ 一个高效的图片压缩工具箱,包含命令行工具 (CLI) 和 Vite 插件。基于 [sharp](https://sharp.pixelplumbing.com/) 构建,提供高性能的图片压缩方案。
4
+
5
+ ## 特性
6
+
7
+ - 🚀 **高性能**:基于 sharp,速度极快。
8
+ - 🛠 **双模支持**:
9
+ - **CLI**:批量扫描并压缩项目图片。
10
+ - **Vite 插件**:在 `vite build` 构建时自动压缩引用的图片资源。
11
+ - ⚙️ **灵活配置**:支持配置文件 (`compress.config.js`),可精细控制压缩参数。
12
+ - 🛡 **安全可靠**:支持备份原文件,默认不覆盖源文件(CLI 模式输出到指定目录)。
13
+ - 📦 **智能缓存**:Vite 插件基于内容 Hash 缓存,避免重复压缩,提升构建速度。
14
+
15
+ ## 安装
16
+
17
+ ```bash
18
+ npm install @zhaoshijun/compress --save-dev
19
+ # 或
20
+ yarn add @zhaoshijun/compress -D
21
+ # 或
22
+ pnpm add @zhaoshijun/compress -D
23
+ ```
24
+
25
+ ## 命令行工具 (CLI)
26
+
27
+ CLI 工具用于批量扫描并压缩本地图片文件。
28
+
29
+ ### 快速开始
30
+
31
+ 1. **初始化配置** (可选)
32
+ 生成默认的 `compress.config.js` 文件:
33
+ ```bash
34
+ npx @zhaoshijun/compress init
35
+ ```
36
+
37
+ 2. **运行压缩**
38
+ 扫描当前目录及子目录下的图片并压缩:
39
+ ```bash
40
+ npx @zhaoshijun/compress
41
+ ```
42
+
43
+ ### 命令行选项
44
+
45
+ | 选项 | 简写 | 描述 |
46
+ | --- | --- | --- |
47
+ | `--config <path>` | `-c` | 指定配置文件路径 |
48
+ | `--backup` | | 启用备份模式(将原文件复制为 `.backup`) |
49
+ | `--dry-run` | | 演练模式,仅列出将要处理的文件,不进行实际压缩和写入 |
50
+ | `--quiet` | `-q` | 静默模式,仅输出错误信息 |
51
+ | `--help` | `-h` | 显示帮助信息 |
52
+
53
+ ### 示例
54
+
55
+ ```bash
56
+ # 仅预览将要压缩的文件
57
+ npx @zhaoshijun/compress --dry-run
58
+
59
+ # 使用指定配置文件并开启静默模式
60
+ npx @zhaoshijun/compress -c ./config/compress.js -q
61
+ ```
62
+
63
+ ## Vite 插件
64
+
65
+ 该插件仅在 `vite build` 生产构建模式下生效,会自动压缩项目中引用的图片资源,并替换构建产物中的引用路径。
66
+
67
+ ### 配置
68
+
69
+ 在 `vite.config.js` 中引入并添加插件:
70
+
71
+ ```javascript
72
+ import { defineConfig } from 'vite';
73
+ import { compressVitePlugin } from '@zhaoshijun/compress';
74
+
75
+ export default defineConfig({
76
+ plugins: [
77
+ compressVitePlugin({
78
+ // 可在此处覆盖 compress.config.js 中的配置
79
+ quality: 80,
80
+ cache: true
81
+ })
82
+ ]
83
+ });
84
+ ```
85
+
86
+ ### 插件特性
87
+
88
+ - **自动替换**:压缩后的文件会自动添加 `.min` 后缀(如 `logo.min.png`),并自动更新 JS/CSS 中的引用路径。
89
+ - **构建缓存**:默认启用缓存(`node_modules/.cache/@zhaoshijun/compress`),只有当图片内容发生变化时才会重新压缩。
90
+ - **开发环境友好**:`vite dev` 模式下插件不运行,不影响开发服务器启动速度。
91
+
92
+ ## 配置文件详解
93
+
94
+ 项目根目录下的 `compress.config.js` 用于统一管理压缩配置。
95
+
96
+ ```javascript
97
+ // compress.config.js
98
+ module.exports = {
99
+ // [CLI专用] 输入文件模式,支持 glob 数组
100
+ // 默认扫描所有子目录下的 jpg, jpeg, png, webp
101
+ input: ['**/*.{jpg,jpeg,png,webp}'],
102
+
103
+ // [CLI专用] 输出目录
104
+ // 默认输出到当前目录下的 compressed 文件夹,保持原目录结构
105
+ output: './compressed',
106
+
107
+ // 尺寸调整 (可选)
108
+ // 注意:scale 与 maxWidth/maxHeight 互斥,优先使用 maxWidth/maxHeight
109
+ resize: {
110
+ maxWidth: 1920, // 最大宽度,保持纵横比
111
+ // maxHeight: 1080, // 最大高度
112
+ // scale: 0.8 // 缩放比例 (0-1)
113
+ },
114
+
115
+ // 通用质量参数 (JPEG/WebP)
116
+ // 范围 1-100,默认 88
117
+ quality: 88,
118
+
119
+ // PNG 专属配置
120
+ pngOptions: {
121
+ compressionLevel: 9, // 压缩等级 0-9,默认 9
122
+ palette: true // 是否启用调色板量化,默认 true (显著减小体积)
123
+ },
124
+
125
+ // 输出文件名后缀
126
+ // 默认 '.min',例如 image.jpg -> image.min.jpg
127
+ suffix: '.min',
128
+
129
+ // [CLI专用] 是否备份原文件
130
+ backup: false,
131
+
132
+ // [Vite插件专用] 配置
133
+ vite: {
134
+ cache: true // 是否启用构建缓存,默认 true
135
+ }
136
+ };
137
+ ```
138
+
139
+ ## 支持的格式
140
+
141
+ - JPEG (`.jpg`, `.jpeg`)
142
+ - PNG (`.png`)
143
+ - WebP (`.webp`)
144
+
145
+ ## 默认排除
146
+
147
+ CLI 工具默认会排除以下目录:
148
+ - `node_modules/`
149
+ - `dist/`
150
+ - `.git/`
151
+ - `coverage/`
152
+
153
+ ## 常见问题
154
+
155
+ **Q: 如何处理损坏的图片?**
156
+ A: 工具会自动检测并跳过损坏的图片,输出警告信息,不会中断整个压缩流程。
157
+
158
+ **Q: Vite 插件会修改源代码中的图片吗?**
159
+ A: 不会。Vite 插件仅在构建过程中处理资源,生成的压缩图片位于构建输出目录(如 `dist/assets`),源代码保持不变。
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import chalk from 'chalk';
5
+ import ora from 'ora';
6
+ import fs from 'fs-extra';
7
+ import path from 'path';
8
+ import fg from 'fast-glob';
9
+ import prettyBytes from 'pretty-bytes';
10
+ import { MultiBar, Presets } from 'cli-progress';
11
+ import { loadConfig } from '../src/config/loader.js';
12
+ import { compressImage } from '../src/core/compressor.js';
13
+ import { DEFAULT_EXCLUDES } from '../src/utils/constants.js';
14
+ import { isSupportedImage as isSupported } from '../src/utils/file-utils.js';
15
+
16
+ const program = new Command();
17
+
18
+ program
19
+ .name('compress')
20
+ .description('Image compression CLI tool')
21
+ .version('1.0.0');
22
+
23
+ program
24
+ .option('-c, --config <path>', '配置文件路径')
25
+ .option('--backup', '启用备份')
26
+ .option('--dry-run', '仅列出将处理的文件,不写入磁盘')
27
+ .option('-q, --quiet', '静默模式,仅输出错误')
28
+ .action(async (options) => {
29
+ try {
30
+ const startTime = Date.now();
31
+
32
+ // 加载配置
33
+ const config = await loadConfig(options.config);
34
+
35
+ // 合并命令行选项
36
+ if (options.backup !== undefined) config.backup = options.backup;
37
+
38
+ // 确定输入源
39
+ // 如果 config.input 未定义,则默认为当前目录 '**/*.{jpg,jpeg,png,webp}'
40
+ // 需要结合排除规则
41
+ const inputPattern = config.input || ['**/*.{jpg,jpeg,png,webp}'];
42
+ const ignorePattern = [...DEFAULT_EXCLUDES]; // 默认排除
43
+ // 如果用户有额外的排除需求,可以在这里扩展,或者通过 config.input 传递排除模式(glob支持!)
44
+
45
+ // 查找文件
46
+ const spinner = ora('Scanning files...').start();
47
+ const files = await fg(inputPattern, {
48
+ ignore: ignorePattern,
49
+ absolute: true,
50
+ onlyFiles: true
51
+ });
52
+
53
+ // 过滤支持的图片格式 (double check)
54
+ const targetFiles = files.filter(file => isSupported(file));
55
+
56
+ spinner.succeed(`Found ${targetFiles.length} images.`);
57
+
58
+ if (targetFiles.length === 0) {
59
+ console.log(chalk.yellow('No images found to compress.'));
60
+ return;
61
+ }
62
+
63
+ if (options.dryRun) {
64
+ console.log(chalk.cyan('\n[Dry Run] Files to be processed:'));
65
+ targetFiles.forEach(f => console.log(`- ${path.relative(process.cwd(), f)}`));
66
+ return;
67
+ }
68
+
69
+ // 准备进度条
70
+ const multibar = new MultiBar({
71
+ clearOnComplete: false,
72
+ hideCursor: true,
73
+ format: '{bar} | {percentage}% | {value}/{total} Files | Current: {file}'
74
+ }, Presets.shades_classic);
75
+
76
+ let processedCount = 0;
77
+ let totalSaved = 0;
78
+ let errors = [];
79
+
80
+ let progressBar;
81
+ if (!options.quiet) {
82
+ progressBar = multibar.create(targetFiles.length, 0, { file: 'Starting...' });
83
+ }
84
+
85
+ for (const file of targetFiles) {
86
+ try {
87
+ const relativePath = path.relative(process.cwd(), file);
88
+ if (!options.quiet && progressBar) progressBar.update(processedCount, { file: relativePath });
89
+
90
+ const originalStat = await fs.stat(file);
91
+ const originalSize = originalStat.size;
92
+
93
+ // 备份
94
+ if (config.backup) {
95
+ await fs.copy(file, `${file}.backup`);
96
+ }
97
+
98
+ // 读取并压缩
99
+ // 注意:CLI 要求输出到 ./compressed/ 目录,文件名添加 .min 后缀
100
+ // 但通常 CLI 工具如果没指定 output,可能会原地修改或者输出到特定目录
101
+ // 根据需求:输出到 ./compressed/ 目录,文件名添加 .min 后缀
102
+
103
+ const outputDir = path.resolve(process.cwd(), config.output || './compressed');
104
+ // 保持目录结构:我们需要计算相对路径,然后拼接到 outputDir
105
+ // 例如 src/assets/img.jpg -> compressed/src/assets/img.min.jpg
106
+
107
+ // 计算相对于 cwd 的路径
108
+ const relDir = path.dirname(relativePath);
109
+ const fileName = path.basename(file, path.extname(file));
110
+ const ext = path.extname(file);
111
+ const outDir = path.join(outputDir, relDir);
112
+ const outFileName = `${fileName}${config.suffix || '.min'}${ext}`;
113
+ const outFilePath = path.join(outDir, outFileName);
114
+
115
+ // 压缩
116
+ const compressedBuffer = await compressImage(file, config, file);
117
+ const compressedSize = compressedBuffer.length;
118
+
119
+ // 写入文件
120
+ await fs.ensureDir(outDir);
121
+ await fs.writeFile(outFilePath, compressedBuffer);
122
+
123
+ const saved = originalSize - compressedSize;
124
+ totalSaved += saved;
125
+ const savedPercent = ((saved / originalSize) * 100).toFixed(2);
126
+
127
+ if (!options.quiet) {
128
+ // 进度条模式下,单独输出每张图的信息可能会打乱进度条,通常在进度条下方或者最后汇总
129
+ // 但需求要求:每张图显示: 原体积 → 新体积 (节省 XX%)
130
+ // 使用 multibar.log 可以避免打乱
131
+ multibar.log(`${chalk.green('✔')} ${relativePath}: ${prettyBytes(originalSize)} → ${prettyBytes(compressedSize)} (Saved ${savedPercent}%)\n`);
132
+ }
133
+
134
+ processedCount++;
135
+ } catch (err) {
136
+ errors.push({ file: path.relative(process.cwd(), file), error: err.message });
137
+ // 损坏图片处理:跳过并输出警告,不中断执行
138
+ if (!options.quiet) {
139
+ multibar.log(`${chalk.red('✖')} ${path.relative(process.cwd(), file)}: ${err.message}\n`);
140
+ }
141
+ }
142
+ }
143
+
144
+ if (!options.quiet) multibar.stop();
145
+
146
+ const duration = ((Date.now() - startTime) / 1000).toFixed(2);
147
+
148
+ console.log('\n' + chalk.bold('Compression Summary:'));
149
+ console.log(`Total Processed: ${processedCount}/${targetFiles.length}`);
150
+ console.log(`Total Saved: ${prettyBytes(totalSaved)}`);
151
+ console.log(`Time Taken: ${duration}s`);
152
+
153
+ if (errors.length > 0) {
154
+ console.log(chalk.yellow(`\nSkipped ${errors.length} files due to errors:`));
155
+ errors.forEach(e => console.log(`- ${e.file}: ${e.error}`));
156
+ }
157
+
158
+ } catch (error) {
159
+ console.error(chalk.red('Error:'), error.message);
160
+ process.exit(1);
161
+ }
162
+ });
163
+
164
+ // Init command
165
+ program
166
+ .command('init')
167
+ .description('Generate default compress.config.js template')
168
+ .action(async () => {
169
+ const targetPath = path.join(process.cwd(), 'compress.config.js');
170
+ if (await fs.pathExists(targetPath)) {
171
+ console.log(chalk.yellow('compress.config.js already exists.'));
172
+ return;
173
+ }
174
+
175
+ const content = `module.exports = {
176
+ // input: ['**/*.{jpg,jpeg,png,webp}'], // glob 模式
177
+ output: './compressed',
178
+ // resize: {
179
+ // maxWidth: 1920,
180
+ // // maxHeight: 1080,
181
+ // // scale: 0.8
182
+ // },
183
+ quality: 88,
184
+ pngOptions: {
185
+ compressionLevel: 9,
186
+ palette: true
187
+ },
188
+ suffix: '.min',
189
+ backup: false,
190
+ vite: {
191
+ cache: true
192
+ }
193
+ };
194
+ `;
195
+ await fs.writeFile(targetPath, content);
196
+ console.log(chalk.green('Created compress.config.js'));
197
+ });
198
+
199
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@zhaoshijun/compress",
3
+ "version": "1.0.0",
4
+ "description": "Image compression CLI and Vite plugin",
5
+ "type": "module",
6
+ "bin": {
7
+ "compress": "./bin/compress.js"
8
+ },
9
+ "main": "./vite/index.js",
10
+ "exports": {
11
+ ".": "./vite/index.js",
12
+ "./vite": "./vite/index.js"
13
+ },
14
+ "scripts": {
15
+ "test": "echo \"Error: no test specified\" && exit 1"
16
+ },
17
+ "dependencies": {
18
+ "chalk": "^5.3.0",
19
+ "cli-progress": "^3.12.0",
20
+ "commander": "^11.1.0",
21
+ "cosmiconfig": "^9.0.0",
22
+ "fast-glob": "^3.3.2",
23
+ "fs-extra": "^11.2.0",
24
+ "ora": "^8.0.1",
25
+ "pretty-bytes": "^6.1.1",
26
+ "sharp": "^0.33.2"
27
+ },
28
+ "peerDependencies": {
29
+ "vite": "^5.0.0"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "vite": {
33
+ "optional": true
34
+ }
35
+ },
36
+ "engines": {
37
+ "node": ">=18.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "vite": "^7.3.1"
41
+ }
42
+ }
@@ -0,0 +1,13 @@
1
+ export const defaultConfig = {
2
+ output: './compressed',
3
+ quality: 88,
4
+ pngOptions: {
5
+ compressionLevel: 9,
6
+ palette: true
7
+ },
8
+ suffix: '.min',
9
+ backup: false,
10
+ vite: {
11
+ cache: true
12
+ }
13
+ };
@@ -0,0 +1,45 @@
1
+ import { cosmiconfig } from 'cosmiconfig';
2
+ import { defaultConfig } from './defaults.js';
3
+
4
+ const moduleName = 'compress';
5
+ const explorer = cosmiconfig(moduleName);
6
+
7
+ /**
8
+ * 加载配置
9
+ * @param {string} [configPath] - 配置文件路径
10
+ * @returns {Promise<Object>} 合并后的配置对象
11
+ */
12
+ export async function loadConfig(configPath) {
13
+ try {
14
+ const result = configPath
15
+ ? await explorer.load(configPath)
16
+ : await explorer.search();
17
+
18
+ if (result && result.config) {
19
+ const userConfig = result.config;
20
+
21
+ // 深度合并配置
22
+ return {
23
+ ...defaultConfig,
24
+ ...userConfig,
25
+ pngOptions: {
26
+ ...defaultConfig.pngOptions,
27
+ ...(userConfig.pngOptions || {})
28
+ },
29
+ vite: {
30
+ ...defaultConfig.vite,
31
+ ...(userConfig.vite || {})
32
+ },
33
+ // resize 如果存在则覆盖
34
+ resize: userConfig.resize || undefined
35
+ };
36
+ }
37
+ } catch (error) {
38
+ if (configPath) {
39
+ throw new Error(`无法加载配置文件 ${configPath}: ${error.message}`);
40
+ }
41
+ // 如果没有找到配置文件,且没有指定路径,则静默使用默认配置
42
+ }
43
+
44
+ return defaultConfig;
45
+ }
@@ -0,0 +1,65 @@
1
+ import sharp from 'sharp';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * 压缩图片
6
+ * @param {string|Buffer} input - 图片路径或 Buffer
7
+ * @param {Object} options - 压缩配置
8
+ * @param {string} [filePath] - 原始文件路径 (用于判断格式)
9
+ * @returns {Promise<Buffer>} 压缩后的 Buffer
10
+ */
11
+ export async function compressImage(input, options, filePath) {
12
+ // 初始化 sharp 实例
13
+ let instance = sharp(input);
14
+ const metadata = await instance.metadata();
15
+
16
+ // 尺寸调整
17
+ if (options.resize) {
18
+ const { maxWidth, maxHeight, scale } = options.resize;
19
+
20
+ if (maxWidth || maxHeight) {
21
+ instance = instance.resize({
22
+ width: maxWidth,
23
+ height: maxHeight,
24
+ fit: 'inside', // 保持宽高比,适应指定尺寸
25
+ withoutEnlargement: true // 不放大
26
+ });
27
+ } else if (scale && scale > 0 && scale < 1) {
28
+ const newWidth = Math.round(metadata.width * scale);
29
+ instance = instance.resize({ width: newWidth });
30
+ }
31
+ }
32
+
33
+ // 格式处理
34
+ const format = metadata.format;
35
+
36
+ switch (format) {
37
+ case 'jpeg':
38
+ case 'jpg':
39
+ instance = instance.jpeg({ quality: options.quality || 88 });
40
+ break;
41
+ case 'png':
42
+ instance = instance.png({
43
+ compressionLevel: options.pngOptions?.compressionLevel ?? 9,
44
+ palette: options.pngOptions?.palette ?? true,
45
+ quality: options.quality // sharp png 也可以接受 quality 参数配合 palette
46
+ });
47
+ break;
48
+ case 'webp':
49
+ instance = instance.webp({ quality: options.quality || 88 });
50
+ break;
51
+ default:
52
+ // 如果不是支持的格式(虽然前面应该过滤了),不做处理直接返回原buffer(如果可能)或者报错
53
+ // 这里假设调用方已经过滤了格式,但为了保险,如果不匹配就不压缩直接输出
54
+ // 但 sharp 可能已经把输入转成了内部格式,所以这里最好还是根据原格式输出
55
+ // 如果格式不受支持,可能在 metadata 阶段就看出来了。
56
+ // 题目要求仅支持 .jpg, .jpeg, .png, .webp
57
+ if (['jpeg', 'jpg', 'png', 'webp'].includes(format)) {
58
+ // fallthrough
59
+ } else {
60
+ throw new Error(`Unsupported format: ${format}`);
61
+ }
62
+ }
63
+
64
+ return instance.toBuffer();
65
+ }
@@ -0,0 +1,23 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+
5
+ const CACHE_DIR = path.resolve('node_modules/.cache/@zhaoshijun/compress');
6
+
7
+ export async function getCache(content) {
8
+ const hash = crypto.createHash('sha256').update(content).digest('hex');
9
+ const cachePath = path.join(CACHE_DIR, hash);
10
+
11
+ if (await fs.pathExists(cachePath)) {
12
+ return await fs.readFile(cachePath);
13
+ }
14
+ return null;
15
+ }
16
+
17
+ export async function setCache(content, compressedContent) {
18
+ const hash = crypto.createHash('sha256').update(content).digest('hex');
19
+ const cachePath = path.join(CACHE_DIR, hash);
20
+
21
+ await fs.ensureDir(CACHE_DIR);
22
+ await fs.writeFile(cachePath, compressedContent);
23
+ }
@@ -0,0 +1,8 @@
1
+ export const SUPPORTED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'];
2
+
3
+ export const DEFAULT_EXCLUDES = [
4
+ 'node_modules/',
5
+ 'dist/',
6
+ '.git/',
7
+ 'coverage/'
8
+ ];
@@ -0,0 +1,12 @@
1
+ import path from 'path';
2
+ import { SUPPORTED_EXTENSIONS } from './constants.js';
3
+
4
+ /**
5
+ * 检查文件是否为支持的图片格式
6
+ * @param {string} filePath - 文件路径
7
+ * @returns {boolean}
8
+ */
9
+ export function isSupportedImage(filePath) {
10
+ const ext = path.extname(filePath).toLowerCase();
11
+ return SUPPORTED_EXTENSIONS.includes(ext);
12
+ }
package/vite/index.js ADDED
@@ -0,0 +1,147 @@
1
+ import path from 'path';
2
+ import chalk from 'chalk';
3
+ import { loadConfig } from '../src/config/loader.js';
4
+ import { compressImage } from '../src/core/compressor.js';
5
+ import { isSupportedImage } from '../src/utils/file-utils.js';
6
+ import { getCache, setCache } from '../src/utils/cache.js';
7
+
8
+ export function compressVitePlugin(options = {}) {
9
+ let config;
10
+
11
+ return {
12
+ name: '@zhaoshijun/compress',
13
+ apply: 'build',
14
+ enforce: 'post', // 确保在其他插件处理完资源后执行
15
+
16
+ async configResolved(resolvedConfig) {
17
+ // 加载项目根目录的配置文件
18
+ const fileConfig = await loadConfig();
19
+
20
+ // 合并配置:优先使用 inline options,其次是 file config
21
+ // 注意:loadConfig 已经合并了 defaults
22
+ // 这里我们需要把 options (vite config 中的参数) 合并进去
23
+ // 题目说:允许在 vite.config.js 中覆盖部分字段
24
+ // compressVitePlugin({ cache: false, quality: 90 })
25
+
26
+ config = {
27
+ ...fileConfig,
28
+ ...options, // 顶层覆盖
29
+ vite: {
30
+ ...fileConfig.vite,
31
+ ...options // 如果 options 里有 cache 等参数,也可以直接混入,或者特定处理
32
+ }
33
+ };
34
+
35
+ // 特殊处理:如果 options 里有 quality,覆盖 config.quality
36
+ if (options.quality) config.quality = options.quality;
37
+ // 如果 options 里有 cache,覆盖 config.vite.cache
38
+ if (options.cache !== undefined) config.vite.cache = options.cache;
39
+ },
40
+
41
+ async generateBundle(outputOptions, bundle) {
42
+ const logger = this.environment?.logger || console; // Vite 6+ might use environment
43
+ const log = (msg) => console.log(msg); // fallback
44
+
45
+ const assets = Object.keys(bundle).filter(fileName => {
46
+ return bundle[fileName].type === 'asset' && isSupportedImage(fileName);
47
+ });
48
+
49
+ if (assets.length === 0) return;
50
+
51
+ console.log(chalk.cyan('\n[compress] Starting image compression...'));
52
+
53
+ for (const fileName of assets) {
54
+ const asset = bundle[fileName];
55
+ const originalSource = asset.source; // Buffer or string
56
+ const originalBuffer = Buffer.isBuffer(originalSource)
57
+ ? originalSource
58
+ : Buffer.from(originalSource);
59
+
60
+ let compressedBuffer;
61
+ let isCached = false;
62
+
63
+ // 缓存检查
64
+ if (config.vite.cache) {
65
+ const cached = await getCache(originalBuffer);
66
+ if (cached) {
67
+ compressedBuffer = cached;
68
+ isCached = true;
69
+ }
70
+ }
71
+
72
+ if (!compressedBuffer) {
73
+ try {
74
+ // 压缩
75
+ compressedBuffer = await compressImage(originalBuffer, config, fileName);
76
+
77
+ // 写入缓存
78
+ if (config.vite.cache) {
79
+ await setCache(originalBuffer, compressedBuffer);
80
+ }
81
+ } catch (error) {
82
+ console.error(chalk.red(`[compress] Failed to compress ${fileName}: ${error.message}`));
83
+ continue; // 跳过该文件,保持原样
84
+ }
85
+ }
86
+
87
+ // 如果压缩后反而变大(极少情况),或者出错,使用原图?
88
+ // 题目未要求,但通常如果变大就丢弃。
89
+ // 不过 sharp 通常不会变大 unless format change or quality increase.
90
+ // 这里默认信任压缩结果。
91
+
92
+ // 更新 bundle
93
+ // 1. 修改文件名:添加 .min
94
+ // 2. 更新内容
95
+ // 3. 更新所有引用
96
+
97
+ const ext = path.extname(fileName);
98
+ const baseName = path.basename(fileName, ext);
99
+ const dirName = path.dirname(fileName);
100
+
101
+ // 注意:fileName 可能是 assets/logo-123.jpg
102
+ // 新名字:assets/logo-123.min.jpg
103
+ const newFileName = path.join(dirName, `${baseName}.min${ext}`).replace(/\\/g, '/');
104
+
105
+ // 更新当前 asset
106
+ asset.source = compressedBuffer;
107
+ asset.fileName = newFileName;
108
+
109
+ // 在 bundle 中重命名 key
110
+ delete bundle[fileName];
111
+ bundle[newFileName] = asset;
112
+
113
+ // 更新引用
114
+ // 遍历所有 chunk,替换 fileName -> newFileName
115
+ // 这是一个简单的字符串替换,对于复杂的引用可能不够,但通常 Vite 的 assets 引用就是字符串
116
+ Object.keys(bundle).forEach(key => {
117
+ const chunk = bundle[key];
118
+ if (chunk.type === 'chunk') {
119
+ // chunk.code 是字符串
120
+ // 替换所有出现的 fileName
121
+ // 需要转义正则特殊字符
122
+ // 但是 fileName 通常包含 hash,是唯一的,直接 replaceAll 应该安全
123
+ // 注意:fileName 是相对路径吗? bundle keys 是相对路径 (e.g. assets/foo.js)
124
+ // 在代码中引用通常是 "./assets/foo.js" 或者 "/assets/foo.js" (depending on base)
125
+ // 简单替换 fileName 字符串通常有效,因为 Vite 生成的引用通常包含该文件名
126
+
127
+ if (chunk.code.includes(fileName)) {
128
+ // 可能会有引用路径前缀问题,比如 "./" + fileName
129
+ // 直接全局替换 fileName -> newFileName
130
+ chunk.code = chunk.code.split(fileName).join(newFileName);
131
+ }
132
+ }
133
+ });
134
+
135
+ // 日志输出
136
+ if (isCached) {
137
+ console.log(chalk.gray(`[compress] Skipped (unchanged): ${fileName} -> ${newFileName}`));
138
+ } else {
139
+ // 没什么要求输出压缩率,但 CLI 有。 Vite 插件只要求输出 Skipped 提示。
140
+ // 我们可以加一个简短的 success log
141
+ // console.log(chalk.green(`[compress] Compressed: ${fileName} -> ${newFileName}`));
142
+ }
143
+ }
144
+ console.log(chalk.cyan('[compress] Finished.\n'));
145
+ }
146
+ };
147
+ }