Electron+IM开发

Electron + IM


模块一:Electron 主进程/渲染进程通信

一、核心原理

Electron 基于 Chromium + Node.js 架构,采用多进程模型,这是Electron开发的核心基石:

  1. 主进程(Main Process):整个应用的入口,全局唯一。负责管理所有窗口、生命周期、系统原生API调用、Node.js全能力访问,相当于应用的「大脑」。
  2. 渲染进程(Renderer Process):每个BrowserWindow对应一个独立渲染进程,负责渲染Web页面、运行前端代码,基于Chromium渲染引擎,默认无法直接访问Node.js API(安全隔离)。
  3. 预加载脚本(Preload Script):运行在渲染进程的隔离上下文里,是唯一能同时接触「Web API」和「Node.js API」的桥梁,通过contextBridge把受控的API暴露给渲染进程,是Electron官方推荐的安全通信方案。
  4. IPC通信核心:基于ipcMain(主进程监听)和ipcRenderer(渲染进程发送)实现进程间通信,核心分为3种模式:
    • 单向通信:渲染→主(send/on)、主→渲染(send/on)
    • 双向调用:渲染调用主进程方法并获取返回值(invoke/handle,最常用)
    • 广播通信:主进程给所有渲染进程群发消息

二、完整可运行示例(符合Electron 25+ 安全规范)

1. 主进程入口 main.js

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
const { app, BrowserWindow, ipcMain, dialog } = require('electron')
const path = require('path')

// 全局窗口实例
let mainWindow

// 创建窗口函数
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
// 安全核心配置
contextIsolation: true, // 开启上下文隔离,默认开启,禁止关闭
nodeIntegration: false, // 关闭渲染进程Node.js集成,默认关闭,禁止开启
preload: path.join(__dirname, 'preload.js') // 预加载脚本路径
}
})

// 加载前端页面
mainWindow.loadFile('index.html')
// 开发环境打开开发者工具
mainWindow.webContents.openDevTools()
}

// 应用就绪后创建窗口
app.whenReady().then(() => {
// 1. 双向通信:注册渲染进程可调用的方法(invoke/handle)
// 场景:IM客户端获取系统通知权限、发送原生通知
ipcMain.handle('notification:send', async (event, content) => {
console.log('收到渲染进程的通知请求:', content)
// 调用系统原生能力(主进程独有)
return {
code: 0,
msg: '通知发送成功',
data: { timestamp: Date.now(), content }
}
})

// 2. 单向通信:监听渲染进程发送的消息(send/on)
// 场景:IM客户端上报窗口状态、用户操作日志
ipcMain.on('log:report', (event, logInfo) => {
console.log('收到用户操作日志:', logInfo)
// 主进程主动给渲染进程回发消息
event.reply('log:report-reply', { status: 'success', time: Date.now() })
})

// 3. 主进程调用原生对话框(IM场景:选择文件发送、导出聊天记录)
ipcMain.handle('file:select', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile', 'multiSelections'],
filters: [{ name: '图片', extensions: ['jpg', 'png', 'gif'] }]
})
return result.filePaths
})

createWindow()
})

// 所有窗口关闭后退出应用(Windows/Linux)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})

2. 预加载脚本 preload.js(核心桥梁)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { contextBridge, ipcRenderer } = require('electron')

// 只给渲染进程暴露受控的API,绝不直接暴露整个ipcRenderer(安全红线)
contextBridge.exposeInMainWorld('electronAPI', {
// 双向调用:发送系统通知
sendNotification: (content) => ipcRenderer.invoke('notification:send', content),
// 单向发送:上报日志
reportLog: (logInfo) => ipcRenderer.send('log:report', logInfo),
// 监听日志上报的回调
onLogReply: (callback) => ipcRenderer.on('log:report-reply', (event, data) => callback(data)),
// 选择文件
selectFile: () => ipcRenderer.invoke('file:select'),
// 移除监听器(内存优化,避免内存泄漏)
removeLogListener: () => ipcRenderer.removeAllListeners('log:report-reply')
})

3. 渲染进程(前端代码)renderer.js

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
// 直接调用预加载脚本暴露的API,完全感知不到Node.js的存在,安全隔离
const { electronAPI } = window

// 1. 调用双向通信方法,发送IM通知
const sendIMNotification = async () => {
const res = await electronAPI.sendNotification('你有一条新的IM消息')
console.log('主进程返回结果:', res)
}

// 2. 上报用户操作日志,监听回调
const reportUserAction = () => {
electronAPI.reportLog({
action: 'send_message',
sessionId: '123456',
userId: 'user_001',
timestamp: Date.now()
})
}

// 监听日志上报的回调
electronAPI.onLogReply((data) => {
console.log('日志上报结果:', data)
})

// 3. 选择文件发送(IM场景)
const selectAndSendFile = async () => {
const filePaths = await electronAPI.selectFile()
console.log('选中的文件路径:', filePaths)
}

// 页面卸载时移除监听器,避免内存泄漏
window.addEventListener('beforeunload', () => {
electronAPI.removeLogListener()
})

// 挂载到window,方便页面按钮调用
window.sendIMNotification = sendIMNotification
window.reportUserAction = reportUserAction
window.selectAndSendFile = selectAndSendFile

4. 页面入口 index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Electron IM客户端</title>
</head>
<body>
<h1>IM客户端功能测试</h1>
<button onclick="sendIMNotification()">发送新消息通知</button>
<button onclick="reportUserAction()">上报用户操作</button>
<button onclick="selectAndSendFile()">选择文件发送</button>
<script src="renderer.js"></script>
</body>
</html>

三、逐模块深度讲解

  1. 安全核心:为什么必须用preload+contextBridge?
    直接在渲染进程开启nodeIntegration: true会导致严重的安全漏洞:一旦前端页面被XSS攻击,攻击者就能直接获取Node.js全能力,访问用户本地文件、执行系统命令。contextIsolation开启后,渲染进程的上下文和预加载脚本的上下文完全隔离,只能通过exposeInMainWorld暴露的受控API通信,从根源上杜绝安全风险。

  2. 通信模式的选型场景

    • invoke/handle:90%的业务场景首选,支持async/await,有明确的返回值,适合「渲染进程调用主进程能力并等待结果」,比如调用原生对话框、获取系统信息、执行文件操作。
    • send/on:适合单向通知,不需要等待返回值,比如上报日志、同步状态,主进程可以通过event.reply回发消息。
    • 主进程主动推送给渲染进程:用mainWindow.webContents.send('事件名', 数据)
  3. 性能与内存优化
    所有通过ipcRenderer.on注册的监听器,必须在组件/页面卸载时通过removeAllListeners移除,否则会导致监听器重复注册、内存泄漏、回调多次触发,这是Electron开发最常见的性能坑。

四、进阶及要点

  • 深入理解主进程/渲染进程的资源隔离:渲染进程的崩溃不会导致整个应用崩溃,主进程的崩溃会导致整个应用退出。
  • 大文件传输优化:IPC通信不适合传输超大文件(超过100MB),会阻塞进程,应该用「共享内存」「文件流分片」「Blob URL」的方式实现。
  • 原生模块集成:Node.js原生C++模块需要对应Electron的Node.js版本重新编译,常用工具electron-rebuild,「原生模块集成」。

模块二:WebSocket IM 心跳保活+断线重连

一、核心原理

WebSocket是IM实时通信的核心协议,基于TCP实现全双工通信,通过HTTP升级握手建立持久连接,解决了HTTP轮询的低效问题。IM场景的核心痛点是网络波动、TCP假死、消息丢失,因此必须实现3个核心能力:

  1. 心跳保活原理
    TCP协议自带的keepalive是系统层面的,默认超时时间长达2小时,完全不适合IM场景。应用层心跳的核心逻辑是:
    • 客户端定时向服务端发送ping心跳包,同时启动超时计时器
    • 服务端收到ping后,立即回复pong
    • 客户端在超时时间内收到pong,说明连接正常,重置计时器
    • 超时未收到pong,判定连接已断开,主动关闭连接,触发重连
  2. 断线重连原理
    监听WebSocket的onerroronclose事件,触发重连逻辑,采用指数退避算法:重连失败后,等待时间按指数级增长(1s→2s→4s→8s→最大32s),避免频繁重连给服务端造成压力,同时设置最大重连次数,防止无限重连。
  3. 消息队列原理
    发送消息时,如果连接处于非OPEN状态,将消息存入本地待发送队列,重连成功后批量发送,保证消息不丢失,同时通过消息ID实现幂等性,避免消息重复发送。

二、完整可运行示例(IM场景封装)

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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
class IMWebSocket {
/**
* 构造函数
* @param {Object} options 配置项
* @param {string} options.url WebSocket连接地址
* @param {number} options.heartbeatInterval 心跳间隔(ms),默认30s
* @param {number} options.heartbeatTimeout 心跳超时时间(ms),默认10s
* @param {number} options.maxReconnectCount 最大重连次数,默认10次
* @param {number} options.maxReconnectInterval 最大重连间隔(ms),默认32s
*/
constructor(options) {
// 基础配置
this.url = options.url
this.heartbeatInterval = options.heartbeatInterval || 30 * 1000
this.heartbeatTimeout = options.heartbeatTimeout || 10 * 1000
this.maxReconnectCount = options.maxReconnectCount || 10
this.maxReconnectInterval = options.maxReconnectInterval || 32 * 1000

// 状态管理
this.ws = null // WebSocket实例
this.reconnectCount = 0 // 当前重连次数
this.heartbeatTimer = null // 心跳定时器
this.heartbeatTimeoutTimer = null // 心跳超时定时器
this.reconnectTimer = null // 重连定时器
this.pendingMessageQueue = [] // 待发送消息队列
this.isManualClose = false // 是否手动关闭连接,手动关闭不触发重连
}

// 1. 建立连接
connect() {
// 避免重复创建实例
if (this.ws && this.ws.readyState === WebSocket.OPEN) return

try {
this.ws = new WebSocket(this.url)
this.bindEvent()
} catch (error) {
console.error('WebSocket连接创建失败:', error)
this.reconnect()
}
}

// 2. 绑定核心事件
bindEvent() {
// 连接成功
this.ws.onopen = () => {
console.log('WebSocket连接成功')
this.reconnectCount = 0 // 重置重连次数
this.startHeartbeat() // 启动心跳
this.flushPendingMessage() // 发送待发送队列的消息
}

// 收到消息
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data)
// 收到心跳pong,重置心跳
if (message.type === 'pong') {
this.resetHeartbeat()
return
}
// 业务消息回调,外部可重写
this.onMessage(message)
}

