图片压缩工具 | Electron+Vue3+Rsbuild开发桌面应用

发布于:2025-05-31 ⋅ 阅读:(21) ⋅ 点赞:(0)

在上一篇文章需求思考及桌面应用开发技术选型中,已经确定了工具的技术方案,现在开始我们要实际动手写代码啦😄

OPEN-IMAGE-TINY,一个基于 Electron + VUE3 的图片压缩工具,项目开源地址:https://github.com/0604hx/open-image-tiny

🧑‍💻 开发环境及依赖

本地 node 版本为 22.14.0

组件/框架 版本 说明
VSCode 最新版 代码编辑器
electron 36.3.1 直接用最新版本
rsbuild 1.3.21 基于 rspack 的打包工具,快
vue 3.5.14
Naive UI 3.41.0 我常用的UI库
sharp 0.34.2 2025年后的版本才支持 avif
electron-builder 26.0.12 electron打包工具
dayjs 1.11.13 日期格式化
lucide-vue-next 0.511.0 图标库
pinia 3.0.2 vue状态管理

🛠️ 搭建基本项目框架

代码逻辑

Electron 是一个基于 Chromium 和 Node.js 的框架,用于构建跨平台的桌面应用程序。它的核心原理可以概括为以下几个关键点:

  1. 多进程

    • 主进程:管理窗口、生命周期,使用 Node.js。
    • 渲染进程:每个窗口是一个网页(Chromium),默认可调用 Node.js API。
  2. 核心机制

    • Chromium:渲染页面,支持 HTML/CSS/JS。
    • Node.js:访问系统资源(如文件、网络)。
    • IPC 通信:主进程与渲染进程通过 ipcMain/ipcRenderer 交互。
  3. 跨平台

    • 打包时包含 Chromium 和 Node.js,生成各平台(Windows/macOS/Linux)应用。

项目结构

OPEN-IMAGE-TINY
├── build 				# electron 打包产物
├── dist				# 前端打包产物
├── docs				#文档
├── electron			# electron 相关代码
│   ├── handler.js		# IPC逻辑
│   ├──  main.js 		# 程序入口
│   ├──  preload.js		# 预加载脚本
│   └── tool.js 		# 工具类
├── public				# 前端资源
├── src					# 标准的 vue3 项目
├── package.json
└── rsbuild.config.mjs	# rsbuild 配置文件

页面布局

App.vue

<template>
    <n-space vertical style="padding: 16px;">
        <n-alert :bordered="false" type="success" closable>
            <template #icon> <Info /> </template>
            WebP 和 AVIF 是两种现代图像格式,目标都是减小文件大小、提升加载速度、同时保持较高画质。
        </n-alert>

        <n-card size="small" hoverable class="text-center clickable" @click="toSelect">
            <div style="margin-bottom: 12px"><n-icon size="48" :depth="3"> <ImagePlus /> </n-icon></div>
            <n-text style="font-size: 16px">点击或者拖动文件到该区域来上传</n-text>
            <n-p depth="3" style="margin: 8px 0 0 0">支持的格式 {{ exts.join("、") }},最多 {{ max }} 张图片</n-p>
        </n-card>

        <n-card title="已选图片" size="small">
            <ImageList :images />
        </n-card>

        <n-card size="small">
            <n-form inline :show-feedback="false">
                <n-form-item label="转换为">
                    <n-select class="cell" :options v-model:value="transfer.target"></n-select>
                </n-form-item>
                <n-form-item label="质量值">
                    <n-input-number class="cell" :min="0" :step="10" :max="100" v-model:value="transfer.quality" />
                </n-form-item>
            </n-form>
        </n-card>

        <div class="text-center">
            <n-button @click="start" size="large" type="primary">开始图片转换</n-button>
        </div>
    </n-space>
</template>

