Electron + React 开发桌面端应用简单入门

128 阅读5分钟

版本说明

  • node:18.17.1
  • Electron:26.2.1
  • Electron Forge:6.4.2
  • react:18.2.0
  • react-router-dom:6.17.0
  • antd:5.10.1
  • ahooks:3.7.8
  • dexie:3.2.4
  • dexie-react-hooks:1.1.6
  • zustand:4.4.3
  • react-refresh:0.14.0
  • @pmmmwh/react-refresh-webpack-plugin:0.5.11
  • react-activation:0.12.4

使用 Electron Forge 工具安装 Electron 工程模板

详情请见:Webpack + Typescript - Electron Forge

// yarn
yarn create electron-app my-new-app --template=webpack-typescript

// npm
npm init electron-app@latest my-new-app -- --template=webpack-typescript

项目目录预览

catalog.png

项目启动预览

view.png

主要目录文件说明

  • /src

    源码目录,包含主进程、渲染进程的源代码

  • /src/index.ts

    项目入口文件,也是主进程文件

  • /src/renderer.ts

    渲染进程入口文件

  • /src/preload.ts

    预加载脚本,在这里可以在页面渲染之前做一些事

  • /forge.config.ts

    Electron Forge 配置文件,具体配置信息查看官方文档

  • /webpack.main.config.ts

    webpack 主入口文件

  • /webpack.plugins.ts

    webpack 插件

  • /webpack.renderer.config.ts

    webpack 配置渲染进程相关的配置项,如:配置 sass-loader...

  • /webpack.rules.ts

    webpack 规则文件

  • /tsconfig.json

    typescript 配置文件

主进程

  • /src/index.ts

1、通过 BrowserWindow 创建窗口

const mainWindow = new BrowserWindow({
  height: 600,
  width: 800,
  webPreferences: {
    preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
  },
});

mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY);

内容安全策略 CSP

内容安全策略(CSP) 是应对跨站脚本攻击和数据注入攻击的又一层保护措施。 官方建议任何载入到 Electron 的站点都要开启

详情请见:[Content Security Policy(内容安全策略)](安全 | Electron (electronjs.org))

// csp
app.whenReady().then(async () => {
  session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
    callback({
      responseHeaders: {
        ...details.responseHeaders,
        'Content-Security-Policy': [`connect-src 'self' http://localhost:4000`],
      },
    });
  });
});

进程通信

通信方式主要有下面三种:

详情请见:进程间通信 | Electron (electronjs.org)

  • (单向)渲染进程 ---> 主进程

    import { ipcMain, ipcRenderer } from 'electron';
    
    // 主进程,使用ipcMain.on监听事件
    ipcMain.on(事件名, 事件处理函数);
    
    // 渲染进程,使用ipcRenderer.send发送数据
    ipcRenderer.send(事件名, 数据);
    
  • (双向)渲染进程 ---> 主进程

    import { ipcMain, ipcRenderer } from 'electron';
    
    // 主进程,使用ipcMain.handle监听事件
    ipcMain.handle(事件名, 事件处理函数);
    
    // 渲染进程,使用ipcRenderer.invoke调用并发送数据
    ipcRenderer.invoke(事件名, 数据);
    
    // ipcRenderer.invoke返回一个promise,接收主进程的回复,例:
    ipcMain.handle('event-name', () => {
      // 回复渲染进程(调用者)
      return 123;
    });
    const data = await ipcRenderer.invoke('event-name', 数据);
    console.log(data); // 123
    
  • 主进程 ---> 渲染进程

    // 主进程使用webContents.send发送数据
    mainWindow.webContents.send(事件名, 数据);
    
    // 渲染进程使用ipcRenderer.on监听事件
    ipcRenderer.on(事件名, 事件处理函数);
    

主进程和渲染进程之间的通信需要通过预渲染进程实现

// /src/preload.ts

import { contextBridge } from 'electron';

// ELECTRON属性会添加到window对象上
contextBridge.exposeInMainWorld('ELECTRON', {
    getName: 'hp'
    // 获取窗口是否处于最大化
    getMaximized: async () => await ipcRenderer.invoke('method:maximized'),
});


