Electron結合React和TypeScript進行開發

bleaka 發表於 2022-04-24
React

結合React+TypeScript進行Electron開發

1. electron基本簡介

electron是使用JavaScript,HTML和CSS構建跨平臺桌面應用程式。我們可以使用一套程式碼打包成Mac、Windows和Linux的應用,electron比你想象的更簡單,如果把你可以建一個網站,你就可以建一個桌面應用程式,我們只需要把精力放在應用的核心上即可。

image-20220417211441061

為什麼選擇electron?

  • Electron 可以讓你使用純JavaScript呼叫豐富的原生APIs來創造桌面應用。你可以把它看作是專注於桌面應用。
  • 在PC端桌面應用開發中,nwjs和electron都是可選的方案,它們都是基於Chromium和Node的結合體,而electron相對而言是更好的選擇方案,它的社群相對比較活躍,bug比較少,文件相對利索簡潔。
  • electron相對來說比nw.js靠譜,有一堆成功的案例:Atom編輯器 Visual Studio Code WordPress等等。
  • Node.js的所有內建模組都在Electron中可用。

2. 快速上手

2.1 安裝React(template為ts)

yarn create react-app electron-demo-ts --template typescript

2.2 快速配置React

工程架構

image-20220418161630936

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Bleak's electron app base react"
    />
    <meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline';">
    <link rel="stylesheet" href="%PUBLIC_URL%/css/reset.css">
    <title>electron App</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
  </body>
</html>

App.tsx

import React from 'react'

export default function App() {
  return (
    <div>App</div>
  )
}

index.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  // <React.StrictMode>
    <App />
  // </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

2.3 安裝electron

electron包安裝到您的應用程式的devDependencies.

// npm
npm install --save-dev electron
// yarn
yarn add --dev electron

2.4 配置main.jspreload.jspackage.json檔案

main.js

// 匯入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 } = require('electron')
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應用後,__dirname為當前檔案路徑
    // mainWindow.loadURL(url.format({
    //   pathname: path.join(__dirname, './build/index.html'),
    //   protocol: 'file:',
    //   slashes: true
    // }));

    
    // 因為我們是載入的react生成的頁面,並不是靜態頁面
    // 所以loadFile換成loadURL。
    // 載入應用 --開發階段  需要執行 yarn start
    mainWindow.loadURL('http://localhost:3000');

    // 解決應用啟動白屏問題
    mainWindow.on('ready-to-show', () => {
        mainWindow.show();
        mainWindow.focus();
    });

    // 當視窗關閉時發出。在你收到這個事件後,你應該刪除對視窗的引用,並避免再使用它。
    mainWindow.on('closed', () => {
        mainWindow = null;
    });
    
    // 在啟動的時候開啟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('qpp---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.

package.json

這時候我們來修改package.json檔案。

  1. 配置啟動檔案,新增main欄位,我們這裡也就是main.js檔案。如果沒有新增,Electron 將嘗試載入包含在package.json檔案目錄中的index.js檔案。
  2. 配置執行命令,使用"electron": "electron ." 區別於react的啟動命令"start": "react-scripts start",
  3. 安裝concurrently: yarn add concurrently
{
    ...
    "main": "main.js", // 配置啟動檔案
  	"homepage": ".", // 設定應用打包的根路徑
    "scripts": {
        "start": "react-scripts start",  // react 啟動命令
        "build": "react-scripts build",
        "test": "react-scripts test",
        "eject": "react-scripts eject",
        "electron": "electron .",  // electron 啟動命令
        "dev": "concurrently \"npm run start\" \"npm run electron\""
    },
}

preload.js

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])
    }
})

此時的工程架構

image-20220418170421857

2.5 執行electron專案

  1. yarn start 然後再開一個終端yarn electron
  2. 或者是npm run dev

image-20220418200715960

其實我們就可以看出Electron就是一個應用套了一個谷歌瀏覽器殼子,然後裡面是前端頁面。

2.6 打包專案

