React项目迁移Electron

从现有的React前端项目,封装成一个基于Electron的软件包,支持打包生成windows和linux版本 。

使用以下步骤,可以在公司当前React前端框架项目的基础上,不做较大改动,生成一个应用程序。

详细步骤:

注:因为单机版系统在局域网或者无网络情况下,此步骤不包含版本自动升级功能。

1.搭建Electron环境

新增package.json

// package.json
// 初始化npm配置
npm init

// 将electron、electron-builder、electron-squirrel-startup添加到开发依赖中
npm install --save-dev electron electron-builder electron-squirrel-startup


/*
  注:云桌面内部electron无法安装,建议将项目放在外网环境下,开发完之后,再归档入库。
*/

创建preload.js预加载脚本

const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
  node: () => process.versions.node,
  chrome: () => process.versions.chrome,
  electron: () => process.versions.electron,
  closeApp: () => ipcRenderer.send("closeAPP"),
  windowTrigger: (type) => ipcRenderer.send("windowTrigger", type),
  onMainWinChange: (callback) => ipcRenderer.on('mainWin-max', callback),
  // 能暴露的不仅仅是函数,我们还可以暴露变量
})

创建main.js入口文件,确保package.json文件中“main”: "main.js"对应起来。

// main.js
const {
  app,
  BrowserWindow,
  ipcMain,
  Menu,
  Tray,
  session,
} = require("electron");
const path = require("path");
const isDev = require("electron-is-dev");
let loading = null;

// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require("electron-squirrel-startup")) {
  app.quit();
}

// 启动动画页
const showLoading = (cb) => {
  loading = new BrowserWindow({
    show: false,
    frame: false, // 无边框(窗口、工具栏等),只包含网页内容
    width: 850,
    height: 490,
    maxWidth: 850,
    maxHeight: 490,
    resizable: false,
    transparent: true, // 窗口是否支持透明,如果想做高级效果最好为true
  });

  loading.once("show", cb);
  if (isDev) {
    loading.loadURL("http://localhost:8080/app-loading.html");
  } else {
    loading.loadURL(`file://${__dirname}/dist/Web/TTS/app-loading.html`);
  }
  loading.isLoading = true
  loading.show();
};

const createWindow = () => {
  // Create the browser window.
  const mainWindow = new BrowserWindow({
    minWidth: 1440,
    minHeight: 1000,
    frame: false,
    show: false,
    webPreferences: {
      preload: path.join(__dirname, "preload.js"),
      // webSecurity: false
      // nodeIntegration: true // 允许渲染进程中使用node模块
    },
  });
  //判断是否是开发模式
  if (isDev) {
    // 加载的端口号根据嵌入项目修改
    mainWindow.loadURL("http://localhost:8080/"); // http://localhost:8080/ 前端开发环境地址
  } else {
    // console.log(__dirname);
    // mainWindow.loadFile(`./dist/Web/TTS/index.html`);
    mainWindow.loadURL(`file://${__dirname}/dist/Web/TTS/index.html`);
  }

  // 接收渲染进程的信息,渲染器触发窗口最小化、最大化和窗口化时
  ipcMain.on("windowTrigger", (event, type) => {
    const currentWin = BrowserWindow.getFocusedWindow();
    // console.log("currentWin", currentWin);
    // console.log("type", type);
    if (type === "min") {
      currentWin.minimize();
    }
    if (type === "max") {
      currentWin.maximize();
    }
    if (type === "mid") {
      currentWin.restore();
      currentWin.setMinimumSize(1200, 800);
      currentWin.center();
    }
  });

  // //接收渲染进程的信息
  ipcMain.on("closeAPP", () => {
    const allWindows = BrowserWindow.getAllWindows();
    const currentWin = BrowserWindow.getFocusedWindow();
    console.log("close", allWindows.length);
    if (allWindows.length > 1) {
      currentWin.close();
    } else {
      currentWin.hide();
    }
  });

  // ipcMain.on("setCookie", (event, cookieArr) => {
  //   console.log("cookieArr", cookieArr)
  //   for (let i = 0;i < cookieArr.length;i++) {
  //     const cookie = cookieArr[i]
  //     session.defaultSession.cookies.set(cookie)
  //   }
  // })

  // Open the DevTools.
  // mainWindow.webContents.openDevTools();

  // 打开新窗口重新设置配置
  mainWindow.webContents.setWindowOpenHandler(({ url }) => {
    // 打开Manage外链时候,新开窗口,保留自带的窗口导航栏
    if (url.indexOf("/Manage/") > -1) {
      return {
        action: "allow",
      };
    }

    return {
      action: "allow",
      overrideBrowserWindowOptions: {
        frame: false,
        minWidth: 1440,
        minHeight: 1000,
        webPreferences: {
          preload: path.join(__dirname, "preload.js"),
        },
      },
    };
  });

  mainWindow.once("ready-to-show", () => {
    loading.hide();
    loading.close();
    mainWindow.show();
  });

  // 创建托盘图标
  const tray = new Tray(
    path.join(app.getAppPath(), "/dist/Web/TTS/static/images/16x16.png")
  );
  const contextMenu = Menu.buildFromTemplate([
    {
      label: "打开主窗口",
      click: (menuItem, browserWindow, event) => {
        const allWindows = BrowserWindow.getAllWindows();
        for (let i = 0; i < allWindows.length; i++) {
          allWindows[i].show();
        }
      },
    },
    {
      label: "隐藏主窗口",
      click: (menuItem, browserWindow, event) => {
        const allWindows = BrowserWindow.getAllWindows();
        for (let i = 0; i < allWindows.length; i++) {
          allWindows[i].hide();
        }
        // browserWindow.show()
      },
    },
    {
      label: "退出",
      click: (menuItem, browserWindow, event) => {
        app.quit();
      },
    },
  ]);
  tray.setToolTip("视图全目标追踪系统");
  tray.on("double-click", function (event, bounds) {
    const allWindows = BrowserWindow.getAllWindows();
    for (let i = 0; i < allWindows.length; i++) {
      allWindows[i].show();
    }
  });
  tray.setContextMenu(contextMenu);
};