// 主进程
// 将最大化状态传递到渲染进程,mainWindow是BrowserWindow的实例对象
ipcMain.handle('method:maximized', () => mainWindow.isMaximized());

// 渲染进程
const name = window.ELECTRON.getName() // hp
const isMaximized = await window.ELECTRON.getMaximized() // true or false

使用 node 模块

electron 默认关闭 node 集成,如需使用需要手动开启,修改文件 /src/index.ts

const createWindow = (): void => {
  // Create the browser window.
  const mainWindow = new BrowserWindow({
   ...
    webPreferences: {
+      nodeIntegration: true,
    },
  });
};

然后可以在预渲染进程中使用 node模块,出于安全考虑,渲染进程使用 node模块 需要通过预渲染进程配合 进程通信 实现

注意:引用node模块时,只能通过 window.require ,使用 `import` 或者 require 会报错,可能和进程沙盒化有关
// /src/preload.ts

import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('ELECTRON', {
    // 暴露整个fs
    fs: window.require('fs')
    // 暴露整个fs(Promises版)
    fsPromises: window.require('fs/promises')
    // 只暴露fs.readFile
    readFile: window.require('fs').readFile
});


// 渲染进程
window.ELECTRON.readFile(path,options,callback)

渲染进程

ReactDOM.createRoot(document.getElementById('app')).render(
  <ConfigProvider
    locale={zhCN}
    theme={{
      token: {
        borderRadius: 2,
      },
      components: {
        Tabs: {
          inkBarColor: 'var(--music-color)',
          itemSelectedColor: 'var(--music-color)',
          itemHoverColor: 'rgba(0, 0, 0, 0.88)',
        },
      },
    }}
    componentSize="small"
  >
    <RouterProvider router={router} future={{ v7_startTransition: true }} />
  </ConfigProvider>,
);

渲染进程默认是 .ts 文件,为了方便使用,可以修改为 .tsx 文件,同时需要修改 /forge.config.ts 配置文件

注意:改完之后,启动项目会有一个报错,但是不影响
const config: ForgeConfig = {
    ...
    plugins: [
        new WebpackPlugin({
            renderer: {
                entryPoints: [
                    {
-                        js: './src/renderer.ts',
+                        js: './src/renderer.tsx',
                    }
                ]
            }
        })
    ]
}

配置路由

import { RouterProvider } from 'react-router-dom';
import ReactDOM from 'react-dom/client';
import router from 'src/router';

ReactDOM.createRoot(document.getElementById('app')).render(
  <RouterProvider router={router} future={{ v7_startTransition: true }} />,
);

注意:

  • 路由模式如果使用的是 history,需要添加路由前缀

    import { routes } from './routes';
    
    const router = createBrowserRouter(routes, { basename: '/main_window' });
    
  • 这个路由前缀是在 /forge.config.ts 配置文件中配置的,默认是 main_window

    const config: ForgeConfig = {
        ...
        plugins: [
            new WebpackPlugin({
                renderer: {
                    entryPoints: [
                        {
                            name: 'main_window',
                        }
                    ]
                }
            })
        ]
    }
    

解决文件修改后,刷新整个项目的问题

  • 安装依赖

    yarn add -D @pmmmwh/react-refresh-webpack-plugin react-refresh
    
  • 修改配置文件 /forge.config.ts

    const config: ForgeConfig = {
        ...
    
        plugins: [
        	new WebpackPlugin({
        		...
    +    		devServer: {
    +    			liveReload: false,
    +    		}
        	})
        ]
    }
    
  • 修改配置文件 webpack.plugins.ts

    + import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
    
    export const plugins = [
    	...
    +	new ReactRefreshWebpackPlugin(),
    ];
    
  • 然后重启项目即可

添加 KeepAlive 缓存

  • 安装依赖

    yarn add react-activation
    
  • 使用

    import KeepAlive, { AliveScope } from 'react-activation';
    import { Outlet, useLocation } from 'react-router-dom';
    
    export default function LayoutBasic() {
        ...
        const location = useLocation();
    
        return (
            <AliveScope>
                ...
                <KeepAlive id={location.pathname + location.search + location.hash} style={{ height: '100%' }}>
                  <Outlet />
                </KeepAlive>
            </AliveScope>
        )
    }
    

