图片压缩工具 | Electron+Vue3+Rsbuild开发桌面应用
在上一篇文章需求思考及桌面应用开发技术选型中,已经确定了工具的技术方案,现在开始我们要实际动手写代码啦😄
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 的框架,用于构建跨平台的桌面应用程序。它的核心原理可以概括为以下几个关键点:
-
多进程
- 主进程:管理窗口、生命周期,使用 Node.js。
- 渲染进程:每个窗口是一个网页(Chromium),默认可调用 Node.js API。
-
核心机制
- Chromium:渲染页面,支持 HTML/CSS/JS。
- Node.js:访问系统资源(如文件、网络)。
- IPC 通信:主进程与渲染进程通过
ipcMain
/ipcRenderer
交互。
-
跨平台
- 打包时包含 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 = 5const 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.valuefiles.forEach(f=>{if(imgs.some(v=> v.uuid == f.uuid))returnimgs.push(f)})if(imgs.length > max){imgs.length = maxmessage.info(`自动移除超范围的图片`)}images.value = imgs}})}const start = ()=>{let imgs = images.valueif(!imgs.length) return message.warning(`请先选择图片`)for(let i=0;i<imgs.length;i++){let img = imgs[i]img.state = 1H.convert(img.path, toRaw(transfer)).then(d=>{if(d && !!d.size){img.output = d.pathimg.sized = d.sizeimg.used = d.usedimg.state = 2}else{img.state = 0img.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 = 18const bordered = falseconst 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 ?? econsole.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"]}]}
}
接着执行命令:
pnpm ui:build
:打包前端到 dist 目录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
。