// 连接关闭
this.ws.onclose = (event) => {
console.log('WebSocket连接关闭:', event.code, event.reason)
this.clearHeartbeat()
// 非手动关闭,触发重连
if (!this.isManualClose) {
this.reconnect()
}
}

// 连接错误
this.ws.onerror = (error) => {
console.error('WebSocket连接错误:', error)
this.clearHeartbeat()
}
}

// 3. 心跳保活核心逻辑
startHeartbeat() {
// 先清空旧定时器
this.clearHeartbeat()
// 定时发送ping心跳包
this.heartbeatTimer = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }))
// 启动超时计时器,超时未收到pong则判定连接断开
this.heartbeatTimeoutTimer = setTimeout(() => {
console.warn('心跳超时,主动关闭连接')
this.ws.close()
}, this.heartbeatTimeout)
}
}, this.heartbeatInterval)
}

// 重置心跳
resetHeartbeat() {
this.clearHeartbeat()
this.startHeartbeat()
}

// 清空心跳定时器
clearHeartbeat() {
clearInterval(this.heartbeatTimer)
clearTimeout(this.heartbeatTimeoutTimer)
this.heartbeatTimer = null
this.heartbeatTimeoutTimer = null
}

// 4. 断线重连核心逻辑(指数退避算法)
reconnect() {
// 超过最大重连次数,停止重连
if (this.reconnectCount >= this.maxReconnectCount) {
console.error('超过最大重连次数,停止重连')
this.onReconnectFail()
return
}

// 计算重连间隔:指数退避 2^n 秒,不超过最大间隔
const delay = Math.min(Math.pow(2, this.reconnectCount) * 1000, this.maxReconnectInterval)
this.reconnectCount++
console.log(`第${this.reconnectCount}次重连,等待${delay}ms后执行`)

// 清空旧的重连定时器
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
// 延迟重连
this.reconnectTimer = setTimeout(() => {
this.connect()
}, delay)
}

// 5. 消息发送与待发送队列
sendMessage(message) {
const sendData = JSON.stringify({
...message,
msgId: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, // 唯一消息ID,幂等性
timestamp: Date.now()
})

// 连接已打开,直接发送
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(sendData)
} else {
// 连接未打开,存入待发送队列
console.warn('连接未就绪,消息存入待发送队列')
this.pendingMessageQueue.push(sendData)
}
}

// 重连成功后,批量发送待发送队列的消息
flushPendingMessage() {
if (this.pendingMessageQueue.length === 0) return
console.log(`发送待发送队列消息,共${this.pendingMessageQueue.length}条`)
this.pendingMessageQueue.forEach((data) => this.ws.send(data))
// 清空队列
this.pendingMessageQueue = []
}

// 6. 手动关闭连接
close() {
this.isManualClose = true
this.clearHeartbeat()
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
if (this.ws) this.ws.close()
this.ws = null
}

// 对外可重写的回调函数
onMessage(message) {
// 外部重写,处理业务消息
console.log('收到业务消息:', message)
}

onReconnectFail() {
// 外部重写,重连失败后的处理
console.error('重连失败,请检查网络或服务端状态')
}
}

// ------------------- 示例使用 -------------------
// 实例化IM客户端
const imClient = new IMWebSocket({
url: 'ws://127.0.0.1:8080/im', // 你的IM服务端WebSocket地址
heartbeatInterval: 30 * 1000, // 30秒发一次心跳
heartbeatTimeout: 10 * 1000, // 10秒没收到pong就超时
})

// 重写消息处理回调
imClient.onMessage = (message) => {
console.log('收到新的IM消息:', message)
// 渲染消息到页面、更新消息列表、通知提示等业务逻辑
}

// 重写重连失败回调
imClient.onReconnectFail = () => {
alert('网络连接异常,请检查网络后重试')
}

// 建立连接
imClient.connect()

// 发送聊天消息
const sendChatMessage = (content, sessionId) => {
imClient.sendMessage({
type: 'chat_message',
sessionId,
content,
fromUserId: 'user_001',
toUserId: 'user_002'
})
}

三、逐模块深度讲解

  1. 心跳保活的核心细节
    心跳间隔和超时时间的选型是IM场景的关键:移动端建议心跳间隔30-60s,超时10s;PC端建议心跳间隔20-30s,超时5s。同时必须在收到服务端的任何消息时都重置心跳,不只是pong包,因为服务端有业务消息下发,本身就说明连接正常。

  2. 指数退避算法的意义
    当服务端宕机时,大量客户端同时频繁重连会导致服务端雪崩,指数退避算法可以让重连请求分散开,降低服务端压力,同时设置最大重连间隔,避免等待时间过长影响用户体验。

  3. 待发送队列与幂等性
    消息队列保证了用户在断网时发送的消息不会丢失,重连成功后会自动补发。每个消息都生成唯一的msgId,服务端通过msgId去重,避免网络波动导致的消息重复发送,这是IM系统的核心要求。

四、进阶及要点

  • 消息乱序解决:TCP协议保证有序,但如果出现重发、分片,可能导致消息乱序,需要给消息添加递增的序列号,客户端按序列号排序渲染。
  • 流量控制:高并发场景下,服务端推送大量消息时,客户端需要实现消息限流,避免主线程阻塞、页面卡顿。
  • 传输安全:IM消息必须用wss://(WebSocket over TLS)加密传输,避免消息被抓包窃取,对应「传输安全方案」。

模块三:IndexedDB 本地消息存储与增量同步

一、核心原理

IndexedDB是浏览器/渲染进程内置的事务型NoSQL数据库,是IM客户端本地存储的唯一合适方案,对比localStorage有压倒性优势:

  • 异步API,不会阻塞主线程,适合大容量数据操作
  • 支持事务,保证数据一致性,成功提交、失败回滚
  • 支持索引,可实现高性能的条件查询、范围查询
  • 无明确容量上限(一般超过500MB会提示用户授权),适合存储海量聊天消息
  • 同源限制,数据安全隔离

IM场景的核心设计:

  1. 核心概念:数据库(DB)→ 对象仓库(Object Store,相当于数据表)→ 索引(Index,加速查询)→ 事务(Transaction)
  2. 表设计:核心表messages,存储聊天消息,主键msgId,索引包括sessionId(会话ID,按会话查消息)、timestamp(发送时间,按时间排序)、sendStatus(发送状态,筛选发送失败的消息)
  3. 增量同步原理:本地存储最新一条消息的同步时间戳,上线后只拉取该时间戳之后的消息,避免全量同步,提升性能,同时处理数据冲突(服务端消息覆盖本地未同步的草稿消息)。

二、完整可运行示例(IM场景封装)

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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
class IMIndexedDB {
constructor() {
this.dbName = 'IM_DB' // 数据库名
this.dbVersion = 1 // 数据库版本
this.storeName = 'messages' // 消息表名
this.db = null // 数据库实例
}

// 1. 初始化/打开数据库
openDB() {
return new Promise((resolve, reject) => {
// 打开数据库,版本号必须是整数
const request = indexedDB.open(this.dbName, this.dbVersion)

// 数据库版本升级时触发(首次打开、版本号升高)
request.onupgradeneeded = (event) => {
this.db = event.target.result
console.log('数据库版本升级,初始化表结构')

// 如果表不存在,创建消息表
if (!this.db.objectStoreNames.contains(this.storeName)) {
// 创建对象仓库,主键为msgId(唯一消息ID)
const store = this.db.createObjectStore(this.storeName, { keyPath: 'msgId' })
// 创建索引,用于加速查询
store.createIndex('sessionId', 'sessionId', { unique: false }) // 按会话ID查询
store.createIndex('timestamp', 'timestamp', { unique: false }) // 按时间排序
store.createIndex('sendStatus', 'sendStatus', { unique: false }) // 按发送状态筛选
}
}

// 数据库打开成功
request.onsuccess = (event) => {
this.db = event.target.result
console.log('数据库打开成功')
resolve(this.db)
}

// 数据库打开失败
request.onerror = (event) => {
console.error('数据库打开失败:', event.target.error)
reject(event.target.error)
}
})
}

// 2. 新增/更新单条消息
addMessage(message) {
return new Promise((resolve, reject) => {
// 开启读写事务
const transaction = this.db.transaction(this.storeName, 'readwrite')
// 获取对象仓库
const store = transaction.objectStore(this.storeName)
// 新增/更新数据(put方法:主键存在则更新,不存在则新增)
const request = store.put(message)

request.onsuccess = () => {
resolve({ code: 0, msg: '消息保存成功' })
}

request.onerror = () => {
reject(request.error)
}

// 事务完成回调
transaction.oncomplete = () => {
console.log('消息保存事务完成')
}

// 事务错误回调
transaction.onerror = (event) => {
console.error('事务执行失败:', event.target.error)
}
})
}

// 3. 批量新增消息(同步离线消息用,性能更高)
batchAddMessages(messages) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(this.storeName, 'readwrite')
const store = transaction.objectStore(this.storeName)

// 批量执行put操作
messages.forEach((message) => {
store.put(message)
})

// 事务完成
transaction.oncomplete = () => {
resolve({ code: 0, msg: `批量保存${messages.length}条消息成功` })
}

// 事务失败
transaction.onerror = (event) => {
reject(event.target.error)
}
})
}

// 4. 按会话ID查询消息(IM最常用场景)
getMessagesBySessionId(sessionId, pageSize = 20, offset = 0) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(this.storeName, 'readonly')
const store = transaction.objectStore(this.storeName)
// 使用sessionId索引查询
const index = store.index('sessionId')
// 游标查询,按时间倒序,支持分页
const request = index.openCursor(IDBKeyRange.only(sessionId), 'prev')

const messages = []
let count = 0
let skipCount = 0

request.onsuccess = (event) => {
const cursor = event.target.result
// 游标遍历
if (cursor) {
// 跳过offset条数据
if (skipCount < offset) {
skipCount++
cursor.continue()
} else {
// 收集数据
messages.push(cursor.value)
count++
// 达到分页大小,停止遍历
if (count < pageSize) {
cursor.continue()
} else {
resolve(messages)
}
}
} else {
// 遍历完成
resolve(messages)
}
}