<script setup>
    import { ref, reactive, toRaw } from 'vue'
    import { NCard, NSpace, NButton, NAlert, NUpload, NUploadDragger, NText, NP, NIcon, NForm, NFormItem, NSelect, NInputNumber, useMessage } from 'naive-ui'
    import { ImagePlus, CirclePlay, Info } from 'lucide-vue-next'

    import ImageList from '@/widget/images.vue'

    const max = 5
    const exts = ["JPG", "JPEG", "PNG", "WEBP", "AVIF"]
    const accept = exts.map(v=>`.${v.toLocaleLowerCase()}`).join(",")
    const options = exts.map(value=>({ value, label:value}))
    const message = useMessage()

    const images = ref([])
    const transfer = reactive({ target:"WEBP", quality:80 })

    const toSelect = ()=> {
        if(!(window.H && window.H.selectFiles))
            return message.error(`请在客户端内运行`)

        if(images.value.length >= max)
            return message.warning(`批量处理上限${max}个图片`)

        H.selectFiles(exts).then(files=>{
            if(Array.isArray(files)){
                /**@type {Array<Object>} */
                let imgs = images.value
                files.forEach(f=>{
                    if(imgs.some(v=> v.uuid == f.uuid))
                        return
                    imgs.push(f)
                })
                if(imgs.length > max){
                    imgs.length = max
                    message.info(`自动移除超范围的图片`)
                }

                images.value = imgs
            }
        })
    }

    const start = ()=>{
        let imgs = images.value
        if(!imgs.length)    return message.warning(`请先选择图片`)

        for(let i=0;i<imgs.length;i++){
            let img = imgs[i]
            img.state = 1
            H.convert(img.path, toRaw(transfer))
                .then(d=>{
                    if(d && !!d.size){
                        img.output = d.path
                        img.sized = d.size
                        img.used = d.used
                        img.state = 2
                    }
                    else{
                        img.state = 0
                        img.fail = d?.fail
                    }
                })
        }
    }
</script>

展示图片清单

images.vue

<template>
    <n-table v-if="images.length" size="small" :bordered :bottom-bordered="false" single-column striped>
        <thead>
            <tr>
                <th>文件名</th>
                <th width="50px">宽度</th>
                <th width="50px">高度</th>
                <th width="65px">原始大小</th>
                <th width="65px">转换后</th>
                <th width="50px">压缩率</th>
                <th width="30px"></th>
            </tr>
        </thead>
        <tbody>
            <tr v-for="(img, index) in images">
                <td>
                    <n-tooltip placement="bottom" :style>
                        <template #trigger>
                            <span class="clickable" @click="open(img.path)">{{ img.name }}</span>
                        </template>
                        {{ img.path }}
                    </n-tooltip>
                </td>
                <td>{{ img.width }}</td>
                <td>{{ img.height }}</td>
                <td>{{ filesize(img.size) }}</td>
                <td> <span class="clickable" @click="open(img.output)">{{ filesize(img.sized) }}</span></td>
                <td>
                    <n-tooltip v-if="img.sized" placement="bottom" :style>
                        <template #trigger><n-tag class="w-full" size="small" :bordered type="primary">{{ ratio(img) }}</n-tag></template>
                        <div>
                            <div><n-tag size="small" :bordered type="primary">路径</n-tag> {{img.output}}</div>
                            <div><n-tag size="small" :bordered type="primary">耗时</n-tag> {{img.used}}毫秒</div>
                        </div>
                    </n-tooltip>
                    <n-tooltip v-else-if="img.fail" :style placement="bottom">
                        <template #trigger><n-tag class="w-full" size="small" :bordered type="error">失败</n-tag></template>
                        {{ img.fail }}
                    </n-tooltip>
                </td>
                <td class="text-center">
                    <!-- <n-icon v-if="img.state==2"  class="clickable" :size color="#18a058" :component="CheckCircle" /> -->
                    <n-spin v-if="img.state==1" :size />
                    <n-icon v-else class="clickable" :size :component="Trash"  @click="()=>images.splice(index, 1)"/>
                </td>
            </tr>
        </tbody>
    </n-table>
    <n-text v-else depth="3">暂未选择图片</n-text>
</template>

<script setup>
    import { NTable, NIcon, NText, NSpin, NTooltip, NTag } from 'naive-ui'
    import { Trash, CheckCircle } from 'lucide-vue-next'

    const size = 18
    const bordered = false
    const style = { maxWidth: `${parseInt(window.innerWidth*0.8)}px` }

    const props = defineProps({
        images:{type:Array, default:[]},    //图片清单
    })

    const ratio = img=>{
        if(!(img.size && img.sized))    return ""
        return ((1-img.sized/img.size)*100).toFixed(2) + "%"
    }
    const open = path=> path && H.open(path)
