使用Promise未搭配async/await时Electron打开对话框卡顿
我之前处理过好几起Electron dialog和Redux Observable(从你提到的epic来看,应该是用了这个库)结合时的卡顿冻结问题,结合你说的typescript-fsa异步工具在其他100个epic都正常的情况,几乎可以确定问题出在Electron dialog的同步阻塞特性和你的epic调度逻辑的冲突上。
问题根源分析
Electron的dialog.showOpenDialog这类API,虽然新版本支持Promise调用,但本质上还是会阻塞主线程——对话框的渲染和用户交互依赖主线程完成。而你的epic运行在Redux的调度流中,当你在epic里直接调用dialog时,主线程被dialog阻塞,没法处理后续的Redux动作(也就是typescript-fsa生成的done/failed动作),最终导致整个程序卡顿冻结。
你的typescript-fsa异步工具本身没有问题,只是Electron dialog的特性刚好和渲染进程的Redux调度流产生了冲突。
可行的解决方案
方案一:把Dialog调用移到主进程(最推荐)
Electron官方更推荐在主进程中调用dialog API,通过IPC和渲染进程通信,这样可以完全避免渲染进程主线程被阻塞。
渲染进程侧(Epic代码)
import { ipcRenderer } from 'electron'; import { actionCreatorFactory } from 'typescript-fsa'; import { from } from 'rxjs'; import { mergeMap, catchError } from 'rxjs/operators'; // 用typescript-fsa创建异步动作 const actionCreator = actionCreatorFactory(); export const openFileDialog = actionCreator.async<void, string[], Error>('OPEN_FILE_DIALOG'); // 对应的Epic export const openFileDialogEpic = (action$) => action$.ofType(openFileDialog.started) .pipe( mergeMap(() => // 通过IPC调用主进程的dialog方法 from(ipcRenderer.invoke('show-open-file-dialog')) .pipe( mergeMap(result => [openFileDialog.done({ result })]), catchError(err => [openFileDialog.failed({ error: err })]) ) ) );
主进程侧代码
import { ipcMain, dialog, BrowserWindow } from 'electron'; // 注册IPC handler ipcMain.handle('show-open-file-dialog', async () => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (!focusedWindow) throw new Error('No focused window'); const dialogResult = await dialog.showOpenDialog(focusedWindow, { properties: ['openFile'], // 可根据需求添加其他配置 filters: [{ name: 'Text Files', extensions: ['txt'] }] }); if (dialogResult.canceled) { throw new Error('User canceled the dialog'); } return dialogResult.filePaths; });
方案二:在渲染进程中延迟调用Dialog(临时 workaround)
如果暂时不想动主进程代码,可以用setTimeout把dialog调用放到事件循环的下一轮,避免阻塞epic的调度流:
import { actionCreatorFactory } from 'typescript-fsa'; import { from } from 'rxjs'; import { mergeMap, catchError } from 'rxjs/operators'; import { dialog } from '@electron/remote'; // Electron 14+需单独安装并启用remote const actionCreator = actionCreatorFactory(); export const openFileDialog = actionCreator.async<void, string[], Error>('OPEN_FILE_DIALOG'); export const openFileDialogEpic = (action$) => action$.ofType(openFileDialog.started) .pipe( mergeMap(() => new Promise<string[]>((resolve, reject) => { // 用setTimeout把dialog调用放到下一个事件循环 setTimeout(async () => { try { const result = await dialog.showOpenDialog({ properties: ['openFile'] }); if (result.canceled) reject(new Error('Canceled')); else resolve(result.filePaths); } catch (err) { reject(err); } }, 0); }) .pipe( mergeMap(result => [openFileDialog.done({ result })]), catchError(err => [openFileDialog.failed({ error: err })]) ) ) );
方案三:调整RxJS的调度器
借助RxJS的调度器,把dialog完成后的动作调度放到异步队列,避免和dialog的阻塞冲突:
import { actionCreatorFactory } from 'typescript-fsa'; import { from, asyncScheduler } from 'rxjs'; import { mergeMap, catchError, observeOn } from 'rxjs/operators'; import { dialog } from '@electron/remote'; const actionCreator = actionCreatorFactory(); export const openFileDialog = actionCreator.async<void, string[], Error>('OPEN_FILE_DIALOG'); export const openFileDialogEpic = (action$) => action$.ofType(openFileDialog.started) .pipe( mergeMap(() => from(dialog.showOpenDialog({ properties: ['openFile'] })) .pipe( // 用asyncScheduler把后续动作放到异步队列 observeOn(asyncScheduler), mergeMap(result => { if (result.canceled) throw new Error('Canceled'); return [openFileDialog.done({ result: result.filePaths })]; }), catchError(err => [openFileDialog.failed({ error: err })]) ) ) );
总结
最稳妥的还是方案一,符合Electron的设计规范,彻底避免渲染进程主线程阻塞的问题。你的typescript-fsa工具本身没有问题,只是Electron dialog的同步特性和渲染进程的Redux调度流产生了冲突,把dialog调用移到主进程就能解决这个问题。
内容的提问来源于stack exchange,提问作者MistyK