使用electron-packager依賴:

yarn add --dev electron-packager

package.json配置打包命令:

"package": "electron-packager . bleak-electron-app --platform=win32 --arch=x64 --overwrite --electron-version=18.1.0 --icon=./public/favicon.ico"

配置解釋:

electron-packager <應用目錄> <應用名稱> <打包平臺> <架構x86 還是 x64> <架構> <electron版本> <圖示>
overwrite 如果輸出目錄已經存在,替換它

然後執行命令:

yarn package

打包時間慢的話可按照下面兩種方式優化:

方法1:
在執行electron-packager前先執行set ELECTRON_MIRROR=http://npm.taobao.org/mirrors/electron/
方法2:
在electron-packager命令列加入引數--download.mirrorOptions.mirror=https://npm.taobao.org/mirrors/electron/
(Windows x64)完整版如下:
electron-packager . bleak-electron-app --platform=win32 --arch=x64 --overwrite --electron-version=18.0.4 --download.mirrorOptions.mirror=https://npm.taobao.org/mirrors/electron/

然後執行bleak-electron-app-win32-x64裡面的exe檔案就可以了。

image-20220418211220809

3. 自動重新整理頁面

當你用react開發的時候,網頁內容會自動熱更新,但是electron視窗的main.js中程式碼發生變化時不能熱載入。

安裝外掛electron-reloader:

yarn add --dev electron-reloader
npm install --save-develectron-reloader

然後在路口引用外掛:

const reloader = require('electron-reloader')
reloader(module)

就可以實現electron外掛熱更新。

4. 主程式和渲染程式

Electron執行package.json的main指令碼的程式稱為主程式。在主程式中執行的指令碼通過建立web頁面來展示使用者節面,一個Electron應用總是有且只有一個主程式。

由於Electron使用了Chromium來展示web頁面,所以Chromium的多程式架構也被使用到,每個Electron鍾大哥web頁面執行在它的叫渲染程式的程式中。

在普通的瀏覽器中,web頁面無法訪問作業系統的原生資源。然而Electron的使用者在Node.js的API支援下可以在頁面中和作業系統進行一些底層互動。

ctrl + shift + i 開啟渲染程式除錯(devtools)

預設開啟除錯:

// 在啟動的時候開啟DevTools
mainWindow.webContents.openDevTools()

5.定義原生選單、頂部選單

5.1 自定義選單

可以使用Menu選單來建立原生應用選單和上下文選單。

  1. 首先判斷是什麼平臺,是mac還是其他:
const isMac = process.platform === 'darwin'
  1. 建立選單模板:

其是由一個個MenuItem組成的,可以在選單項官網API檢視。