</script>

图片转换代码

/**
 * @typedef {Object} ConvertConfig - 转换格式
 * @property {String} target - 目标格式
 * @property {Number} quality - 质量
 */

/**
 * 转换图片格式
 * @param {String} origin
 * @param {String} target
 * @param {ConvertConfig} config
 */
exports.convertFormat = async (origin, target, config)=>{
    const started = Date.now()
    const format = config.target.toLowerCase()
    const ext = path.extname(origin)

    if(`.${format}` == ext.toLowerCase()){
        console.debug(`${origin} 已经是 ${format} 格式,无需转换...`)
        return
    }

    if(!target){
        const dir = path.dirname(origin)
        const base = path.basename(origin, ext)
        target = path.join(dir, `${base}.${format}`)
    }

    let img = sharp(origin)
    try{
        await img.toFormat(format, { quality: config.quality }).toFile(target)
    }catch(e){
        img.destroy()
        let fail = e.message ?? e
        console.error(`转换出错`, fail)
        return { fail }
    }

    return { path: target, size: statSync(target).size, used: Date.now() - started }
}

🧩 配置应用图标

我觉得应用图标是非常重要的一个要素,是用户开始使用应用的第一印象,值得下点功夫😄。我通过稿定设计用印章模版做了个图标。

另外还可以在iconfont-阿里巴巴矢量图标库中找现成的,通常改下颜色就能用。最后,将图片转换为 ico 格式。

📦 打包为 exe

首先我们在 package.json 中配置electron-builder

"build": {
    "appId": "open-image-tiny",
    "productName": "图片压缩工具",
    "artifactName": "${productName}-${os}-${arch}-${version}.${ext}",
    "copyright": "Copyright © 2009-2025 集成显卡",
    "asar": true,
    "compression": "maximum",
    "asarUnpack": [
    ],
    "files": [
        "dist/**/*",
        "electron/**/*"
    ],
    "directories": {
        "output": "build"
    },
    "win": {
        "icon": "./public/logo.ico",
        "target": [
            {
                "target": "7z",
                "arch": ["x64"]
            }
        ]
    }
}

接着执行命令:

  1. pnpm ui:build:打包前端到 dist 目录
  2. pnpm package:7z:通过 electron-builder 打包到 build 目录,并压缩为 7z 格式(小新16Pro 2021款下耗时 2 分钟左右😂)


产物如下:

📷 运行预览


程序启动速度是蛮快的,内存占用情况:

❓问题集锦

依赖下载慢或者失败

可以通过设置国内镜像解决,在项目根目录创建.npmrc文件,写入以下内容:

electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
sharp_binary_host=https://npmmirror.com/mirrors/sharp/
sharp_libvips_binary_host=https://npmmirror.com/mirrors/sharp-libvips/
registry=https://registry.npmmirror.com/

sharp 文件句柄占用

使用 sharp.js 打开 webp 格式文件时,即使通过调用其 destory 方法,该文件依然提示被程序占用,此时无法在资源管理器中删除😂。

目前还没有解决办法。

前端如何获取选择文件的绝对路径

通过 H5 file 标签无法获取所选文件的绝对路径,需要借助主进程。

// preload.js 注册相应函数
contextBridge.exposeInMainWorld("H", {
    selectFiles: (accept)=> ipcRenderer.invoke("select-files", accept)
})


/**
 * main.js 中处理业务逻辑
 * @param {Electron.IpcMainInvokeEvent} e
 * @param {Array<String>} accept - 支持的格式
 */
'select-files': async (e, accept=["JPG","JPEG","PNG","WEBP","AVIF"])=>{
    let files = dialog.showOpenDialogSync({
        title: `选择图片`,
        filters: [{ name:"图片", extensions:accept }],
        properties: ['openFile','createDirectory', 'multiSelections']
    })

    return files ?? []
}

前端依赖也被打包到 electron 中?

默认情况下,electron-builder 会将项目根目录下 package.json 中的 dependencies 依赖打包到最终产物,如果不希望前端依赖被打包,最简单的做法是把相关依赖转移到devDependencies