Electron + React 构建一个和打印机交互的供应商系统小结

上次写过一篇使用 Tauri 写一个收银台系统,其中我对于 Electron 并没有做更深的技术调研,这次正好公司又来了一个新的需求,要为我们的供应商写一个简单的打印每日配送商品标签的小系统,这次我选择了使用 ElectronReact 来做,这篇文章也是记录一下整体开发的流程和其中遇到的一些问题,以及我个人对于 ElectronTauri 的比较和理解。

项目构建

  1. 使用 create-react-app 进行 React 项目的构建
  2. npm install electron ----save-dev (必须安装在 devDependencies 下)
  3. npm install electron-builder --save-dev (必须安装在 devDependencies 下)
  4. npm install electron-reloader
  5. npm install concurrently
  6. npm install cross-env --save-dev
  7. npm install wait-on --save-dev

说明

electron-builder 是用来参与打包,electron-reloader 是参与开发的时候热更代码修改的,concurrently cross-env wait-on 这三个是用来做开发模式启动相关命令的。

为什么 electronelectron-builder 要放在 devDependencies 下,是因为 electron 并不是你应用打包后运行依赖的npm包,应用打包时通过打包工具将electron环境打到包内,提供 electron 的环境,所以 electron 这个 npm 包放到 devDependencies 内,完全是为了你本地开发的时候有 electron 环境。

  1. 在根目录下新建 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.
  1. 在根目录下新建 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)),
})
  1. 修改 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 的最重要的理由。

难点

怎么和主线程通信获得数据我就不细说了,这个你只要去他的文档里面学习一下,再搜一下就知道怎么写了,这里我主要写一些我遇上过得问题及其解决方案。

  1. 构造打印内容

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、和包含widthheight的对象;printwidthheight的单位是微米(micron),printToPDFwidthheight的单位英寸(inches)

  1. 打包

我刚开始打包的时候出现过各种问题,什么找不到 preload.js 啊,页面白屏啊,99%的问题,实际上都是因为各种目录地址的问题,在搜了各种解决方案之后,我上面提供的相关配置和 main.js preload.js 两个文件的存放位置,应该打出来的包,是没有问题。特别是 main.js 里面的代码要做开发和打包的环境区分,因为两者加载的内容是完全不一样的。

相关推荐
熊的猫1 小时前
JS 中的类型 & 类型判断 & 类型转换
前端·javascript·vue.js·chrome·react.js·前端框架·node.js
瑶琴AI前端1 小时前
uniapp组件实现省市区三级联动选择
java·前端·uni-app
会发光的猪。1 小时前
如何在vscode中安装git详细新手教程
前端·ide·git·vscode
我要洋人死2 小时前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人3 小时前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人3 小时前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR3 小时前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香3 小时前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q2498596933 小时前
前端预览word、excel、ppt
前端·word·excel
小华同学ai3 小时前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书