背景
基于Electron研发的一款IM企业通信桌面端应用,会存在非常多文件、图片、视频类型回话消息。在Electron应用(桌面客户端软件)中,快速加载并显示图片是提升用户体验的关键。然而,传统的图片加载方式往往存在加载速度慢、资源占用高等问题,影响了用户的使用体验。
解决的问题
-
支持自定义配置存储的磁盘位置
-
支持长期存储
-
支持自定义存储大小
-
支持自定义存储类型(如图片、视频、文件,或者更细致化到MIME)
-
支持清除缓存
-
缓存计算不阻塞主线程
现有技术
-
强制缓存:from disk cache和from memory cache;
-
webReqeust请求拦截;
现有技术的优缺点
-
强制缓存:from disk cache和from memory cache;强缓存可以通过设置两种HTTP Header实现:Expires和Cache-Control;强缓存所缓存的资源位置是浏览器内核所控制的,我们无法人为变更。
-
利用Electron提供的webReqeust对网络资源请求进行拦截,将资源存储到本地指定位置;webRequest提供了onBeforeReuqest、onCompleted两个方法,支持对请求发送前和请求响应成功进行处理;请求前检查资源是否已经被缓存,如果已经被缓存,则直接返回被缓存的资源路径。如果缓存不存在,则等待请求响应,并将响应的资源下载到本地。
实现方案
缓存配置
由业务层控制是否运行自动缓存,以及缓存资源大小限制。
缓存配置信息:
1let db: any = null;
2
3const fileCacheConfig = {
4 path: app.getPath("userData"),
5 isAutoCache: true,
6 isLimitSize: 1024 * 1024 * 100,
7};
8
9interface requestItem {
10 id: string;
11 originUrl: string;
12 resourceUrl: string;
13 resourceId: string;
14 isDownloading: boolean;
15}
16
17const requestMap: Record<string, requestItem> = {};
18
19const cacheDirPathMap = {
20 file: path.join(appPath, "Cache", "File"),
21 image: path.join(appPath, "Cache", "Image"),
22 video: path.join(appPath, "Cache", "Video"),
23};
24
初始化数据库
1
2
3 * 初始化数据
4 */
5function initData() {
6 try {
7
8 const appPath = fileCacheConfig.path || app.getPath("userData");
9
10
11 const filePathDatabase = path.join(app.getPath("userData"), "cache.db");
12 Object.values(cacheDirPathMap).forEach((path) => {
13
14 if (!fs.existsSync(path)) {
15
16 fs.mkdirSync(path, { recursive: true });
17 Logger.log(`目录已经被创建: ${path}`);
18 }
19 });
20
21
22 db = new Database(filePathDatabase);
23
24 db.exec(
25 `CREATE TABLE IF NOT EXISTS cache (
26 id integer primary key AUTOINCREMENT,
27 filePath text,
28 fileId text,
29 fileMd5 text);
30
31 CREATE TABLE IF NOT EXISTS webRequestCache (
32 id integer primary key AUTOINCREMENT,
33 filePath text,
34 url text,
35 fileMd5 text);
36 `
37 );
38 } catch (error) {
39 Logger.log("数据库表初始化异常", error);
40 }
41}
42
onBeforeRequest
发起请求前,先检查请求的资源接口地址是否已经在本地存在了,如果已经存在则直接返回,status为200。
1
2 session.defaultSession.webRequest.onBeforeRequest({ urls: [], types: [] }, (details, callback) => {
3 const url = details.url;
4 const id = `${details.id}`;
5
6 if (db && fileCacheConfig.isAutoCache) {
7
8
9 const isHasTableData = getCacheTable(url) as any;
10 if (isHasTableData && fs.existsSync(isHasTableData.filePath)) {
11
12 callback({ redirectURL: `file:///${hasSqliteData.filePath}` });
13 } else {
14
15 if (!requestMap[id]?.isDownloading) {
16 requestMap[id] = {
17 id,
18 originUrl: url,
19 resourceUrl: "",
20 resourceId: "",
21 isDownloading: false,
22 };
23 }
24 }
25 }
26
27 if (requestMap[id] && !requestMap[id]?.isDownloading) {
28 requestMap[id].resourceUrl = url;
29 }
30
31 callback({});
32 });
onCompleted
创建换一个后置请求处理方法:
1
2 session.defaultSession.webRequest.onCompleted({ urls: [], types: [] }, (details) => {
3 const url = details.url;
4 const id = `${details.id}`;
5
6 if (requestMap[id]) {
7 const flagUrl = setDetailsId(url, id);
8 const fileName = createFileName(url, id);
9
10 if (fileName && getFileSize(url) < fileCacheConfig.isLimitSize && details.resourceType === "image") {
11 const savePath = path.join(cacheDirPathMap[details.resourceType], fileName);
12
13 const instance = MainWindow.getInstance();
14 instance.saveFilePath = savePath;
15 instance.saveFilePathMap[url] = savePath;
16 instance.mainWindow?.webContents?.downloadURL?.(url);
17
18
19 requestMap[id].isDownloading = true;
20 } else {
21
22 if (requestMap[id]) {
23 delete requestMap[id];
24 }
25 }
26 }
27 });
28
资源下载
1
2 session.defaultSession.on("will-download", (event: Event, item: DownloadItem, webContents: WebContents) => {
3 const url = item.getURL();
4 const id = getDetailsId(url) || "";
5 try {
6 item.once("done", async (_, state: string) => {
7 if (state === "completed" && id) {
8
9 const fileMd5 = (await getFileMd5(item.savePath)) as string;
10
11 const requestItem = findRequestItemByID(id);
12
13 if (requestItem && fileMd5) {
14
15 updateCacheTable({
16 url: requestItem.originUrl,
17 filePath: item.savePath,
18 fileMd5: fileMd5,
19 });
20 }
21 } else {
22 Logger.log(`资源下载失败: ${state}`);
23 }
24
25
26 if (requestMap[id]) {
27 delete requestMap[id];
28 }
29 });
30 } catch (error) {
31 Logger.log("下载失败:", error);
32 }
33 });
完整代码
1
2const { app, session } = require('electron')
3import Logger from "electron-log";
4import fs from "fs";
5import path from "path";
6import Database from 'better-sqlite3';
7import crypto from 'crypto';
8
9
10const gotTheLock = app.requestSingleInstanceLock();
11
12if (!gotTheLock) {
13
14 app.quit();
15} else {
16
17 app.on("second-instance", (event, commandLine, workingDirectory) => {
18
19
20 if (MainWindow.mainWindow) {
21 MainWindow.getInstance().setWindowVisible(true, false);
22 MainWindow.mainWindow.focus();
23 }
24 });
25
26 if (app.isReady()) {
27 startApp();
28 } else {
29 app.once("ready", startApp);
30 }
31}
32
33
34
35function startApp() {
36
37 MainWindow.getInstance();
38
39 initSession();
40}
41
42
43
44
45app.on("window-all-closed", () => {
46
47 app.quit();
48});
49
50
51
52let db: any = null;
53const fileCacheConfig = {
54 path: app.getPath("userData"),
55 isAutoCache: true,
56 isLimitSize: 1024 * 1024 * 100,
57};
58
59interface requestItem {
60 id: string;
61 originUrl: string;
62 resourceUrl: string;
63 resourceId: string;
64 isDownloading: boolean;
65}
66
67const requestMap: Record<string, requestItem> = {};
68
69const cacheDirPathMap = {
70 file: path.join(appPath, "Cache", "File"),
71 image: path.join(appPath, "Cache", "Image"),
72 video: path.join(appPath, "Cache", "Video"),
73};
74
75
76 * 初始化数据
77 */
78function initData() {
79 try {
80
81 const appPath = fileCacheConfig.path || app.getPath("userData");
82
83
84 const filePathDatabase = path.join(app.getPath("userData"), "cache.db");
85 Object.values(cacheDirPathMap).forEach((path) => {
86
87 if (!fs.existsSync(path)) {
88
89 fs.mkdirSync(path, { recursive: true });
90 Logger.log(`目录已经被创建: ${path}`);
91 }
92 });
93
94
95 db = new Database(filePathDatabase);
96
97 db.exec(
98 `CREATE TABLE IF NOT EXISTS cache (
99 id integer primary key AUTOINCREMENT,
100 filePath text,
101 fileId text,
102 fileMd5 text);
103
104 CREATE TABLE IF NOT EXISTS webRequestCache (
105 id integer primary key AUTOINCREMENT,
106 filePath text,
107 url text,
108 fileMd5 text);
109 `
110 );
111 } catch (error) {
112 Logger.log("数据库表初始化异常", error);
113 }
114}
115
116
117 * 查询webRequestCache表
118 * @param url url
119 * @returns *
120 */
121const getCacheTable = (url: string) => {
122 try {
123 const stmt = db.prepare(`SELECT * FROM webRequestCache WHERE url = ?`);
124 const result = stmt.get(url);
125 return result;
126 } catch (error) {
127 return false;
128 }
129};
130
131
132 * 设置下载任务的id
133 * 主要用作任务映射,每个任务都是一个独立的id,类似于迅雷多列下载
134 *
135 * 通过key = value 的形式,作为映射规则
136 * @param url
137 * @param id
138 * @returns
139 */
140export const setDetailsId = (url: string, id: string) => {
141 if (url.indexOf("?") > -1) {
142 return url + `&downloadItemDetailsId=${id}`;
143 } else {
144 return url + `?downloadItemDetailsId=${id}`;
145 }
146};
147
148
149 * 查找本地缓存的是否存在该资源
150 * @param url url
151 * @returns *
152 */
153const findRequestItemByUrl = (url: string): requestItem | undefined => {
154 return Object.values(requestMap).find((item: requestItem) => {
155 return item.resourceUrl === url;
156 });
157};
158
159
160 * 创建文件名
161 * @param url 链接
162 * @param id id
163 * @returns *
164 */
165function createFileName(url: string, id: string) {
166
167 try {
168 const extname = path.extname(url.replace(/?.*/gi, ""));
169 const requestItem = requestMap[id] || findRequestItemByUrl(id);
170 const fileName = getStringMd5(requestItem.originUrl);
171 return `${fileName}${extname}`;
172 } catch (err) {
173 return false;
174 }
175}
176
177
178 * 获取下载项
179 * @param url
180 * @returns
181 */
182export const getDetailsId = (url: string) => {
183 const reg = new RegExp(`&downloadItemDetailsId=(\w*)`);
184 const result = url.match(reg);
185 if (result) {
186 return result[1];
187 }
188 return false;
189};
190
191
192 * 通过资源id查找下载项
193 * @param id id
194 * @returns *
195 */
196const findRequestItemByID = (id: string): requestItem | undefined => {
197 return Object.values(requestMap).find((item: requestItem) => {
198 return item.resourceId === id;
199 });
200};
201
202
203 * 更新数据库
204 * @param data *
205 * @returns *
206 */
207const updateCacheTable = (data: { url: string; filePath: string; fileMd5: string }) => {
208 try {
209 const hasSqliteData = selectWebRequestCacheTable(data.url) as any;
210
211 if (hasSqliteData) {
212 const update = db.prepare("UPDATE webRequestCache SET filePath = ?, fileMd5 = ? WHERE url = ?");
213 update.run(data.filePath, data.fileMd5, data.url);
214 return;
215 }
216 const insert = db.prepare(
217 "INSERT INTO webRequestCache (filePath, url, fileMd5) VALUES (@filePath, @url, @fileMd5)"
218 );
219 insert.run(data);
220 } catch (error) {
221 Logger.log("记录到数据库报错", error);
222 return false;
223 }
224};
225
226
227 * 获取文件的md5
228 * 为了避免文件重复,使用文件的md5作为文件名
229 * 如果是不同名称,但是文件内容相同,则使用相同的文件名,能优化重复文件资源占用的问题
230 * @param filePath 文件路径
231 * @returns
232 */
233export const getFileMd5 = (filePath: string) => {
234 return new Promise((resolve, _) => {
235 const hash = crypto.createHash("md5");
236 const stream = fs.createReadStream(filePath);
237
238 stream.on("data", (chunk: any) => {
239 hash.update(chunk, "utf8");
240 });
241 stream.on("end", () => {
242 const md5 = hash.digest("hex");
243 resolve(md5);
244 });
245 });
246};
247
248
249 * 初始化session事件
250 */
251function initSession() {
252
253 initData();
254
255
256 session.defaultSession.webRequest.onBeforeRequest({ urls: [], types: [] }, (details, callback) => {
257 const url = details.url;
258 const id = `${details.id}`;
259
260 if (db && fileCacheConfig.isAutoCache) {
261
262
263 const isHasTableData = getCacheTable(url) as any;
264 if (isHasTableData && fs.existsSync(isHasTableData.filePath)) {
265
266 callback({ redirectURL: `file:///${hasSqliteData.filePath}` });
267 } else {
268
269 if (!requestMap[id]?.isDownloading) {
270 requestMap[id] = {
271 id,
272 originUrl: url,
273 resourceUrl: "",
274 resourceId: "",
275 isDownloading: false,
276 };
277 }
278 }
279 }
280
281 if (requestMap[id] && !requestMap[id]?.isDownloading) {
282 requestMap[id].resourceUrl = url;
283 }
284
285 callback({});
286 });
287
288 session.defaultSession.webRequest.onCompleted({ urls: [], types: [] }, (details) => {
289 const url = details.url;
290 const id = `${details.id}`;
291
292 if (requestMap[id]) {
293 const flagUrl = setDetailsId(url, id);
294 const fileName = createFileName(url, id);
295
296 if (fileName && getFileSize(url) < fileCacheConfig.isLimitSize && details.resourceType === "image") {
297 const savePath = path.join(cacheDirPathMap[details.resourceType], fileName);
298
299 const instance = MainWindow.getInstance();
300 instance.saveFilePath = savePath;
301 instance.saveFilePathMap[url] = savePath;
302 instance.mainWindow?.webContents?.downloadURL?.(url);
303
304
305 requestMap[id].isDownloading = true;
306 } else {
307
308 if (requestMap[id]) {
309 delete requestMap[id];
310 }
311 }
312 }
313 });
314
315 session.defaultSession.on("will-download", (event: Event, item: DownloadItem, webContents: WebContents) => {
316 const url = item.getURL();
317 const id = getDetailsId(url) || "";
318 try {
319 item.once("done", async (_, state: string) => {
320 if (state === "completed" && id) {
321
322 const fileMd5 = (await getFileMd5(item.savePath)) as string;
323
324 const requestItem = findRequestItemByID(id);
325
326 if (requestItem && fileMd5) {
327
328 updateCacheTable({
329 url: requestItem.originUrl,
330 filePath: item.savePath,
331 fileMd5: fileMd5,
332 });
333 }
334 } else {
335 Logger.log(`资源下载失败: ${state}`);
336 }
337
338
339 if (requestMap[id]) {
340 delete requestMap[id];
341 }
342 });
343 } catch (error) {
344 Logger.log("下载失败:", error);
345 }
346 });
347}
348
349
问题解惑
1. 支持自定义配置存储的磁盘位置
配置信息fileCacheConfig
中,支持修改缓存资源存储的位置。
2. 支持长期存储
只要应用没有被卸载,缓存会一直存在于设备本地。除非用户主动清理缓存。
应用的缓存位置默认通过app.getPath("userData")
获取。
mac端默认是:~/Users/[用户名称]/Library/Application Support/[应用名称]/
windows端默认是:%用户名称%\AppData\Roaming\{应用名称}\
3. 支持自定义存储大小
在响应拦截器中,我们做了资源大小的检查getFileSize(url) < fileCacheConfig.isLimitSize
,当资源小于100M时(支持自定义配置)才会缓存到本地。
这个打开可以在配置项中修改。
getFileSize
函数是通过获取请求头header
中返回是length
字段计算得来的。
4. 支持自定义存储类型(如图片、视频、文件,或者更细致化到MIME)
当前演示的是缓存image
类型,具体支持的类型有很多,参见Electron官网;
resourceType
string - 可以是mainFrame
,subFrame
,stylesheet
,script
,image
,font
,object
,xhr
,ping
,cspReport
,media
,webSocket
或other
。
如果是视频类型,则在相应拦截器中添加<font style="color:rgb(28, 30, 33);background-color:rgb(246, 247, 248);">details.resourceType === 'media'</font>
5. 支持缓存清除
-
需要清除sqlite数据库,因为数据库中的
url
映射到的是本地资源路径。 -
需要清除
fileCacheConfig.path
下的资源内容。
1
2
3const cacheDirPathMap = {
4 file: path.join(appPath, "Cache", "File"),
5 image: path.join(appPath, "Cache", "Image"),
6 video: path.join(appPath, "Cache", "Video"),
7};
8
9
10 * 删除文件夹文件
11 *
12 * @private
13 * @async
14 * @param {string} folderPath
15 * @returns {*}
16 */
17async function clearCache() {
18 try {
19
20 for (let path of Object.values(cacheDirPathMap)) {
21 await promisify(fs.rm)(path, { recursive: true })
22 }
23
24 db.prepare(`DELETE FROM cache`).run()
25 } catch (error) {
26 Logger.log('[sqlite] 删除失败', error);
27 }
28}
29
6. 缓存计算不阻塞主线程
文件的下载和缓存是由will-download
处理的。
在Electron中,will-download
事件本身并不会直接阻塞主进程。will-download
是Electron中用于监听和控制文件下载的一个事件,它属于Electron的session
对象。当一个文件开始下载时,这个事件会被触发,允许开发者在下载过程中进行自定义处理,比如设置文件的保存路径、监听下载进度等。
will-download
事件的工作原理
-
当一个下载请求发生时,Electron的
session
对象会触发will-download
事件。 -
这个事件的处理程序中,开发者可以访问到与下载相关的
DownloadItem
对象,通过该对象可以控制下载过程,比如设置下载路径、暂停或取消下载等。 -
will-download
事件的处理是异步的,它不会直接阻塞主进程。主进程可以继续执行其他任务,而下载过程则在后台进行。
7. 强制缓存和webRequest会有冲突吗?
两者确实会有冲突。
强制换存的作用:
-
强缓存机制(如 HTTP 的
Cache-Control
,Expires
)在浏览器中是用来控制请求的缓存行为的。如果某个请求被强缓存,浏览器在接下来的请求中不会与服务器通信,而是直接从缓存中读取资源。 -
强缓存一般分为两种:
-
协商缓存(需要向服务器确认资源是否更新);
-
强制缓存(完全由客户端控制,不与服务器通信)。
webRequest.onBeforeRequest
和缓存的关系
-
**onBeforeRequest**
允许你在资源请求发出前进行拦截和重定向。如果你通过这个拦截器对请求进行了修改,比如更改了 URL 或重定向了请求,强缓存机制可能会被绕过,因为请求已经被更改。 -
如果资源已被强缓存,浏览器不会发出请求,因此也不会触发
onBeforeRequest
。
webRequest.onCompleted
和缓存的关系
**onCompleted**
会在请求完成后触发。如果资源是从缓存中获取的,这个事件依然会触发。不过,当强缓存生效时,可能根本不会进行网络请求,所以即使使用onCompleted
监听,也可能不会有实际请求完成的事件。
冲突可能性
-
**请求被缓存,不触发 **
**onBeforeRequest**
:如果强缓存生效,那么请求不会被发出,这意味着onBeforeRequest
不会被触发,因为没有请求发送到服务器。 -
缓存读取与
**onCompleted**
的问题:如果资源是从缓存中加载的,onCompleted
依然可能会触发,但是不会有实际网络请求,只会报告资源从缓存中读取成功。
如何避免冲突
如果你希望确保 onBeforeRequest
和 onCompleted
始终生效并能拦截所有请求(包括缓存中的请求),你可以通过以下几种方式禁用缓存或手动控制缓存行为:
- 在请求拦截器中禁用缓存: 你可以在
onBeforeRequest
中通过设置 HTTP 请求头Cache-Control: no-cache
来绕过缓存,确保请求每次都会发出:
1
2session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
3 details.requestHeaders['Cache-Control'] = 'no-cache';
4 callback({ cancel: false, requestHeaders: details.requestHeaders });
5});
6
- 禁用 Electron 的全局缓存: 你可以通过
session
API 来禁用 Electron 的缓存。
1const { session } = require('electron');
2session.defaultSession.webRequest.onBeforeRequest((details, callback) => {
3 callback({
4 cancel: false,
5 requestHeaders: { ...details.requestHeaders, 'Cache-Control': 'no-cache' }
6 });
7});
- 清除缓存: 如果缓存内容导致问题,你可以在需要的时候手动清除缓存,确保所有请求重新加载:
1session.defaultSession.clearCache().then(() => {
2 console.log('Cache cleared');
3});