• Electron结合React和TypeScript进行开发


    结合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()
    }
    })
    ......
    }
    ......
  • 相关阅读:
    nginx
    「PAT乙级真题解析」Basic Level 1101 B是A的多少倍 (问题分析+完整步骤+伪代码描述+提交通过代码)
    每日三题 8.17
    Oracle自治事务示例演示
    vue3 html2canvas 网络图片 空白 及使用
    为了摸鱼,我开发了一个工具网站
    习题 --- 双指针算法、离散化
    Leetcode 150:逆波兰表达式求值
    操作系统 —— 信号
    基于Redis Cluster的分布式锁实现以互斥方式操作共享资源
  • 原文地址:https://www.cnblogs.com/bleaka/p/16184636.html