const template = [
  // { role: 'appMenu' }
  // 如果是mac系統才有
  ...(isMac ? [{
    label: app.name,
    submenu: [
      { role: 'about' },
      { type: 'separator' },
      { role: 'services' },
      { type: 'separator' },
      { role: 'hide' },
      { role: 'hideOthers' },
      { role: 'unhide' },
      { type: 'separator' },
      { role: 'quit' }
    ]
  }] : []),
  // { role: 'fileMenu' }
  {
    label: '檔案',
    submenu: [
      isMac ? { role: 'close' } : { role: 'quit', label: '退出' }
    ]
  },
  // { role: 'editMenu' }
  {
    label: '編輯',
    submenu: [
      { role: 'undo', label: '撤消' },
      { role: 'redo', label: '恢復' },
      { type: 'separator' },
      { role: 'cut', label: '剪下' },
      { role: 'copy', label: '複製' },
      { role: 'paste', label: '貼上' },
      ...(isMac ? [
        { role: 'pasteAndMatchStyle' },
        { role: 'delete' },
        { role: 'selectAll' },
        { type: 'separator' },
        {
          label: 'Speech',
          submenu: [
            { role: 'startSpeaking' },
            { role: 'stopSpeaking' }
          ]
        }
      ] : [
        { role: 'delete', label: '刪除' },
        { type: 'separator' },
        { role: 'selectAll', label: '全選' }
      ])
    ]
  },
  // { role: 'viewMenu' }
  {
    label: '檢視',
    submenu: [
      { role: 'reload', label: '重新載入' },
      { role: 'forceReload', label: '強制重新載入' },
      { role: 'toggleDevTools', label: '切換開發工具欄' },
      { type: 'separator' },
      { role: 'resetZoom', label: '原始開發工具欄視窗大小' },
      { role: 'zoomIn', label: '放大開發工具欄視窗'},
      { role: 'zoomOut', label: '縮小開發工具欄視窗' },
      { type: 'separator' },
      { role: 'togglefullscreen', label:'切換開發工具欄全屏' }
    ]
  },
  // { role: 'windowMenu' }
  {
    label: '視窗',
    submenu: [
      { role: 'minimize', label:'最小化' },
      ...(isMac ? [
        { type: 'separator' },
        { role: 'front' },
        { type: 'separator' },
        { role: 'window' }
      ] : [
        { role: 'close', label: '關閉' }
      ])
    ]
  },
  {
    role: 'help',
    label: '幫助',
    submenu: [
      {
        label: '從Electron官網學習更多',
        click: async () => {
          const { shell } = require('electron')
          await shell.openExternal('https://electronjs.org')
        }
      }
    ]
  }
]
  1. 根據模板建立menu:
const menu = Menu.buildFromTemplate(template)
  1. 設定選單:
Menu.setApplicationMenu(menu)

5.2 給選單定義點選事件

可以通過click屬性來設定點選事件

5.3 抽離選單定義

建立一個menu.js:

const {app, Menu } = require('electron')

const isMac = process.platform === 'darwin'

const template = [
  // { role: 'appMenu' }
  // 如果是mac系統才有
  ...(isMac ? [{
    label: app.name,
    submenu: [
      { role: 'about' },
      { type: 'separator' },
      { role: 'services' },
      { type: 'separator' },
      { role: 'hide' },
      { role: 'hideOthers' },
      { role: 'unhide' },
      { type: 'separator' },
      { role: 'quit' }
    ]
  }] : []),
  // { role: 'fileMenu' }
  {
    label: '檔案',
    submenu: [
      isMac ? { role: 'close' } : { role: 'quit', label: '退出' }
    ]
  },
  // { role: 'editMenu' }
  {
    label: '編輯',
    submenu: [
      { role: 'undo', label: '撤消' },
      { role: 'redo', label: '恢復' },
      { type: 'separator' },
      { role: 'cut', label: '剪下' },
      { role: 'copy', label: '複製' },
      { role: 'paste', label: '貼上' },
      ...(isMac ? [
        { role: 'pasteAndMatchStyle' },
        { role: 'delete' },
        { role: 'selectAll' },
        { type: 'separator' },
        {
          label: 'Speech',
          submenu: [
            { role: 'startSpeaking' },
            { role: 'stopSpeaking' }
          ]
        }
      ] : [
        { role: 'delete', label: '刪除' },
        { type: 'separator' },
        { role: 'selectAll', label: '全選' }
      ])
    ]
  },
  // { role: 'viewMenu' }
  {
    label: '檢視',
    submenu: [
      { role: 'reload', label: '重新載入' },
      { role: 'forceReload', label: '強制重新載入' },
      { role: 'toggleDevTools', label: '切換開發工具欄' },
      { type: 'separator' },
      { role: 'resetZoom', label: '原始開發工具欄視窗大小' },
      { role: 'zoomIn', label: '放大開發工具欄視窗'},
      { role: 'zoomOut', label: '縮小開發工具欄視窗' },
      { type: 'separator' },
      { role: 'togglefullscreen', label:'切換開發工具欄全屏' }
    ]
  },
  // { role: 'windowMenu' }
  {
    label: '視窗',
    submenu: [
      { role: 'minimize', label:'最小化' },
      ...(isMac ? [
        { type: 'separator' },
        { role: 'front' },
        { type: 'separator' },
        { role: 'window' }
      ] : [
        { role: 'close', label: '關閉' }
      ])
    ]
  },
  {
    role: 'help',
    label: '幫助',
    submenu: [
      {
        label: '從Electron官網學習更多',
        click: async () => {
          const { shell } = require('electron')
          await shell.openExternal('https://electronjs.org')
        }
      }
    ]
  }
]

