@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 +15 -0
- package/README.md +159 -0
- package/bin/compress.js +199 -0
- package/package.json +42 -0
- package/src/config/defaults.js +13 -0
- package/src/config/loader.js +45 -0
- package/src/core/compressor.js +65 -0
- package/src/utils/cache.js +23 -0
- package/src/utils/constants.js +8 -0
- package/src/utils/file-utils.js +12 -0
- package/vite/index.js +147 -0
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`),源代码保持不变。
|
package/bin/compress.js
ADDED
|
@@ -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,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,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
|
+
}
|