手把手教你开发一个快速、高性能、高质量压缩图片的 Vite 插件

5,574 阅读11分钟

ErKeLost

随着目前越来越多的项目使用 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.gif

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 ~ webp
  • cache: 是否开启缓存模式
  • 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 常用钩子
configvite 独有的钩子:可以在 vite 被解析之前修改 vite 的相关配置。钩子接受原始用户配置 config 和一个描述配置环境的变量 env
configResolvedvite 独有的钩子:在解析 vite 配置后调用。使用这个钩子读取和存储最终解析的配置。
configureServervite 独有的钩子:主要用来配置开发服务器,为 dev-server 添加自定义的中间件
generateBundle输出阶段钩子通用钩子:在调用 bundle.write 之前触发 接受 options, bundles, isWrite 三个参数
closeBundle通用钩子:在服务器关闭时被调用

unplugin 通用钩子
load构建阶段的通用钩子:在每个传入模块请求时被调用,可用来返回自定义的内容
transform构建阶段的通用钩子:在每个传入模块请求时被调用,转换单个模块
buildStart构建阶段的通用钩子:在服务器启动时被调用:每次开始构建时调用
writeBundle输出阶段钩子通用钩子:在调用 bundle.write 后,将所有的打包之后的 chunk 都写入文件 ,提供正在写入的文件的完整列表及其详细信息。

具体更多钩子函数,以及如何使用可以查看rollupunplugin

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 流程解析

编写压缩图片插件,依据属性进行开发

  1. mode:支持 squoosh 和 sharp 模式, 需要暴露出一套接口来兼容两套不同平台的 api
  2. compress:对不同图片类型进行压缩,提供不同类型的图片的属性值来修改最后图片的质量与效果
  3. conversion: 图片类型转换是插件最核心的一点,需要考虑,如何修改打包之后的 js 或者 css 代码
  • 第一种方式: closeBundle 时,构建结束,服务器关闭时,获取构建之后的所有 chunk,读取文件,批量 replace 图片模块后缀
  • 第二种方式: 在 load 传入模块的时候拦截,所有图片模块,返回自定义模块,然后在 generateBundle 钩子中重新写入新的文件信息然后返回,最终打包出来的就是我们自定义出来的文件

第二种方式更符合 unplugin-image 作用于 build 模式 , 需要用到以下钩子

  • configResolved 使用这个钩子读取和存储最终解析出来的配置,根据插件参数或者命令做不同的操作
  • load load 钩子会在每个模块传入请求时被调用,可以返回自定义内容
  • generateBundle 输出阶段钩子,再调用 bundle.write 之前立即触发这个 hook

image.png

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;
    });
  }

generateSquooshBundlegenerateSharpBundle 就是具体压缩图片的方法,根据用户传递参数来判断最后编译成什么类型,具体实现就是上文中 squooshsharp 的基本使用

在自定义返回 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 就大功告成了

2.gif

欢迎加入 DevUI 开源社区

欢迎加入 DevUI ! 大家一起检视代码、分析组件实现原理、分享最新的前端技术

感兴趣可以添加 DevUI 小助手微信:opentiny,拉你到我们的官方交流群。

加入 DevUI 开源社区你将收获:

直接的价值:

  1. 通过打造一个实际的 vue3 组件库项目,学习最新的Vite+Vue3+TypeScript+JSX技术
  2. 学习从 0 到 1 搭建一个自己的组件库的整套流程和方法论,包括组件库工程化、组件的设计和开发等
  3. 为自己的简历和职业生涯添彩,参与过优秀的开源项目,这本身就是受面试官青睐的亮点
  4. 结识一群优秀的、热爱学习、热爱开源的小伙伴,大家一起打造一个伟大的产品

长远的价值:

  1. 打造个人品牌,提升个人影响力
  2. 培养良好的编码习惯
  3. 获得华为云 DevUI 团队的荣誉&认可和定制小礼物
  4. 成为 PMC & Committer 之后还能参与 DevUI 整个开源生态的决策和长远规划,培养自己的管理和规划能力
  5. 未来有更多机会和可能

文 / DevUI社区Committer ErKeLost

本文正在参加「金石计划 . 瓜分6万现金大奖」