request.onerror = () => {
reject(request.error)
}
})
}

// 5. 获取会话最新消息的时间戳(增量同步用)
getLatestMessageTimestamp(sessionId) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(this.storeName, 'readonly')
const store = transaction.objectStore(this.storeName)
const index = store.index('sessionId')
// 倒序查询第一条,就是最新的消息
const request = index.openCursor(IDBKeyRange.only(sessionId), 'prev')

request.onsuccess = (event) => {
const cursor = event.target.result
if (cursor) {
resolve(cursor.value.timestamp)
} else {
resolve(0) // 没有消息,返回0,拉取全量消息
}
}

request.onerror = () => {
reject(request.error)
}
})
}

// 6. 删除单条消息
deleteMessage(msgId) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(this.storeName, 'readwrite')
const store = transaction.objectStore(this.storeName)
const request = store.delete(msgId)

request.onsuccess = () => {
resolve({ code: 0, msg: '消息删除成功' })
}

request.onerror = () => {
reject(request.error)
}
})
}

// 7. 关闭数据库
closeDB() {
if (this.db) {
this.db.close()
this.db = null
console.log('数据库已关闭')
}
}
}

// ------------------- 示例使用 -------------------
// 实例化数据库
const imDB = new IMIndexedDB()

// 初始化流程
const initIMDB = async () => {
try {
// 打开数据库
await imDB.openDB()

// 1. 新增一条聊天消息
await imDB.addMessage({
msgId: 'msg_1710000000000_abc123',
sessionId: 'session_001',
content: '你好,这是一条测试消息',
fromUserId: 'user_001',
toUserId: 'user_002',
timestamp: 1710000000000,
sendStatus: 'success' // success:发送成功 pending:发送中 failed:发送失败
})

// 2. 按会话ID查询消息,分页20条
const messages = await imDB.getMessagesBySessionId('session_001', 20, 0)
console.log('会话消息列表:', messages)

// 3. 获取会话最新消息时间戳,用于增量同步
const latestTimestamp = await imDB.getLatestMessageTimestamp('session_001')
console.log('最新消息时间戳:', latestTimestamp)

// 4. 增量同步:拉取服务端最新消息,批量存入本地
// const newMessages = await fetchNewMessagesFromServer(latestTimestamp)
// await imDB.batchAddMessages(newMessages)

} catch (error) {
console.error('数据库操作失败:', error)
}
}

// 执行初始化
initIMDB()

三、逐模块深度讲解

  1. 数据库版本与表结构初始化
    IndexedDB的表结构创建、索引修改只能在onupgradeneeded事件中执行,该事件只有在数据库首次打开、或者版本号升高时才会触发。如果需要新增表、修改索引,只需要升高版本号,在该事件中处理即可。

  2. 事务的核心作用
    所有数据库操作都必须在事务中执行,事务有3种模式:

    • readonly:只读事务,多个只读事务可以并行执行,性能更高
    • readwrite:读写事务,同一时间只能有一个读写事务执行,保证数据一致性
    • versionchange:版本变更事务,只能在onupgradeneeded中使用
      批量操作必须放在同一个事务中执行,要么全部成功,要么全部失败,避免出现数据不一致的情况,比如批量同步消息时,中途失败会自动回滚,不会出现部分消息存入、部分没存入的问题。
  3. 索引与游标分页
    按会话ID查询消息是IM场景最高频的操作,给sessionId创建索引,能把查询性能从O(n)提升到O(log n)。游标cursor是IndexedDB遍历大量数据的最佳方式,相比getAll(),游标不会一次性把所有数据加载到内存中,分页查询时内存占用更低,性能更好。

四、进阶/要点

  • 性能优化:批量插入大量消息时,用单个事务批量执行,而不是每个消息开一个事务,性能能提升10倍以上。
  • 数据同步冲突解决:采用「服务端消息优先」策略,同一条消息(同msgId),服务端的消息覆盖本地的草稿消息,保证数据一致性。
  • 本地存储优化:超过3个月的历史消息,自动归档到本地文件,只保留热数据在IndexedDB中,提升查询性能。

模块四:Vue3+Pinia 复杂SPA状态管理

一、核心原理

Pinia是Vue3官方推荐的状态管理库,完全替代Vuex,核心基于Vue3的响应式系统,天生适配TypeScript,是复杂SPA状态管理的首选。

  1. 核心设计原理
    • 每个Store是一个独立的响应式对象,没有命名空间的概念,天然模块化
    • 取消了Vuex的mutations,只有stategettersactions,简化了状态修改流程
    • 完全支持TypeScript类型推导,不需要额外的类型封装
    • 支持插件化、持久化、服务端渲染
  2. IM场景的状态设计
    复杂IM单页应用的核心状态分为4个模块,全局共享,跨组件访问:
    • 用户状态:当前登录用户信息、token、权限
    • 会话状态:会话列表、当前选中的会话、未读消息计数
    • 消息状态:当前会话的消息列表、发送中的消息、草稿消息
    • 连接状态:WebSocket连接状态、网络状态、重连状态

二、完整可运行示例(IM场景Pinia Store)

1. 会话状态Store stores/imSession.js

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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 采用Option API风格,适合复杂业务逻辑,TypeScript支持同样完善
export const useIMSessionStore = defineStore('imSession', () => {
// ------------------- state:响应式状态 -------------------
// 会话列表
const sessionList = ref([])
// 当前选中的会话ID
const activeSessionId = ref('')
// 会话总未读数
const totalUnreadCount = computed(() => {
return sessionList.value.reduce((total, session) => total + session.unreadCount, 0)
})
// 当前激活的会话详情
const activeSession = computed(() => {
return sessionList.value.find(session => session.sessionId === activeSessionId.value) || null
})

// ------------------- actions:修改状态/业务逻辑 -------------------
/**
* 初始化会话列表
* @param {Array} list 会话列表数据
*/
const initSessionList = (list) => {
sessionList.value = list
}

/**
* 切换激活的会话
* @param {string} sessionId 会话ID
*/
const setActiveSession = (sessionId) => {
activeSessionId.value = sessionId
// 切换会话后,清空该会话的未读数
clearSessionUnread(sessionId)
}

/**
* 清空指定会话的未读数
* @param {string} sessionId 会话ID
*/
const clearSessionUnread = (sessionId) => {
const session = sessionList.value.find(item => item.sessionId === sessionId)
if (session) {
session.unreadCount = 0
}
}

/**
* 收到新消息,更新会话列表
* @param {Object} message 新消息
*/
const updateSessionByNewMessage = (message) => {
const { sessionId, content, timestamp, fromUserId } = message
const sessionIndex = sessionList.value.findIndex(item => item.sessionId === sessionId)

if (sessionIndex !== -1) {
// 会话已存在,更新最新消息和时间
const session = sessionList.value[sessionIndex]
session.lastMessage = content
session.lastMessageTime = timestamp
// 如果不是当前激活的会话,增加未读数
if (sessionId !== activeSessionId.value) {
session.unreadCount += 1
}
// 将会话置顶
sessionList.value.splice(sessionIndex, 1)
sessionList.value.unshift(session)
} else {
// 会话不存在,新增会话
const newSession = {
sessionId,
sessionName: fromUserId,
lastMessage: content,
lastMessageTime: timestamp,
unreadCount: sessionId === activeSessionId.value ? 0 : 1,
avatar: ''
}
sessionList.value.unshift(newSession)
}
}

/**
* 删除会话
* @param {string} sessionId 会话ID
*/
const deleteSession = (sessionId) => {
sessionList.value = sessionList.value.filter(item => item.sessionId !== sessionId)
// 如果删除的是当前激活的会话,清空激活状态
if (activeSessionId.value === sessionId) {
activeSessionId.value = ''
}
}

// 对外暴露状态和方法
return {
// state
sessionList,
activeSessionId,
// getters
totalUnreadCount,
activeSession,
// actions
initSessionList,
setActiveSession,
clearSessionUnread,
updateSessionByNewMessage,
deleteSession
}
})

2. 消息状态Store stores/imMessage.js

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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useIMSessionStore } from './imSession'
import { imDB } from '../utils/imIndexedDB' // 上面封装的IndexedDB实例
import { imClient } from '../utils/imWebSocket' // 上面封装的WebSocket实例

