上次写过一篇使用 Tauri
写一个收银台系统,其中我对于 Electron
并没有做更深的技术调研,这次正好公司又来了一个新的需求,要为我们的供应商写一个简单的打印每日配送商品标签的小系统,这次我选择了使用 Electron
和 React
来做,这篇文章也是记录一下整体开发的流程和其中遇到的一些问题,以及我个人对于 Electron
和 Tauri
的比较和理解。
项目构建
- 使用
create-react-app
进行React
项目的构建 npm install electron ----save-dev
(必须安装在devDependencies
下)npm install electron-builder --save-dev
(必须安装在devDependencies
下)npm install electron-reloader
npm install concurrently
npm install cross-env --save-dev
npm install wait-on --save-dev
说明
electron-builder
是用来参与打包,electron-reloader
是参与开发的时候热更代码修改的,concurrently
cross-env
wait-on
这三个是用来做开发模式启动相关命令的。
为什么 electron
和 electron-builder
要放在 devDependencies
下,是因为 electron
并不是你应用打包后运行依赖的npm包,应用打包时通过打包工具将electron环境打到包内,提供 electron
的环境,所以 electron
这个 npm 包放到 devDependencies
内,完全是为了你本地开发的时候有 electron
环境。
- 在根目录下新建
main.js
javascript
// 导入app、BrowserWindow模块
// app 控制应用程序的事件生命周期。事件调用app.on('eventName', callback),方法调用app.functionName(arg)
// BrowserWindow 创建和控制浏览器窗口。new BrowserWindow([options]) 事件和方法调用同app
// Electron参考文档 <https://www.electronjs.org/docs>
const { app, BrowserWindow, nativeImage, ipcMain } = require('electron')
const moment = require('moment')
const url = require('url')
try {
require('electron-reloader')(module)
} catch (e) {
console.log(e)
}
const path = require('path')
// const url = require('url');
function createWindow() {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 800, // 窗口宽度
height: 600, // 窗口高度
// title: "Electron app", // 窗口标题,如果由loadURL()加载的HTML文件中含有标签<title>,该属性可忽略
icon: nativeImage.createFromPath('public/favicon.ico'), // "string" || nativeImage.createFromPath('public/favicon.ico')从位于 path 的文件创建新的 NativeImage 实例
webPreferences: {
// 网页功能设置
webviewTag: true, // 是否使用<webview>标签 在一个独立的 frame 和进程里显示外部 web 内容
webSecurity: false, // 禁用同源策略
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: true, // 是否启用node集成 渲染进程的内容有访问node的能力,建议设置为true, 否则在render页面会提示node找不到的错误
},
})
// 因为我们是加载的react生成的页面,并不是静态页面
// 所以loadFile换成loadURL。
// 加载应用 --开发阶段 需要运行 yarn start
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('<http://localhost:3000>')
} else {
// 加载应用 --打包react应用后,__dirname为当前文件路径
mainWindow.loadURL(url.format({
pathname: path.join(__dirname, 'build/index.html'),
protocol: 'file:',
slashes: true
}));
}
ipcMain.on('getPrinterList', (event) => {
//主线程获取打印机列表
mainWindow.webContents.getPrintersAsync().then((printers) => {
//通过webContents发送事件到渲染线程,同时将打印机列表也传过去
mainWindow.webContents.send('getPrinterList', printers)
})
})
ipcMain.handle('printTest', async (event, printer) => {
const printWindow = new BrowserWindow({
// 实例化一个新的浏览器窗口用于打印
webPreferences: {
nodeIntegration: true,
webSecurity: false,
enableRemoteModule: true,
},
show: false,
width: 40,
height: 30,
fullscreenable: true,
minimizable: false,
})
// 打印窗口添加html页面
printWindow.loadURL(
`data:text/html;charset=utf-8,${encodeURI(
`<!doctype html>
<html>
<style>@page{margin: 0}div{margin:0;font-size:12px;font-weight:400;line-height:12px}</style>
<body>
<div>时间:${moment().format('YYYY-MM-DD HH:mm:ss')}</div>
<div>测试商品</div>
<div>2斤</div>
<div>序号:1</div>
</body>
</html>`,
)}`,
)
printWindow.webContents.once('did-finish-load', () => {
// 等待页面加载完成过后再打印
printWindow.webContents.print(
{
silent: true, // 不显示打印对话框
printBackground: true, // 是否打印背景图像
deviceName: printer.name,
pageSize: {
width: 40000,
height: 30000,
},
},
(success, failureReason) => {
mainWindow.webContents.send('res', { success, failureReason })
printWindow.close() // 打印过后关闭该窗口
},
)
})
})
ipcMain.handle('print', async (event, printer, skus) => {
console.log(printer)
const printWindow = new BrowserWindow({
// 实例化一个新的浏览器窗口用于打印
webPreferences: {
nodeIntegration: true,
webSecurity: false,
enableRemoteModule: true,
},
show: false,
width: 40,
height: 30,
fullscreenable: true,
minimizable: false,
})
// 打印窗口添加html页面
printWindow.loadURL(
`data:text/html;charset=utf-8,${encodeURI(
`<!doctype html>
<html>
<style>@page{margin: 0}div{margin:0;font-size:12px;font-weight:400;line-height:12px}</style>
<body>
${skus.map((sku, index) => {
return `<div style="page-break-after: always;"> <div>时间:${moment().format('YYYY-MM-DD HH:mm:ss')}</div> <div>${sku.skuName}</div> <div>${sku.num}${sku.unit}</div> <div>序号:${index + 1}</div> </div>`
})}
</body>
</html>`,
)}`,
)
printWindow.webContents.once('did-finish-load', () => {
// 等待页面加载完成过后再打印
printWindow.webContents.print(
{
silent: true, // 不显示打印对话框
printBackground: true, // 是否打印背景图像
deviceName: printer,
pageSize: {
width: 40000,
height: 30000,
},
},
(success, failureReason) => {
mainWindow.webContents.send('res', { success, failureReason })
printWindow.close() // 打印过后关闭该窗口
},
)
})
})
// 解决应用启动白屏问题
mainWindow.on('ready-to-show', () => {
mainWindow.show()
mainWindow.focus()
})
// 当窗口关闭时发出。在你收到这个事件后,你应该删除对窗口的引用,并避免再使用它。
// mainWindow.on('closed', () => {
// mainWindow = null
// })
if (process.env.NODE_ENV === 'development') {
// 在启动的时候打开DevTools
mainWindow.webContents.openDevTools()
}
}
app.allowRendererProcessReuse = true
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
console.log('---whenready---')
createWindow()
})
// Quit when all windows are closed.
app.on('window-all-closed', function () {
// On macOS it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
console.log('window-all-closed')
if (process.platform !== 'darwin') app.quit()
})
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
- 在根目录下新建
preload.js
javascript
window.addEventListener('DOMContentLoaded', () => {
const replaceText = (selector, text) => {
const element = document.getElementById(selector)
if (element) element.innerText = text
}
for (const dependency of ['chrome', 'node', 'electron']) {
replaceText(`${dependency}-version`, process.versions[dependency])
}
})
const { contextBridge, ipcRenderer, remote, shell } = require('electron')
contextBridge.exposeInMainWorld('electron', {
ipcRenderer,
remote,
shell,
onPrintList: (handler) => ipcRenderer.once('getPrinterList', (event, ...args) => handler(...args)),
})
- 修改
package.json
json
{
//...
"homepage": "./",
"main": "./main.js",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"electron": "electron .",
"dev": "cross-env NODE_ENV=development concurrently \"wait-on tcp:127.0.0.1:3000 && npm run electron\" \"npm run start\"",
"pack:mac": "npm run build && electron-builder -m",
"pack:win": "npm run build && electron-builder -w"
},
//...
"build": {
"appId": "gm.sorting.app",
"productName": "Sorting System",
"directories": {
"output": "dist",
"buildResources": "build"
},
"mac": {
"target": "dmg"
},
"nsis": {
"oneClick": false,
"shortcutName": "供应商系统",
"allowToChangeInstallationDirectory": true
},
"win": {
"target": [
"nsis",
"zip"
]
},
"files": [
"build/**/*",
"./main.js",
"./preload.js"
],
"extends": null
}
}
然后就可以开始按照正常的 React
项目进行开发了
调研以及选择
这次的需求中有一个,是要我做一个可选择打印机列表,然后支持手动选择并且作为配置保存下来。
一开始我是打算还用 Tauri
的,然后我就在 Tauri
里面找对于打印机的支持。我在上一篇文章里面写过, Tauri
里面实现小票的打印,是通过 rust
代码的串口连接,通过硬件设备的 pid 和 vid,再通过 eos/pos 指令集实现,非常的麻烦。但是桌面端应用对于打印功能的需求应该很常见,距离上次使用大概过去了半年,我就想看看 Tauri
是不是对于打印做了一些支持工作,然后我就发现了这个 github.com/tauri-apps/... 。果不其然,开发团队有这个想法,但是还并未完全实现该功能。于是我就转头去看了 electron
。
成熟的 electron
果然是有 api 的,在 main.js
中这样就能获取到打印机列表了:
javascript
ipcMain.on('getPrinterList', (event) => {
//主线程获取打印机列表
mainWindow.webContents.getPrintersAsync().then((printers) => {
//通过webContents发送事件到渲染线程,同时将打印机列表也传过去
mainWindow.webContents.send('getPrinterList', printers)
})
})
要注意一点,很多网络上的文章写的是 webContents.getPrinters
这个方法,但是在 26.0.0 的更新中,该方法已经被弃用了,使用 webContents.getPrintersAsync
代替。
这就是我选用 electron
的最重要的理由。
难点
怎么和主线程通信获得数据我就不细说了,这个你只要去他的文档里面学习一下,再搜一下就知道怎么写了,这里我主要写一些我遇上过得问题及其解决方案。
- 构造打印内容
Electron
的打印本质上其实就是网页打印,所以我们在主线程里面只需要构造出一个页面,然后调用打印机就行了,底层的实现 electron
已经帮我们做好了,我们只需要调用它的 api 就行了
javascript
ipcMain.handle('print', async (event, printer, skus) => {
console.log(printer)
const printWindow = new BrowserWindow({
// 实例化一个新的浏览器窗口用于打印
webPreferences: {
nodeIntegration: true,
webSecurity: false,
enableRemoteModule: true,
},
show: false,
width: 40,
height: 30,
fullscreenable: true,
minimizable: false,
})
// 打印窗口添加html页面
printWindow.loadURL(
`data:text/html;charset=utf-8,${encodeURI(
`<!doctype html>
<html>
<style>@page{margin: 0}div{margin:0;font-size:12px;font-weight:400;line-height:12px}</style>
<body>
${skus.map((sku, index) => {
return `<div style="page-break-after: always;"> <div>时间:${moment().format('YYYY-MM-DD HH:mm:ss')}</div> <div>${sku.skuName}</div> <div>${sku.num}${sku.unit}</div> <div>序号:${index + 1}</div> </div>`
})}
</body>
</html>`,
)}`,
)
printWindow.webContents.once('did-finish-load', () => {
// 等待页面加载完成过后再打印
printWindow.webContents.print(
{
silent: true, // 不显示打印对话框
printBackground: true, // 是否打印背景图像
deviceName: printer,
pageSize: {
width: 40000,
height: 30000,
},
},
(success, failureReason) => {
mainWindow.webContents.send('res', { success, failureReason })
printWindow.close() // 打印过后关闭该窗口
},
)
})
})
特别注意:
pagesize 能接收的参数有A0
, A1
, A2
, A3
, A4
, A5
, A6
, Legal
, Letter
, Tabloid
、和包含width
,height
的对象;print
中width
,height
的单位是微米(micron),printToPDF
中width
,height
的单位英寸(inches)
- 打包
我刚开始打包的时候出现过各种问题,什么找不到 preload.js
啊,页面白屏啊,99%的问题,实际上都是因为各种目录地址的问题,在搜了各种解决方案之后,我上面提供的相关配置和 main.js
preload.js
两个文件的存放位置,应该打出来的包,是没有问题。特别是 main.js
里面的代码要做开发和打包的环境区分,因为两者加载的内容是完全不一样的。