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 里面的代码要做开发和打包的环境区分,因为两者加载的内容是完全不一样的。

相关推荐
乐容3 分钟前
react 中解决 类型“never”上不存在属性“value”。
前端·react.js·前端框架
木子七19 分钟前
vue2-路由Router
前端·vue
知野小兔41 分钟前
【Angular】eventDispatcher详解
前端·javascript·angular.js
苦逼的猿宝1 小时前
Echarts中柱状图完成横向布局
前端·javascript·echarts
禾戊之昂1 小时前
【Electron学习笔记(一)】Electron基本介绍和环境搭建
前端·javascript·electron·node.js
加班是不可能的,除非双倍日工资1 小时前
js 原生拖拽排序功能 简单实现
前端·javascript
放逐者-保持本心,方可放逐1 小时前
dom 元素应用 + for 循环应用
前端·javascript·for
冰冻果冻1 小时前
vue--制作购物车
前端·javascript·vue.js
前端设计诗1 小时前
CSS clamp() 函数:构建更智能的响应式设计
前端·css·less·css3·html5
大梦百万秋1 小时前
React前端框架基础知识详解
前端·react.js·前端框架