簡介 Intro 來介紹一下如何使用Electron開發框架,並且結合TypeScript與Angular設定
並且最終使用Electron-Forge打包成桌面應用程式。
Type-Safe IPC : Eliminates runtime errors by enforcing a strict contract for IPC channels, payloads, and return types between the main process and renderer processes.
Simplified Utility Process Management : Provides a straightforward wrapper for creating, communicating with, and managing Electron’s utilityProcess.
Angular-Friendly Service : Includes an example of an injectable Angular service (IPCRendererService) that wraps IPC calls in an RxJS-friendly way.
如果你懶得看文章,也可以直接參考範例專案
前置準備 在真正開始之前,需要先確保相關的開發環境準備
TypeScript
Node.js
Electron
Electron-Forge
ESLint
如果你是在windows環境下,則建議還要多準備以下幾個
Wix Toolset
Visual Studio Build Tools
章節介紹
資料結構宣告
文件結構
共用參考結構
Electron結構宣告
Angular結構宣告
Angular Service封裝
不同的設定方法差別
使用git submodule維護前端與後端共同結構參考 (推薦)
使前端或後端其中一方,屬於對方的文件結構之下 (稍微不推薦)
使用tsconfig.json的paths與references來維護前端與後端的共同結構參考 (不推薦)
使用Electron-Forge打包成桌面應用程式
資料結構的宣告 文件結構 根據不同的做法,會有不同的文件結構
使用git submodule維護前端與後端共同結構參考 (推薦)
使前端或後端其中一方,屬於對方的文件結構之下 (稍微不推薦)
使用tsconfig.json的paths與references來維護前端與後端的共同結構參考 (不推薦)
我自己就只先示範第一種
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 [獨立的共享宣告結構專案] shared-declare-types/ (git repository) ├── ipc.ts ├── ipc-valid-channel.ts ├── ipc-routes.declare.ts └── data.ts ========================================================= [主專案,引用共享宣告結構專案] project-root/ (git repository) ├── electron-src/ | ├── declares/ | | ├── ipc-main-overwrite.declare.ts | | ├── app-router.ts │ | └── electron-ipc-route.declare.ts │ ├── app.ts │ ├── ipc-routes.ts │ └── preload.ts │ └── shared-declare-types/ (git submodule -> shared-declare-types) │ ├── ipc.ts | ├── ipc-routes.declare.ts │ ├── ipc-valid-channel.ts │ └── data.ts ├── angular-app/ │ ├── app/ | ├── @services/ │ │ ├── ipc-renderer.service.ts │ │ └── startup_test.service.ts │ └── shared-declare-types/ (git submodule -> shared-declare-types) │ ├── ipc.ts │ ├── ipc-valid-channel.ts | ├── ipc-routes.declare.ts │ └── data.ts ├── forge.config.ts ├── tsconfig.json └── package.json
共同參考結構 首先我們需要先宣告一個結構,這個結構是假裝前後端的TS都需要共同參考的結構
Example: shared-declare-types/data.ts
1 2 3 4 5 6 export interface Data<T extends string> { key : string ; value: string ; timestamp: number ; description?: T; }
接著需要定義IPC共用通道宣告
Example: shared-declare-types/ipc-valid-channel.ts
1 2 3 4 5 6 export interface IPCValidChannelMap { startup_test : "startup_test" ; } export type IPCValidChannel = keyof IPCValidChannelMap;
定義Electron IPC接口的宣告
Example: shared-declare-types/ipc.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import { IpcMain, IpcMainEvent, IpcMainInvokeEvent } from "electron" ;export type IPCValidChannel<T> = T extends string ? T : never ;export interface IPCRenderer<T> { send<R = never | undefined >(channel: IPCValidChannel<T>): void ; send<R extends any []>(channel: IPCValidChannel<T>, ...args: R): void ; receive<K>(channel: IPCValidChannel<T>, func: (args: K ) => void ): void ; invoke<P extends any [], R>(channel: IPCValidChannel<T>, ...args: P): Promise <R>; removeAllListeners(channel: IPCValidChannel<T>): void ; } export interface IPCRendererReceiveMessageHandler { (...args: any []): void ; } export interface IPCMessageHandler<T extends IpcMainEvent = IpcMainEvent> { (event: T, ...args: any []): void ; } export type Params<H, G extends IpcMainEvent = IpcMainEvent> = H extends (ev: G, ...args: infer P) => any ? P : never ;
定義Electron IPC路由簽章
Example: shared-declare-types/ipc-routes.declare.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import { IpcMainEvent, IpcMainInvokeEvent } from "electron" ; import { Data } from "./shared-declare-types/data" ;export interface startup_test<T extends IpcMainInvokeEvent> { (_ev: T, ...args: any []): string ; } export interface ipc_message_wtih_args<T extends IpcMainEvent, U extends Data<string>> { (_ev: T, desc : U): Promise <boolean >; }
這些參考分別會被Electron的Preload與Angular的Service共同參考
Electron結構宣告 在Electron的主進程中宣告相關定義
定義Electron IPC 被複寫
Example: electron-src/decalres/ipc-main-overwrite.declare.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 export class IPCMainOverwrite { private _ipc: IpcMain; constructor (ipc: IpcMain ) { this ._ipc = ipc; } private ipcSocket<T, R = IpcMainEvent>(channel: IPCValidChannel, callback : (channel: string , _ev: R, ...args: any [] ) => T, _ev : R, ...args: any []): T { return callback(channel, _ev, ...args); } public handle<R>(channel: IPCValidChannel, callback : <T extends IpcMainInvokeEvent>(_ev: T, ...args: any[]) => R | Promise<R>): void { this._ipc.handle(channel, callback); } public on<R>(channel: IPCValidChannel, callback: <T extends IpcMainEvent>(channel: string, _ev: T, ...args: any[]) => R): void { this._ipc.on(channel, (event, ...args) => { const result = this.ipcSocket<ReturnType<typeof callback>>(channel, callback, event, ...args); Promise.resolve(result).then((result) => { event.reply(channel, result); }).catch((error) => { console.log(error); }); }); } }
宣告IPC路由的簽章,並組合路由
為每個 IPC 通道定義特定的輸入和輸出。
這為每個通道的功能創建了明確的宣告。
將所有路由簽章合併到一個主介面。此介面代表了 IPC 系統的整個 API 介面。
Example: electron-src/decalres/electron-ipc-route.declare.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import { IpcMainEvent, IpcMainInvokeEvent } from "electron" ; import { startup_test, ipc_message_wtih_args } from "electron-src/shared-declare-types/ipc-routes.declare" import { Data } from "./shared-declare-types/data" ;export interface ElectronIpcMainRouter { startup_test : startup_test<IpcMainInvokeEvent>; ipc_message_wtih_args: ipc_message_wtih_args<IpcMainEvent, Data<string >>; }
實作AppMainRouter
使用 ElectronIpcMainRouter 介面來實作你的路由器。 這個路由器將會處理 IPC 的請求。
Example: electron-src/declares/app-router.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import { ElectronIpcMainRouter } from "electron-src/declares/electron-ipc-route.declare" ; export class AppRouter implements ElectronIpcMainRouter { public static create_router(): AppRouter { return new AppRouter(); } private constructor ( ) { } startup_test(_ev: IpcMainInvokeEvent): string { return "Pong" ; } ipc_message_wtih_args(_ev: IpcMainEvent, desc : Data<string >): Promise <boolean > { return Promise .resolve(true ); } }
在Electron主程式中的宣告
在 Electron 主行程檔案中,引用 AppRouter 並實作 IPC 路由。
並且使用 IPCMainOverwrite 來處理 IPC 的請求。
這樣可以確保你的 IPC 路由是類型安全的,並且符合你在 ElectronIpcMainRouter 中定義的介面。
TypeScript 會強制你的實作(函數體)與路由器介面的簽章相符。
Example: electron-src/ipc-routes.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 import { IpcMain, IpcMainEvent, IpcMainInvokeEvent } from "electron" ;import { IPCMainOverwrite } from "electron-src/declares/ipc-main-overwrite.declare.ts" ; import { AppRouter } from "electron-src/declares/app-router" ; export class IPCRoute { private ipc: IPCMainOverwrite; private router: AppRouter; public static create_ipc_router(ipc: IpcMain): IPCRoute { const router = AppRouter.create_router(); return new IPCRoute(ipc, router); } private constructor (_parent_ipc: IpcMain, router: AppRouter ) { this .ipc = new IPCMainOverwrite(_parent_ipc); this .router = router; this .ipc.handle("startup_test" , router.startup_test); } public handle(channel: IPCValidChannel, callback : <T extends IpcMainInvokeEvent>(_ev: T, ...args: any[]) => any): void { this.ipc.handle(channel, callback); } public on(channel: IPCValidChannel, callback: <T extends IpcMainEvent>(channel: string, _ev: T, ...args: any[]) => any): void { this.ipc.on(channel, callback); } }
在Electron Entry Point中使用IPCRoute
Example: electron-src/app.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import { app, BrowserWindow, ipcMain, Menu, dialog, nativeImage, NativeImage, screen } from "electron" ; import { IPCRoute } from "./ipc-routes" ; const ipc_route = IPCRoute.create_ipc_router(ipcMain);
在Electron主程式中使用Preload腳本
Example: electron-src/preload.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 import { contextBridge, ipcRenderer } from "electron" ;import { IPCRenderer } from "electron-src/shared-declare-types/ipc" ; import { IPCValidChannel } from "electron-src/shared-declare-types/ipc-valid-channel" ; ipcRenderer.setMaxListeners(Infinity ); const valid_IPC_channels: Array <IPCValidChannel> = [ "startup_test" ]; contextBridge.exposeInMainWorld( "ipcRenderer" , <IPCRenderer<IPCValidChannel>>{ send : <R extends any[]>(channel: IPCValidChannel, ...args: R): void => { if (valid_IPC_channels.includes(channel)) { ipcRenderer.send(channel, ...args); } else { console.error(`[ipcRenderer.send]Invalid IPC channel: ${channel}`); } }, // eslint-disable-next-line @typescript-eslint/ban-types receive: <U>(channel: IPCValidChannel, func: (args: U) => void): void => { if (valid_IPC_channels.includes(channel)) { ipcRenderer.on(channel, (event, args: U) => func(args)); } else { console.error(`[ipcRenderer.receive]Invalid IPC channel: ${channel}`); } }, // eslint-disable-next-line arrow-body-style invoke: <P extends any[], R>(channel: IPCValidChannel, ...args: P): Promise<R> => { if (valid_IPC_channels.includes(channel)) { return ipcRenderer.invoke(channel, ...args) as Promise<R>; } console.error(`[ipcRenderer.invoke]Invalid IPC channel: ${channel}`); return Promise.reject(new Error(`[ipcRenderer.invoke]Invalid IPC channel: ${channel}`)); }, removeAllListeners: (channel: IPCValidChannel): void => { if (valid_IPC_channels.includes(channel)) { ipcRenderer.removeAllListeners(channel); } else { console.error(`[ipcRenderer.removeAllListeners]Invalid IPC channel: ${channel}`); } } } );
Angular結構宣告 接著輪到Angular的部分,會簡單的多
將Electron的IPC Renderer封裝成Angular Service即可
Angular IpcRenderer Service
Example: angular-app/app/@services/ipc-renderer.service.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 import { Injectable, OnInit, Inject, Optional } from "@angular/core" ;import { switchMap, distinctUntilChanged, catchError, map } from "rxjs/operators" ;import { forkJoin, of , takeUntil, ReplaySubject, Observable, lastValueFrom, from , mergeMap, share } from "rxjs" ;import { IPCRenderer, Params } from "angular-app/app/shared-declare-types/ipc" ;import { IPCValidChannel } from "angular-app/app/shared-declare-types/ipc-valid-channel" ;declare global { interface Window { ipcRenderer : IPCRenderer<IPCValidChannel>; } } @Injectable ({ providedIn : "root" }) export class IPCRendererService { private _destroyed$: ReplaySubject<boolean > = new ReplaySubject(1 ); private readonly receive_map: Map <IPCValidChannel, Observable<any >> = new Map <IPCValidChannel, Observable<any >>(); constructor ( ) { console .info("[IPCRendererService.constructor]IPCRendererService constructed." ); } public send<T=never |undefined >(channel: IPCValidChannel): void ; public send<T extends any []>(channel: IPCValidChannel, ...data: T): void ; public send<T extends any []>(...args: T): void { if (window .ipcRenderer && window .ipcRenderer.send) { if (args.length === 1 ) { window .ipcRenderer.send<T>(args[0 ]); } else if (args.length > 1 ) { window .ipcRenderer.send<Array <any >>(args[0 ], ...args.slice(1 )); } else { console .error(`[IPCRendererService.send]Invalid IPC channel: ${args[0 ]} ` ); } } else { console .error(`[IPCRendererService.send]Invalid IPC channel: ${args[0 ]} ` ); } } public invoke<P extends any [], R>(channel: IPCValidChannel, ...args: P): Promise <R> { return new Promise ((resolve, reject ) => { if (window .ipcRenderer && window .ipcRenderer.invoke) { window .ipcRenderer.invoke<P, R>(channel, ...args).then((data: R ) => { resolve(data); }).catch((error: Error ) => { reject(error); }); } else { console .error(`[IPCRendererService.invoke]Invalid IPC channel: ${channel} ` ); return reject(new Error (`[IPCRendererService.invoke]Invalid IPC channel: ${channel} ` )); } }); } public receive<T>(channel: IPCValidChannel): Observable<T> { if (this .receive_map.has(channel)) { return this .receive_map.get(channel) as Observable<T>; } const observable = (new Observable((subscriber ) => { if (window .ipcRenderer && window .ipcRenderer.receive) { window .ipcRenderer.receive<T>(channel, (data ) => { subscriber.next(data); }); } else { console .error(`[IPCRendererService.receive]Invalid IPC channel: ${channel} ` ); subscriber.error(new Error (`[IPCRendererService.receive]Invalid IPC channel: ${channel} ` )); } })).pipe<T>(share<any >()); this .receive_map.set(channel, observable); observable.subscribe((data ) => { }); return observable; } }
可能有的人會發現,為什麼並沒有自動推論型別,這是因為需要在範型中加入參數,進行推論,比如你封裝startup_test這個通道
封裝startup_test通道
Example: angular-app/app/@services/startup_test.service.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 import { Injectable, OnInit, Inject, Optional } from "@angular/core" ;import { switchMap, distinctUntilChanged, catchError, map } from "rxjs/operators" ;import { forkJoin, of , takeUntil, ReplaySubject, Observable, lastValueFrom, from , mergeMap, share } from "rxjs" ;import { IPCRenderer, Params } from "angular-app/app/shared-declare-types/ipc.model" ;import { IPCValidChannel } from "angular-app/app/shared-declare-types/ipc-valid-channel" ;import { IPCRendererService } from "./ipc-renderer.service" ;import { startup_test } from "angular-app/shared-declare-types/ipc-routes.declare" ; @Injectable ({ providedIn : "root" }) export class SomeService { private _destroyed$: ReplaySubject<boolean > = new ReplaySubject(1 ); constructor (private ipcRenderer: IPCRendererService ) { } public ping ( ) { return from (this .ipcRenderer.invoke<Params<startup_test<any >>, ReturnType<startup_test<any >>>("startup_test" )); } }