当前位置: 首页 > news >正文

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

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

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

🧑‍💻 开发环境及依赖

本地 node 版本为 22.14.0

组件/框架版本说明
VSCode最新版代码编辑器
electron36.3.1直接用最新版本
rsbuild1.3.21基于 rspack 的打包工具,快
vue3.5.14
Naive UI3.41.0我常用的UI库
sharp0.34.22025年后的版本才支持 avif
electron-builder26.0.12electron打包工具
dayjs1.11.13日期格式化
lucide-vue-next0.511.0图标库
pinia3.0.2vue状态管理

🛠️ 搭建基本项目框架

代码逻辑

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 = 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"]}]}
}

接着执行命令:

  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

相关文章:

  • React useEffect和useEffectLa
  • 鸿蒙OSUniApp 实现带搜索功能的下拉菜单#三方框架 #Uniapp
  • element的el-table翻页选中功能
  • Kubernetes Admission Controller (准入控制器)详解:作用、原理、常见类型
  • DRF的使用
  • 计算机系统结构-第四章节-背诵
  • openpi π₀ 项目部署运行逻辑(四)——机器人主控程序 main.py — aloha_real
  • 使用Gemini, LangChain, Gradio打造一个书籍推荐系统 (第三部分)
  • MySQL问题:主要索引类型(聚簇、辅助、覆盖、前缀)
  • Debian 11 之使用hostapd与dnsmasq进行AP设置
  • C++ STL 容器:List 深度解析与实践指南
  • 手机收不到WiFi,手动输入WiFi名称进行连接不不行,可能是WiFi频道设置不对
  • 仿真环境中机器人抓取与操作上手指南
  • 从零实现本地语音识别(FunASR)
  • Vue3 封装el-table组件
  • 13. CSS定位与伪类/伪元素
  • 从 PyTorch 到 TensorFlow Lite:模型训练与推理
  • 从Node.js到Go:如何从NestJS丝滑切换并爱上Sponge框架
  • jenkins-jenkins简介
  • 微信小程序一次性订阅封装
  • 网站初期建设的成本来源/百度竞价点击软件
  • 中国机械加工网平台/seo优化技术排名
  • 网站开发项目经理/公司网站如何制作
  • 泰兴做网站/sem网络推广公司
  • 北京电力建设公司现状/北京网站优化步
  • wordpress首页访问密码/专业seo站长工具