theme: devui-blue
随着目前越来越多的项目使用 vite 开发,本地使用图片的项目也不在少数,为了提升用户体验,我开发了一个压缩图片的插件
unplugin-imagemin 是一个基于 unplugin + sharp + squoosh 构建的快速、高性能、高质量压缩图片的 Vite 插件。
项目地址 github 传送门
1. 用法
1.1 安装
# npm
npm i unplugin-imagemin -D
# yarn
yarn add unplugin-imagemin -D
# pnpm
pnpm i unplugin-imagemin -D
1.2 基本使用
import { defineConfig } from 'vite';
import imagemin from 'unplugin-imagemin/vite';
export default defineConfig({
plugins: [
imagemin(),
],
});
1.3 效果如下
2. 依赖分析
2.1 Unplugin
unplugin将优秀的Rollup 插件 API扩展为统一的插件接口,并提供基于所用构建工具的兼容层。支持 vite,webpack 等多种构建工具使用编写插件,不仅仅构建插件功能的通用钩子兼容, 还可以针对不同构建工具提供特定钩子函数 具体可见
2.2 Sharp
sharp 是基于libvips (具有低内存需求的快速图像处理库)将普通大图片转换成更小的、对 web 更友好的 JPEG、PNG、WebP 等不同尺寸的图像库 sharp 已经开源了将近 10 年 目前在 github 上有 23.9k 的 star。
2.3 Squoosh
squoosh 是 Chrome 团队的实验项目,部分图片转换库基于 rust,也是一种高效压缩图片的工具,Squoosh 可以减小文件大小并保持高质量。图片差异更小, squoosh 在线体验squoosh app目前在 github 上有 17.9k 的 star。
3. 插件流程
3.1 unplugin-imagemin 参数
mode
: 支持使用 sharp 和 squoosh 进行编译压缩,默认值sharp
compress
: 传入压缩不同图片格式参数详见不同模式图片压缩参数类型conversion
: 构建之后生成的资源图片类型转换 例如:png ~ webpcache
: 是否开启缓存模式cacheDir
: 缓存文件地址
3.2 示例
import { defineConfig } from "vite";
import imagemin from "unplugin-imagemin/vite";
export default defineConfig({
plugins: [
imagemin({
mode: "sharp",
// mode: 'squoosh',
compress: {
jpeg: {
// 0 ~ 100
quality: 25,
},
png: {
// 0 ~ 100
quality: 25,
},
webp: {
// 0 ~ 100
quality: 25,
},
},
conversion: [
{ from: "png", to: "webp" },
{ from: "jpeg", to: "png" },
],
cache: false,
}),
],
});
3.3 Unplugin 基本使用
和编写普通 vite 插件一样,创建一个返回插件对象的工厂函数,允许用户自定义并且修改插件的默认行为
基本使用
export default function myPlugin() {
return {
name: 'transform-file-action',
apply: 'build',
enforce: 'post',
transform(src, id) {
if (fileRegex.test(id)) {
return {
code: compileFileToJS(src),
map: null // 如果可行将提供 source map
}
}
}
}
}
基本属性
enforce
为了与某些 Rollup 插件兼容,可能需要强制修改插件的执行顺序,或者只在构建时使用。这应该是 Vite 插件的实现细节。可以使用 enforce
修饰符来强制插件的位置
pre
:在 Vite 核心插件之前调用该插件- 默认:在 Vite 核心插件之后调用该插件
post
:在 Vite 构建插件之后调用该插件
apply
默认情况下插件在开发 (serve) 和生产 (build) 模式中都会调用。如果插件在服务或构建期间按需使用,请使用 apply
属性指明它们仅在 'build'
或 'serve'
模式时调用:
vite 常用钩子 | |
---|---|
config | vite 独有的钩子:可以在 vite 被解析之前修改 vite 的相关配置。钩子接受原始用户配置 config 和一个描述配置环境的变量 env |
configResolved | vite 独有的钩子:在解析 vite 配置后调用。使用这个钩子读取和存储最终解析的配置。 |
configureServer | vite 独有的钩子:主要用来配置开发服务器,为 dev-server 添加自定义的中间件 |
generateBundle | 输出阶段钩子通用钩子:在调用 bundle.write 之前触发 接受 options, bundles, isWrite 三个参数 |
closeBundle | 通用钩子:在服务器关闭时被调用 |
unplugin 通用钩子 | |
---|---|
load | 构建阶段的通用钩子:在每个传入模块请求时被调用,可用来返回自定义的内容 |
transform | 构建阶段的通用钩子:在每个传入模块请求时被调用,转换单个模块 |
buildStart | 构建阶段的通用钩子:在服务器启动时被调用:每次开始构建时调用 |
writeBundle | 输出阶段钩子通用钩子:在调用 bundle.write 后,将所有的打包之后的 chunk 都写入文件 ,提供正在写入的文件的完整列表及其详细信息。 |
具体更多钩子函数,以及如何使用可以查看rollup 和 unplugin
3.4 Squoosh 基本使用
谷歌官方提供了 LibSquoosh 一种实验性的方式, 可以在 JavaScript 程序中运行, LibSquoosh 使用工作池来并行处理图像
安装
npm install @squoosh/lib
基本使用
import { ImagePool } from '@squoosh/lib';
import { cpus } from 'os';
const imagePool = new ImagePool(cpus().length);
创建一个图像池,通过这个图像池来进行图片编码,imagePool 构造函数接收一个参数,可以规定并行运行的图像编码数量
const file = await fs.readFile('./path/image.png');
const path = path.join(process.cwd(), ./path/image.png');
const image = imagePool.ingestImage(file);
const image = imagePool.ingestImage(path);
然后根据 ingestImage 方法获取到原始图像, 支持传入一个文件 buffer 或者图像路径
接下来就可以对当前传入图像进行编码,提取信息等操作
const result = await image.encode(
webp: {
quality: 90,
},
);
const binary = await image.encodedWith.webp.binary
fs.writeFile('/path/image.webp', binary);
encode 返回一个 promise 表示对图像进行编码操作 参数可以理解为从插件参数中传入的 compress 属性
一般情况下,我们对图像编码之后需要写入文件,可以通过 encodeWith 获取编码之后的图片 buffer 并且写入文件中
imagePool.close()
进行编码之后我们需要关闭 imagePool 管道进程,否则会阻塞当前进程不会被关闭
3.5 Sharp 基本使用
安装
pnpm add sharp
基本使用
import sharp from 'sharp';
const input = sharp('input.jpg')
const create = sharp({
create: {
width: 300,
height: 200,
channels: 4,
background: { r: 255, g: 0, b: 0, alpha: 0.5 }
}
})
sharp 方法支持传入一个文件 buffer, 图像路径, 或者一个对象支持创建新图像,返回一个当前图像类型的实例
输出
const res = await sharp(input).toFile('output.png')
toFile 方法会直接输出文件 res 包含文件的所有信息,当然也可以通过输出各种其他类型信息获取到 buffer 进而通过 fs 写入文件
const binary = await sharp(input)
.jpeg({
quality: 100,
})
.toBuffer();
fs.writeFile('output.jpeg', binary);
3.3 流程解析
编写压缩图片插件,依据属性进行开发
mode
:支持 squoosh 和 sharp 模式, 需要暴露出一套接口来兼容两套不同平台的 apicompress
:对不同图片类型进行压缩,提供不同类型的图片的属性值来修改最后图片的质量与效果conversion
: 图片类型转换是插件最核心的一点,需要考虑,如何修改打包之后的 js 或者 css 代码
- 第一种方式: closeBundle 时,构建结束,服务器关闭时,获取构建之后的所有 chunk,读取文件,批量 replace 图片模块后缀
- 第二种方式: 在 load 传入模块的时候拦截,所有图片模块,返回自定义模块,然后在 generateBundle
钩子中重新写入新的文件信息然后返回,最终打包出来的就是我们自定义出来的文件
第二种方式更符合
unplugin-image 作用于 build 模式 , 需要用到以下钩子
configResolved
使用这个钩子读取和存储最终解析出来的配置,根据插件参数或者命令做不同的操作load
load 钩子会在每个模块传入请求时被调用,可以返回自定义内容generateBundle
输出阶段钩子,再调用 bundle.write 之前立即触发这个 hook
4 代码实现
4.1 创建项目
可以直接使用 antfu 的unplugin-starter模版
本文从头搭建项目,插件编写 demo 讲解核心逻辑
- 创建项目
// 新建一个文件夹
mkdir unplugin-imagemin
// 进入项目
cd unplugin-imagemin
// 初始化 使用pnpm 初始化仓库
pnpm init
- 创建 workspace
// 创建 pnpm-workspace.yaml
touch pnpm-workspace.yaml
// 编写需要管理的包 在 pnpm-workspace中编写
packages:
- 'playground/**'
// 创建文件目录
mkdir playground
// 安装开发依赖 -w root根目录安装
pnpm install typescript tsup -Dw
// 安装生产依赖
pnpm install unplugin sharp @squoosh/lib -w
// 进入
新建 src 目录 index.ts
import { createUnplugin } from 'unplugin'
import Context from './core/context';
export default createUnplugin(options => (
const ctx = new Context();
const assignOptions = Object.assign({}, resolveDefaultOptions, options);
return {
name: 'unplugin-image',
apply: 'build',
enforce: 'pre',
// 解析属性
async configResolved(config) {
ctx.handleMergeOptionHook({ ...config, options: assignOptions });
},
// 自定义图片模块返回内容
async load(id) {
const imageModule = ctx.loadBundleHook(id);
if (imageModule) {
return imageModule;
}
},
// 根据load 获取到的自定义asset 资源生成文件
async generateBundle(_, bundler) {
await ctx.generateBundleHook(bundler);
},
}
))
4.2 构建全局上下文
import { createFilter } from '@rollup/pluginutils';
export default class Context {
config: ResolvedOptions;
imageModulePath: string[] = [];
files: string[] = [];
assetPath: string[] = [];
filter = createFilter(extRE, [
/[\/]node_modules[\/]/,
/[\/]\.git[\/]/,
]);
handleMergeOptionHook() {}
loadBundleHook() {}
generateBundleHook() {}
}
4.3 解析 configResolved
configResolved 钩子可以获取到当前 vite 可配置参数 config,我们需要用户配置的其他参数例如base
,outDir
,command
来保证我们最后输出的资源地址是正确的, 我们把所有参数结合起来 定义一个 class 供接下来的钩子函数使用
handleMergeOptionHook(useConfig: any) {
const {
base,
command,
root,
build: { assetsDir, outDir },
options,
} = useConfig;
const cwd = process.cwd();
const isBuild = command === 'build';
const cacheDir = join(root, 'node_modules', options.cacheDir, 'unplugin-imagemin');
const isTurn = isTurnImageType(options.conversion);
const outputPath = resolve(root, outDir);
const chooseConfig = {
base,
command,
root,
cwd,
outDir,
assetsDir,
options,
isBuild,
cacheDir,
outputPath,
isTurn,
};
// squoosh & sharp merge config options
this.mergeConfig = resolveOptions(defaultOptions, chooseConfig);
this.config = chooseConfig;
}
4.4 解析 load 钩子
loadBundleHook 钩子会获取到所有需要构建的模块,包括 js,css, assets 和第三方库引用的包,
然后过滤出来所有图片模块,自定义返回我们需要的内容
import { createHash } from 'node:crypto';
loadBundleHook (id) {
const imageModuleFlag = this.filter(id);
if (imageModuleFlag) {
const { path } = parseId(id);
this.imageModulePath.push(path);
const generateSrc = getBundleImageSrc(path, this.config.options);
const base = basename(path, extname(path));
const generatePath = join(
`${this.config.base}${this.config.assetsDir}`,
`${base}.${generateSrc}`,
);
return `export default ${devalue(generatePath)}`;
}
}
function getBundleImageSrc(filename: string, options: any) {
const currentType =
options.conversion.find(
(item) => item.from === extname(filename).slice(1),
) ?? extname(filename).slice(1);
const id = generateImageID(
filename,
currentType.to ?? extname(filename).slice(1),
);
return id;
}
// 生成当前模块文件 hash 值,拼接到构建路径后
export function generateImageID(filename: string, format: string = 'jpeg') {
return `${createHash('sha256')
.update(filename)
.digest('hex')
.slice(0, 8)}.${format}`;
}
4.5 generateBundleHook 根据返回自定义内容生成 asset 文件
根据 load 过滤出来的 图片模块路径,针对不同模式进行压缩操作
async generateBundleHook(bundler) {
this.chunks = bundler;
if (!(await exists(this.config.cacheDir))) {
await mkdir(this.config.cacheDir, { recursive: true });
}
const imagePool = new ImagePool();
this.startGenerate();
let spinner;
spinner = await loadWithRocketGradient('');
const { mode } = this.config.options;
const generateImageBundle = this.imageModulePath.map(async (item) => {
if (mode === 'squoosh') {
const squooshBundle = await this.generateSquooshBundle(imagePool, item);
return squooshBundle;
}
if (mode === 'sharp') {
const sharpBundle = await this.generateSharpBundle(item);
return sharpBundle;
}
});
const result = await Promise.all(generateImageBundle);
imagePool.close();
this.generateBundleFile(bundler, result);
logger(pluginTitle('✨'), chalk.yellow('Successfully'));
spinner.text = chalk.yellow('Image conversion completed!');
spinner.succeed();
}
generateBundleFile(bundler, result) {
result.forEach((asset) => {
bundler[asset.fileName] = asset;
});
}
generateSquooshBundle
和 generateSharpBundle
就是具体压缩图片的方法,根据用户传递参数来判断最后编译成什么类型,具体实现就是上文中 squoosh
和 sharp
的基本使用
在自定义返回 load 钩子加载的 id 之后,需要返回当前自定义 assets 模块 bundle 格式如下
return {
fileName: join(assetsDir, imageName),
name: imageName,
source: buffer,
isAsset: true,
type: 'asset',
};
然后我们在 bundle write 之前 加入这些 图片模块 result 代表上图返回对象
generateBundleFile(bundler, result) {
result.forEach((asset) => {
bundler[asset.fileName] = asset;
});
}
然后根据不同模式的库传递不同的方法和参数,简易版本的压缩图片插件就完成了
4.5 打包
使用 tsup 进行打包,src 目录下有一个 index.ts 然后我们需要创建一个 vite.ts
通过返回 unplugin 对应构建工具的函数来调用
import unpluginImagemin from "./index";
export default unplugin.vite;
新建tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
// 构建所有ts文件
entry: ['./src/*.ts'],
format: ['esm', 'cjs'],
target: 'node14',
clean: true,
dts: true,
splitting: true,
shims: true,
});
4.6 playground 测试
在根目录里创建一个 vite 项目
pnpm create vite playground --template vue
在 playground 中引入 unplugin-imagemin
"devDependencies": {
"@vitejs/plugin-vue": "^3.2.0",
"typescript": "^4.6.4",
"unplugin-imagemin": "workspace:*",
"vite": "^3.2.3",
"vue-tsc": "^1.0.9"
}
修改vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import imagemin from 'unplugin-imagemin/vite';
export default defineConfig({
plugins: [
vue(),
imagemin({
mode: 'sharp',
compress: {
jpeg: {
quality: 25,
},
png: {
quality: 25,
},
webp: {
quality: 25,
},
},
conversion: [
{ from: 'png', to: 'webp' },
{ from: 'jpeg', to: 'png' },
]
}),
],
});
运行 pnpm build
就大功告成了
欢迎加入 DevUI 开源社区
欢迎加入 DevUI ! 大家一起检视代码、分析组件实现原理、分享最新的前端技术
感兴趣可以添加 DevUI 小助手微信:devui-official
,拉你到我们的官方交流群。
加入 DevUI 开源社区你将收获:
直接的价值:
- 通过打造一个实际的 vue3 组件库项目,学习最新的
Vite
+Vue3
+TypeScript
+JSX
技术 - 学习从 0 到 1 搭建一个自己的组件库的整套流程和方法论,包括组件库工程化、组件的设计和开发等
- 为自己的简历和职业生涯添彩,参与过优秀的开源项目,这本身就是受面试官青睐的亮点
- 结识一群优秀的、热爱学习、热爱开源的小伙伴,大家一起打造一个伟大的产品
长远的价值:
- 打造个人品牌,提升个人影响力
- 培养良好的编码习惯
- 获得华为云 DevUI 团队的荣誉&认可和定制小礼物
- 成为 PMC & Committer 之后还能参与 DevUI 整个开源生态的决策和长远规划,培养自己的管理和规划能力
- 未来有更多机会和可能
文 / DevUI社区Committer ErKeLost
本文正在参加「金石计划 . 瓜分6万现金大奖」
暂无评论内容