[Electron]Redux 主从模式

在 Redux 上实现主从听起来是一件很奇怪的事情, 事实上我也只有最近折腾 Electron 项目时才用上了这么奇怪的技巧.

先描述下项目的业务场景: 我们的项目简单的说是一个基于 Electron 的文件管理系统, 类似于 Google Driver, 主要功能有如下几点:

  1. 支持树形目录结构的文件系统
  2. 多标签页同时打开
  3. 本地的数据存储与异步同步

由于 Electron 的版本是在 web 的基础上增强了本地数据存储的特性, 很自然的, 我们的页面复用了 web 版 React + Redux 的架构. 并在 Redux 的 Action 层封装了从本地获取数据同时更新远程资源的逻辑, 并以中间件的形式实现了本地数据到服务端的增量同步.

对 web 而言, 每打开一个页面就意味一个 Redux 实例被创建, 对应在 Electron 上也就是每一个 Webcontent 对应了一个 Redux. 这带来了许多问题

  1. 不同的 Redux 数据内容可能不相同

    比如在 A 进程编辑了文件名, B 进程看到的也应该是编辑后的文件名, 无论是否联网

  2. 数据同步存在竞争关系

    这里的同步是双向的, 可能一个文件创建的动作被两个不同的进程推送给了服务端, 也可能是两个不同的进程同时操作了同一个本地数据, 导致本地出现了预期外的结果.

  3. 存在一定的网络请求浪费

    服务端到本地的同步是通过 socket 实现的, 这意味着每一个进程都需要建立一个 socket 连接.
    并且当一个新的进程被创建时, 所有初始化的请求都会被触发一遍

根据上面的问题, 解答的方法几乎是不言而喻的. 如果可以有一个 ReduxMaster 来负责内容同步与网络请求, 在所有需要用到 Redux 的进程创建一个 ReduxClient, 只用来同步 ReduxMaster 的状态, 所有的问题就都迎刃而解了.

(PS: 其实一开始我是叫 Master / Slaver 的, 这样的命名更加传统, 不过有一点点不符合现代人的政治正确标准)

原理

先抽象出一个简单的例子, 文件重命名. 在 Redux 中, 我们需要 dispatch 一个 updateFileName 的 Action, 经过 file 的 reducer, 将最终的状态存入 store.

现在, 这个事件由某一个 ReduxClient 触发, 我们希望由 ReduxMaster 来实现这个 Action, 再将状态更新至每一个 ReduxClient 中.

在 ReduxClient 中, 我们需要一个 Redux 中间件 将需要由 ReduxMaster 执行的 action 拦截并将 action 的名称与参数通知给 ReduxMaster 所在的进程. 再由 ReduxMaster 将执行完的结果广播给出去. 最后由 ReduxClient 将结果转化为 state.

当然在现实中, Action 是会承载 sideEffect 的. 以上面的例子而言 updateFileName 内部可能包含了一个网络请求, 这样的 Action 无法直接在进程间传递. 因此我们需要在各个进程间做出一个约定, 将 Action 映射为一个字符串 ActionName. 这样, 进程通信的过程中, 我们只需要传递 ActionName 和参数, 就可以由 ReduxMaster 完成一模一样的请求.

这里涉及到的不同的进程之间的通信, 在 Electron 中, 用 ipc 可以很容易的达成. 在我们的例子里, ReduxMaster 运行在一个 renderer 进程中, ReduxClient 通过获取 Master 所在的 Webcontents.send 发起通知, ReduxMaster 与 ReduxClient 均通过 ipcrenderer.on 接收通知. ReduxMaster 通过遍历所有 webContents 的 send 方法进行广播.

如果想在主进程里运行 ReduxMaster, 把 Webcontents.send 替换为 ipcrenderer.send, 接收替换为 ipcMain.on 即可.

实现

为了便于调用, 将 ipc 封装成 Promise 的形式

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
interface Data<P> {
args: P
id: string
reply: boolean
}

interface Result<T> {
data: T
id: string
}

// ReduxClient
const masterWebContents: Electron.webContents
function sendMessage<P = {}, T = {}> (channel: string, args: P, reply = true): Promise<T> {
return new Promise<T>((resolve, reject) => {
// 生成一个随机 id, 当复数 ipc 同时存在时可以对应正确的回复
const id = uuid()
masterWebContents.send(channel, {
args: args,
id: id,
reply: reply,
})
if (!reply) {
return Promise.resolve()
}
const listener = (ev: Electron.Event, result: Result<>) => {
if (result.id === id) {
clearSetTimeout(clearHandle)
resolve(result)
}
}
// 超时机制
const clearHandle = setTimeout(() => {
ipcRenderer.removeListener(`${channel}-reply`, listener)
reject()
}, TIMEOUT)
ipcRenderer.once(`${channel}-reply`, listener)
})
}

// ReduxMaster
async function listenMessage (channel: string, callback: Function) {
ipcrenderer.on(channel, (ev: Electron.Event, data: Data) => {
const resultData = await callback(data)
ev.send(`${channel}-reply`, {
id: data.id,
data: resultData,
})
})
}

ReduxClient

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
import { createStore, compose, applyMiddleware } from 'redux'
const preloadedState = await sendMessage<void, State>('reduxcluster:get-state')
const store = createStore(reducers, preloadedState. compose(
// ...other enhancers
applyMiddleware(
createClientMiddleWare(),
// ...other middlewares
)
))

listenMessage<any, void>('reduxcluster:sync', (ev, args) => {
store.dispatch('sync' args)
})

function createClientMiddleWare () {
return ({dispatch}) => (next: Function) => (action: Action) => {
// 跳过不需要同步的, 以及广播定义的的 actions
if (checkSkip(action)) {
return next(action)
}
sendMessage('reduxcluster:dispatch', {
type: action.toString(),
args: action.args,
})
}
}

ReduxMaster

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
const store = createStore(reducers, preloadedState. compose(
// ...other enhancers
applyMiddleware(
// ...other middlewares
createMasterMiddleWare(),
)
))

listenMessage('reduxcluster:get-state', () => {
return store.getState()
})

listenMessage('reduxcluster:dispatch', ({ type, args }) => {
const action = getActionFromType(type)
store.dispatch(action(...args))
})

function createMasterMiddleWare() {
// 这里的 createMasterMiddleWare 是最后一个 middleware, 我们认为这里的 action 已经是纯对象了
return () => (next: Function) => (action: Object) => {
if (!checkSkip(action)) {
sendMessage('reduxcluster:sync', {action})
}
next(action)
}
}