// 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.on("ready", () => showLoading(createWindow));

// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
  console.log("window-all-closed");
  if (process.platform !== "darwin") {
    app.quit();
  }
});

app.on("activate", () => {
  // On OS X 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();
  }
});

app.on("browser-window-created", (ev, window) => {
  // console.log(ev)
  console.log("new browser-window be created");
  // // 如果不是loading状态,才使窗口最大化
  // if (loading) {
  //   console.log("loading.isVisible", loading.isVisible());
  // } else {
  // }
  // console.log("window.show", window.show)

  // if (!loading && (loading && !loading.isVisible())) {
  //   console.log("maxxxxxx");
  //   // window.maximize();
  // }
  window.once('ready-to-show', () => {
    // loading弹框不用全屏化
    if (window.isLoading) {
      return
    }
    window.maximize()
  })
  window.on("maximize", (e) => {
    // console.log('窗口最大化时触发')
    window.webContents.send("mainWin-max", true);
  });
  window.on("unmaximize", (e) => {
    // console.log('当窗口从最大化状态退出时触发')
    window.webContents.send("mainWin-max", false);
  });
});

// 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 import them here.

2.嵌入React项目

  • 将react项目所需要的dependenciesdevDependencies添加到package.json,修改package.jsonnameproductNamedescription等内容。

  • 进行 npm i,安装剩余依赖。如果在云桌面外部且使用了@yisa私有包,需要把@yisa手动复制到node_modules下面。

  • 添加scripts命令:

    {
      ...
      "scripts": {
        "dev": "webpack-dev-server --config ./scripts/dev.js",
        "build": "webpack --config ./scripts/build.js",
        "start": "concurrently \"npm run dev\" \"electron .\"",
        "ele": "electron .",
        "package": "npm run build && electron-builder build --publish never",
        "package-win": "npm run build && electron-builder build --win --x64",
        "package-linux": "npm run build && electron-builder build --linux",
        "package-mac": "npm run build && electron-builder build --mac"
      },
      ...
    }
    
  • 尝试npm run dev,查看程序是否正常启动,如有报错解决报错,此处不受Electron影响。

  • npm run ele启动开发环境客户端。

  • 正常启动。

3.打包分发程序

关于打包工具,Electron官方教程使用的是electron-forge,但是因为其无法自定义修改安装位置,所以这里使用了electron-builder作为打包工具。

package.json增加build配置:

{
  ...
 "build": {
    "productName": "视图全目标追踪系统",
    "appId": "ei_fronte01",
    "copyright": "xxxx",
    "files": [
      "./dist/Web",
      "node_modules/",
      "main.js",
      "preload.js"
    ],
    "win": {
      "icon": "./src/assets/icons/256x256.png",
      "target": [
        "nsis",
        "msi"
      ]
    },
    "linux": {
      "icon": "./src/assets/icons/256x256.png",
      "target": [
        "deb",
        "rpm",
        "AppImage"
      ],
      "category": "Development"
    },
    "directories": {
      "output": "dist/App"
    },
    "nsis": {
      "oneClick": false,
      "allowElevation": true,
      "allowToChangeInstallationDirectory": true,
      "createDesktopShortcut": true,
      "createStartMenuShortcut": true
    },
    "electronDownload": {
      "mirror": "https://npm.taobao.org/mirrors/electron/"
    }
  },
...
}
// 注:具体配置信息查看文档:https://www.electron.build/configuration/configuration

最后执行npm run package-win,等待几分钟之后查看dist\App下边的安装包。

请注意Electron不允许跨系统打包,所以打linux包需要在linux环境上。为避免在linux服务器上部署打包前端文件过于繁琐,且公司网络限制导致Electron环境安装失败问题。可以在Windows上使用electronuserland/builder Docker镜像,具体步骤详见此处

问题收集

  1. file协议与http协议之间请求镖头无法携带cookie信息,导致后端接口不通。

    解决方案:

    后端去除http请求表头必须携带cookie的判断(这个判断是php方法自带的,跟后端沟通过,这个值要么是写死,要么不带的话接口就无法请求,需要在用到session这个值得地方单独处理一下。后期等更换IAM管理,这种问题就不存在了)。前端拿到token信息之后可以存到localstorage、sessionstorage或者全局变量。  单机版如果用老版后台登录权限管理的,这个问题避免不了。

  2. asar文件可以被解密,暂不解决。

  3. 像Electron安装失败,可能是请求GitHub资源超时,可以更换npm源。

  4. 如果启动、打包失败,可能是某个配置项或者某行代码出错,这种自己排错就好。

  5. 系统整体交互优化不足,不太像个软件程序,需要与产品沟通逐步优化。

  6. 系统导航之间的调换,如果是当前系统建议在当前页面跳转,如果需要新页面打开的话,需要注意多窗口共享配置及用户信息的问题;如果是外链系统,不要使用无边框配置,因为用户打开了就没有办法关掉新窗口,毕竟外链系统无法交互electron,可以使用mainWindow.webContents.setWindowOpenHandler()监听修改配置,详细使用方法查看此处

  7. main.js加载一个app-loading.html作为启动动画,需要提前配置好,功能样式根据产品需求自定。

  8. 待收集…