【前端工程化】我开源了个高效的图片压缩工具 - fast-imagemin-cli

2,537 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天,点击查看活动详情

Hello,我是Xc,一位因antfu结缘开源的前端菜鸟,今天和大家分享最近开源的一个高效的图片压缩工具fast-imagemin-cli

什么是图片压缩

顾名思义就是压缩图片大小,有效的图片压缩会优化网页的加载,我们常见的图片压缩处理主要有以下2种:

  • 借助tinypng等图片压缩网站或者工具提前压缩好再放入项目中
  • 借助脚手架插件在编译时进行压缩输出

为什么做这个工具

源于实际的需求吧,部分类型项目存在较多的图片资源,基于上面2种方案先说下在生产中存在的一些问题吧。

  • 借助压缩工具:由于项目是多人开发,可能存在有时候忘记压缩的情况,导致一个较大的图片资源直接给编译到生产中了,影响了网页的加载。
  • 借助脚手架插件:因为图片资源较多,每次编译时在图片压缩上都消耗大量时间去做重复的事情。

如何做这个工具

就在思考要如何解决这类问题时候,正好在 antfu开源探店直播项目收集 中看到了 速冻鱼 大佬开源的 easy-tinypng-cli 插件,它可以自动帮忙压缩替换图片资源。

然后又结合了目前项目在用的vite-plugin-imagemin,整理了下这两个不满足我的需求的地方:

  • 使用 vite-plugin-imagemin 时,每次构建都必须重新压缩,我认为这是不必要的。
  • 使用easy-tinypng-cli时,需要联网,有500个限制,不能满足大量处理。

好奇心让我去看了这两个工具的源码,发现easy-tinypng-cli其中设计的recode.json来作为一个记录表,防止图片进行二次压缩,导致浪费每个月的次数。

vite-plugin-imagemin是一个基于imagemin系列插件开发一个组合压缩图片插件。

瞬间有个想法戛然而生,我把他们结合一下是不是就行了?

  • 利用vite-plugin-imagemin中的压缩功能解决easy-tinypng-cli的联网和数量限制问题。
  • 利用easy-tinypng-cli中的recode设计来解决vite-plugin-imagemin每次的重复压缩工作问题。

开发

工具类型定位

是打算将其做成cli工具还是一个编译插件,这里我选择将其作为类似easy-tinypng-cli的工具,做成插件开发成本较大,要需要处理多种脚手架的问题,虽然说可以用unplugin来开发兼容多种脚手加插件,但是还是觉得cli工具更方便,所以决定将其作为一个cli工具进行开发。

工具整体流程设计

graph TD
A[执行命令] --> B(读取配置)
B --> |未读取到配置文件| C(结束)
B --> |读取到配置文件| D(获取配置目录下的所有图片路径)
D --> E(加载imagemin插件)
E --> F(遍历文件路径)
F --> G(根据文件内容获取hash)
G --> R(比对记录表)
R --> |hash存在| K(跳过该路径)
R --> |hash不存在| L(压缩并替换源文件并写入记录)
L --> 结束

实现配置加载

参考了easy-tinypng-cli中使用的unconfig进行配置文件的加载,可以加载执行命令目录下文件名与files配置匹配的文件,支持多种拓展名,拓展名匹配优先级按照extensions

import { loadConfig } from 'unconfig'

const { config } = await loadConfig<FmConfig>({
    sources: [
      {
        files: 'fm.config',
        extensions: ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs', 'json', ''],
      },
    ],
  })

遍历图片文件路径

首先需要一个遍历方法

export function walkSync(currentDirPath: string, callback: (filePath: string, dirent: fs.Dirent) => void) {
  fs.readdirSync(currentDirPath, { withFileTypes: true }).forEach((dirent: fs.Dirent) => {
    const filePath = path.join(currentDirPath, dirent.name)
    if (dirent.isFile())
      callback(filePath, dirent)

    else if (dirent.isDirectory())
      walkSync(filePath, callback)
  })
}

根据配置文件中获取到的目录配置,进行遍历

