在上一篇文章需求思考及桌面应用开发技术选型中,已经确定了工具的技术方案,现在开始我们要实际动手写代码啦😄
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 = 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"]
}
]
}
}
接着执行命令:
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
。