const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)

然後在main.jscreateWindow的方法使用require呼叫:

const createWindow = () => {
    ......
    require('./menu')
    ......
}

5.4 自定義頂部選單

我們可以自定義頂部選單,通過以下兩個步驟進行:

  • 先通過frame建立無邊框視窗。
function createWindow () {
    const mainWindow = new BrowserWindow({
        ......
        frame: false
    })    
}
  • 然後再通過前端頁面佈局設定頂部選單

如果想讓頂部選單支援拖拽,可以加如下css:

-webkit-app-region: drag;

5.5 在渲染程式中使用主程式方法remote和electron(點選建立新視窗)

我們想要通過remote來使用主程式方法和功能。

  1. 首先要安裝@electron/remote
yarn add @electron/remote
  1. 在主程式main.js中配置remote:
const remote = require("@electron/remote/main")
remote.initialize()

const createWindow = () => {
    let mainWindow = new BrowserWindow({
        ......
        webPreferences: { // 網頁功能設定
        	......
            nodeIntegration: true, // 是否啟用node整合 渲染程式的內容有訪問node的能力,建議設定為true, 否則在render頁面會提示node找不到的錯誤
            contextIsolation : false, //允許渲染程式使用Nodejs
        }
    })
    remote.enable(mainWindow.webContents)
}
  1. 在渲染程式中使用remote的BrowserWindow:App.tsx
import React from 'react'
// 使用electron的功能
// const electron = window.require('electron')
// 使用remote
const { BrowserWindow } = window.require("@electron/remote")

export default function App() {
  const openNewWindow = () => {
    new BrowserWindow({
      width:500,
      height:500
    })
  }

  return (
    <div>
      App
      <div>
        <button onClick={openNewWindow}>點我開啟新視窗</button>
      </div>
    </div>
  )
}

我們想要通過使用electron提供給渲染程式的API:

const electron = window.require('electron')

然後從electron中提取方法。

5.6 點選開啟瀏覽器

使用electron中的shell可以實現此功能:

import React from 'react'
// 使用electron的功能
// const electron = window.require('electron')
// 使用remote
// const { BrowserWindow } = window.require("@electron/remote")
// 使用shell
const { shell } = window.require('electron')

export default function App() {
  const openNewWindow = () => {
    shell.openExternal('https://www.baidu.com')
  }

  return (
    <div>
      App
      <div>
        <button onClick={openNewWindow}>點我開啟新視窗開啟百度</button>
      </div>
    </div>
  )
}

6. 開啟對話方塊讀取檔案

6.1 讀取檔案

主程式中的dialog模組可以顯示用於開啟和儲存檔案、警報等的本機系統對話方塊。

因為dialog模組屬於主程式,如果我們在渲染程式中需要使用則需要使用remote模組。

App.tsx

import React,{ useRef } from 'react'
// 使用electron的功能
// const electron = window.require('electron')

// 使用remote
// const remote = window.require('@electron/remote')
// const { BrowserWindow } = window.require("@electron/remote")
const { dialog } = window.require("@electron/remote")

// 使用shell
const { shell } = window.require('electron')

// 使用fs
const fs = window.require('fs')