export const useIMMessageStore = defineStore('imMessage', () => {
// ------------------- state -------------------
// 当前会话的消息列表
const messageList = ref([])
// 发送中的消息集合(loading状态)
const sendingMsgSet = ref(new Set())
// 当前会话的草稿消息
const draftMap = ref(new Map())

// ------------------- actions -------------------
/**
* 初始化当前会话的消息列表
* @param {string} sessionId 会话ID
* @param {Array} messages 消息列表
*/
const initMessageList = (sessionId, messages) => {
messageList.value = messages
}

/**
* 发送聊天消息
* @param {string} content 消息内容
* @param {string} sessionId 会话ID
*/
const sendMessage = async (content, sessionId) => {
if (!content.trim() || !sessionId) return

// 生成唯一消息ID
const msgId = `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
const timestamp = Date.now()

// 1. 构造消息对象
const message = {
msgId,
sessionId,
content,
fromUserId: 'user_001',
toUserId: useIMSessionStore().activeSession.toUserId,
timestamp,
sendStatus: 'pending'
}

// 2. 乐观更新:先添加到消息列表,用户无感知
messageList.value.push(message)
// 3. 标记发送中状态
sendingMsgSet.value.add(msgId)
// 4. 存入本地IndexedDB
await imDB.addMessage(message)
// 5. 更新会话列表的最新消息
useIMSessionStore().updateSessionByNewMessage(message)
// 6. 通过WebSocket发送消息
imClient.sendMessage(message)

return msgId
}

/**
* 收到新消息,添加到消息列表
* @param {Object} message 新消息
*/
const receiveMessage = async (message) => {
const { sessionId } = message
// 1. 存入本地IndexedDB
await imDB.addMessage(message)
// 2. 如果是当前激活的会话,添加到消息列表
if (sessionId === useIMSessionStore().activeSessionId) {
messageList.value.push(message)
}
// 3. 更新会话列表
useIMSessionStore().updateSessionByNewMessage(message)
}

/**
* 更新消息发送状态
* @param {string} msgId 消息ID
* @param {string} status 状态:success/failed
*/
const updateMessageStatus = (msgId, status) => {
const message = messageList.value.find(item => item.msgId === msgId)
if (message) {
message.sendStatus = status
}
// 移除发送中状态
sendingMsgSet.value.delete(msgId)
}

/**
* 保存会话草稿
* @param {string} sessionId 会话ID
* @param {string} content 草稿内容
*/
const saveDraft = (sessionId, content) => {
draftMap.value.set(sessionId, content)
}

/**
* 获取会话草稿
* @param {string} sessionId 会话ID
*/
const getDraft = (sessionId) => {
return draftMap.value.get(sessionId) || ''
}

// 对外暴露
return {
messageList,
sendingMsgSet,
draftMap,
initMessageList,
sendMessage,
receiveMessage,
updateMessageStatus,
saveDraft,
getDraft
}
})

3. Vue组件中使用示例

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
<template>
<div class="im-chat-page">
<!-- 会话列表 -->
<div class="session-list">
<div
v-for="session in sessionList"
:key="session.sessionId"
:class="['session-item', { active: session.sessionId === activeSessionId }]"
@click="switchSession(session.sessionId)"
>
<div class="session-name">{{ session.sessionName }}</div>
<div class="last-message">{{ session.lastMessage }}</div>
<div class="unread-badge" v-if="session.unreadCount > 0">
{{ session.unreadCount }}
</div>
</div>
</div>

<!-- 聊天区域 -->
<div class="chat-area" v-if="activeSession">
<div class="message-list">
<div
v-for="message in messageList"
:key="message.msgId"
:class="['message-item', message.fromUserId === currentUserId ? 'self' : 'other']"
>
<div class="message-content">{{ message.content }}</div>
<div class="message-status">
{{ message.sendStatus === 'pending' ? '发送中' : message.sendStatus === 'failed' ? '发送失败' : '' }}
</div>
</div>
</div>

<!-- 输入框 -->
<div class="input-area">
<textarea
v-model="inputContent"
placeholder="请输入消息..."
@keydown.enter.prevent="sendMessage"
></textarea>
<button @click="sendMessage">发送</button>
</div>
</div>
</div>
</template>

<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia'
import { useIMSessionStore } from '@/stores/imSession'
import { useIMMessageStore } from '@/stores/imMessage'

// 从Store中获取响应式状态,用storeToRefs保持响应式,避免解构丢失响应式
const imSessionStore = useIMSessionStore()
const imMessageStore = useIMMessageStore()
const { sessionList, activeSessionId, activeSession, totalUnreadCount } = storeToRefs(imSessionStore)
const { messageList, sendingMsgSet } = storeToRefs(imMessageStore)

// 本地状态
const inputContent = ref('')
const currentUserId = 'user_001'

// 切换会话
const switchSession = (sessionId) => {
imSessionStore.setActiveSession(sessionId)
// 恢复草稿
inputContent.value = imMessageStore.getDraft(sessionId)
}

// 发送消息
const sendMessage = () => {
imMessageStore.sendMessage(inputContent.value, activeSessionId.value)
inputContent.value = ''
}

// 监听输入框变化,保存草稿
watch(inputContent, (newVal) => {
if (activeSessionId.value) {
imMessageStore.saveDraft(activeSessionId.value, newVal)
}
})

// 页面卸载前保存草稿
onBeforeUnmount(() => {
if (activeSessionId.value) {
imMessageStore.saveDraft(activeSessionId.value, inputContent.value)
}
})
</script>

三、逐模块深度讲解

  1. Store的两种写法
    Pinia支持Option API和Composition API两种写法,上面示例用的是Composition API风格,和Vue3的setup语法完美契合,更灵活,适合复杂的业务逻辑,TypeScript类型推导也更友好。

  2. storeToRefs的核心作用
    直接解构Store中的state和getters会丢失响应式,storeToRefs会把state和getters转换成ref对象,保持响应式,同时不会把actions转换成ref,actions可以直接解构使用。

  3. IM场景的状态设计最佳实践

    • 状态分层:将会话、消息、用户、连接状态拆分到不同的Store中,避免单个Store过于臃肿,维护性更高
    • 乐观更新:发送消息时,先把消息添加到页面,再等待服务端响应,用户无感知,体验更好
    • 数据持久化:所有消息都先存入本地IndexedDB,再同步到服务端,保证离线也能查看消息
    • 跨Store通信:在一个Store中可以直接导入另一个Store,调用其actions,访问其state,非常灵活

四、进阶/要点

  • 性能优化:消息列表渲染优化,用虚拟列表解决万条消息渲染卡顿问题,「SPA性能优化」
  • 状态持久化:用pinia-plugin-persistedstate插件实现状态持久化,刷新页面后会话列表、草稿不丢失
  • 模块化设计:大型项目中,按业务模块拆分Store,每个Store负责单一职责,符合单一原则,可维护性更高

整体串联

以上4个模块完全覆盖核心要求,组合起来就是一个完整的Electron IM客户端的核心架构:

  1. 前端层:Vue3+Pinia实现页面渲染和状态管理
  2. 通信层:WebSocket实现实时消息收发、心跳保活、断线重连
  3. 存储层:IndexedDB实现本地消息存储、增量同步
  4. 原生层:Electron实现跨平台桌面客户端、原生能力调用、进程通信

Electron + SQLite + ORM 完整操作指南(TypeORM + better-sqlite3)

在Electron中操作SQLite,TypeORM + better-sqlite3是目前最成熟、性能最好的方案:

  • TypeORM:TypeScript/JavaScript生态最完善的ORM框架,支持装饰器、迁移、事务,与Electron兼容性极佳
  • better-sqlite3:性能最强的SQLite Node.js驱动(同步API,无回调地狱),原生支持Electron

本指南完全贴合IM桌面客户端场景,从环境搭建→模型定义→CRUD操作→Electron安全集成,提供完整可运行的代码和深度原理讲解。


一、核心原理与架构设计

1. 为什么选这个组合?

对比项 TypeORM + better-sqlite3 Prisma + sqlite3 Sequelize + sqlite3
性能 极高(同步API,无异步开销) 中等(异步API) 中等
TypeScript支持 完美(装饰器+类型推导) 完美(Schema生成类型) 良好
Electron兼容性 极佳(原生模块易编译) 一般(Prisma Client需特殊配置) 良好
学习曲线 平缓(文档完善) 中等(需学Schema语言) 平缓
IM场景适配 完美(支持事务、索引、复杂查询) 良好 良好

2. Electron进程架构设计(安全红线)

SQLite是文件型数据库,不支持多进程并发写入,因此必须严格遵循:

  • 数据库操作只在主进程执行:渲染进程通过IPC通信调用主进程的数据库方法
  • 禁止在渲染进程直接操作数据库:违反Electron安全最佳实践(nodeIntegration: false
  • 使用预加载脚本(preload)暴露受控API:通过contextBridge暴露IPC调用方法,保证安全

二、环境搭建与依赖安装

1. 初始化Electron项目(以Vite+Electron为例)

1
2
3
4
5
6
# 创建Vite项目
npm create vite@latest electron-im-sqlite -- --template vue-ts
cd electron-im-sqlite

# 安装Electron相关依赖
npm install -D electron electron-builder vite-plugin-electron vite-plugin-electron-renderer

2. 安装核心依赖

1
2
3
4
5
# TypeORM + better-sqlite3 + 反射元数据(TypeORM必需)
npm install typeorm better-sqlite3 reflect-metadata

# 类型定义(TypeScript项目必需)
npm install -D @types/better-sqlite3 electron-rebuild

3. 配置原生模块编译(关键步骤)

better-sqlite3原生C++模块,必须编译成对应Electron的Node.js版本,否则会报Module did not self-register错误。

package.json中添加编译脚本:

1
2
3
4
5
6
{
"scripts": {
"rebuild": "electron-rebuild -f -w better-sqlite3",
"postinstall": "npm run rebuild" // 安装依赖后自动编译
}
}

执行编译:

1
npm run rebuild

三、TypeORM配置与模型定义

1. 项目目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
electron-im-sqlite/
├── src/
│ ├── main/ # 主进程代码
│ │ ├── index.ts # 主进程入口
│ │ ├── database/ # 数据库相关
│ │ │ ├── data-source.ts # TypeORM数据源配置
│ │ │ └── entities/ # 实体(模型)定义
│ │ │ └── Message.ts # IM消息实体
│ │ └── ipc/ # IPC通信处理
│ │ └── db-ipc.ts
│ ├── preload/ # 预加载脚本
│ │ └── index.ts
│ └── renderer/ # 渲染进程代码(Vue)
└── package.json

2. 定义IM消息实体(Model)

创建src/main/database/entities/Message.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
import { Entity, PrimaryColumn, Column, Index } from 'typeorm'

/**
* IM聊天消息实体
* 对应数据库表:message
*/
@Entity('message')
export class Message {
/**
* 消息唯一ID(主键)
* 格式:msg_时间戳_随机字符串
*/
@PrimaryColumn({ type: 'varchar', length: 50 })
msgId: string

/**
* 会话ID(索引,用于按会话查询消息)
*/
@Index() // 自动创建索引,大幅提升查询性能
@Column({ type: 'varchar', length: 50 })
sessionId: string

/**
* 消息发送者ID
*/
@Column({ type: 'varchar', length: 50 })
fromUserId: string

/**
* 消息接收者ID
*/
@Column({ type: 'varchar', length: 50 })
toUserId: string

/**
* 消息内容
*/
@Column({ type: 'text' })
content: string

/**
* 消息类型:text-文本 image-图片 file-文件
*/
@Column({ type: 'varchar', length: 20, default: 'text' })
msgType: string

/**
* 消息发送状态:pending-发送中 success-发送成功 failed-发送失败
*/
@Column({ type: 'varchar', length: 20, default: 'pending' })
sendStatus: string

/**
* 消息时间戳(索引,用于按时间排序)
*/
@Index()
@Column({ type: 'bigint' })
timestamp: number

/**
* 是否已读:0-未读 1-已读
*/
@Column({ type: 'tinyint', default: 0 })
isRead: number
}

3. 配置TypeORM数据源

创建src/main/database/data-source.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
import 'reflect-metadata' // 必须在第一行导入
import { DataSource } from 'typeorm'
import { app } from 'electron'
import path from 'path'
import { Message } from './entities/Message'

/**
* 获取数据库文件路径
* 存放在Electron用户数据目录下,保证每个用户独立数据库,且权限安全
* Windows: C:\Users\用户名\AppData\Roaming\electron-im-sqlite\im.db
* macOS: ~/Library/Application Support/electron-im-sqlite/im.db
*/
const getDBPath = (): string => {
const userDataPath = app.getPath('userData')
return path.join(userDataPath, 'im.db')
}

/**
* TypeORM数据源配置
*/
export const AppDataSource = new DataSource({
// 数据库类型
type: 'better-sqlite3',
// 数据库文件路径
database: getDBPath(),
// 实体列表(所有模型都要在这里注册)
entities: [Message],
// 同步模式:开发环境设为true,自动创建/更新表结构
// ⚠️ 生产环境必须设为false,使用迁移(migration)管理表结构
synchronize: process.env.NODE_ENV === 'development',
// 日志模式:开发环境设为true,打印SQL语句
logging: process.env.NODE_ENV === 'development',
// better-sqlite3专用配置
driver: require('better-sqlite3'),
// 开启WAL模式(Write-Ahead Logging),大幅提升并发性能
// SQLite默认是串行写入,WAL模式支持读并发,写性能提升10倍以上
extra: {
journalMode: 'WAL',
synchronous: 'NORMAL'
}
})

四、Electron主进程集成与IPC通信

1. 初始化数据库(主进程入口)

修改src/main/index.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
import { app, BrowserWindow } from 'electron'
import path from 'path'
import { AppDataSource } from './database/data-source'
import './ipc/db-ipc' // 导入IPC通信处理

let mainWindow: BrowserWindow | null

// 1. 应用就绪后,先初始化数据库,再创建窗口
app.whenReady().then(async () => {
try {
// 初始化TypeORM数据源
await AppDataSource.initialize()
console.log('✅ 数据库初始化成功')
} catch (error) {
console.error('❌ 数据库初始化失败:', error)
app.quit() // 数据库初始化失败,退出应用
return
}

// 创建窗口
createWindow()
})

// 2. 创建窗口函数
const createWindow = () => {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
// 安全配置(必须严格遵守)
contextIsolation: true,
nodeIntegration: false,
preload: path.join(__dirname, '../preload/index.js')
}
})

// 加载前端页面
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:5173')
mainWindow.webContents.openDevTools()
} else {
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
}
}

// 3. 应用关闭时,销毁数据库连接
app.on('before-quit', async () => {
if (AppDataSource.isInitialized) {
await AppDataSource.destroy()
console.log('✅ 数据库连接已关闭')
}
})

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})

app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})

2. 封装数据库操作与IPC通信

创建src/main/ipc/db-ipc.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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
import { ipcMain } from 'electron'
import { AppDataSource } from '../database/data-source'
import { Message } from '../database/entities/Message'

// 获取Message实体的Repository(TypeORM的核心操作对象)
const getMessageRepository = () => {
return AppDataSource.getRepository(Message)
}

// ------------------- IPC通信处理 -------------------

/**
* 1. 保存/更新单条消息
* 渲染进程调用:electronAPI.dbSaveMessage(message)
*/
ipcMain.handle('db:saveMessage', async (event, message: Message) => {
try {
const repo = getMessageRepository()
// save方法:主键存在则更新,不存在则插入
const savedMessage = await repo.save(message)
return { code: 0, msg: '保存成功', data: savedMessage }
} catch (error) {
console.error('保存消息失败:', error)
return { code: -1, msg: '保存失败', error: (error as Error).message }
}
})

/**
* 2. 批量保存消息(同步离线消息用,性能更高)
* 渲染进程调用:electronAPI.dbBatchSaveMessages(messages)
*/
ipcMain.handle('db:batchSaveMessages', async (event, messages: Message[]) => {
try {
const repo = getMessageRepository()
// 开启事务:要么全部成功,要么全部失败,保证数据一致性
const savedMessages = await AppDataSource.transaction(async (transactionalEntityManager) => {
return await transactionalEntityManager.save(messages)
})
return { code: 0, msg: `批量保存${savedMessages.length}条消息成功`, data: savedMessages }
} catch (error) {
console.error('批量保存消息失败:', error)
return { code: -1, msg: '批量保存失败', error: (error as Error).message }
}
})

/**
* 3. 按会话ID查询消息(分页+按时间倒序)
* 渲染进程调用:electronAPI.dbGetMessagesBySessionId(sessionId, pageSize, offset)
*/
ipcMain.handle(
'db:getMessagesBySessionId',
async (event, sessionId: string, pageSize: number = 20, offset: number = 0) => {
try {
const repo = getMessageRepository()
// TypeORM查询构建器:灵活构建复杂查询
const [messages, total] = await repo
.createQueryBuilder('message')
.where('message.sessionId = :sessionId', { sessionId })
.orderBy('message.timestamp', 'DESC') // 按时间倒序
.skip(offset) // 跳过offset条
.take(pageSize) // 取pageSize条
.getManyAndCount() // 获取数据+总数

return { code: 0, msg: '查询成功', data: { messages, total } }
} catch (error) {
console.error('查询消息失败:', error)
return { code: -1, msg: '查询失败', error: (error as Error).message }
}
}
)

/**
* 4. 更新消息发送状态
* 渲染进程调用:electronAPI.dbUpdateMessageStatus(msgId, status)
*/
ipcMain.handle('db:updateMessageStatus', async (event, msgId: string, status: string) => {
try {
const repo = getMessageRepository()
// update方法:只更新指定字段,性能更高
const result = await repo.update({ msgId }, { sendStatus: status })
if (result.affected === 0) {
return { code: -1, msg: '消息不存在' }
}
return { code: 0, msg: '更新成功' }
} catch (error) {
console.error('更新消息状态失败:', error)
return { code: -1, msg: '更新失败', error: (error as Error).message }
}
})

/**
* 5. 获取会话最新消息的时间戳(增量同步用)
* 渲染进程调用:electronAPI.dbGetLatestTimestamp(sessionId)
*/
ipcMain.handle('db:getLatestTimestamp', async (event, sessionId: string) => {
try {
const repo = getMessageRepository()
const latestMessage = await repo
.createQueryBuilder('message')
.where('message.sessionId = :sessionId', { sessionId })
.orderBy('message.timestamp', 'DESC')
.getOne()

return { code: 0, msg: '查询成功', data: latestMessage?.timestamp || 0 }
} catch (error) {
console.error('查询最新时间戳失败:', error)
return { code: -1, msg: '查询失败', error: (error as Error).message }
}
})

/**
* 6. 删除单条消息
* 渲染进程调用:electronAPI.dbDeleteMessage(msgId)
*/
ipcMain.handle('db:deleteMessage', async (event, msgId: string) => {
try {
const repo = getMessageRepository()
const result = await repo.delete({ msgId })
if (result.affected === 0) {
return { code: -1, msg: '消息不存在' }
}
return { code: 0, msg: '删除成功' }
} catch (error) {
console.error('删除消息失败:', error)
return { code: -1, msg: '删除失败', error: (error as Error).message }
}
})

五、预加载脚本与渲染进程调用

1. 预加载脚本(preload)

创建src/preload/index.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
import { contextBridge, ipcRenderer } from 'electron'
import type { Message } from '../main/database/entities/Message'

// 定义暴露给渲染进程的API类型(TypeScript类型安全)
export interface ElectronAPI {
// 数据库操作API
dbSaveMessage: (message: Message) => Promise<{ code: number; msg: string; data?: Message }>
dbBatchSaveMessages: (messages: Message[]) => Promise<{ code: number; msg: string; data?: Message[] }>
dbGetMessagesBySessionId: (
sessionId: string,
pageSize?: number,
offset?: number
) => Promise<{ code: number; msg: string; data?: { messages: Message[]; total: number } }>
dbUpdateMessageStatus: (msgId: string, status: string) => Promise<{ code: number; msg: string }>
dbGetLatestTimestamp: (sessionId: string) => Promise<{ code: number; msg: string; data: number }>
dbDeleteMessage: (msgId: string) => Promise<{ code: number; msg: string }>
}

// 通过contextBridge暴露受控API,绝不直接暴露ipcRenderer
contextBridge.exposeInMainWorld('electronAPI', {
dbSaveMessage: (message) => ipcRenderer.invoke('db:saveMessage', message),
dbBatchSaveMessages: (messages) => ipcRenderer.invoke('db:batchSaveMessages', messages),
dbGetMessagesBySessionId: (sessionId, pageSize, offset) =>
ipcRenderer.invoke('db:getMessagesBySessionId', sessionId, pageSize, offset),
dbUpdateMessageStatus: (msgId, status) => ipcRenderer.invoke('db:updateMessageStatus', msgId, status),
dbGetLatestTimestamp: (sessionId) => ipcRenderer.invoke('db:getLatestTimestamp', sessionId),
dbDeleteMessage: (msgId) => ipcRenderer.invoke('db:deleteMessage', msgId)
} satisfies ElectronAPI)

2. 渲染进程(Vue组件)中调用

修改src/renderer/src/App.vue

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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
<template>
<div class="im-app">
<h1>Electron IM SQLite 示例</h1>

<!-- 操作按钮 -->
<div class="actions">
<button @click="saveTestMessage">保存测试消息</button>
<button @click="loadMessages">加载会话消息</button>
<button @click="updateMessageStatus">更新消息状态</button>
<button @click="deleteMessage">删除消息</button>
</div>

<!-- 消息列表 -->
<div class="message-list" v-if="messages.length > 0">
<h3>会话消息列表(共{{ total }}条)</h3>
<div v-for="msg in messages" :key="msg.msgId" class="message-item">
<div class="msg-content">{{ msg.content }}</div>
<div class="msg-meta">
时间:{{ new Date(msg.timestamp).toLocaleString() }} |
状态:{{ msg.sendStatus }} |
ID:{{ msg.msgId }}
</div>
</div>
</div>

<!-- 结果提示 -->
<div class="result" v-if="result">{{ result }}</div>
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

// 获取TypeScript类型支持
const { electronAPI } = window as unknown as {
electronAPI: import('../../preload').ElectronAPI
}

// 本地状态
const messages = ref<import('../../main/database/entities/Message').Message[]>([])
const total = ref(0)
const result = ref('')
const testSessionId = 'session_001'
let testMsgId = ''

// 1. 保存测试消息
const saveTestMessage = async () => {
const msgId = `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
testMsgId = msgId

const message = {
msgId,
sessionId: testSessionId,
fromUserId: 'user_001',
toUserId: 'user_002',
content: `测试消息 - ${new Date().toLocaleString()}`,
msgType: 'text',
sendStatus: 'pending',
timestamp: Date.now(),
isRead: 0
}

const res = await electronAPI.dbSaveMessage(message)
result.value = res.code === 0 ? `✅ ${res.msg}` : `❌ ${res.msg}`
}

// 2. 加载会话消息
const loadMessages = async () => {
const res = await electronAPI.dbGetMessagesBySessionId(testSessionId, 20, 0)
if (res.code === 0 && res.data) {
messages.value = res.data.messages
total.value = res.data.total
result.value = `✅ 加载成功,共${res.data.total}条消息`
} else {
result.value = `❌ ${res.msg}`
}
}

// 3. 更新消息状态
const updateMessageStatus = async () => {
if (!testMsgId) {
result.value = '❌ 请先保存一条测试消息'
return
}
const res = await electronAPI.dbUpdateMessageStatus(testMsgId, 'success')
result.value = res.code === 0 ? `✅ ${res.msg}` : `❌ ${res.msg}`
// 重新加载消息
await loadMessages()
}

// 4. 删除消息
const deleteMessage = async () => {
if (!testMsgId) {
result.value = '❌ 请先保存一条测试消息'
return
}
const res = await electronAPI.dbDeleteMessage(testMsgId)
result.value = res.code === 0 ? `✅ ${res.msg}` : `❌ ${res.msg}`
// 重新加载消息
await loadMessages()
}
</script>

<style scoped>
.im-app {
padding: 20px;
}
.actions {
margin: 20px 0;
gap: 10px;
display: flex;
}
.message-item {
border: 1px solid #eee;
padding: 10px;
margin: 10px 0;
border-radius: 4px;
}
.msg-meta {
font-size: 12px;
color: #666;
margin-top: 5px;
}
.result {
margin-top: 20px;
padding: 10px;
background: #f5f5f5;
border-radius: 4px;
}
</style>

六、核心进阶与最佳实践

1. WAL模式与性能优化

在TypeORM配置中开启的journalMode: 'WAL'是SQLite性能提升的关键:

  • 原理:WAL(Write-Ahead Logging)将写入操作先写入WAL文件,后台异步合并到主数据库,读操作可以直接读主数据库+WAL文件,实现读并发
  • 性能提升:写入性能提升10-100倍,读性能提升2-3倍,完全满足IM场景的高并发消息写入需求
  • 注意事项:WAL模式会生成-wal-shm两个临时文件,不要手动删除

2. 事务处理(数据一致性保证)

批量操作必须使用事务,比如同步100条离线消息,要么全部成功,要么全部失败,避免出现部分消息存入的情况:

1
2
3
4
5
6
// TypeORM事务示例
await AppDataSource.transaction(async (transactionalEntityManager) => {
// 所有操作都通过transactionalEntityManager执行
await transactionalEntityManager.save(messages)
await transactionalEntityManager.update(Message, { sessionId }, { isRead: 1 })
})

3. 索引优化(查询性能提升)

在IM场景中,sessionIdtimestamp是最高频的查询条件,必须创建索引:

  • @Index()装饰器:自动创建索引,查询性能从O(n)提升到O(log n)
  • 复合索引:如果经常按sessionId + timestamp查询,可以创建复合索引:
    1
    @Index(['sessionId', 'timestamp']) // 复合索引

4. 生产环境表结构管理(迁移)

开发环境用synchronize: true自动创建表,生产环境必须用迁移(Migration)管理表结构,避免数据丢失:

1
2
3
4
5
# 1. 生成迁移文件
npx typeorm-ts-node-commonjs migration:generate src/main/database/migrations/InitMessage -d src/main/database/data-source.ts

# 2. 运行迁移
npx typeorm-ts-node-commonjs migration:run -d src/main/database/data-source.ts

5. 数据库加密(敏感数据保护)

如果IM消息包含敏感数据,可以用better-sqlite3的加密扩展sqlcipher,或者在应用层对敏感字段加密:

1
2
3
4
5
6
7
8
9
10
// 应用层加密示例(消息内容加密存储)
import crypto from 'crypto'

const encrypt = (content: string, key: string) => {
const cipher = crypto.createCipher('aes192', key)
return cipher.update(content, 'utf8', 'hex') + cipher.final('hex')
}

// 保存消息时加密
message.content = encrypt(message.content, 'your-secret-key')

七、常见问题排查

  1. Module did not self-register错误

    • 原因:better-sqlite3没有编译成对应Electron的Node.js版本
    • 解决:执行npm run rebuild重新编译
  2. 数据库文件锁定错误

    • 原因:SQLite不支持多进程并发写入,或者上一个进程没有正常关闭数据库连接
    • 解决:确保所有数据库操作都在主进程执行,应用关闭时调用AppDataSource.destroy()
  3. 查询性能慢

    • 原因:没有创建索引,或者查询条件没有用到索引
    • 解决:给高频查询字段加@Index(),用TypeORM的logging: true查看SQL语句,分析索引使用情况
  4. TypeORM类型错误

    • 原因:实体定义的类型和数据库字段类型不匹配
    • 解决:严格按照TypeORM文档定义实体类型,bigint类型在JavaScript中用number处理

八、运行项目

1
2
3
4
5
6
7
8
# 1. 安装依赖
npm install

# 2. 编译原生模块
npm run rebuild

# 3. 开发模式运行
npm run dev

点击页面上的按钮,即可测试SQLite的CRUD操作,数据库文件会自动生成在Electron用户数据目录下。

IM SDK 多版本兼容与平滑升级完整方案

IM SDK 的多版本兼容和平滑升级是一个系统性工程,核心目标是保证新旧版本客户端互通、API 不崩溃、数据不丢失、升级无感知。本方案从协议层、API 层、数据层、发布层、服务端适配5个维度展开,提供可落地的架构设计和代码示例。


一、核心设计原则

  1. 语义化版本(SemVer):严格遵循 MAJOR.MINOR.PATCH 版本规则
    • MAJOR:大版本,允许破坏性变更(需提前6个月 deprecation 警告)
    • MINOR:小版本,新增功能,完全向后兼容
    • PATCH:补丁版本,修复 bug,完全向后兼容
  2. 双向兼容:不仅新版本兼容旧版本,旧版本也要能兼容新版本(至少能正常运行,不崩溃)
  3. 渐进式升级:给用户足够的过渡期,deprecation 的 API 至少保留 2 个大版本
  4. 可回滚:新版本发布后出现问题,能快速回滚到旧版本,服务端和 SDK 都要支持

二、协议层兼容(IM 互通的基础)

IM 的核心是网络通信,协议层兼容是新旧版本互通的前提,90%的 IM 兼容问题都出在协议层

1. 可扩展的协议格式选型

协议格式 可扩展性 兼容性 性能 IM 场景适配
Protocol Buffers (Protobuf) 极高(支持字段新增/废弃) 完美(旧版本能解析新版本的未知字段) 极高 ✅ 首选
Thrift 良好 极高 ✅ 可选
JSON 中(字段缺失会报错) ❌ 不推荐

Protobuf 兼容设计示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
syntax = "proto3";

// IM 聊天消息协议
message ChatMessage {
// 核心字段(永远不能修改字段号!)
string msg_id = 1; // 消息ID(主键)
string session_id = 2; // 会话ID
string from_user_id = 3; // 发送者ID
string content = 4; // 消息内容
int64 timestamp = 5; // 时间戳

// 扩展字段(新增字段必须用 optional,且字段号递增)
optional string msg_type = 6; // 消息类型(v1.1 新增)
optional bytes media_data = 7; // 媒体数据(v1.2 新增)

// 废弃字段(用 reserved 标记,永远不能复用字段号!)
// string old_field = 8; // v1.0 废弃
reserved 8;
}

核心规则

  • 永远不能修改已有字段的字段号类型
  • 新增字段必须用 optional(proto3 中默认 optional)
  • 废弃字段必须用 reserved 标记,禁止复用字段号
  • 旧版本解析新版本消息时,会自动忽略未知字段,不会崩溃

2. 协议版本协商机制

在 SDK 与服务端握手阶段,必须协商协议版本,确保双方使用都能支持的最高版本通信。

握手流程设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1. 客户端 → 服务端:HelloRequest
{
"sdk_version": "2.1.0",
"protocol_version": "3.0",
"supported_protocols": ["3.0", "2.5", "2.0"]
}

2. 服务端 → 客户端:HelloResponse
{
"code": 0,
"negotiated_protocol": "2.5", // 服务端最高支持 2.5,协商使用 2.5
"server_version": "1.5.0",
"upgrade_hint": "建议升级 SDK 到 2.1.0 以获得新功能"
}

服务端兼容逻辑

  • 服务端同时维护多个版本的协议解析器
  • 根据客户端的 supported_protocols,选择双方都支持的最高版本
  • 如果客户端版本过低,返回 upgrade_required 错误,强制升级(仅在安全漏洞时使用)

三、API 层兼容(客户端开发体验的核心)

SDK 对外暴露的 API 是开发者最关心的部分,API 层兼容的目标是新版本 SDK 替换旧版本后,开发者的代码不用改就能编译运行

1. API 设计的兼容原则

  1. 选项对象模式:避免使用多个参数,改用选项对象,方便新增参数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // ❌ 旧版本:多参数模式,新增参数会破坏兼容性
    function sendMessage(content: string, toUserId: string) {}

    // ✅ 新版本:选项对象模式,新增参数不影响旧代码
    interface SendMessageOptions {
    content: string;
    toUserId: string;
    msgType?: string; // v1.1 新增可选参数
    priority?: number; // v1.2 新增可选参数
    }
    function sendMessage(options: SendMessageOptions) {}
  2. 方法重载:对于必须修改参数的方法,提供重载版本,保留旧方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // 旧版本方法(保留,标记为 deprecated)
    /**
    * @deprecated 请使用 sendMessage(options) 替代
    */
    function sendMessage(content: string, toUserId: string): Promise<void>;

    // 新版本方法
    function sendMessage(options: SendMessageOptions): Promise<Message>;

    // 实现兼容逻辑
    function sendMessage(arg1: any, arg2?: any): Promise<any> {
    if (typeof arg1 === 'string' && typeof arg2 === 'string') {
    // 旧版本调用,转换为新版本格式
    return sendMessage({ content: arg1, toUserId: arg2 });
    }
    // 新版本调用
    return doSendMessage(arg1);
    }
  3. Deprecation 警告机制:对于即将废弃的 API,提前给出警告,给开发者过渡期

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /**
    * 登录方法
    * @deprecated 自 v2.0 起废弃,请使用 loginWithToken() 替代
    * 将于 v4.0 移除
    */
    function login(username: string, password: string): Promise<void> {
    console.warn('[IM SDK] login() 已废弃,请使用 loginWithToken()');
    // 兼容逻辑
    return loginWithToken(convertToToken(username, password));
    }

2. 接口隔离与模块化设计

将 SDK 拆分为核心模块扩展模块,核心模块保持稳定,扩展模块可以独立升级:

1
2
3
4
5
6
7
8
9
10
im-sdk/
├── core/ # 核心模块(稳定,大版本才变更)
│ ├── connection/ # 连接管理
│ ├── protocol/ # 协议解析
│ └── storage/ # 数据存储
├── modules/ # 扩展模块(可独立升级)
│ ├── chat/ # 聊天功能(v1.0)
│ ├── group/ # 群组功能(v1.1 新增)
│ └── rtc/ # 音视频功能(v2.0 新增)
└── index.ts # 入口文件

好处

  • 开发者可以只引入需要的模块,减小包体积
  • 扩展模块升级不影响核心模块,降低风险
  • 可以按需发布模块更新,不用整个 SDK 都升级

四、数据层兼容(用户体验的核心)

IM SDK 会在本地存储大量消息、会话、用户数据,数据层兼容的目标是升级 SDK 后,旧数据不丢失、能正常读取、不用重新登录

1. 数据库版本管理与迁移机制

使用版本化数据库,每次数据库结构变更时,编写迁移脚本,自动将旧数据转换为新格式。

SQLite 数据库迁移示例(TypeORM)

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
// 1. 数据库版本存储
@Entity('db_version')
export class DBVersion {
@PrimaryColumn()
id: number = 1; // 单例

@Column()
version: number; // 当前数据库版本
}

// 2. 迁移脚本管理
const migrations = [
{ version: 1, up: migrateV1ToV2 },
{ version: 2, up: migrateV2ToV3 },
{ version: 3, up: migrateV3ToV4 },
];

// 3. 自动迁移逻辑
async function autoMigrate() {
const versionRepo = dataSource.getRepository(DBVersion);
let currentVersion = await versionRepo.findOne({ where: { id: 1 } });

if (!currentVersion) {
// 首次安装,初始化最新版本
await versionRepo.save({ id: 1, version: migrations.length });
return;
}

// 执行增量迁移
for (let i = currentVersion.version; i < migrations.length; i++) {
const migration = migrations[i];
console.log(`[IM SDK] 执行数据库迁移:v${i} → v${i + 1}`);
await dataSource.transaction(async (tx) => {
await migration.up(tx);
await versionRepo.update({ id: 1 }, { version: i + 1 });
});
}
}

// 4. 具体迁移脚本示例:v1 → v2(新增 msg_type 字段)
async function migrateV1ToV2(tx: EntityManager) {
// 1. 给 message 表新增 msg_type 字段
await tx.query(`ALTER TABLE message ADD COLUMN msg_type TEXT DEFAULT 'text'`);
// 2. 给旧数据填充默认值
await tx.query(`UPDATE message SET msg_type = 'text' WHERE msg_type IS NULL`);
// 3. 创建索引
await tx.query(`CREATE INDEX IF NOT EXISTS idx_msg_type ON message(msg_type)`);
}

核心规则

  • 每次数据库结构变更,必须编写迁移脚本
  • 迁移必须在事务中执行,失败自动回滚
  • 迁移前必须备份数据库(可选但推荐)
  • 禁止删除字段,只能标记为废弃(避免旧版本 SDK 读取失败)

2. 认证信息兼容

升级 SDK 后,用户不应该需要重新登录,这就要求认证信息的存储格式兼容:

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
// 旧版本:存储 token
interface OldAuthInfo {
token: string;
expireTime: number;
}

// 新版本:新增 refresh_token,兼容旧版本
interface NewAuthInfo {
token: string;
refreshToken: string; // v2.0 新增
expireTime: number;
}

// 兼容读取逻辑
async function getAuthInfo(): Promise<NewAuthInfo | null> {
const stored = await localStorage.getItem('auth_info');
if (!stored) return null;

const data = JSON.parse(stored);
// 旧版本数据,自动转换为新版本格式
if (!data.refreshToken) {
const newData: NewAuthInfo = {
...data,
refreshToken: await exchangeRefreshToken(data.token), // 用旧 token 换取 refresh_token
};
await saveAuthInfo(newData);
return newData;
}
return data;
}

五、发布层:灰度发布与回滚机制

新版本 SDK 不能一下子全量发布,必须通过灰度发布逐步放量,观察指标,出现问题快速回滚。

1. 灰度发布策略

灰度阶段 放量比例 目标用户 观察指标
内部测试 0% 内部员工、测试团队 崩溃率、错误率、功能完整性
小范围灰度 1% - 5% 活跃用户、志愿者用户 崩溃率、错误率、用户反馈
中范围灰度 10% - 30% 按地区、按设备类型放量 性能指标、兼容性问题
全量发布 100% 所有用户 整体指标、监控告警

灰度发布工具

  • 自研灰度系统:根据用户 ID、设备 ID、地区等规则放量
  • 第三方平台:Firebase Remote Config、阿里云移动热修复等

2. 回滚机制

新版本发布后,如果出现严重问题(崩溃率飙升、核心功能不可用),必须能快速回滚到旧版本:

  1. SDK 端回滚
    • 如果是动态更新的 SDK(如 Web SDK、React Native SDK),可以直接回滚 CDN 资源
    • 如果是原生 SDK(iOS/Android/Windows),需要发布一个补丁版本,代码回退到旧版本,版本号递增(如 2.1.12.1.2,但代码是 2.1.0
  2. 服务端回滚
    • 服务端同时部署多个版本,通过流量切换回滚到旧版本
    • 数据库如果有变更,需要编写回滚脚本(迁移脚本要可逆)

3. 监控与告警

建立完善的监控体系,实时观察新版本的运行状态:

  • 崩溃监控:Firebase Crashlytics、Sentry、Bugly
  • 错误监控:自定义错误上报,监控协议错误、API 错误
  • 性能监控:连接耗时、消息发送耗时、数据库操作耗时
  • 业务监控:消息发送成功率、登录成功率、用户活跃度

告警规则

  • 崩溃率超过 0.1%,立即告警
  • 错误率超过 1%,立即告警
  • 核心功能成功率低于 99%,立即告警

六、服务端适配:多版本 SDK 同时支持

SDK 的兼容离不开服务端的配合,服务端必须同时支持多个版本的 SDK,不能因为服务端升级导致旧版本 SDK 不可用。

1. 服务端多版本架构

采用API 网关 + 多版本服务的架构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
                ┌─────────────┐
│ API 网关 │
│ (版本路由) │
└──────┬──────┘

┌───────────────┼───────────────┐
│ │ │
┌──────▼──────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ IM Service │ │IM Service │ │IM Service │
v1.0 │ │ v2.0 │ │ v3.0
└─────────────┘ └───────────┘ └───────────┘
│ │ │
┌──────▼───────────────▼───────────────▼──────┐
│ 共享数据库、消息队列 │
└───────────────────────────────────────────────┘

版本路由规则

  • API 网关根据 SDK 请求头中的 X-SDK-Version 路由到对应的服务版本
  • 旧版本 SDK 请求路由到旧版本服务,新版本 SDK 请求路由到新版本服务
  • 共享数据库和消息队列,保证新旧版本数据互通

2. 服务端兼容逻辑

  1. 协议兼容:服务端同时维护多个版本的协议解析器,能解析旧版本的协议
  2. 数据兼容:数据库结构变更时,保持旧字段不变,新增字段用默认值填充
  3. API 兼容:旧版本 API 继续保留,标记为 deprecated,至少保留 2 个大版本
  4. 消息互通:新旧版本 SDK 发送的消息,双方都能接收和解析(依赖协议层兼容)

七、完整落地流程

  1. 需求阶段:评估变更对兼容性的影响,如果是破坏性变更,提前规划 deprecation 周期
  2. 设计阶段:设计协议、API、数据库的兼容方案,编写迁移脚本
  3. 开发阶段:实现兼容逻辑,编写单元测试和兼容性测试
  4. 测试阶段
    • 兼容性测试:新旧版本 SDK 互通测试、数据迁移测试
    • 压力测试:验证新版本性能
    • 灰度测试:内部小范围测试
  5. 发布阶段:按灰度策略逐步放量,实时监控指标
  6. 运维阶段:观察监控数据,出现问题快速回滚,收集用户反馈

八、总结

IM SDK 的多版本兼容和平滑升级是一个长期工程,需要从协议、API、数据、发布、服务端5个维度全面考虑,核心是“向前兼容、向后兼容、可回滚”。通过严格遵循语义化版本、可扩展协议设计、数据库迁移机制、灰度发布策略,可以将升级风险降到最低,保证用户体验的连续性。

设计模式

IM SDK(即时通讯软件开发工具包)的核心场景是长连接管理、消息收发、状态同步、重连机制、事件通知,其架构设计强调高解耦、高可扩展、高稳定性,通常会组合使用多种设计模式来解决不同模块的问题。以下是IM SDK中最常用的设计模式,结合具体业务场景逐一说明:


一、核心高频设计模式

1. 单例模式(Singleton)【连接管理核心】

应用场景

  • 全局唯一长连接:一个用户通常只需要一个WebSocket/TCP长连接,避免多个连接导致的资源浪费、消息重复、状态混乱。
  • 全局消息队列:统一管理待发送消息、离线消息的队列,确保消息有序。
  • 全局配置中心:统一管理SDK的初始化配置、用户信息、Token等。

代码示例(全局WebSocket连接管理)

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
class IMClient {
// 私有静态属性,存储唯一实例
static #instance = null;

constructor(config) {
if (IMClient.#instance) {
return IMClient.#instance;
}
this.config = config;
this.ws = null;
this.isConnected = false;
IMClient.#instance = this;
}

// 全局访问点
static getInstance(config) {
if (!IMClient.#instance) {
IMClient.#instance = new IMClient(config);
}
return IMClient.#instance;
}

connect() {
if (this.isConnected) return;
this.ws = new WebSocket(this.config.wsUrl);
this.ws.onopen = () => {
this.isConnected = true;
console.log("IM连接成功");
};
}
}

// 验证:无论调用多少次,永远是同一个连接实例
const client1 = IMClient.getInstance({ wsUrl: "wss://im.example.com" });
const client2 = IMClient.getInstance();
console.log(client1 === client2); // true
client1.connect();

2. 发布-订阅模式(Publish-Subscribe)【事件通知核心】

应用场景

  • SDK内部模块解耦:连接模块、消息模块、状态模块之间通过事件通信,互不感知。
  • 向上层应用通知事件:收到新消息、连接状态变化、用户上线/下线、消息发送成功/失败等,通过事件通知上层UI更新。
  • 跨页面/跨组件通信:在单页应用中,不同页面的IM状态同步。

代码示例(IM事件总线)

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
// 通用事件总线
class IMEventEmitter {
constructor() {
this.events = Object.create(null);
}

on(eventName, callback) {
if (!this.events[eventName]) this.events[eventName] = [];
this.events[eventName].push(callback);
}

emit(eventName, ...args) {
this.events[eventName]?.forEach(cb => cb(...args));
}

off(eventName, callback) {
if (!this.events[eventName]) return;
this.events[eventName] = this.events[eventName].filter(cb => cb !== callback);
}
}

// IM SDK核心类
class IMClient {
constructor() {
this.eventBus = new IMEventEmitter();
this.ws = null;
}

connect() {
this.ws = new WebSocket("wss://im.example.com");
this.ws.onopen = () => {
// 连接成功,发布事件
this.eventBus.emit("connected");
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
// 收到新消息,发布事件
this.eventBus.emit("message", message);
};
this.ws.onclose = () => {
// 连接断开,发布事件
this.eventBus.emit("disconnected");
};
}
}

// 上层应用使用:订阅事件,更新UI
const imClient = new IMClient();
imClient.connect();

// 订阅连接成功事件
imClient.eventBus.on("connected", () => {
console.log("UI更新:显示在线状态");
});
// 订阅新消息事件
imClient.eventBus.on("message", (msg) => {
console.log("UI更新:渲染新消息", msg);
});
// 订阅连接断开事件
imClient.eventBus.on("disconnected", () => {
console.log("UI更新:显示离线状态,提示重连");
});

3. 状态模式(State)【连接状态管理核心】

应用场景

  • 长连接状态流转:连接中、已连接、断开、重连中、已失效等不同状态,对应不同的行为(比如已连接状态下直接发消息,断开状态下先缓存消息再触发重连)。
  • 消息状态管理:待发送、发送中、发送成功、发送失败、已撤回等。
  • 用户在线状态:在线、离线、忙碌、隐身等。

核心优势

消除大量的 if (state === 'connected') { ... } else if (state === 'disconnected') { ... } 状态判断代码,每个状态的行为独立封装,易扩展、易维护。

代码示例(连接状态管理)

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
94
// 1. 定义状态类,封装每个状态的行为
class ConnectedState {
constructor(client) {
this.client = client;
}
// 已连接状态下,直接发送消息
sendMessage(message) {
console.log("直接发送消息:", message);
this.client.ws.send(JSON.stringify(message));
}
// 已连接状态下,无需重连
reconnect() {
console.log("已连接,无需重连");
}
}

class DisconnectedState {
constructor(client) {
this.client = client;
}
// 断开状态下,先缓存消息,再触发重连
sendMessage(message) {
console.log("缓存消息:", message);
this.client.messageQueue.push(message);
this.reconnect();
}
// 断开状态下,执行重连
reconnect() {
console.log("开始重连...");
this.client.setState(this.client.reconnectingState);
// 模拟重连逻辑
setTimeout(() => {
this.client.setState(this.client.connectedState);
console.log("重连成功,发送缓存消息");
this.client.flushMessageQueue();
}, 2000);
}
}

class ReconnectingState {
constructor(client) {
this.client = client;
}
// 重连中状态下,仅缓存消息
sendMessage(message) {
console.log("重连中,缓存消息:", message);
this.client.messageQueue.push(message);
}
// 重连中状态下,避免重复重连
reconnect() {
console.log("重连中,请勿重复操作");
}
}

// 2. IM SDK上下文
class IMClient {
constructor() {
// 初始化所有状态
this.connectedState = new ConnectedState(this);
this.disconnectedState = new DisconnectedState(this);
this.reconnectingState = new ReconnectingState(this);
// 初始状态为断开
this.currentState = this.disconnectedState;
this.messageQueue = [];
this.ws = { send: () => {} }; // 模拟WebSocket
}

// 设置状态
setState(state) {
this.currentState = state;
}

// 统一的发送消息接口,行为由当前状态决定
sendMessage(message) {
this.currentState.sendMessage(message);
}

// 统一的重连接口
reconnect() {
this.currentState.reconnect();
}

// 发送缓存消息
flushMessageQueue() {
this.messageQueue.forEach(msg => this.sendMessage(msg));
this.messageQueue = [];
}
}

// 使用
const imClient = new IMClient();
imClient.sendMessage({ type: "text", content: "你好" }); // 断开状态:缓存消息 + 触发重连
imClient.sendMessage({ type: "text", content: "在吗" }); // 重连中:仅缓存消息
// 2秒后重连成功,自动发送缓存消息

4. 职责链模式(Chain of Responsibility)【消息处理核心】

应用场景

  • 消息发送前处理:序列化 → 加密 → 添加时间戳/消息ID → 签名 → 缓存 → 发送,每个步骤是一个拦截器,组成一条链。
  • 消息接收后处理:解密 → 反序列化 → 去重 → 存储 → 分发,依次处理。
  • 重连拦截:网络检查 → Token刷新 → 重连策略选择 → 执行重连。

核心优势

每个处理逻辑独立封装,可灵活添加、删除、调整顺序,符合开闭原则。

代码示例(消息发送拦截器链)

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
// 职责链模式:消息发送拦截器
// 1. 拦截器基类
class Interceptor {
constructor() {
this.next = null;
}

setNext(interceptor) {
this.next = interceptor;
return interceptor; // 支持链式调用
}

handle(message) {
if (this.next) {
return this.next.handle(message);
}
return message;
}
}

// 2. 具体拦截器:添加消息ID
class MessageIdInterceptor extends Interceptor {
handle(message) {
message.id = Date.now().toString() + Math.random().toString(36).substr(2, 9);
console.log("添加消息ID:", message.id);
return super.handle(message);
}
}

// 3. 具体拦截器:添加时间戳
class TimestampInterceptor extends Interceptor {
handle(message) {
message.timestamp = Date.now();
console.log("添加时间戳:", message.timestamp);
return super.handle(message);
}
}

// 4. 具体拦截器:序列化
class SerializeInterceptor extends Interceptor {
handle(message) {
const serialized = JSON.stringify(message);
console.log("序列化消息:", serialized);
return super.handle(serialized);
}
}

// 5. IM SDK使用拦截器链
class IMClient {
constructor() {
// 构建拦截器链:添加ID → 添加时间戳 → 序列化
this.interceptorChain = new MessageIdInterceptor();
this.interceptorChain
.setNext(new TimestampInterceptor())
.setNext(new SerializeInterceptor());
}

sendMessage(message) {
// 执行拦截器链
const processedMessage = this.interceptorChain.handle(message);
console.log("最终发送消息:", processedMessage);
// 实际发送逻辑
}
}

// 使用
const imClient = new IMClient();
imClient.sendMessage({ type: "text", content: "职责链测试" });
// 输出:
// 添加消息ID:...
// 添加时间戳:...
// 序列化消息:...
// 最终发送消息:...

二、辅助常用设计模式

1. 策略模式(Strategy)

应用场景

  • 重连策略:指数退避重连、固定间隔重连、根据网络质量动态调整重连间隔,封装成不同策略灵活切换。
  • 消息加密策略:AES、RSA、国密SM2/SM4,根据安全需求选择。
  • 存储策略:LocalStorage、IndexedDB、内存存储,根据消息量选择。

2. 代理模式(Proxy)

应用场景

  • 消息发送代理:在连接不稳定时,代理先缓存消息,等连接恢复再发送,上层无需关心连接状态。
  • 日志/监控代理:对SDK的所有接口做代理,自动添加日志、性能监控、异常上报。
  • 权限代理:发送消息前检查用户权限、Token有效性。

3. 工厂模式(Factory)

应用场景

  • 消息对象创建:根据消息类型(文本、图片、语音、视频、自定义),工厂动态创建对应的消息对象,上层无需关心创建细节。
  • 连接对象创建:根据环境(浏览器WebSocket、Node.js net模块、小程序Socket),工厂创建对应的连接实例。

三、IM SDK设计模式选型总结

设计模式 核心解决问题 IM SDK典型落地
单例模式 全局唯一实例 全局长连接、消息队列、配置中心
发布-订阅模式 模块解耦、事件通知 连接状态变化、新消息通知、用户状态同步
状态模式 状态流转、消除if-else 连接状态、消息状态、用户在线状态
职责链模式 消息处理流程 消息发送/接收拦截器链
策略模式 算法灵活切换 重连策略、加密策略、存储策略
代理模式 访问控制、功能增强 消息缓存代理、日志监控代理
工厂模式 对象创建封装 消息对象创建、连接对象创建

核心原则

IM SDK通常是多种设计模式组合使用,不要为了用模式而硬套,优先保证:

  1. 核心模块解耦:连接、消息、状态、存储模块独立,通过事件通信。
  2. 可扩展性:方便添加新的消息类型、新的重连策略、新的加密方式。
  3. 稳定性:异常处理、重连机制、消息缓存兜底,保证消息不丢失、不重复。

其它重点

20250428-ES5+语法重点.md
20250507-Typescript开发重点.md
20250829-前端常用的设计模式.md
20250103-前端常用的数据库.md


Electron+IM开发
https://cszy.top/20260215 Electron+IM开发/
作者
csorz
发布于
2026年2月15日
许可协议