const { include, options } = config
  const includeArr = Array.isArray(include) ? include : [include]
  const filePaths: string[] = []
  includeArr.forEach((targetDir) => {
    const currentDirPath = path.resolve(process.cwd(), targetDir)
    walkSync(currentDirPath, (filePath: string, dirent: fs.Dirent) => {
      if (isImageExp(dirent.name)) { // 判断是否是图片文件
        const hash = getHashOfFile(filePath)  // 获取文件hash
        if (force || !oldTinyMap.has(hash)) // 判断是否已经压缩过
          filePaths.push(filePath)
      }
    })
  })

压缩方法

const plugins = getImageminPlugins(options) // 复用vite-plugin-imagemin中的方法
    if (plugins.length === 0) {
      debug(chalk.redBright('Please configure at least one compression configuration parameter.'))
      return
    }
    const tasks = filePaths.map(filePath => Promise.resolve(processFile(plugins!, filePath, fs.readFileSync(filePath))))
    const spinner = ora({ text: chalk.bold.yellow('compressing...'), color: 'yellow' }).start()
   // 利用promise.all去执行压缩操作任务
    await Promise.all(tasks).then((results) => {
      spinner.stop()
      const total = results.length
      const success = results.filter(result => result).length
      const fail = total - success
      debug(`compress results: total: ${chalk.bold.blue(total)} success: ${chalk.bold.green(success)} fail: ${chalk.bold.red(fail)}`)
      // eslint-disable-next-line no-console
      console.table(Array.from(tinyMap.values()), ['relativePath', 'oldSize', 'size', 'ratio', 'time(ms)'])
      Array.from(tinyMap.entries()).forEach(([key, value]) => {
        oldTinyMap.set(key, value)
      })
      // 写入日志
      fs.writeFileSync(RecordFilePath, JSON.stringify(Object.fromEntries(oldTinyMap.entries()), null, '\t'))
    })

主体功能就开发完成了,有兴趣的可以去fast-imagemin-cli查看源码

应用

安装

# pnpm
pnpm install fast-imagemin-cli -D

# npm
npm install fast-imagemin-cli -D

# yarn
yarn add fast-imagemin-cli -D

imagemin 安装问题

感谢大佬指出这个问题,这个问题我在vite-plugin-imagemin的README中有看到相应的解决方案,如下

由于 imagemin 在国内不好安装。现提供几个解决方案

  1. 使用 yarn 在 package.json 内配置(推荐)
"resolutions": {
    "bin-wrapper": "npm:bin-wrapper-china"
  },

2. 使用 npm,在电脑 host 文件加上如下配置即可

199.232.4.133 raw.githubusercontent.com

Config

fm.config.ts or fm.config.json

  • inclde(required): 需要压缩的图片目录会自动递归遍历.
  • options: imagemin 配置项
// fm.config.ts
import { defineFmConfig } from 'fast-imagemin-cli/support'

export default defineFmConfig({
  include: ['./src/assets'], // string | string[]
  options: {
    gifsicle: {
      optimizationLevel: 7,
      interlaced: false,
    },
    optipng: {
      optimizationLevel: 7,
    },
    mozjpeg: {
      quality: 20,
    },
    pngquant: {
      quality: [0.8, 0.9],
      speed: 4,
    },
    svgo: {
      plugins: [
        {
          name: 'removeViewBox',
        },
        {
          name: 'removeEmptyAttrs',
          active: false,
        },
      ],
    },
  },
})

Options

paramstypedefaultdefault
svgoobject or false-See Options
gifsicleobject or false-See Options
mozjpegobject or false-See Options
optipngobject or false-See Options
pngquantobject or false-See Options
webpobject or false-See Options

添加 Script

// package.json
{
  "scripts": {
    "fm": "fm" // or "fm --force" 强制压缩,跳过记录
    // 如果担心会忘记压缩的情况,可以直接把命令配置到编译命令中
    "build": "fm && vue-tsc --noEmit && vite build" 
  }
}

Demo

xlegex

first

你会发现其中一个 .png 文件压缩耗时 76930ms, 如果每次都花这么长时间,会大大影响工作效率。

second

第二次就只需要5ms,节省了77000ms。

最后

感谢easy-tinypng-clivite-plugin-imagemin提供的灵感

项目地址

fast-imagemin-cli

使用中有任何问题可以提issue哦~~

如果这个插件能够帮助到你或者让你觉得有用可以给个star和关注~~