使用数据仓库

  • 详情请见:zustand

  • 安装 zustand

    yarn add zustand
    
  • 使用非常简单

    import { create, StateCreator } from 'zustand';
    import { persist } from 'zustand/middleware';
    
    type State = {
      music: any;
      updateMusic: (value: any) => void;
    };
    
    const stateCreator: StateCreator<State> = (set, get) => ({
      music: null,
      updateMusic: (value) => set({ music: value }),
    });
    
    // 持久化
    const persistStore = persist(stateCreator, { name: 'playingStore' });
    
    export default create(persistStore);
    

    组件中使用

    import store from './store'
    
    export default function MyComponent() {
        const [music] = store(state => [state.music])
    
        ...
    }
    

使用 IndexedDB

  • 原生的 IndexedDB 操作比较繁琐,这里借助一个插件:dexie

    yarn add dexie dexie-react-hooks
    
  • 详情见官网:Get started with Dexie in React

  • 初始化

    • 方式 1

      import Dexie from 'dexie';
      
      export const db = new Dexie('myDatabase');
      db.version(1).stores({
        friends: '++id, name, age', // Primary key and indexed props
      });
      
    • 方式 2

      import Dexie, { Table } from 'dexie';
      
      export interface Friend {
        id?: number;
        name: string;
        age: number;
      }
      
      export class MySubClassedDexie extends Dexie {
        // 'friends' is added by dexie when declaring the stores()
        // We just tell the typing system this is the case
        friends!: Table<Friend>;
      
        constructor() {
          super('myDatabase');
          this.version(1).stores({
            friends: '++id, name, age', // Primary key and indexed props
          });
        }
      }
      
      export const db = new MySubClassedDexie();
      
  • 使用 liveQuery 订阅数据变化

    import { liveQuery } from 'dexie'
    
    export class MySubClassedDexie extends Dexie {
        ...
        constructor() {
            ...
        }
    
        private subscribeLikeMusics() {
            // 监听 喜欢的音乐 数据变化,更新 likeStore、playStore 中的数据
            const likeMusicsObservable = liveQuery(() => this.likeMusics.toArray());
            likeMusicsObservable.subscribe({
              next: (value) => {
                const ids = value.map((item) => item.id);
                myFavoriteStore.setState({ musicIds: ids });
              },
            });
      	}
    }
    
  • 组件内使用 useLiveQuery 监听数据变化,比 liveQuery 方便一些

    import { useLiveQuery } from 'dexie-react-hooks';
    import dexieIndexedDB from 'src/store/dexieIndexedDB';
    
    export default function MyComponent() {
        const musics = useLiveQuery(() => dexieIndexedDB.likeMusics.toArray());
        ...
    }
    

安装开发者工具

  • 详情请见:开发者工具扩展 | Electron (electronjs.org)

  • **React Developer Tools,Redux DevTools **

    // /src/index.ts
    
    import { app, session } from 'electron';
    import path from 'path';
    import os from 'os';
    
    app.whenReady().then(async () => {
      const reactDevToolsPath = path.join(
        os.homedir(),
        `/AppData/Local/Google/Chrome/User Data/Default/Extensions/fmkadmapgofadopljbjfkapdkoienihi/4.28.5_0`,
      );
    
      const reduxDevToolsPath = path.join(
        os.homedir(),
        `/AppData/Local/Google/Chrome/User Data/Default/Extensions/lmhkpmbekcpmknklioeibfkpmmfibljd/3.1.3_0`,
      );
      await session.defaultSession.loadExtension(reactDevToolsPath);
      await session.defaultSession.loadExtension(reduxDevToolsPath);
    });
    
  • 注意:对于不同的 electron 版本,开发者工具的 manifest_version 版本会影响扩展的正常加载(当前文档中的版本可以正常加载

    manifest_version:表示谷歌浏览器插件接口版本,最新版本是 3

    具体参考:Electron(v26.2.1)无法加载 React Developer Tools(v4.28.0) - 掘金 (juejin.cn)