之前写文章简单介绍过,Electron 这个可以使用前端技术栈非常方便地开发桌面端应用的框架。而一旦涉及到桌面端应用,多窗口就是个绕不开的话题;而在多窗口应用中,不同窗口间的状态共享和响应式也是非常重要的特性。这篇文章就简单介绍下,使用 Electron Store 实现 Electron 多窗口应用的响应式状态共享的方案和简单实践。
在多窗口应用中,不同窗口会有各自独立维护的变量,在各自页面中完成闭环管理;但是也有一些涉及应用程序全局的状态,需要在不同窗口中进行共享。
一些应用程序全局维度的变量,应该以共享状态的形式能够被所有窗口访问(包括读取和写入)。在 Electron 应用中,共享状态除了需要在渲染进程中能被访问,还需要在主进程中共享。
全局状态一般是会对全部窗口都产生影响的变量,因此页面之间共享的状态应该是响应式的,即一个页面对共享状态的修改应该能被另一个页面监听到。
如本文标题所述,我使用 Electron Store 作为实现这一特性的基础。
在决定使用 Electron Store 之前,Redux 和 LocalStorage 都曾被纳入考虑中。但是详细对比之后,最终我还是选择了 Electron Store。
LocalStorage 的以下缺点让我否决了它:
LocalStorage 只支持使用字符串,想要存储对象我需要手动 parse 和 stringify。
LocalStorage 虽然支持监听,但是只能监听其他窗口发生的状态变化;这就意味着,同一个窗口(同一个页面)的不同组件不能监听彼此对共享状态的改变,这在 React 和 Vue 这些 SPA 应用的组件化开发模式下是不可接受的。
LocalStorage 只能在浏览器环境使用,也就是说 Electron 的主进程无法访问共享状态。
Redux 作为常与 React 出双入对的框架,对 React 的适配好了不少,但是它仍有在 Electron 水土不服的问题:
Redux 无法原生支持持久化,而需要应用程序全局共享的状态有不少是需要持久化的,Redux 无法满足这个需求。
Redux 虽然可以在同页面的组件间实现共享和响应式,但是一旦跨窗口,就连最基本的访问都做不到——也就是完全无法跨窗口共享状态。
Electron Store 是一个非官方开发的 npm 库,用于在 Electron 应用中提供数据持久化的能力。它主要特性包括:
对 Electron 应用的数据进行持久化。
以数据的原始类型(字符串、数字或数组、对象等)而非经过 stringify 的字符串进行存储,读写状态无需经过手动转换,更方便。
支持对全部数据或某个特定的数据进行监听,并且支持本窗口和其他窗口的监听——这让我们能实现页面内不同组件以及不同窗口的数据共享和响应式。
Electron Store 这个库本身比较成熟,也提供了丰富的 API,直接引入然后安装它的官方文档进行调用就可以了;但是这个库提供的是比较原始的用 set 和 get 字符串 key 的方式读写状态的 API,而我的应用规模较大,有一个集中化的状态管理会更加便于后续的维护。所以,我仿照 React Redux 和 Vue Pinia 的状态管理方式,实现了集中化的状态管理。
在 React Redux 和 Vue Pinia 的实践中,按模块对状态进行划分是一个很常见的操作,特别是在应用规模较大、需要管理的状态较多时。因此,我也讲我的应用的状态划分为多个模块,并在统一的入口文件中引用和注册模块;在被业务逻辑调用时,业务代码只需要引入入口文件即可完成调用。
下面的代码以富文本编辑器的状态管理为例,涉及富文本编辑器已保存的文件列表的管理:
import Store from 'electron-store';import getArticles from './articles';const store = new Store();const storage = () => { const articles = getArticles(store); return {
articles,
};
};export default storage;// store/articles.tsimport Store from 'electron-store';
interface Article { // 文章类的定义,略去}const articles = (store: Store) => { const setArticleList = (articleList: Article[]) => {
store.set('articleList', articleList);
}; const getArticleList = () => { return (store.get('articleList') as Article[]) || [];
}; const addArticle = (article: Article) => {
store.set('articleList', [...((store.get('articleList') as Article[]) || []), article]);
}; const removeArticle = (articleId: string) => { const articleList = (store.get('articleList') as Article[]) || []; const index = articleList.findIndex((article) => article.id === articleId); if (index >= 0) {
articleList.splice(index, 1);
store.set('articleList', articleList);
}
}; const updateArticle = (articleId: string, updatedArticle: Article) => { const articleList = (store.get('articleList') as Article[]) || []; const index = articleList.findIndex((article) => article.id === articleId); if (index >= 0) {
articleList.splice(index, 1, updatedArticle);
store.set('articleList', articleList);
}
}; return {
setArticleList,
getArticleList,
addArticle,
removeArticle,
updateArticle,
};
};export default articles;
首先在 index.ts
中完成 Electron Store 的初始化逻辑,在 articles.ts
中完成状态增删改查的具体逻辑,之后在index.ts
中注册 article
模块即可。需要注意的是,Electron Store 要在 index.ts
的最外层完成初始化,这样可以保证每个窗口只会初始化一次;如果放在了storage
函数里面,那么同一个窗口中的每个业务组件调用都会初始化一次,造成不必要的性能浪费。
Electron Store 官方提供了监听状态的 API,不过是传入字符串形式的状态名称实现调用的,这样用起来可复用性不高而且可维护性差。我们可以在状态模块的逻辑中自行定义监听函数,实现状态的按需监听。当然,要启用 Electron Store 的监听能力,需要在初始化时传入参数 { watch: true }
。
// store/index.tsimport Store from 'electron-store';import getArticles from './articles';const store = new Store({ watch: true });const storage = () => { const articles = getArticles(store); return {
articles,
};
};export default storage;// store/articlesimport Store from 'electron-store';const articles = (store: Store) => { // 其他方法略去
const watchArticleList = (
callback: (articleList: Article[], oldArticleList: Article[]) => void, ) => {
store.onDidChange('articleList', (newValue, oldValue) => { callback(newValue as Article[], oldValue as Article[]);
});
}; return { /// 其他方法略去
watchArticleList,
};
};export default articles;
在业务组件中,只需要引入 Storage 的入口文件,然后按模块调用方法即可。
import Storage from 'store';const storage = Storage();const App: React.FC = () => { // 将文章列表初始化为 Storage 中的文章列表
const [articles, setArticles] = useState(storage.articles.getArticleList()); // Storage 中的文章列表改变(被其他组件或其他窗口)修改时,同步修改 React State 中的变量
storage.articles.watchArticleList((articles) => setArticleList(articles)); // 其余代码略去};
Electron Store 是一个经过了长期维护的较稳定的框架,同时也是一个实现原理不那么复杂的框架;但另一方面,作为一个涉及到持久化和响应式的桌面端跨平台框架,它也有一些潜在的坑点。
Electron Store 的持久化依赖 Electron 提供的 Node 能力,借助 fs 将状态保存成硬盘中的一个 JSON 文件。而它的响应式能力则是 Node 中 fs 的 watch
和 watchFile
所支持的(watch
和 watchFile
的选择取决于系统平台是 WIndows 还是 Mac 或 Linux)。从这个框架的方案设计来看,它的核心功能的实现还是很简洁的。
如果你在 Mac 和 Windows 同时开发,你会发现这个框架有一个很奇怪的问题:在 Windows 上,响应式触发正常,一个窗口对共享状态的变更会立刻反应到另一个窗口;但是在 Mac 上,状态的改变被另一个窗口监听会有一个较明显的延迟。
这是因为,Electron Store 的响应式能力在 Mac 上是使用 fs.watchFile
实现的,而在 Windows 上是 fs.watch
实现的。这里的区别涉及到 Node fs 模块对这两个方法的底层实现不同:fs.watch
的底层实现是基于操作系统事件,而 fs.watchFile
的底层实现方式是轮询。
在 node_modules 中找到 conf
这个 npm 包(它是 Electron Store 作者的另一个作品,Electron Store 引用了它),可以看到 watch 部分代码的实现:
if (process.platform === 'win32') {
fs.watch(this.path, { persistent: false }, debounceFn(() => { // On Linux and Windows, writing to the config file emits a `rename` event, so we skip checking the event type.
this.events.emit('change');
}, { wait: 100 }));
}else {
fs.watchFile(this.path, { persistent: false }, debounceFn(() => { this.events.emit('change');
}, { wait: 5000 }));
}
这两种方法都各自有其问题:
fs.watch
会去监听操作系统的事件,但这在不同操作系统的实现是不同的;在 Mac 上,通过 fs.watch
监听的文件产生的变化只会被触发一次,导致 Electron Store 的 watch 只能在一个窗口的不同组件内作用,而不能跨窗口共享;而在 Windows,它表现正常。因此,Electron 在 Windows 使用了 fs.watch
。
fs.watchFile
使用轮询,抹平了系统实现的差异,让 Mac 也能实现跨窗口共享;但它的问题在于,大量的轮询可能会造成一定的性能问题;因此 Electron Store 的作者在 Mac 平台使用 fs.watchFile
实现的同时,加了一个很大的 debounce。
在我的应用程序中,响应式状态的及时性非常重要,我不希望用户感知到明显的延迟;而我刚才提到的集中状态管理可以约束 watch 被初始化的次数,也就限制了 fs.watchFile 产生轮询的数量,不至于引起太大的性能问题。所以,我们只需要 watchFile 中的 debounce 去掉,然后把 interval 属性改小一些,即可解决问题:
if (process.platform === 'win32') {
fs.watch(this.path, { persistent: false }, debounceFn(() => { // On Linux and Windows, writing to the config file emits a `rename` event, so we skip checking the event type.
this.events.emit('change');
}, { wait: 100 }));
}else {
fs.watchFile(this.path, { persistent: false, interval: 200 }, () => { this.events.emit('change');
});
}
在解决方案中我们对 npm 包做了改动,而通常 npm 包是不会被 git 追踪的。考虑到团队协作或者多设备开发的场景,我们当然需要把对 npm 包的修改同步到 git。这非常简单,直接使用 patch-package
这个包即可:
npm i patch-package
安装 patch-package
包。
按照上面的解决方案修改 conf
包。
在项目目录中执行 npx patch-package conf
生成 conf
包的修改 patch。
让 git 追踪生成的 patch 文件。
在 package.json
中的 scripts
中添加 "postinstall": "patch-package"
脚本,这样每次 npm i
之后会自动执行 patch
命令。