之前用 Node.js 开发了一款在线版 Mp4 转换器,有同学反映需要本地要安装 ffmpeg,使用起来比较麻烦。其实,我们可以将该应用转换为 Elctron 桌面版,并将 ffmpeg 打包进去做成便携版,这样不用安装,还不用连网。
最终效果如下:
electron-vite-vue 模板
本项目有两个版本:electron-vite-vue 脚手架版和 JS 原生版。原生版在渲染进程模块使用了大量的模板字符串,没有脚手架版那么方便。毕竟 Vue 模板可以绑定数据。基于数据驱动是现代 JS 框架的精髓。
模板是基于 Vite 官方的 template-vue-ts 脚手架搭建的,所以绝大部分都是 Vite + Vue3 + ts 工程的文件(可以看作Vue 前端,其中 App.vue
是根组件,渲染进程模块的大部分逻辑都写在这里),除了:
- electron 文件夹:可以看作 Node 后端,其中 main.ts 是主入口,主进程模块的大部分逻辑都写在这里。
- electron-builder.json5:Electron 专属打包配置文件,JS 原生版的导报配置式放在
package.json
中。
有了 Vite 的加持,可以使用 Node.js ESM 包。
在生成的模板中集成
element-plus
有点问题:tsconfig.json
配置项moduleResolution
设置成Bundler
呼不出代码提示,我改成Node
后就可以了。
如你所见,本质上,Electron 开发就是 JS 全栈开发。
桌面版的特性
桌面版和在线版的 Mp4 转换器相比,我们共用了视频读取、视频转换代码逻辑。其他部分或多或少有些差别,毕竟桌面版有不少原生操作,用户体验体验更好:
- 菜单操作:桌面独有的功能,添加了
视频文件
、帮助
两个操作入口 - 多窗口操作:为方便显示,预览视频单独使用了一个窗口
- 选择视频文件使用 Electron 原生
dialog
:不用上传视频后才能读取视频信息 - 新增本地保存视频文件功能:同样使用 Elctron 原生
dialog
- 使用 Element-plus UI 库:是不是比在线版漂亮一些?
- 使用进程间通信(IPC):不用调用 Web API,离线也能转换
转换成功后会直接打开预览视频,因为一旦将视频读入内存,再次转换很快就能完成,所以没有做预览视频的其他入口。
自定义菜单
本项目中,我们使用 Menu.buildFromTemplate(menuTemplate)
来自定义原生应用菜单,template
是一个选项类型的数组,用于构建 MenuItem
。通过模板创建的原生菜单是基于数据驱动的:
// electron/config.ts
import { MenuItem, MenuItemConstructorOptions, shell } from 'electron'
const isMac = process.platform === 'darwin'
const menuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [
{
label: '视频文件',
submenu: [
// ...
{
id: 'saveFile',
label: '保存视频',
accelerator: 'CmdOrCtrl+S',
enabled: false
},
{ type: 'separator' },
isMac
? {
role: 'close',
label: '退出',
accelerator: 'Cmd+Q'
}
: {
role: 'quit',
label: '退出',
accelerator: 'Ctrl+Q'
}
]
},
{
label: '帮助',
submenu: [
// ...
{
id: 'support',
label: '技术支持',
click() {
shell.openExternal('mailto:riafan@hotmail.com')
}
}
]
}
]
export { menuTemplate }
在 macOS 上将 menu 设置成应用内菜单,在 Windows 和 Linux 上,menu 将会被设置成窗口顶部菜单。
对于 MenuItem 来说,除了设置 id
和 label
属性外,在本项目中,我们还设置了:
- accelerator 快捷键:设置为
CmdOrCtrl+S
,表示 macOS 上按 Cmd+S,Windows 上按 Ctrl+S 会保存视频 - enabled 是否激活:本项目中,
保存视频
菜单项默认是禁用的,只有打开过视频才是激活的 - click 点击菜单项的回调函数:本项目中,点击
技术支持
菜单项会打开系统发送电子邮件的默认程序 - role 定义菜单项的操作: 本项目中,设置
role: 'close'
会调用系统默认的退出应用操作,省去了对各系统分别设置 click 属性 - type 定义菜单项类型:默认为
normal
。设置为separator
会在菜单项之间显示一条分割线
指定
click
属性,role
属性将被忽略。
打开视频
进程间通信 (IPC) 是在 Electron 中构建功能丰富的桌面应用程序的关键部分之一。 由于主进程和渲染器进程在 Electron 的进程模型具有不同的职责,因此 IPC 是执行许多常见任务的唯一方法。
下面是打开视频的时序图:
对于打开视频来说,可能是通过选择菜单项、点击选择按钮或是拖放到按钮区触发的。点击选择按钮或是拖放到按钮区是从渲染线程发起的,选择视频后会返回视频的文件路径,因此应该使用渲染器进程到主进程双向通信模式。而选择菜单项是从主线程发起的,可以使用主进程到渲染器进程单向通信模式。渲染器接收消息后可以复用双向通信模式,一样可以返回视频的文件路径。下面使用 contextBridge
API 将这段代码暴露给渲染器进程。
// preload.ts
selectFile: () => ipcRenderer.invoke('dialog:selectFile'),
onSelectFile: (callback: () => void) =>
ipcRenderer.on('menu:selectFile', callback),
Electron 主进程侧(main.ts
)需要使用 showOpenDialog
方法打开对话框选择一个视频文件:
// electron/main.ts
async function handleSelectFile() {
const { canceled, filePaths } = await dialog.showOpenDialog({
filters: [{ name: fileType, extensions: allowFormats }]
})
if (!canceled) {
return filePaths[0]
}
}
filters
用于规定用户可见或可选的特定类型范围,设置filters
的 extensions
属性会按文件后缀名过滤。如果 extensions
不设置成 ['*']
,则对话框没有 所有文件
选项(Web 应用的文件选择框总是有的),因此 Electron 中不必写文件类型检验的逻辑,指定文件类型即可。
showOpenDialog
方法会返回用户选择的文件路径,如果对话框被取消了 ,则返回 undefined
。
读取、转换视频
这一块代码实现和 Web 版差不多,只是数据通信使用的是 IPC 而不是 Web API。读取、转换视频的预加载脚本如下:
// preload.ts
readFile: (path: string) => ipcRenderer.invoke('video:readFile', path),
convertFile: (params: Params) =>
ipcRenderer.invoke('video:convertFile', params)
如你所见,readFile
和 convertFile
都是使用渲染器进程到主进程双向通信模式。
// electron/main.ts
.on('end', () => {
console.log('file has been converted succesfully')
createPreview({
width,
height
})
resolve(output)
})
视频转换成功后会另外创建一个窗口来预览视频。
预览视频
当然,创建预览窗口是在 Electron 主进程侧(main.ts
)完成的。
// electron/main.ts
const win = new BrowserWindow({
width: width + 16,
height: height + 88,
webPreferences: {
preload: path.join(__dirname, 'preview.js')
}
})
if (isDev) {
win.loadURL(path.posix.join(VITE_DEV_SERVER_URL, 'preview.html'))
} else {
win.loadFile(path.join(process.env.DIST, 'preview.html'))
}
// ...
win.removeMenu()
每个 Electron 应用都会为每个打开的应用程序窗口 ( 与每个网页嵌入 ) 生成一个单独的渲染器进程,多窗口意味着多渲染器进程。通常新建应用程序窗口需要初始化窗口的宽高、预加载脚本和加载页面。
此处的预览窗口宽高是根据视频宽高计算出来的。为了安全,我们还新建了预加载脚本 preview.js
,在应用程序窗口构造方法中的 webPreferences 选项里将其附加到主进程。注意,我们还需要为其额外配置一个入口,可以在 vite.config.ts
配置:
electron([
// ...
{
entry: 'electron/preview.ts',
}
])
预加载脚本 preview.ts
的代码如下:
// electron/preview.ts
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('electronAPI', {
previewFile: (callback: () => void) =>
ipcRenderer.on('video:preview', callback),
saveFile: (callback: () => void) =>
ipcRenderer.invoke('dialog:saveFile', callback)
})
注意:此处的
saveFile
回调函数与 'electron/preload.ts' 中的saveFile
回调函数是一样的。
因为加载了新页面 preview.html
,但 Vite 默认是单页面的,只有 index.html
这个单一入口,所以我们还需要为其额外配置一个入口,可以在 vite.config.ts
配置:
build: {
rollupOptions: {
input: {
index: path.join(__dirname, 'index.html'),
preview: path.join(__dirname, 'preview.html'),
}
}
}
注意:开发环境下运行本脚手架,加载页面一定要使用服务器地址。
预览窗口不需要菜单,我们使用 win.removeMenu()
将其移除。
保存视频
下面是保存视频的时序图:
和打开视频类似,我们需要定义其预加载脚本:
// preload.ts
saveFile: () => ipcRenderer.invoke('dialog:saveFile'),
onSaveFile: (callback: () => void) =>
ipcRenderer.on('menu:saveFile', callback)
Electron 主进程侧(main.ts
)需要使用 showSaveDialog
方法打开对话框来保存视频文件:
async function handleSaveFile() {
const { canceled, filePath } = await dialog.showSaveDialog({
filters: [{ name: fileType, extensions: ['mp4'] }],
defaultPath: path.join(app.getPath('videos'), `${Date.now()}.mp4`)
})
if (!canceled) {
return new Promise((resolve, reject) => {
const rs = fs.createReadStream(output)
const ws = fs.createWriteStream(filePath!)
rs.pipe(ws)
rs.on('end', () => {
resolve('视频保存成功')
}).on('error', (error: any) => {
reject(error)
})
})
}
}
保存视频实际上是个拷贝文件的过程,这里使用了
fs
模块和pipe
操作。
打包应用程序
之前提过,打包是在 electron-builder
中配置的:
{
directories: {
output: 'release'
},
files: [
'dist-electron',
'dist',
"!**node_modules/ff*-static/bin/!(win32)",
"!**node_modules/ff*-static/bin/win32/ia32"
],
win: {
icon: "res/icon.png",
target: [
{
target: 'portable',
arch: ['x64']
}
],
"artifactName": "${productName}_${version}.${ext}"
}
}
我们的目标是打包 win32 平台 x64 架构下的 portable
版本,配置很简单,重点说说 files
。
dist-electron
包含 Node.js 后端打包文件,dist
包含 Vue 前端打包文件。那两个文件正则表达式呢?
使用 Electron 打包的时候设置 asar
为 true
,electron-builder 会智能的把一些 native
的程序(包括 exe执行文件)打包到 app.asar.unpacked
中。也就是说,如果不使用文件正则表达式筛选特定平台,会复制 ffmpeg-static
和 ffprobe-static
整个依赖包。那样打包文件就太大了。
注意:我们需要通过替换来获取ffmpeg 二进制文件的路径,代码如下:
// Get the paths to the packaged versions of the binaries we want to use
const ffmpegPath = require('ffmpeg-static').replace(
'app.asar',
'app.asar.unpacked'
)
const ffprobePath = require('ffprobe-static').path.replace(
'app.asar',
'app.asar.unpacked'
)
// tell the ffmpeg package where it can find the needed binaries.
ffmpeg.setFfmpegPath(ffmpegPath)
ffmpeg.setFfprobePath(ffprobePath)
关于如何在 Electron 应用中包含 ffmpeg 二进制文件,可参考这篇文章。
打包文件是个漫长的过程,如何快速验证打包是否正确呢?
electron-builder --dir
运行这个 electron-builder 命令可以快速生成未压缩的打包文件,可以协助排查问题。
本项目如果使用 JS 原生版打包,最终这个便携式 Mp4 转换器只有 70 M 左右。要知道,ffmpeg 那两个二进制文件都有60 M 左右。真是爽歪歪!😆
链接地址
- electron-vite-vue 脚手架版 项目地址
- Elctron + JS 原生版 项目地址
- 原文:Electron Mp4 转换器