export default function App() {
  // ref 
  const textRef = useRef<HTMLTextAreaElement | null>(null)

  const openNewWindow = () => {
    shell.openExternal('https://www.baidu.com')
  }

  const openFile = () => {
    const res = dialog.showOpenDialogSync({
      title: '讀取檔案', // 對話方塊視窗的標題
      buttonLabel: "讀取", // 按鈕的自定義標籤, 當為空時, 將使用預設標籤。
      filters: [ // 用於規定使用者可見或可選的特定型別範圍
        //{ name: 'Images', extensions: ['jpg', 'png', 'gif', 'jpeg', 'webp'] },
        //{ name: 'Movies', extensions: ['mkv', 'avi', 'mp4'] },
        { name: 'Custom File Type', extensions: ['js'] },
        { name: 'All Files', extensions: ['*'] },
      ]
    })
    const fileContent:string  = fs.readFileSync(res[0]).toString();
    (textRef.current as HTMLTextAreaElement).value = fileContent
  }

  return (
    <div>
      App Test
      <div>
        <button onClick={openNewWindow}>點我開啟新視窗開啟百度</button>
      </div>
      <div>
        <button onClick={openFile}>開啟檔案</button>
        <textarea ref={textRef}></textarea>
      </div>
    </div>
  )
}

6.2 儲存檔案

儲存檔案需要使用dialog函式裡的showSaveDialogSync,與之前的讀取檔案所用到的showOpenDialogSync類似:

const saveFile = () => {
    const res = dialog.showSaveDialogSync({
        title:'儲存檔案',
        buttonLable: "儲存",
        filters: [
            { name: 'index', extensions: ['js']}
        ]
    })
    fs.writeFileSync(res, textRef.current?.value)
}

7. 定義快捷鍵

7.1 主執行緒定義

引入globalShortcut

const {app, BrowserWindow, nativeImage, globalShortcut } = require('electron')

註冊快捷鍵列印字串、視窗最大化、視窗最小化、關閉視窗。

const createWindow = () => {
    ......
    // 註冊快捷鍵
    globalShortcut.register('CommandOrControl+X', () => {
        console.log('CommandOrControl + X is pressed')
    })

    globalShortcut.register('CommandOrControl+M', () => {
        mainWindow.maximize()
    })

    globalShortcut.register('CommandOrControl+T', () => {
        mainWindow.unmaximize()
    })

    globalShortcut.register('CommandOrControl+H', () => {
        mainWindow.close()
    })

    // 檢查快捷鍵是否註冊成功
    // console.log(globalShortcut.isRegistered('CommandOrControl+X'))
}

// 將要退出時的生命週期,登出快捷鍵
app.on('will-quit', () => {
    // 登出快捷鍵
    globalShortcut.unregister('CommandOrControl+X')
    // 登出所有快捷鍵
    globalShortcut.unregisterAll()
})

7.2在渲染程式中定義

通過retmote來定義

const { globalShortcut } = window.require("@electron/remote")


globalShortcut.register("Ctrl+O", () => {
    console.log('ctrl+O is pressed.')
})

8. 主程式和渲染程式通訊

在渲染程式使用ipcRenderer,主程式使用ipcMain,可以實現主程式和渲染程式的通訊:

App.tsx

......
import React,{ useState, useRef } from 'react'
const { shell, ipcRenderer } = window.require('electron')
export default function App() {
    // state
  	const [windowSize, setWindowSize] = useState('max-window')
    ......
    // 傳參
    const maxWindow = () => {
        ipcRenderer.send('max-window', windowSize);
        if(windowSize === 'max-window') {
            setWindowSize('unmax-window')
        } else {
            setWindowSize('max-window')
        }
    }
        ......
        return (
            <div>
                    <div>
                        <button onClick={maxWindow}>與主程式進行通訊,視窗最大化或取消視窗最大化</button>
                    </div>
                </div>
            </div>
        )
}

main.js

const {app, BrowserWindow, nativeImage, globalShortcut, ipcMain } = require('electron')
const createWindow = () => {
    let mainWindow = ......
    ......
    // 定義通訊事件
    ipcMain.on('max-window', (event, arg) => {
        if(arg === 'max-window') {
            mainWindow.maximize()
        } else if (arg === 'unmax-window') {
            mainWindow.unmaximize()
        }
    })
    ......
}
......